Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/audit-lock-state-zh-vocabulary-5004.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': patch
---

The metadata Audit panel's lock column now shows Chinese for every lock state it can print.

`AuditPanel` renders `MetadataAuditEntry.lockState` through
`translateConsoleValue('lock', …)`, but `CONSOLE_VALUE_ZH.lock` held
`draft` / `locked` / `published` / `none` — a draft-status vocabulary, not the
ADR-0010 §3.6 four-state lock. The misalignment was total rather than partial
(objectui#5004): `draft` / `locked` / `published` matched no value `lockState`
can hold; `none`, the only entry that did match, is excluded by the call site's
own guard (an unlocked row renders an em dash) and so was unreachable; and the
three values that actually reach the helper — `no-overlay` / `no-delete` /
`full` — had no entry at all. Hit rate 0/3. A zh-CN admin opening any locked
item's Audit panel read bare English tokens in a column headed 锁状态.

The table is now the lock vocabulary it claims to be — `禁止编辑` / `禁止删除` /
`完全锁定`, wording tracked to the lock-banner sentences a reader meets on the
same screen — and the three draft-status entries are gone. They were dead in the
measured sense: `translateConsoleValue('lock', …)` has exactly one call site
repo-wide and `CONSOLE_VALUE_ZH` is module-private, so nothing else could read
them.

Following objectui#4982's `LAYER_SCOPE_ZH`, the keys are bound to their
producer's union — `Record< NonNullable< MetadataAuditEntry['lockState'] >,
string >`. A fifth lock state therefore fails `type-check` naming the label that
is missing, instead of the column silently shipping a raw English value. The
union is this repo's own hand-written one in `@object-ui/data-objectstack`, not a
`@objectstack/spec` enum; whether the spec should own the lock vocabulary is a
separate question and is deliberately not answered here.

`none` is kept in the record even though the call site never asks for it, so the
key set stays complete over the producer's type rather than tracking a `!==`
guard in another file. Unchanged on purpose: `translateConsoleValue` is still
zh-only, and the em-dash branch for `none` / `null` still renders exactly as
before.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Audit tab's lock column speaks the UI language (objectui#5004).
*
* `AuditPanel` renders `MetadataAuditEntry.lockState` through
* `translateConsoleValue('lock', …)`, and `CONSOLE_VALUE_ZH.lock` used to hold
* `draft` / `locked` / `published` / `none` — a draft-status vocabulary, not the
* ADR-0010 §3.6 four-state lock. Alignment was total, not partial:
*
* - `draft` / `locked` / `published` matched NO value `lockState` can hold;
* - `none`, the one entry that did match, is excluded by the call site's own
* guard (an unlocked row renders an em dash), so it was unreachable;
* - the three values that DO reach the helper — `no-overlay` / `no-delete` /
* `full` — had no entry, so zh-CN fell through to the raw token.
*
* Hit rate 0/3: a zh-CN admin opening any locked item's Audit panel read bare
* English in a column headed 锁状态.
*
* The state list below is bound to `MetadataAuditEntry['lockState']` itself
* rather than spelled freely, so a fifth lock state is covered by these cases
* the moment the union gains it. Unlike objectui#4982's overlay scopes there is
* no `@objectstack/spec` enum to read at runtime — this union is hand-written in
* `packages/data-objectstack` — hence the `satisfies`-checked key trick instead
* of a `.options` array. (The compile-time half of the coverage is
* `LOCK_STATE_ZH` in `./i18n`, whose key type is that union: a new state with no
* zh-CN label fails `type-check` before it can reach the column.)
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import type {
MetadataAuditEntry,
MetadataClient,
} from '@object-ui/data-objectstack';
import { AuditPanel } from './AuditPanel';
import { translateConsoleValue } from './i18n';

afterEach(cleanup);

type LockState = NonNullable<MetadataAuditEntry['lockState']>;

/**
* Every lock state the producer type can send. `satisfies` makes the record
* exhaustive in both directions — a state added to the union leaves a key
* missing, a state renamed leaves an extra one — so this list cannot drift from
* `lockState` without failing `type-check`.
*/
const ALL_LOCK_STATES = Object.keys({
none: 0,
'no-overlay': 0,
'no-delete': 0,
full: 0,
} satisfies Record<LockState, 0>) as LockState[];

/** The states the column actually prints — `none` takes the em-dash branch. */
const RENDERED_LOCK_STATES = ALL_LOCK_STATES.filter((s) => s !== 'none');

/** The draft-status entries the table used to hold, none of them a lock state. */
const RETIRED_ENTRIES = ['draft', 'locked', 'published'];

const EM_DASH = '—';

function auditRow(over: Partial<MetadataAuditEntry> = {}): MetadataAuditEntry {
return {
id: 'a_1',
occurredAt: '2026-08-17T10:00:00.000Z',
actor: 'admin@objectos.ai',
source: 'protocol.saveMetaItem',
operation: 'save',
outcome: 'denied',
code: 'item_locked',
lockState: 'full',
lockOverridden: false,
requestId: null,
note: null,
...over,
};
}

/** Minimal stand-in — the panel only calls `audit()` on its client. */
function clientWith(events: MetadataAuditEntry[]): MetadataClient {
const stub: Pick<MetadataClient, 'audit'> = {
audit: async () => ({ events }),
};
return stub as MetadataClient;
}

async function renderPanel(over: Partial<MetadataAuditEntry>, locale: string) {
render(
<AuditPanel
type="object"
name="a_account"
client={clientWith([auditRow(over)])}
locale={locale}
/>,
);
// The panel loads in an effect; wait for the row's actor cell to land.
await screen.findByText('admin@objectos.ai');
}

describe('AuditPanel lock-state column (objectui#5004)', () => {
it('every lock state the union can hold has a zh-CN label', () => {
for (const state of ALL_LOCK_STATES) {
expect(
translateConsoleValue('lock', state, 'zh-CN'),
`CONSOLE_VALUE_ZH.lock has no entry for lock state '${state}'`,
).not.toBe(state);
}
});

it.each(RENDERED_LOCK_STATES)('renders the zh-CN label for lockState=%s', async (state) => {
await renderPanel({ lockState: state }, 'zh-CN');
const zh = translateConsoleValue('lock', state, 'zh-CN');
expect(screen.getByText(zh)).toBeInTheDocument();
// The raw producer token must not be on screen anywhere.
expect(screen.queryByText(state)).toBeNull();
});

it('keeps the em-dash branch for an unlocked row', async () => {
await renderPanel({ lockState: 'none' }, 'zh-CN');
expect(screen.getByText(EM_DASH)).toBeInTheDocument();
// `none` has a label, but the guard at the call site means the column never
// asks for it — the em dash is the unlocked presentation, not a fallback.
expect(screen.queryByText(translateConsoleValue('lock', 'none', 'zh-CN'))).toBeNull();
});

it('keeps the em-dash branch for a null lockState', async () => {
await renderPanel({ lockState: null }, 'zh-CN');
expect(screen.getByText(EM_DASH)).toBeInTheDocument();
});

it('keeps the override star next to the localized label', async () => {
// `lockOverridden` appends ' *' as a sibling text node; pinned so the label
// change did not disturb the forced-write marker.
await renderPanel({ lockState: 'no-delete', lockOverridden: true }, 'zh-CN');
const zh = translateConsoleValue('lock', 'no-delete', 'zh-CN');
expect(screen.getByText(`${zh} *`)).toBeInTheDocument();
});

it('leaves the raw value alone outside zh — the helper has always done that', async () => {
// `translateConsoleValue` returns the input verbatim for every non-zh
// locale. Whether the other nine locale packs should gain these values is a
// separate decision, deliberately not made here; this case pins that this
// fix did not quietly make it.
await renderPanel({ lockState: 'no-overlay' }, 'en-US');
expect(screen.getByText('no-overlay')).toBeInTheDocument();
});

it('does not resurrect the draft-status entries as lock labels', async () => {
// The three retired keys are values `lockState` cannot hold. Falling through
// to the raw token is the correct answer for them now — an entry here would
// be another 0-hit-rate row, which is the defect this file guards.
for (const stale of RETIRED_ENTRIES) {
expect(translateConsoleValue('lock', stale, 'zh-CN')).toBe(stale);
}
});
});
50 changes: 46 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,9 +54,13 @@
*/

import { useObjectTranslation } from '@object-ui/i18n';
// Type-only (erased at build): the overlay-scope label table below is keyed by
// the spec's enum rather than by hand — see `LAYER_SCOPE_ZH`.
import type { MetadataOverlayScope } from '@object-ui/data-objectstack';
// Type-only (erased at build): the overlay-scope and lock-state label tables
// below are keyed by their producer's own union rather than by hand — see
// `LAYER_SCOPE_ZH` and `LOCK_STATE_ZH`.
import type {
MetadataAuditEntry,
MetadataOverlayScope,
} from '@object-ui/data-objectstack';

export type SupportedLocale = 'en-US' | 'zh-CN';

Expand DownExpand Up@@ -4124,6 +4128,44 @@ const LAYER_SCOPE_ZH: Record<NonNullable<MetadataOverlayScope>, string> = {
env: '环境',
};

/**
* zh-CN labels for the ADR-0010 §3.6 four-state metadata lock, keyed by the
* producer field the only consumer actually renders.
*
* The key type is `MetadataAuditEntry['lockState']` — the hand-written union in
* `packages/data-objectstack/src/metadata-client.ts`, not a `@objectstack/spec`
* enum, because this repo owns that union today. (Whether the spec should own
* the lock vocabulary is a separate question; it is deliberately not answered
* here.) Binding the keys means a fifth lock state added to the union stops this
* record from compiling and names the label that is missing, instead of the
* column silently shipping a raw English token.
*
* That silent path is objectui#5004, and it was total rather than partial: the
* table used to hold `draft` / `locked` / `published` / `none` — a draft-status
* vocabulary — so of the three values the audit column can actually show, zero
* had an entry. A zh-CN admin read bare `no-overlay` / `no-delete` / `full`.
*
* Wording tracks the lock banner sentences a reader meets on the same screen
* (`engine.edit.lockNoOverlay` 不可编辑 / `lockNoDelete` 不可删除 / `lockFull`
* 不可编辑或删除), compressed to state names for a narrow badge column.
*
* `none` is currently unreachable through the sole call site — `AuditPanel`
* renders an em dash for an unlocked/`null` row rather than a word — and is kept
* anyway: the key set follows the producer's type, so the record stays complete
* over the domain and does not need editing the day that presentation choice
* changes.
*
* Deliberately zh-only, like every other group here — `translateConsoleValue`
* returns the raw value for the other nine locale packs by existing design, and
* whether to extend it is a separate decision, not a rider on this fix.
*/
const LOCK_STATE_ZH: Record<NonNullable<MetadataAuditEntry['lockState']>, string> = {
none: '无',
'no-overlay': '禁止编辑',
'no-delete': '禁止删除',
full: '完全锁定',
};

/**
* zh-CN labels for the metadata-editor side panels' raw enum-ish values —
* History operation badges, Audit operation/outcome/lock-state, and the Layered
Expand All@@ -4143,7 +4185,7 @@ const CONSOLE_VALUE_ZH: Record<string, Record<string, string>> = {
rollback: '回滚',
},
outcome: { allowed: '允许', denied: '拒绝', forced: '强制' },
lock: { draft: '草稿', locked: '已锁定', published: '已发布', none: '无' },
lock: LOCK_STATE_ZH,
layer: {
artifact: '工件',
none: '无',
Expand Down
Loading