Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ec3f11cdd | ||
|
|
5ce1a2928e | ||
|
|
339d31f05c | ||
|
|
a444280cb4 | ||
|
|
c0788d15c0 | ||
|
|
7704867e05 | ||
|
|
04a4ab33dd | ||
|
|
b640ec486d | ||
|
|
0bd8c6c16a | ||
|
|
6ac9187c52 | ||
|
|
dcd11a3b17 | ||
|
|
3dd37d053b | ||
|
|
64894ad96e |
@@ -210,6 +210,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"hooks": "*",
|
||||
"i18n": "*",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.4",
|
||||
@@ -271,6 +272,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"hooks": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
"state": "workspace:*",
|
||||
"widgets": "workspace:*",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026.08.23.02 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
|
||||
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
|
||||
|
||||
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
|
||||
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
|
||||
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
|
||||
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
|
||||
|
||||
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
|
||||
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
|
||||
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
|
||||
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
|
||||
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
|
||||
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
|
||||
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
|
||||
| `officer-dev` | — | — | — | — | — | SKIPPED |
|
||||
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
|
||||
|
||||
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
|
||||
`sendoff_senders` are new tables, so the diff was additive.
|
||||
|
||||
## What restarted, beyond what the plan asked for
|
||||
|
||||
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
|
||||
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
|
||||
not a fault, and it is the larger part of what moved:
|
||||
|
||||
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
|
||||
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
|
||||
- `fofos` — one (`officer-gitea`).
|
||||
- `production-pertento-bridge` — one (`officer-gitea`).
|
||||
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
|
||||
- `macbookpro` — none; no plugins are installed there.
|
||||
|
||||
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
|
||||
on any host was `errored`.
|
||||
|
||||
## Hosts the plan flagged
|
||||
|
||||
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
|
||||
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
|
||||
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
|
||||
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
|
||||
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
|
||||
steps, with `pm2` run separately through `$SHELL -ic`.
|
||||
|
||||
## Things worth knowing, none of them failures
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
|
||||
it, and it was left alone.
|
||||
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
|
||||
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
|
||||
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
|
||||
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
|
||||
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
|
||||
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
|
||||
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
|
||||
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
|
||||
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
|
||||
@@ -0,0 +1,64 @@
|
||||
# 2026.08.23.03
|
||||
|
||||
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
|
||||
changes and nothing reads the new table yet.
|
||||
|
||||
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
|
||||
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
|
||||
existing data.
|
||||
|
||||
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
|
||||
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
|
||||
`bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
|
||||
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
|
||||
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
|
||||
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
|
||||
`db:push` and is a later release.
|
||||
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
|
||||
step here. It never overwrites an existing row.
|
||||
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
|
||||
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
|
||||
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
|
||||
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
|
||||
(`en` → `en-GB`), with no database migration.
|
||||
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
|
||||
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
|
||||
create.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
|
||||
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
|
||||
exists and holds four rows on that host. `db:push` can fail and still exit 0.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
|
||||
the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## The check that matters this release
|
||||
|
||||
```sql
|
||||
select count(*) from roles; -- expect 4
|
||||
```
|
||||
|
||||
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
|
||||
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
|
||||
per host in the report.
|
||||
|
||||
No direct SQL is needed and none is authorised for this release.
|
||||
|
||||
## Hosts to expect trouble from
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The rollout runs from here. It already holds the release and the four rows, verified locally.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 2026.08.23.03 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
|
||||
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
|
||||
attempted, nothing spent on it).
|
||||
|
||||
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
|
||||
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
|
||||
authorised and none was needed.
|
||||
|
||||
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
|
||||
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
|
||||
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
|
||||
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
|
||||
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
|
||||
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
|
||||
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
|
||||
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
|
||||
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
|
||||
|
||||
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
|
||||
`online`** — not just `officer`, every entry.
|
||||
|
||||
## Notes, and what was not done
|
||||
|
||||
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
|
||||
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
|
||||
restarting `officer` on it is the owner's deliberate last step.
|
||||
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
|
||||
never have collided with it, so it is not a reason to skip a host.
|
||||
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
|
||||
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
|
||||
before `officer` itself was touched.
|
||||
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
|
||||
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
|
||||
- **Nothing was retried and nothing was repaired.** No step failed on any host.
|
||||
+57
-36
@@ -28,13 +28,13 @@ the server does instead, which is the one decision that reaches outside this bou
|
||||
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
|
||||
also about **5% deployed**, and that gap is the whole job.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
||||
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
|
||||
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
|
||||
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||
| | |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
||||
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
|
||||
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
|
||||
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||
|
||||
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
||||
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
|
||||
@@ -45,7 +45,7 @@ typecheck failure, which is worth more than it sounds when one agent is editing
|
||||
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
||||
runtime and unused.
|
||||
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
|
||||
the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates.
|
||||
the _browser's_ locale, not the user's chosen one, so a Portuguese UI shows American dates.
|
||||
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
|
||||
from `pt`.
|
||||
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
||||
@@ -59,12 +59,12 @@ typecheck failure, which is worth more than it sounds when one agent is editing
|
||||
|
||||
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
||||
|
||||
| area | files | occurrences |
|
||||
| --- | --- | --- |
|
||||
| `officer-web` (shell) | 132 | 556 |
|
||||
| `officerdev` (panels) | 213 | 567 |
|
||||
| shared `components` | 91 | 47 |
|
||||
| **total** | **436** | **1170** |
|
||||
| area | files | occurrences |
|
||||
| --------------------- | ------- | ----------- |
|
||||
| `officer-web` (shell) | 132 | 556 |
|
||||
| `officerdev` (panels) | 213 | 567 |
|
||||
| shared `components` | 91 | 47 |
|
||||
| **total** | **436** | **1170** |
|
||||
|
||||
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
||||
|
||||
@@ -101,29 +101,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to
|
||||
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
|
||||
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
|
||||
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
|
||||
the prose was careful not to be. When in doubt, one code for one *response*, not one per branch.
|
||||
the prose was careful not to be. When in doubt, one code for one _response_, not one per branch.
|
||||
|
||||
### D2. Regional locales, BCP 47, with fallback chains
|
||||
### D2. Regional locales, BCP 47, and every catalog is complete
|
||||
|
||||
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it,
|
||||
and it is the owner's own language, so getting it wrong is felt immediately.
|
||||
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
|
||||
it is the owner's own language, so getting it wrong is felt immediately.
|
||||
|
||||
Resolution walks a chain and stops at the first hit:
|
||||
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
|
||||
every key the UI has, translated for that locale, or it does not ship.
|
||||
|
||||
```
|
||||
pt-BR → pt → en
|
||||
en-GB → en
|
||||
```
|
||||
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
|
||||
carried the shared bulk and a regional file carried only the differences. That is the conventional design
|
||||
and it was rejected for a product reason that outranks the engineering one:
|
||||
|
||||
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.**
|
||||
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
|
||||
differently. Two near-identical full catalogs would drift within a month.
|
||||
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
|
||||
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
|
||||
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
|
||||
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
|
||||
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
|
||||
variant's words.
|
||||
|
||||
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
|
||||
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
|
||||
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
|
||||
by the test rather than by a fallback quietly papering over a gap.
|
||||
|
||||
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
|
||||
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
|
||||
base-versus-overlay merge order, and no question about which file a given string lives in.
|
||||
|
||||
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
|
||||
|
||||
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to
|
||||
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
|
||||
widening on read over a database migration — a stale bare code should resolve, not fail.
|
||||
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
|
||||
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
|
||||
over a database migration — a stale bare code should resolve, not fail.
|
||||
|
||||
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
|
||||
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
|
||||
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
|
||||
the other.
|
||||
|
||||
### D3. RTL — prepare, do not ship
|
||||
|
||||
@@ -148,7 +165,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre
|
||||
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
||||
lines of glue, listed in "What it cannot do yet".
|
||||
|
||||
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms.
|
||||
Revisit this only if message _formatting_ gets genuinely complex — nested selects, gendered forms.
|
||||
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||||
|
||||
### D5. Keys, not English source text
|
||||
@@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
|
||||
- `namespace` — `app` / `panel` / `ui`, per D6.
|
||||
- `area` — the route or panel it belongs to; matches the directory.
|
||||
- `component` — the file, lowercased.
|
||||
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||
|
||||
Rules that keep 1200 keys navigable:
|
||||
|
||||
@@ -245,7 +262,7 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
|
||||
|
||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes*
|
||||
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error _codes_
|
||||
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||
names, database values.
|
||||
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
||||
@@ -304,9 +321,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0.
|
||||
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
|
||||
meter: the count goes down as work lands.
|
||||
|
||||
**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the
|
||||
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
|
||||
one guard that stops a locale silently degrading into English.
|
||||
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
|
||||
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
|
||||
`bun docs:check` fails on a stale number.
|
||||
|
||||
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
|
||||
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
|
||||
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
|
||||
|
||||
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
|
||||
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
|
||||
@@ -339,7 +360,7 @@ else will ever notice.
|
||||
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
||||
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||
manifest. Their strings are not counted above.
|
||||
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
|
||||
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
|
||||
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.02",
|
||||
"version": "2026.08.23.03",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -30,7 +30,7 @@
|
||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
|
||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
|
||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
|
||||
@@ -192,6 +192,51 @@ assemble at the end from memory is a report that will be wrong about the host th
|
||||
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
||||
one that stopped in the middle and said nothing about the four hosts it never reached.
|
||||
|
||||
### Schema changes, and why `db:push` cannot be trusted
|
||||
|
||||
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
||||
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
||||
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
||||
success while the schema is half-applied.
|
||||
|
||||
So on any release whose plan says the schema changed:
|
||||
|
||||
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
||||
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
||||
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
||||
a guess.
|
||||
|
||||
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
||||
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
||||
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
||||
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
||||
|
||||
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
||||
|
||||
This is allowed and it is not a licence to improvise. The rules, in order:
|
||||
|
||||
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
||||
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
||||
that is a code change and it is not yours to make on five production hosts at 3am.
|
||||
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
||||
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
||||
the fleet has diverged in a way nothing will detect.
|
||||
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
||||
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
||||
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
||||
forensic exercise.
|
||||
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
||||
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
||||
finding for the report rather than something to push past.
|
||||
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
||||
the specific check, and its answer, for each.
|
||||
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
||||
repair you invented.
|
||||
|
||||
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
||||
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
||||
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
||||
|
||||
### Skip the host you are running on
|
||||
|
||||
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Seed the `roles` table with the roles the platform's own code names.
|
||||
*
|
||||
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
|
||||
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
|
||||
* mechanism `backfill-session-models.ts` relies on.
|
||||
*
|
||||
* ── Why these four are `isSystem` ──
|
||||
*
|
||||
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||
*
|
||||
* ── What this deliberately does NOT do ──
|
||||
*
|
||||
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||
*
|
||||
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||
* unattended.
|
||||
*
|
||||
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||
*/
|
||||
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||
|
||||
const SEED: { name: string; description: string }[] = [
|
||||
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await waitForDatabase();
|
||||
let created = 0;
|
||||
for (const role of SEED) {
|
||||
if (await seedRole({ ...role, isSystem: true })) created += 1;
|
||||
}
|
||||
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
|
||||
}
|
||||
|
||||
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
|
||||
// a successful push look like a failed one — the rows are additive and the next push tries again.
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
|
||||
})
|
||||
.finally(() => process.exit(0));
|
||||
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { SHIPPED_LOCALES } from '@/lib/locales';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
@@ -56,7 +57,8 @@ export const Languages = () => {
|
||||
const removeSpoken = (code: string) => {
|
||||
const next = spoken.filter((s) => s !== code);
|
||||
const updates = { ...settings.languages, spoken: next };
|
||||
if (defaultLang === code) updates.default = next[0] ?? 'en';
|
||||
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
|
||||
// language never leaves it without a valid value.
|
||||
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
|
||||
save(updates);
|
||||
};
|
||||
@@ -104,7 +106,7 @@ export const Languages = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default language */}
|
||||
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
|
||||
<select
|
||||
@@ -112,9 +114,9 @@ export const Languages = () => {
|
||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{spoken.map((code) => (
|
||||
{SHIPPED_LOCALES.map(({ code, label }) => (
|
||||
<option key={code} value={code}>
|
||||
{getLabel(code)}
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { I18nProvider, localeDirection } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import en from '../locales/en.json';
|
||||
import pt from '../locales/pt.json';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
|
||||
const translations: TranslationMap = { en, pt };
|
||||
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
|
||||
|
||||
/**
|
||||
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
|
||||
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||
*/
|
||||
const translations: TranslationMap = Object.fromEntries(
|
||||
SHIPPED_LOCALES.map(({ code }) => [
|
||||
code,
|
||||
{
|
||||
app: appCatalogs[code] ?? {},
|
||||
panel: panelCatalogs[code] ?? {},
|
||||
ui: uiCatalogs[code] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
type I18nBridgeProps = {
|
||||
children: ReactNode;
|
||||
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
||||
const { settings } = useSettings();
|
||||
const locale = settings.languages.default;
|
||||
|
||||
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
|
||||
// RTL catalog needs no second pass over the tree.
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = localeDirection(locale);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<I18nProvider translations={translations} locale={locale}>
|
||||
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { TranslationKeys } from 'i18n';
|
||||
import type { InterpolationParams } from 'i18n';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from '../locales/en.json';
|
||||
import type en from '../locales/en-GB.json';
|
||||
|
||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||
|
||||
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses.
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
|
||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
|
||||
return { t, locale };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
|
||||
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
|
||||
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
|
||||
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
|
||||
*/
|
||||
export type ShippedLocale = { code: string; label: string };
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = [
|
||||
{ code: 'en-GB', label: 'English (UK)' },
|
||||
{ code: 'pt-PT', label: 'Português (Portugal)' },
|
||||
];
|
||||
|
||||
export const FALLBACK_LOCALE = 'en-GB';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||
// not a warning — there is no regional fallback to paper over the gap.
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||
export * from './sendoff';
|
||||
export * from './roles';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { roles } from './schema';
|
||||
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { roles } from './schema';
|
||||
|
||||
export type RoleRow = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
provisioningScript: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every role, with how many accounts hold each.
|
||||
*
|
||||
* The count is here rather than in a second query because every caller needs it: a picker wants to show
|
||||
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
|
||||
* between them.
|
||||
*/
|
||||
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
provisioningScript: roles.provisioningScript,
|
||||
createdAt: roles.createdAt,
|
||||
userCount: sql<number>`count(${users.id})::int`,
|
||||
})
|
||||
.from(roles)
|
||||
.leftJoin(users, eq(users.role, roles.name))
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||
.orderBy(asc(roles.name));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||
*
|
||||
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||
* deploy — so the seed establishes a row and never touches it again.
|
||||
*/
|
||||
export async function seedRole(row: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning({ name: roles.name });
|
||||
return inserted.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* The roles this server has, as data rather than a TypeScript union.
|
||||
*
|
||||
* ── Why the NAME is the primary key ──
|
||||
*
|
||||
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
|
||||
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
|
||||
* file standing between a Member and a shell — to buy nothing the name does not already give, because
|
||||
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
|
||||
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
|
||||
* changes.
|
||||
*
|
||||
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||
*
|
||||
* ── What is NOT here ──
|
||||
*
|
||||
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
|
||||
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
|
||||
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
|
||||
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
|
||||
*
|
||||
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
|
||||
* its provisioning, not its grants.
|
||||
*/
|
||||
export const roles = pgTable('roles', {
|
||||
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
|
||||
name: text('name').primaryKey(),
|
||||
/** One line, for a picker. Never load-bearing — nothing branches on it. */
|
||||
description: text('description'),
|
||||
/**
|
||||
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||
*
|
||||
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||
* discovering it as a runtime failure somewhere else.
|
||||
*/
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
/**
|
||||
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||
* becomes a row. Null today, and nothing reads it.
|
||||
*
|
||||
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||
*/
|
||||
provisioningScript: text('provisioning_script'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export * from './anthropic-accounts/schema'; // anthropic_accounts — switchabl
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
|
||||
export * from './roles/schema'; // roles
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
// Loading, failed, empty. See docs/design-language-interface.md § 6.
|
||||
//
|
||||
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
|
||||
<Shell>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</Shell>
|
||||
);
|
||||
export const LoadingBlock = ({ label }: { label?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Shell>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { Column } from './DataTable';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { DataTable, useDataControl } from './DataTable';
|
||||
import enGB from '../locales/en-GB.json';
|
||||
|
||||
/**
|
||||
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
|
||||
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
|
||||
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
|
||||
*/
|
||||
const withI18n = (ui: ReactNode) => (
|
||||
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
type TestItem = {
|
||||
id: number;
|
||||
@@ -55,7 +69,7 @@ const TestWrapper = ({
|
||||
describe('DataTable', () => {
|
||||
describe('rendering', () => {
|
||||
test('renders table with data', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
expect(screen.getByRole('table')).toBeDefined();
|
||||
expect(screen.getByText('Alpha')).toBeDefined();
|
||||
@@ -63,7 +77,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('renders column headers', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
// Component renders both desktop and mobile versions (hidden via CSS)
|
||||
// so we use getAllByText and verify at least one exists
|
||||
@@ -74,7 +88,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('renders correct number of rows based on page size', () => {
|
||||
render(<TestWrapper pageSize={3} />);
|
||||
render(withI18n(<TestWrapper pageSize={3} />));
|
||||
|
||||
const rows = screen.getAllByRole('row');
|
||||
// 1 header row + 3 data rows
|
||||
@@ -82,20 +96,20 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('shows no data message when data is empty', () => {
|
||||
render(<TestWrapper data={[]} noDataMessage="No items found" />);
|
||||
render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
|
||||
|
||||
expect(screen.getByText('No items found')).toBeDefined();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows default no data message', () => {
|
||||
render(<TestWrapper data={[]} />);
|
||||
render(withI18n(<TestWrapper data={[]} />));
|
||||
|
||||
expect(screen.getByText('No Data Found.')).toBeDefined();
|
||||
});
|
||||
|
||||
test('renders noDataCTA when provided', () => {
|
||||
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />);
|
||||
render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
|
||||
});
|
||||
@@ -106,7 +120,7 @@ describe('DataTable', () => {
|
||||
return <DataTable dataController={controller} columns={columns} />;
|
||||
};
|
||||
|
||||
const { container } = render(<NullWrapper />);
|
||||
const { container } = render(withI18n(<NullWrapper />));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -118,7 +132,7 @@ describe('DataTable', () => {
|
||||
return <DataTable dataController={controller} />;
|
||||
};
|
||||
|
||||
render(<AutoColumnsWrapper />);
|
||||
render(withI18n(<AutoColumnsWrapper />));
|
||||
|
||||
// Should generate columns from object keys (rendered for both mobile/desktop)
|
||||
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
|
||||
@@ -130,7 +144,7 @@ describe('DataTable', () => {
|
||||
|
||||
describe('sorting', () => {
|
||||
test('clicking sortable column header triggers sort', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
// Get the first "Name" element (desktop version)
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
@@ -143,7 +157,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('clicking same column twice reverses sort order', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
fireEvent.click(nameHeaders[0]!); // asc
|
||||
@@ -156,7 +170,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('sort icon is displayed for sortable columns', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
// Each sortable column should have the ArrowUpDown icon
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
@@ -169,19 +183,19 @@ describe('DataTable', () => {
|
||||
|
||||
describe('pagination', () => {
|
||||
test('shows pagination bar when data exceeds page size', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
||||
});
|
||||
|
||||
test('does not show pagination when all data fits on one page', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
expect(screen.queryByText(/Showing page/)).toBeNull();
|
||||
});
|
||||
|
||||
test('next button navigates to next page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -192,7 +206,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('previous button navigates to previous page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -205,7 +219,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('first button goes to first page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -217,7 +231,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('last button goes to last page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const lastButton = screen.getByRole('button', { name: 'Last' });
|
||||
fireEvent.click(lastButton);
|
||||
@@ -226,7 +240,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('navigation buttons are disabled at boundaries', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const firstBtn = screen.getByRole('button', { name: 'First' });
|
||||
const prevBtn = screen.getByRole('button', { name: 'Prev' });
|
||||
@@ -258,7 +272,7 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: false },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
|
||||
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
@@ -271,7 +285,7 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: true },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -283,11 +297,11 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: () => showStatus },
|
||||
];
|
||||
|
||||
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
|
||||
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
expect(screen.queryByText('Status')).toBeNull();
|
||||
|
||||
showStatus = true;
|
||||
rerender(<TestWrapper cols={conditionalColumns} />);
|
||||
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -303,7 +317,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
||||
|
||||
const formattedNames = screen.getAllByTestId('formatted-name');
|
||||
expect(formattedNames.length).toBeGreaterThan(0);
|
||||
@@ -319,7 +333,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
||||
|
||||
// Each row should have a unique test ID based on item.id
|
||||
expect(screen.getByTestId('item-1')).toBeDefined();
|
||||
@@ -337,7 +351,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={actionColumns} />);
|
||||
render(withI18n(<TestWrapper cols={actionColumns} />));
|
||||
|
||||
expect(screen.getByTestId('action-1')).toBeDefined();
|
||||
expect(screen.getByText('Edit Alpha')).toBeDefined();
|
||||
@@ -351,7 +365,7 @@ describe('DataTable', () => {
|
||||
{ field: 'name', label: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={tooltipColumns} />);
|
||||
render(withI18n(<TestWrapper cols={tooltipColumns} />));
|
||||
|
||||
// The tooltip indicator (ⓘ) should be present in the ID column headers
|
||||
const idHeaders = screen.getAllByText(/ID/);
|
||||
@@ -367,7 +381,7 @@ describe('DataTable', () => {
|
||||
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={mobileColumns} />);
|
||||
render(withI18n(<TestWrapper cols={mobileColumns} />));
|
||||
|
||||
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
|
||||
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
|
||||
@@ -396,7 +410,7 @@ describe('DataTable', () => {
|
||||
);
|
||||
};
|
||||
|
||||
render(<SearchableTable />);
|
||||
render(withI18n(<SearchableTable />));
|
||||
|
||||
const searchInput = screen.getByTestId('search-input');
|
||||
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
|
||||
@@ -420,7 +434,7 @@ describe('DataTable', () => {
|
||||
);
|
||||
};
|
||||
|
||||
render(<ExternalSortTable />);
|
||||
render(withI18n(<ExternalSortTable />));
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-btn'));
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { PaginationBar } from './PaginationBar';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const [columns, setColumns] = useState<Column<T>[]>([]);
|
||||
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
|
||||
const theUniqueKey = uniqueKey || 'id';
|
||||
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
if (rawData.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
|
||||
<div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
|
||||
{noDataCTA && <>{noDataCTA}</>}
|
||||
</div>
|
||||
);
|
||||
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
<span className="min-[500px]:hidden">
|
||||
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
|
||||
</span>
|
||||
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
|
||||
{sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltip && (
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type PaginationBarProps<T> = {
|
||||
dataManager: UseDataControlReturn<T>;
|
||||
};
|
||||
|
||||
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
|
||||
|
||||
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
|
||||
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
{/* LEFT */}
|
||||
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||
<span className="hidden min-[500px]:inline">
|
||||
Showing page {currentPage} of {pageCount}
|
||||
{t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
|
||||
</span>
|
||||
<span className="min-[500px]:hidden">
|
||||
p {currentPage}/{pageCount}
|
||||
{t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
|
||||
</span>
|
||||
|
||||
<span className="bg-border h-5 w-px" aria-hidden="true" />
|
||||
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
hover:bg-background
|
||||
data-[state=open]:bg-muted
|
||||
"
|
||||
aria-label="Rows per page"
|
||||
aria-label={t('dataTable.pagination.rowsPerPage')}
|
||||
>
|
||||
<SelectValue className="text-muted-foreground">
|
||||
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
|
||||
<span className="min-[500px]:hidden">{pageSize}/p</span>
|
||||
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
|
||||
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
data-[state=checked]:text-muted-foreground
|
||||
"
|
||||
>
|
||||
<span className="hidden min-[500px]:inline">{n} / page</span>
|
||||
<span className="min-[500px]:hidden">{n}/p</span>
|
||||
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
|
||||
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="First"
|
||||
aria-label={t('dataTable.pagination.first')}
|
||||
>
|
||||
«
|
||||
</Button>
|
||||
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Prev"
|
||||
aria-label={t('dataTable.pagination.prev')}
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Next"
|
||||
aria-label={t('dataTable.pagination.next')}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(pageCount)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Last"
|
||||
aria-label={t('dataTable.pagination.last')}
|
||||
>
|
||||
»
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
|
||||
|
||||
export const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { open, disabled = false, title, description, children } = props;
|
||||
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props;
|
||||
const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setConfirming(true);
|
||||
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || confirming}
|
||||
className="sm:ml-2"
|
||||
className="sm:ms-2"
|
||||
>
|
||||
{confirmLabel}
|
||||
{confirmLabel ?? t('button.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type PaperDialogProps = {
|
||||
open: boolean;
|
||||
@@ -11,6 +12,8 @@ type PaperDialogProps = {
|
||||
};
|
||||
|
||||
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOpenAutoFocus = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
onOpenAutoFocus?.(ev);
|
||||
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
|
||||
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
|
||||
style={cardStyle()}
|
||||
>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<span className="h-4 w-4 text-duck-dark">✕</span>
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Server Error
|
||||
{t('error.server.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
|
||||
<p>{apiError?.message}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Unauthorized Access
|
||||
{t('error.forbidden.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">
|
||||
Your are not authorized to perform this action.
|
||||
</DialogDescription>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>If you think this is an error, please contact your manager.</p>
|
||||
<p>{t('error.forbidden.hint')}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Server Error
|
||||
{t('error.server.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
|
||||
<p>{apiError?.message}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
const { signout } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Invalid Credentials
|
||||
{t('error.unauthorized.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">Your access token has expired</DialogDescription>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">
|
||||
{t('error.unauthorized.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>Please Signin again</p>
|
||||
<p>{t('error.unauthorized.hint')}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type SvgLogoProps = {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
};
|
||||
|
||||
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/officer-logo.svg"
|
||||
alt="Officer Icon"
|
||||
style={{ width: config.width, height: 'auto' }}
|
||||
/>
|
||||
);
|
||||
return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type SvgLogoProps = {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
square?: boolean;
|
||||
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
|
||||
}
|
||||
|
||||
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = sizeConfig.regular[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/officer-logo.svg"
|
||||
alt="Officer Logo"
|
||||
style={{ width: config.width, height: 'auto' }}
|
||||
/>
|
||||
);
|
||||
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
|
||||
}
|
||||
|
||||
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = sizeConfig.square[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/officer-logo.svg"
|
||||
alt="Officer Logo"
|
||||
style={{ width: config.width, height: 'auto' }}
|
||||
/>
|
||||
);
|
||||
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Search as SearchIcon, X as ClearIcon } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type SearchInputProps = {
|
||||
className?: string;
|
||||
@@ -12,7 +13,8 @@ type SearchInputProps = {
|
||||
};
|
||||
|
||||
export function SearchInput(props: SearchInputProps) {
|
||||
const { className, handleSearch, value = '', placeholder = 'Search' } = props;
|
||||
const { t } = useTranslation();
|
||||
const { className, handleSearch, value = '', placeholder = t('search.placeholder') } = props;
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'}
|
||||
aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
|
||||
onClick={() => {
|
||||
setIsMobileSearchOpen((v) => !v);
|
||||
if (!isMobileSearchOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 160);
|
||||
}
|
||||
}}
|
||||
className="ml-auto shrink-0 md:hidden"
|
||||
className="ms-auto shrink-0 md:hidden"
|
||||
>
|
||||
{isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />}
|
||||
</Button>
|
||||
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
|
||||
className={cn(
|
||||
'relative w-fit max-w-lg',
|
||||
isMobileSearchOpen
|
||||
? 'absolute left-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
|
||||
? 'absolute start-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
|
||||
: 'hidden w-auto md:block',
|
||||
className,
|
||||
)}
|
||||
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
|
||||
{!!value ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
aria-label={t('search.clear')}
|
||||
onClick={() => handleSearch('')}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
|
||||
className="absolute start-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
|
||||
>
|
||||
<ClearIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<SearchIcon className="text-muted-foreground h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(ev) => handleSearch(ev.target.value)}
|
||||
className="pl-10 pr-4 text-sm"
|
||||
className="ps-10 pe-4 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
|
||||
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const Select = (props: SelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { options, value, onChange, onValueChange, label } = props;
|
||||
const { name = '', className, placeholder = 'Select...', update } = props;
|
||||
const { name = '', className, placeholder = t('select.placeholder'), update } = props;
|
||||
|
||||
if (!options) return null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from 'react';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type TabsProps = {
|
||||
tabList: { value: string; label: string }[];
|
||||
@@ -8,6 +9,7 @@ type TabsProps = {
|
||||
setActiveTab: (tab: string) => void;
|
||||
};
|
||||
const TabsComponent = (props: TabsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { tabList, activeTab, setActiveTab } = props;
|
||||
const tabsListRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
|
||||
<div className="block lg:hidden">
|
||||
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Filter" />
|
||||
<SelectValue placeholder={t('tabs.filterPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabList.map((f) => (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"ok": "Ok",
|
||||
"close": "Close"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search",
|
||||
"open": "Open search",
|
||||
"close": "Close search",
|
||||
"clear": "Clear search"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Select..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filter"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "No Data Found.",
|
||||
"pagination": {
|
||||
"showingPage": "Showing page {{current}} of {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"perPage": "{{size}} / page",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "First",
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"last": "Last"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"morePages": "More pages",
|
||||
"goToPrevious": "Go to previous page",
|
||||
"goToNext": "Go to next page"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Previous slide",
|
||||
"next": "Next slide"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "More"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle color mode"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Unauthorized Access",
|
||||
"description": "You are not authorized to perform this action.",
|
||||
"hint": "If you think this is an error, please contact your manager."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Invalid Credentials",
|
||||
"description": "Your access token has expired",
|
||||
"hint": "Please sign in again"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server Error"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Officer Icon",
|
||||
"logo": "Officer Logo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import enGB from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
|
||||
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
|
||||
describe('shared components (ui.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "OK",
|
||||
"close": "Fechar"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar",
|
||||
"open": "Abrir pesquisa",
|
||||
"close": "Fechar pesquisa",
|
||||
"clear": "Limpar pesquisa"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Selecionar..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrar"
|
||||
},
|
||||
"state": {
|
||||
"loading": "A carregar…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Não foram encontrados dados.",
|
||||
"pagination": {
|
||||
"showingPage": "A mostrar página {{current}} de {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Linhas por página",
|
||||
"perPage": "{{size}} / página",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "Primeira",
|
||||
"prev": "Anterior",
|
||||
"next": "Seguinte",
|
||||
"last": "Última"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Paginação",
|
||||
"previous": "Anterior",
|
||||
"next": "Seguinte",
|
||||
"morePages": "Mais páginas",
|
||||
"goToPrevious": "Ir para a página anterior",
|
||||
"goToNext": "Ir para a página seguinte"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositivo anterior",
|
||||
"next": "Diapositivo seguinte"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Mais"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Alternar modo de cor"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Acesso não autorizado",
|
||||
"description": "Não tem autorização para executar esta ação.",
|
||||
"hint": "Se acha que isto é um erro, contacte o seu gestor."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Credenciais inválidas",
|
||||
"description": "O seu token de acesso expirou",
|
||||
"hint": "Inicie sessão novamente"
|
||||
},
|
||||
"server": {
|
||||
"title": "Erro do servidor"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Ícone Officer",
|
||||
"logo": "Logótipo Officer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from './en-GB.json';
|
||||
|
||||
type UiTranslationKey = TranslationKeys<typeof en>;
|
||||
|
||||
// The `ui.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params);
|
||||
return { t, locale };
|
||||
};
|
||||
@@ -45,6 +45,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"hooks": "*",
|
||||
"i18n": "*",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useColorMode } from './ThemeProvider';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
|
||||
|
||||
export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||
const { colorDark, colorLight } = props;
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggle = () => {
|
||||
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
|
||||
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||
colorDark && 'text-black',
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle color mode</span>
|
||||
<span className="sr-only">{t('theme.toggle')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">{t('breadcrumb.more')}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
|
||||
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-left-12 top-1/2 -translate-y-1/2'
|
||||
? '-start-12 top-1/2 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
<span className="sr-only">{t('carousel.previous')}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
|
||||
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-right-12 top-1/2 -translate-y-1/2'
|
||||
? '-end-12 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
<span className="sr-only">{t('carousel.next')}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -19,7 +21,7 @@ export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, Reac
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle color mode</span>
|
||||
<span className="sr-only">{t('theme.toggle')}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ButtonProps } from '@/components/ui/button';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label={t('pagination.label')}
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 pl-2.5', className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={t('pagination.goToPrevious')}
|
||||
size="default"
|
||||
className={cn('gap-1 ps-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>{t('pagination.previous')}</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
};
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 pr-2.5', className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={t('pagination.goToNext')}
|
||||
size="default"
|
||||
className={cn('gap-1 pe-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>{t('pagination.next')}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
};
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">{t('pagination.morePages')}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
@@ -51,18 +52,21 @@ interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
({ side = 'right', className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -11,15 +11,19 @@ export const I18nContext = createContext<I18nContextValue>({
|
||||
type I18nProviderProps = {
|
||||
translations: TranslationMap;
|
||||
locale: string;
|
||||
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
|
||||
fallbackLocale?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
|
||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||
const value = useMemo<I18nContextValue>(() => {
|
||||
const t = (key: string, params?: InterpolationParams) =>
|
||||
interpolate(resolveKey(translations, locale, key), params);
|
||||
const t = (key: string, params?: InterpolationParams) => {
|
||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
|
||||
};
|
||||
return { t, locale };
|
||||
}, [translations, locale]);
|
||||
}, [translations, locale, fallbackLocale]);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { I18nProvider, I18nContext } from './I18nProvider';
|
||||
export { useTranslation } from './useTranslation';
|
||||
export { resolveKey, interpolate } from './resolve';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types';
|
||||
export { useFormat } from './useFormat';
|
||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
export { findMissingKeys } from './parity';
|
||||
export type { MissingKey } from './parity';
|
||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isPluralForms } from './resolve';
|
||||
|
||||
type Catalog = Record<string, unknown>;
|
||||
|
||||
const flattenKeys = (obj: Catalog, prefix = ''): string[] =>
|
||||
Object.entries(obj).flatMap(([k, v]) => {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v) && !isPluralForms(v)) {
|
||||
return flattenKeys(v as Catalog, path);
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
|
||||
export type MissingKey = { locale: string; key: string };
|
||||
|
||||
/**
|
||||
* Per D2, every shipped locale is a COMPLETE catalog — no regional inheritance. So a key `en` has
|
||||
* and a locale's own file lacks is not a fallback case, it's a red build (docs/localization.md,
|
||||
* "Tooling and guardrails").
|
||||
*/
|
||||
export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
|
||||
const enKeys = flattenKeys(en);
|
||||
return Object.entries(locales).flatMap(([locale, catalog]) => {
|
||||
const ownKeys = new Set(flattenKeys(catalog));
|
||||
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
import { findMissingKeys } from './parity';
|
||||
import type { TranslationMap } from './types';
|
||||
|
||||
describe('resolveKey', () => {
|
||||
const translations: TranslationMap = {
|
||||
'pt-PT': { greeting: 'Olá {{name}}' },
|
||||
'en-GB': { greeting: 'Hello {{name}}', onlyInEnglish: 'English only' },
|
||||
};
|
||||
|
||||
test('resolves from the active locale', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'greeting', 'en-GB')).toBe('Olá {{name}}');
|
||||
});
|
||||
|
||||
test('falls back to the fallback locale on a miss', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'onlyInEnglish', 'en-GB')).toBe('English only');
|
||||
});
|
||||
|
||||
test('renders the raw key when even the fallback is missing', () => {
|
||||
expect(resolveKey(translations, 'pt-PT', 'nowhere', 'en-GB')).toBe('nowhere');
|
||||
});
|
||||
|
||||
test('never walks a chain between two non-fallback locales', () => {
|
||||
const withThird: TranslationMap = { ...translations, 'pt-BR': {} };
|
||||
// pt-BR has no key of its own and must NOT inherit pt-PT's — only the fallback locale counts.
|
||||
expect(resolveKey(withThird, 'pt-BR', 'greeting', 'en-GB')).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
test('selects a plural branch via Intl.PluralRules', () => {
|
||||
const plurals: TranslationMap = {
|
||||
'en-GB': { files: { one: '{{count}} file', other: '{{count}} files' } },
|
||||
};
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 1)).toBe('{{count}} file');
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 2)).toBe('{{count}} files');
|
||||
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 0)).toBe('{{count}} files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolate', () => {
|
||||
test('substitutes params', () => {
|
||||
expect(interpolate('Hello {{name}}', { name: 'Ana' })).toBe('Hello Ana');
|
||||
});
|
||||
|
||||
test('leaves an unknown placeholder untouched', () => {
|
||||
expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}');
|
||||
});
|
||||
|
||||
test('passes a plain string through with no params', () => {
|
||||
expect(interpolate('Hello')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPluralForms', () => {
|
||||
test('accepts an open record of CLDR categories', () => {
|
||||
expect(isPluralForms({ one: 'a', other: 'b' })).toBe(true);
|
||||
expect(isPluralForms({ other: 'b' })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a plain nested catalog node', () => {
|
||||
expect(isPluralForms({ title: 'a', description: 'b' })).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-objects', () => {
|
||||
expect(isPluralForms('a string')).toBe(false);
|
||||
expect(isPluralForms(null)).toBe(false);
|
||||
expect(isPluralForms(['one', 'other'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localeDirection', () => {
|
||||
test('ltr for English and Portuguese', () => {
|
||||
expect(localeDirection('en-GB')).toBe('ltr');
|
||||
expect(localeDirection('pt-PT')).toBe('ltr');
|
||||
});
|
||||
|
||||
test('rtl for Arabic and Hebrew, by language subtag', () => {
|
||||
expect(localeDirection('ar')).toBe('rtl');
|
||||
expect(localeDirection('ar-EG')).toBe('rtl');
|
||||
expect(localeDirection('he-IL')).toBe('rtl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMissingKeys', () => {
|
||||
test('flags a key en has and a locale lacks', () => {
|
||||
const en = { common: { save: 'Save', cancel: 'Cancel' } };
|
||||
const ptPT = { common: { save: 'Guardar' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([{ locale: 'pt-PT', key: 'common.cancel' }]);
|
||||
});
|
||||
|
||||
test('a complete catalog has no gaps', () => {
|
||||
const en = { common: { save: 'Save' } };
|
||||
const ptPT = { common: { save: 'Guardar' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
|
||||
test('a plural-forms leaf counts as one key, not one per category', () => {
|
||||
const en = { files: { one: '{{count}} file', other: '{{count}} files' } };
|
||||
const ptPT = { files: { one: '{{count}} ficheiro', other: '{{count}} ficheiros' } };
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -2,6 +2,12 @@ export type TranslationMap = Record<string, Record<string, unknown>>;
|
||||
|
||||
export type InterpolationParams = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* A pluralizable catalog entry — CLDR's category set (Intl.PluralRules), never a fixed
|
||||
* `{one, other}` shape, because some locales need up to all six (see docs/localization.md D7).
|
||||
*/
|
||||
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||
|
||||
export type I18nContextValue = {
|
||||
t: (key: string, params?: InterpolationParams) => string;
|
||||
locale: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { I18nContext } from './I18nProvider';
|
||||
|
||||
type DateInput = Date | number | string;
|
||||
|
||||
/**
|
||||
* Date, time and number formatting bound to the active locale, via `Intl` (D7) — the replacement for
|
||||
* the ~23 `toLocale*()` call sites that otherwise follow the browser's locale and quietly contradict
|
||||
* the rest of the UI. Moving those call sites is phase 1-3 work; this is just the hook they move to.
|
||||
*/
|
||||
export const useFormat = () => {
|
||||
const { locale } = useContext(I18nContext);
|
||||
|
||||
return useMemo(() => {
|
||||
const date = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, options).format(new Date(value));
|
||||
|
||||
const time = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, { timeStyle: 'short', ...options }).format(new Date(value));
|
||||
|
||||
const dateTime = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', ...options }).format(new Date(value));
|
||||
|
||||
const number = (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
new Intl.NumberFormat(locale, options).format(value);
|
||||
|
||||
const relative = (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) =>
|
||||
new Intl.RelativeTimeFormat(locale, options).format(value, unit);
|
||||
|
||||
return { date, time, dateTime, number, relative };
|
||||
}, [locale]);
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"hooks": "workspace:*",
|
||||
"i18n": "workspace:*",
|
||||
"state": "workspace:*",
|
||||
"widgets": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './hooks';
|
||||
export * from './channels';
|
||||
// The panel apps' own i18n catalog — I18nBridge merges it into the app-wide map under `panel.*`.
|
||||
export { panelCatalogs } from './locales';
|
||||
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
||||
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||
export type { PageTitleOverride } from './page-title';
|
||||
@@ -49,13 +51,11 @@ export type {
|
||||
CommandHqCostSummary,
|
||||
} from './apps/CommandHQ';
|
||||
|
||||
|
||||
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
|
||||
// from the app is the param name and the link builder.
|
||||
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
||||
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
|
||||
|
||||
|
||||
export {
|
||||
useFilesAPI,
|
||||
useTasks,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import enGB from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
/** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */
|
||||
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||
// (no panel is converted in this phase) — the test still runs so it is load-bearing from day one.
|
||||
describe('officerdev (panel.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from './en-GB.json';
|
||||
|
||||
type PanelTranslationKey = TranslationKeys<typeof en>;
|
||||
|
||||
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses.
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params);
|
||||
return { t, locale };
|
||||
};
|
||||
@@ -5,6 +5,13 @@ import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
const QUERY_KEY = ['USER_SETTINGS'];
|
||||
|
||||
/**
|
||||
* UI locales went from bare codes to regional BCP 47 tags (docs/localization.md D2). A stale bare
|
||||
* code must resolve rather than fail, and this is a one-time widening on read, not a DB migration.
|
||||
*/
|
||||
const WIDEN_BARE_LOCALE: Record<string, string> = { en: 'en-GB', pt: 'pt-PT' };
|
||||
const widenLocale = (code: string): string => WIDEN_BARE_LOCALE[code] ?? code;
|
||||
|
||||
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
|
||||
ai: {
|
||||
@@ -14,7 +21,11 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||
},
|
||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
||||
languages: {
|
||||
...DEFAULT_SETTINGS.languages,
|
||||
...saved.languages,
|
||||
default: widenLocale(saved.languages?.default ?? DEFAULT_SETTINGS.languages.default),
|
||||
},
|
||||
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
||||
});
|
||||
|
||||
@@ -121,7 +132,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
},
|
||||
languages: {
|
||||
spoken: ['en'],
|
||||
default: 'en',
|
||||
default: 'en-GB',
|
||||
translateTo: 'en',
|
||||
},
|
||||
tts: {
|
||||
|
||||
Reference in New Issue
Block a user