diff --git a/.changeset/6285-capability-labels-derive-from-spec.md b/.changeset/6285-capability-labels-derive-from-spec.md new file mode 100644 index 0000000000..5c5c1affa0 --- /dev/null +++ b/.changeset/6285-capability-labels-derive-from-spec.md @@ -0,0 +1,26 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +The capability picker localizes `manage_sharing` (objectui#6285). Before this, "Manage +Sharing" was the one platform capability in `sys_permission_set`'s picker that rendered in +English in every locale, beside seven siblings that translated — a user-visible missing +translation, in all ten packs at once. + +The cause was an unchecked copy. `CURATED_CAPABILITY_LABELS` in +`CapabilityMultiSelectField.tsx` listed seven capability names under a doc comment claiming +it mirrored `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`; the spec grew an eighth +member and the list did not follow, so `manage_sharing` fell through to the English label +the `sys_capability` registry serves. Nothing could catch it: the i18n gate reads that list +as this key family's vocabulary and checks the members it names — all seven had keys — and +no instrument compared the vocabulary to the array it was named after. + +`capability.label.manage_sharing` is now authored in all ten packs and in the field widgets' +provider-less defaults map, the list carries the member, and the prose claim is replaced by +a check: `CapabilityMultiSelectField.specParity-6285.test.tsx` imports `PLATFORM_CAPABILITIES` +and fails on any difference in either direction, reading the declaration through the i18n +gate's own source reader so what it pins is exactly what that gate consumes. `labelFor` also +gains a `defaultValue`, so a capability that arrives in a future spec bump before its +translation is authored degrades to the registry's English label rather than rendering a raw +i18n key at the user. diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx new file mode 100644 index 0000000000..fff5ec158b --- /dev/null +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.specParity-6285.test.tsx @@ -0,0 +1,244 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6285 — `CURATED_CAPABILITY_LABELS` equals `@objectstack/spec/security`'s + * `PLATFORM_CAPABILITIES`, and every member of it reaches the screen as a + * localized label. + * + * ## What went wrong, and why nothing caught it + * + * The declaration was a seven-member list under a doc comment claiming it + * mirrored `PLATFORM_CAPABILITIES`. The spec grew an eighth member + * (`manage_sharing`) and the list did not follow, so that capability fell + * through to `byName.get(name)?.label` — the English label the `sys_capability` + * registry serves — and rendered untranslated in all ten packs, beside seven + * siblings that localize. Every gate stayed green, and the reason is exact: + * `scripts/check-i18n-call-site-keys.mjs` reads this declaration as the + * `capability.label.` family's vocabulary and checks that each member it names + * has an `en` key. All seven did. No instrument compared the vocabulary to the + * array it was named after, so the member the list never mentioned was invisible + * to the entire toolchain. + * + * This file is that missing comparison. It is the reason the list is allowed to + * stay a repo-local literal (see the declaration's own comment for why a runtime + * derivation was measured and declined), and it is deliberately the same shape + * as `packages/app-shell/src/hooks/__tests__/tenancyPostureWall.parity.test.ts`: + * a local restatement, held to the protocol by a test that imports the protocol. + * Tests are not bundled, so the assertion is free. + * + * ## Two layers, and both are needed + * + * - SOURCE parity — the member set equals the spec's names, in both + * directions. Read with the i18n gate's OWN `readVocabulary`, so what this + * file pins is precisely what that gate consumes; a rewrite that made the + * declaration unreadable to the gate would fail here first, instead of + * silently downgrading the gate to a prefix check. + * - RENDERED parity — each member resolves to a real localized string on + * screen. Source parity alone would pass with the label table empty, and the + * `en` table alone says nothing about `useFieldTranslation`'s provider-less + * defaults map, which is what actually renders when no I18nProvider is + * mounted. + * + * Every registry row fed in below carries a label deliberately UNLIKE the + * localized one (`REGISTRY …`). "The picker renders" passes in both worlds; each + * assertion here is written to fail in exactly one. If the list regressed — or + * if `labelFor`'s `defaultValue` fallback were doing the rendering instead of a + * real translation — `REGISTRY …` is what would appear, and the equality reports + * it by name. + * + * The dotted members are their own case. The spec spells `setup.access`, + * `setup.write` and `studio.access` with a dot; the keys spell them with an + * underscore, and `labelFor` bridges that with `name.replace(/\./g, '_')`. The + * transform is restated in this file ON PURPOSE rather than imported from the + * widget: a pin that shares the implementation's own helper moves when the + * implementation moves and pins nothing. + * + * ## PREDICTIONS, written before the run + * + * On this branch: all green. Against the pre-fix declaration (seven members, no + * `capability.label.manage_sharing` key), expected RED on source parity, on the + * `en`-coverage assertion, and on the two rendering assertions that name + * `manage_sharing`; expected GREEN on the dotted-member, orphan and non-vacuity + * assertions, which describe behaviour the seven-member list already had. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; +import { builtInLocales } from '@object-ui/i18n'; +// The i18n gate's own source reader (objectui#4964). Importing it — rather than +// re-implementing a regex — is what makes "the vocabulary that gate consumes" +// and "the thing this file pins" the same object. Plain JS, untyped here, the +// same arrangement `packages/layout/src/__tests__/readme-registration-keys.test.ts` +// uses for `scripts/component-registrations.mjs`. +// @ts-expect-error — plain-JS shared helper, intentionally untyped +import { readVocabulary } from '../../../../scripts/check-i18n-call-site-keys.mjs'; +import { CapabilityMultiSelectField } from './CapabilityMultiSelectField'; + +/** + * The root `readVocabulary` resolves `module` against. `'.'` — the process cwd — + * IS the repo root here, and not by luck: `scripts/vitest-invocation-guard.mjs` + * refuses any invocation whose vitest root is not the repo root (objectui#3378, + * #3288), which is the same invariant. Spelled this way rather than as + * `resolve(__dirname, '../../../..')` because this package's test program + * deliberately does not name `types: ['node']` — see the long note in + * `packages/fields/tsconfig.test.json` — and a test file is not the place to + * change that program. If the invariant ever broke, `declaredMembers()` throws + * the message below rather than reporting an empty vocabulary. + */ +const VOCABULARY_ROOT = '.'; + +/** The registry entry `check-i18n-call-site-keys.mjs` declares for this family. */ +const VOCABULARY = { + module: 'packages/fields/src/widgets/CapabilityMultiSelectField.tsx', + name: 'CURATED_CAPABILITY_LABELS', + kind: 'set', +} as const; + +/** + * The dot -> underscore transform `labelFor` applies before building the key. + * Deliberately a second statement of it (see the header). + */ +const keyPart = (specName: string) => specName.replace(/\./g, '_'); + +/** Spec member names in the alphabet the i18n keys are written in. */ +const specKeyParts = () => PLATFORM_CAPABILITIES.map((c) => keyPart(c.name)).sort(); + +/** The declaration, read exactly as the i18n gate reads it. */ +const declaredMembers = (): string[] => { + const members = readVocabulary(VOCABULARY_ROOT, VOCABULARY) as string[] | null; + if (members === null) { + throw new Error( + `${VOCABULARY.name} is unreadable as a \`${VOCABULARY.kind}\` in ${VOCABULARY.module} — ` + + 'either the declaration moved, was renamed, or was rewritten into a shape the i18n ' + + 'gate cannot parse (which would silently downgrade `capability.label.` to a prefix ' + + 'check), or this run\'s cwd is not the repo root and the file was never opened.', + ); + } + return [...members].sort(); +}; + +/** The `en` pack's `capability.label` table, or a loud failure if it moved. */ +const enLabels = (): Record => { + const capability = (builtInLocales.en as Record).capability as + | { label?: unknown } + | undefined; + const node = capability?.label; + if (!node || typeof node !== 'object') { + throw new Error('en.capability.label is missing — the label table moved or was renamed'); + } + return node as Record; +}; + +/** + * One `sys_capability` row per declared platform capability, each carrying a + * label that is NOT the localized one. Any assertion that reads `REGISTRY …` + * back has caught the widget falling through to the registry. + */ +const registryRows = () => + PLATFORM_CAPABILITIES.map((c) => ({ + name: c.name, + label: `REGISTRY ${c.label}`, + description: c.description, + scope: c.scope, + active: true, + })); + +const mockDataSource = (rows: unknown[]) => + ({ find: vi.fn().mockResolvedValue({ data: rows }) }) as any; + +const renderPicker = (rows: unknown[]) => + render( + , + ); + +describe('the curated capability set is PLATFORM_CAPABILITIES (objectui#6285)', () => { + it('reads a non-empty vocabulary, spec array and label table', () => { + // The positive control for every set-difference and every loop below. Each + // of them passes vacuously if its side resolved to nothing, and a vacuous + // pass reads exactly like a real one. Counts are floors, not pins: pinning + // today's 8 would re-create the hand-maintained copy this file exists to + // hold in check. + expect(PLATFORM_CAPABILITIES.length).toBeGreaterThan(5); + expect(declaredMembers().length).toBeGreaterThan(5); + expect(Object.keys(enLabels()).length).toBeGreaterThan(5); + }); + + it('declares exactly the spec\'s capability names, in both directions', () => { + // The assertion the `Mirrors` comment used to make in prose. `toEqual` on + // two sorted arrays fails on a member the spec added and this list lacks + // (the defect this card is) AND on one this list kept after the spec dropped + // it, which no rendering assertion can see. + expect(declaredMembers()).toEqual(specKeyParts()); + }); + + it('`en` carries a label for every declared platform capability', () => { + // Membership alone is not enough: a member with no key renders the registry + // label through `labelFor`'s `defaultValue` — quietly, and in every locale + // at once. This is the half that fails when the spec grows a member and + // nobody authors the translation. + const labels = enLabels(); + const missing = specKeyParts().filter( + (k) => typeof labels[k] !== 'string' || !(labels[k] as string).trim(), + ); + expect(missing).toEqual([]); + }); + + it('has no orphan label left behind by a capability the spec removed', () => { + const declared = new Set(specKeyParts()); + expect(Object.keys(enLabels()).filter((k) => !declared.has(k))).toEqual([]); + }); + + it('renders `manage_sharing` as its localized label, not the registry English', async () => { + // The member the seven-name list was missing. Red before the fix: the picker + // rendered `REGISTRY Manage Sharing`. + renderPicker(registryRows()); + const expected = enLabels()[keyPart('manage_sharing')] as string; + expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'REGISTRY Manage Sharing' }), + ).not.toBeInTheDocument(); + }); + + it('still resolves the three dotted spec names through the underscore keys', async () => { + renderPicker(registryRows()); + const dotted = PLATFORM_CAPABILITIES.filter((c) => c.name.includes('.')); + // Not pinned to exactly 3: a count is the hand-maintained copy this file + // exists to retire. The floor is only here so the loop cannot pass vacuously. + expect(dotted.length).toBeGreaterThanOrEqual(3); + for (const cap of dotted) { + const expected = enLabels()[keyPart(cap.name)] as string; + expect(await screen.findByRole('button', { name: expected })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: `REGISTRY ${cap.label}` }), + ).not.toBeInTheDocument(); + } + }); + + it('renders every declared platform capability as its localized label', async () => { + // The whole vocabulary in one sweep, and the assertion that ties the + // provider-less defaults map to the `en` pack: this render mounts no + // I18nProvider, so the string on screen comes from `FIELD_DEFAULTS`, while + // the expectation is read out of `en`. They must agree member by member. + renderPicker(registryRows()); + const expectedNames = PLATFORM_CAPABILITIES.map((c) => enLabels()[keyPart(c.name)] as string); + await screen.findByRole('button', { name: expectedNames[0] }); + for (const name of expectedNames) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + // And nothing fell through to the registry. + expect(screen.queryAllByRole('button', { name: /^REGISTRY / })).toEqual([]); + }); +}); diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx index 254e3f0571..cf60991d66 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx @@ -73,20 +73,73 @@ export function parseCapabilityNames(value: unknown): string[] { const SCOPE_ORDER = ['platform', 'org', 'other'] as const; /** - * objectui#2600 B5 — the curated platform capabilities are a FIXED, known set - * whose labels the sys_capability registry serves in English. Localize just - * these client-side via `capability.label.` (dots → underscores); - * package- and admin-authored capabilities keep their authored registry label. - * (Mirrors @objectstack/spec/security `PLATFORM_CAPABILITIES`.) + * objectui#2600 B5 — the curated platform capabilities whose labels this picker + * localizes client-side via `capability.label.`; package- and + * admin-authored capabilities keep their authored `sys_capability` label. + * + * ## This IS `@objectstack/spec/security`'s `PLATFORM_CAPABILITIES`, and the + * ## equality is CHECKED rather than claimed (objectui#6285) + * + * The names below are the spec's, with the dot spellings written as underscores + * (see the transform note under the declaration). It used to say that in prose + * and nothing held it: the spec grew `manage_sharing`, this list did not follow, + * and that capability fell through to the English label the `sys_capability` + * registry serves — untranslated in all ten packs, beside seven siblings that + * localize, with every gate green. + * + * What holds it now is `CapabilityMultiSelectField.specParity-6285.test.tsx`, + * which imports `PLATFORM_CAPABILITIES` and fails on ANY difference in either + * direction — a member the spec added and this list lacks, or one this list + * keeps after the spec dropped it. A spec bump that moves the vocabulary turns + * CI red here before it can reach a screen. + * + * ## Why a repo-local list rather than a runtime derivation + * + * `new Set(PLATFORM_CAPABILITIES.map(…))` was built and measured (objectui#6285, + * and the branch history carries the numbers). It costs nothing in bundle terms + * — +0.3 KB gzipped on the console's eager closure, because the console's graph + * already reaches those modules — so bundle size is NOT the reason, and the + * `useTenancyPosture.ts` precedent's reason does not apply here. + * + * The reason is instrument coverage. `scripts/check-i18n-call-site-keys.mjs` + * reads this declaration as the `capability.label.` family's vocabulary and + * expands it into exact `en` key checks; its reader parses source and needs a + * literal `new Set([…])`, so a computed initialiser is `unreadable-vocabulary` + * and the family would have to fall back to `enumerable: false`, dropping the + * gate from 18 vocabularies / 113 exactly-checked members to 17 / 105. The gate + * documents one bridge for a vocabulary living in a dependency — "a repo-local + * exhaustive `Record` this reader can read" — and it is unavailable: + * `PlatformCapability.name` is typed `string`, so the spec publishes no union to + * key a `Record` by. + * + * And the benefit a derivation would have bought is not real. A capability the + * spec adds cannot "arrive automatically": its `capability.label.*` key still + * has to be authored by a human in ten packs, or it renders the registry English + * (this card's exact defect) or a raw key. `manage_sharing` is the proof — it + * had no key anywhere. Deriving does not remove the human step; it only chooses + * which instrument reports it. So the shape that keeps BOTH instruments — this + * list read by the gate, and the parity test read by CI — wins on the only axis + * that separates them. + * + * ⛔ Do not hand-edit this list to match a new spec release without also + * authoring `capability.label.` in all ten packs and in + * `useFieldTranslation.ts`; the parity test fails on the first, and + * `all-locales-key-parity.test.ts` on the second. */ const CURATED_CAPABILITY_LABELS = new Set([ 'manage_users', 'manage_org_users', 'manage_metadata', 'manage_platform_settings', + // The spec spells these three with a dot (`setup.access`, `setup.write`, + // `studio.access`). `labelFor` normalises dots to underscores before building + // the key, so membership is written in the key's alphabet, not the spec's. + // The parity test applies the same transform and would fail if either side + // stopped agreeing. 'setup_access', 'setup_write', 'studio_access', + 'manage_sharing', ]); export function CapabilityMultiSelectField({ @@ -153,9 +206,19 @@ export function CapabilityMultiSelectField({ // Curated platform caps get a localized label (objectui#2600 B5); everything // else keeps the registry-served label. const labelFor = (name: string) => { + const registryLabel = byName.get(name)?.label || name; const norm = name.replace(/\./g, '_'); - if (CURATED_CAPABILITY_LABELS.has(norm)) return t(`capability.label.${norm}`); - return byName.get(name)?.label || name; + if (!CURATED_CAPABILITY_LABELS.has(norm)) return registryLabel; + // objectui#6285 — the membership is now open-ended: a capability the spec + // adds joins this set the moment the pin is bumped, which is the point, but + // its `capability.label.*` key still has to be authored by a human in the + // ten packs. `defaultValue` makes that window degrade to the registry's + // English label — exactly what this picker did for `manage_sharing` before + // this change — instead of rendering a raw i18n key at the user, which + // would be strictly worse than the defect being fixed. It is a fallback of + // last resort, not the mechanism: the spec-derivation test fails in CI on + // the same event, so the window should never reach a screen. + return t(`capability.label.${norm}`, { defaultValue: registryLabel }); }; // Group options by scope for the editable grid. Computed BEFORE the readonly diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index 8f51ab4cdf..df1534fa39 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -135,6 +135,7 @@ const FIELD_DEFAULTS: Record = { 'capability.label.setup_access': 'Setup Access', 'capability.label.setup_write': 'Write Settings', 'capability.label.studio_access': 'Studio Access', + 'capability.label.manage_sharing': 'Manage Sharing', }; export const useFieldTranslation = createSafeTranslation( diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index bf360cf3a5..2aa85428bb 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -23,6 +23,7 @@ const ar = { setup_access: "الوصول إلى الإعداد", setup_write: "كتابة الإعدادات", studio_access: "الوصول إلى Studio", + manage_sharing: "إدارة المشاركة", }, group: { platform: 'منصة', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3ebf496cd9..f2d3303fba 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -23,6 +23,7 @@ const de = { setup_access: "Zugriff auf die Einrichtung", setup_write: "Einstellungen schreiben", studio_access: "Studio-Zugriff", + manage_sharing: "Freigaben verwalten", }, group: { platform: 'Plattform', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1e191a0914..cfba3209be 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -34,6 +34,7 @@ const en = { setup_access: 'Setup Access', setup_write: 'Write Settings', studio_access: 'Studio Access', + manage_sharing: 'Manage Sharing', }, }, // objectui#3546 slice six — the read-only facet summary + Studio deep-link a diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e3fa453afd..490c307bba 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -23,6 +23,7 @@ const es = { setup_access: "Acceso a la configuración", setup_write: "Escritura de la configuración", studio_access: "Acceso a Studio", + manage_sharing: "Gestionar el uso compartido", }, group: { platform: 'Plataforma', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index dfee807716..f16338e705 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -23,6 +23,7 @@ const fr = { setup_access: "Accès à la configuration", setup_write: "Écriture des paramètres", studio_access: "Accès à Studio", + manage_sharing: "Gérer le partage", }, group: { platform: 'Plateforme', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3376606a92..7c14d6dd55 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -23,6 +23,7 @@ const ja = { setup_access: "設定へのアクセス", setup_write: "設定の書き込み", studio_access: "Studio へのアクセス", + manage_sharing: "共有の管理", }, group: { platform: 'プラットフォーム', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e10f1fd463..7f3e40a005 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -23,6 +23,7 @@ const ko = { setup_access: "설정 접근", setup_write: "설정 쓰기", studio_access: "Studio 접근", + manage_sharing: "공유 관리", }, group: { platform: '플랫폼', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 7e70bf5ca3..5a115d4731 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -23,6 +23,7 @@ const pt = { setup_access: "Acesso à configuração", setup_write: "Gravação de configurações", studio_access: "Acesso ao Studio", + manage_sharing: "Gerenciar compartilhamento", }, group: { platform: 'Plataforma', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 3b52685e9c..9981b31eab 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -23,6 +23,7 @@ const ru = { setup_access: "Доступ к настройке", setup_write: "Запись настроек", studio_access: "Доступ к Studio", + manage_sharing: "Управление общим доступом", }, group: { platform: 'Платформа', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 8f3706e479..6ac6f20014 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -33,6 +33,7 @@ const zh = { setup_access: '访问 Setup', setup_write: '保存设置', studio_access: '访问 Studio', + manage_sharing: '管理共享', }, }, // objectui#3546 切片六 —— 权限集记录上六个授权面的只读摘要与 Studio 深链