diff --git a/.changeset/7149-activity-timeline-remaining-literals.md b/.changeset/7149-activity-timeline-remaining-literals.md new file mode 100644 index 0000000000..af65d500ad --- /dev/null +++ b/.changeset/7149-activity-timeline-remaining-literals.md @@ -0,0 +1,38 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +`ActivityTimeline` speaks the session locale — the other 18 literals +(objectui#7149). + +objectui#7142 gave this component its first `t()` call (the empty-state title) +and filed the sweep that found the rest. Until now a zh activity tab read +`"Activity(0)暂无活动记录"`: one translated string in a component that was +otherwise entirely English. + +All 18 now resolve from the ten packs, in three groups: + +- **Relative timestamps and the card title** (`just now`, `{{count}}m/h/d ago`, + `Activity`) — these render on *every* activity tab. All five were a pure + lookup swap: the `en` pack value was already byte-identical to the literal, + and the sibling `RecordActivityTimeline` already used the same keys. +- **The `formatFieldChange` sentences** — assembled in code, so they needed new + keys *with* interpolation holes rather than a lookup. Same reachability as the + timestamps: they render for any entry whose optional `description` is absent. + The quotes live inside each pack's value, so every locale punctuates its own + way (de `„…“`, zh `“…”`, ja `「…」`, fr/ru `«…»`). +- **The six filter chips and the chip group's accessible name** — reachable only + through the published export, since no host in this repo passes `filterable`. + +Ten new `detail.*` keys across all ten packs (no inline `defaultValue` — +objectui#3517), mirrored byte-for-byte into `DETAIL_DEFAULT_TRANSLATIONS` so a +provider-less host still reads English rather than a raw key. + +One deliberate English copy change: the chip group's `aria-label` was +`"Activity type filter"` and now resolves `detail.filterActivity` +(`"Filter activity"`) — the key `RecordActivityTimeline` already uses for the +accessible name of its own activity filter, so one control does not carry two +names across two components. + +Also drops the unused `Filter` import (a pre-existing eslint warning). diff --git a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 1430dea422..1b3bb8fa6c 100644 --- a/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -268,8 +268,13 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 55 once objectui#6655 added `timeline.unsupported.objectBoundGantt`, the // object-bound timeline's refusal of `variant: gantt`, which names the // refused variant — „gantt“, a literal span, because the quoted thing is an - // authoring value the author typed rather than data the runtime filled in. - expect(okSpans, 'correctly paired spans').toBe(55); + // authoring value the author typed rather than data the runtime filled in, + // 58 once objectui#7149 gave `ActivityTimeline`'s assembled sentences pack + // keys: `detail.activityFieldChanged` quotes the OLD and NEW field values + // („{{old}}“ / „{{new}}“, two spans in one value, like `navigationSync. + // renamedPage` above) and `detail.activityStatusChanged` the new status + // („{{value}}“) — three interpolated spans, all runtime data. + expect(okSpans, 'correctly paired spans').toBe(58); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -289,11 +294,12 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 53 / 53 / 0 after objectui#5232 added // `console.objectView.viewConfigPermissionDenied`; 54 / 54 / 0 after // objectui#6301 added `packagedAutomation.cloneCreated`; 55 / 55 / 0 after - // objectui#6655 added `timeline.unsupported.objectBoundGantt`. `rdq` staying - // at 0 is the load-bearing half: each new value added a MATCHED „…“ pair, - // not a stray closer that would have made `close === open` true for the - // wrong reason. - expect({ open, close, rdq }).toEqual({ open: 55, close: 55, rdq: 0 }); + // objectui#6655 added `timeline.unsupported.objectBoundGantt`; 58 / 58 / 0 + // after objectui#7149 added the two quoted `ActivityTimeline` sentences + // (three spans between them). `rdq` staying at 0 is the load-bearing half: + // each new value added a MATCHED „…“ pair, not a stray closer that would + // have made `close === open` true for the wrong reason. + expect({ open, close, rdq }).toEqual({ open: 58, close: 58, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 5f51e825ea..6712f64de8 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -960,6 +960,19 @@ const ar = { tasksOnly: "المهام فقط", leaveCommentPlaceholder: "اترك تعليقاً… (Ctrl+Enter للإرسال)", noActivity: "لا يوجد نشاط مسجل", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'الكل', + createsFilter: 'عمليات الإنشاء', + deletesFilter: 'عمليات الحذف', + statusChangesFilter: 'تغييرات الحالة', + activityEmptyValue: '(فارغ)', + activityFieldChanged: 'غيّر {{field}} من "{{old}}" إلى "{{new}}"', + activityCreated: 'أنشأ هذا السجل', + activityDeleted: 'حذف هذا السجل', + activityStatusChanged: 'غيّر الحالة إلى "{{value}}"', + activityUpdated: 'حدّث السجل', loadMore: "تحميل المزيد", edited: "(معدل)", via: "عبر {{source}}", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index c7e206527e..341859f16b 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -954,6 +954,19 @@ const de = { tasksOnly: "Nur Aufgaben", leaveCommentPlaceholder: "Kommentar hinterlassen… (Strg+Enter zum Senden)", noActivity: "Keine Aktivitäten aufgezeichnet", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'Alle', + createsFilter: 'Erstellungen', + deletesFilter: 'Löschungen', + statusChangesFilter: 'Statusänderungen', + activityEmptyValue: '(leer)', + activityFieldChanged: '{{field}} von „{{old}}“ zu „{{new}}“ geändert', + activityCreated: 'Diesen Datensatz erstellt', + activityDeleted: 'Diesen Datensatz gelöscht', + activityStatusChanged: 'Status zu „{{value}}“ geändert', + activityUpdated: 'Datensatz aktualisiert', loadMore: "Mehr laden", edited: "(bearbeitet)", via: "über {{source}}", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 44003f7a1b..784bd83852 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1168,6 +1168,19 @@ const en = { tasksOnly: 'Tasks Only', leaveCommentPlaceholder: 'Leave a comment… (Ctrl+Enter to submit)', noActivity: 'No activity recorded', + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'All', + createsFilter: 'Creates', + deletesFilter: 'Deletes', + statusChangesFilter: 'Status Changes', + activityEmptyValue: '(empty)', + activityFieldChanged: 'Changed {{field}} from "{{old}}" to "{{new}}"', + activityCreated: 'Created this record', + activityDeleted: 'Deleted this record', + activityStatusChanged: 'Changed status to "{{value}}"', + activityUpdated: 'Updated record', loadMore: 'Load more', edited: '(edited)', via: 'via {{source}}', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 98eea37077..41a8567e9b 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -958,6 +958,19 @@ const es = { tasksOnly: "Solo tareas", leaveCommentPlaceholder: "Deja un comentario… (Ctrl+Enter para enviar)", noActivity: "Sin actividad registrada", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'Todo', + createsFilter: 'Creaciones', + deletesFilter: 'Eliminaciones', + statusChangesFilter: 'Cambios de estado', + activityEmptyValue: '(vacío)', + activityFieldChanged: 'Cambió {{field}} de "{{old}}" a "{{new}}"', + activityCreated: 'Creó este registro', + activityDeleted: 'Eliminó este registro', + activityStatusChanged: 'Cambió el estado a "{{value}}"', + activityUpdated: 'Actualizó el registro', loadMore: "Cargar más", edited: "(editado)", via: "vía {{source}}", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5a846f8642..410342cda3 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -956,6 +956,19 @@ const fr = { tasksOnly: "Tâches uniquement", leaveCommentPlaceholder: "Laisser un commentaire… (Ctrl+Entrée pour envoyer)", noActivity: "Aucune activité enregistrée", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'Tout', + createsFilter: 'Créations', + deletesFilter: 'Suppressions', + statusChangesFilter: 'Changements de statut', + activityEmptyValue: '(vide)', + activityFieldChanged: '{{field}} modifié de « {{old}} » à « {{new}} »', + activityCreated: 'A créé cet enregistrement', + activityDeleted: 'A supprimé cet enregistrement', + activityStatusChanged: 'A changé le statut en « {{value}} »', + activityUpdated: 'A mis à jour l\'enregistrement', loadMore: "Charger plus", edited: "(modifié)", via: "via {{source}}", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 00ffc6ed8b..378480167f 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -965,6 +965,19 @@ const ja = { tasksOnly: "タスクのみ", leaveCommentPlaceholder: "コメントを入力… (Ctrl+Enterで送信)", noActivity: "アクティビティの記録なし", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'すべて', + createsFilter: '作成', + deletesFilter: '削除', + statusChangesFilter: 'ステータス変更', + activityEmptyValue: '(空)', + activityFieldChanged: '{{field}} を「{{old}}」から「{{new}}」に変更しました', + activityCreated: 'このレコードを作成しました', + activityDeleted: 'このレコードを削除しました', + activityStatusChanged: 'ステータスを「{{value}}」に変更しました', + activityUpdated: 'レコードを更新しました', loadMore: "さらに読み込む", edited: "(編集済み)", via: "{{source}} 経由", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index bce8804861..6181226f0c 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -954,6 +954,19 @@ const ko = { tasksOnly: "작업만", leaveCommentPlaceholder: "댓글 남기기… (Ctrl+Enter로 제출)", noActivity: "기록된 활동 없음", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: '전체', + createsFilter: '생성', + deletesFilter: '삭제', + statusChangesFilter: '상태 변경', + activityEmptyValue: '(비어 있음)', + activityFieldChanged: '{{field}}을(를) "{{old}}"에서 "{{new}}"(으)로 변경했습니다', + activityCreated: '이 레코드를 만들었습니다', + activityDeleted: '이 레코드를 삭제했습니다', + activityStatusChanged: '상태를 "{{value}}"(으)로 변경했습니다', + activityUpdated: '레코드를 업데이트했습니다', loadMore: "더 보기", edited: "(수정됨)", via: "{{source}} 통해", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index b36dde1f1e..5d11e1a8be 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -955,6 +955,19 @@ const pt = { tasksOnly: "Apenas tarefas", leaveCommentPlaceholder: "Deixar um comentário… (Ctrl+Enter para enviar)", noActivity: "Nenhuma atividade registrada", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'Tudo', + createsFilter: 'Criações', + deletesFilter: 'Exclusões', + statusChangesFilter: 'Alterações de status', + activityEmptyValue: '(vazio)', + activityFieldChanged: 'Alterou {{field}} de "{{old}}" para "{{new}}"', + activityCreated: 'Criou este registro', + activityDeleted: 'Excluiu este registro', + activityStatusChanged: 'Alterou o status para "{{value}}"', + activityUpdated: 'Atualizou o registro', loadMore: "Carregar mais", edited: "(editado)", via: "via {{source}}", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 7be7c8e5e8..faadda2ba2 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -973,6 +973,19 @@ const ru = { tasksOnly: "Только задачи", leaveCommentPlaceholder: "Оставить комментарий… (Ctrl+Enter для отправки)", noActivity: "Нет зарегистрированной активности", + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: 'Все', + createsFilter: 'Создания', + deletesFilter: 'Удаления', + statusChangesFilter: 'Изменения статуса', + activityEmptyValue: '(пусто)', + activityFieldChanged: 'Изменено поле {{field}} с «{{old}}» на «{{new}}»', + activityCreated: 'Создал эту запись', + activityDeleted: 'Удалил эту запись', + activityStatusChanged: 'Изменил статус на «{{value}}»', + activityUpdated: 'Обновил запись', loadMore: "Загрузить ещё", edited: "(изменено)", via: "через {{source}}", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 90dc6e7f53..443d019fc6 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1057,6 +1057,19 @@ const zh = { tasksOnly: '仅任务', leaveCommentPlaceholder: '留下评论… (Ctrl+Enter 提交)', noActivity: '暂无活动记录', + // objectui#7149 — the rest of `ActivityTimeline`. The four chip labels the + // `detail.*` pack did not already name, and the `formatFieldChange` + // sentences, which are assembled in code and so need interpolation holes. + allFilter: '全部', + createsFilter: '创建', + deletesFilter: '删除', + statusChangesFilter: '状态变更', + activityEmptyValue: '(空)', + activityFieldChanged: '将 {{field}} 从“{{old}}”改为“{{new}}”', + activityCreated: '创建了此记录', + activityDeleted: '删除了此记录', + activityStatusChanged: '将状态改为“{{value}}”', + activityUpdated: '更新了记录', loadMore: '加载更多', edited: '(已编辑)', via: '通过 {{source}}', diff --git a/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx b/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx new file mode 100644 index 0000000000..231535f31d --- /dev/null +++ b/packages/plugin-detail/src/ActivityTimeline.remainingLiterals.i18n.test.tsx @@ -0,0 +1,277 @@ +/** + * 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. + */ + +/** + * The rest of `ActivityTimeline` speaks the session locale — objectui#7149. + * + * objectui#7142 fixed ONE literal in this component (the empty-state title) and + * filed the sweep that found the other 18. The state that fix left behind is + * what this file repairs and is worth naming exactly, because "the component is + * translated" was never true of it: a zh card read `"Activity(0)暂无活动记录"` + * — the empty state correct, the card title beside it still English. + * + * ## Why the assertions are zh/ja/ar and not en + * + * An `en`-only test is green BEFORE the fix as well, for every one of these + * literals: each replacement key's `en` value is byte-identical to the literal + * it replaces (verified against the pack objects, not by a dotted-key grep — + * the packs are nested, so `detail.justNow` greps to zero against the very pack + * that defines it). English therefore cannot discriminate a pack lookup from a + * hardcoded string. Non-Latin locales can, which is why they carry the load + * here — the same shape objectui#7142 used. + * + * ## The three groups, and why they are one file + * + * They differ in reachability, not in defect: + * + * - **Timestamps + card title** render on EVERY activity tab. `DetailView` + * mounts this component at two call sites and both are gated only on + * `activities.length > 0`. + * - **The `formatFieldChange` sentences** render on every activity tab too, + * for any entry whose optional `description` is absent — which is the + * normal shape of a structured `field_change` / `create` / `delete` / + * `status_change` entry. Same reachability as the timestamps; they are a + * separate group only because they are assembled in code and so needed new + * keys WITH interpolation holes rather than a lookup swap. + * - **The filter chips + the group's aria-label** need `filterable`, which no + * host in this repo passes; they are reachable through the published export + * (`packages/plugin-detail/src/index.tsx`) by an outside consumer. + * + * No inline `defaultValue` anywhere (objectui#3517): every key resolves from the + * ten packs, or from `DETAIL_DEFAULT_TRANSLATIONS` on a provider-less host. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import type { ActivityEntry } from '@object-ui/types'; +import { ActivityTimeline } from './ActivityTimeline'; + +afterEach(() => cleanup()); + +/** Fixed clock offsets, chosen to land in each `formatTimestamp` branch. */ +const MINUTES = 5; +const HOURS = 3; +const DAYS = 2; + +const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); + +const entry = (over: Partial): ActivityEntry => + ({ + id: 'a1', + type: 'field_change', + user: 'Ada', + timestamp: ago(MINUTES * 60_000), + ...over, + }) as ActivityEntry; + +function renderIn(language: string | null, activities: ActivityEntry[], filterable = false) { + const ui = ; + if (language === null) return render(ui); + return render( + + {ui} + , + ); +} + +describe('objectui#7149 — relative timestamps resolve from the packs', () => { + it('renders zh relative times, with the count interpolated into the pack value', () => { + renderIn('zh', [ + entry({ id: 'm', timestamp: ago(MINUTES * 60_000) }), + entry({ id: 'h', timestamp: ago(HOURS * 3_600_000) }), + entry({ id: 'd', timestamp: ago(DAYS * 86_400_000) }), + entry({ id: 'n', timestamp: ago(1_000) }), + ]); + + // The `{{count}}` hole is filled, and filled by the ZH pack value — + // "5分钟前", not "5m ago". Both halves matter: a lookup that resolved but + // did not interpolate would render the literal braces. + expect(screen.getByText(`${MINUTES}分钟前`)).toBeTruthy(); + expect(screen.getByText(`${HOURS}小时前`)).toBeTruthy(); + expect(screen.getByText(`${DAYS}天前`)).toBeTruthy(); + expect(screen.getByText('刚刚')).toBeTruthy(); + + // The regression itself: the English literals must not survive. + expect(screen.queryByText(`${MINUTES}m ago`)).toBeNull(); + expect(screen.queryByText('just now')).toBeNull(); + // …nor an uninterpolated hole. + expect(screen.queryByText('{{count}}分钟前')).toBeNull(); + }); + + it('renders ja and ar relative times', () => { + renderIn('ja', [entry({ timestamp: ago(MINUTES * 60_000) })]); + expect(screen.getByText(`${MINUTES}分前`)).toBeTruthy(); + cleanup(); + + renderIn('ar', [entry({ timestamp: ago(MINUTES * 60_000) })]); + expect(screen.getByText(`منذ ${MINUTES} دقيقة`)).toBeTruthy(); + }); + + it('still reads English with no provider mounted — the defaults map, not a raw key', () => { + renderIn(null, [entry({ timestamp: ago(MINUTES * 60_000) })]); + + expect(screen.getByText(`${MINUTES}m ago`)).toBeTruthy(); + expect(screen.queryByText('detail.minutesAgo')).toBeNull(); + // The provider-less path interpolates too (`interpolateFallback`), so the + // braces must be gone here as well — objectui#6219's failure shape. + expect(screen.queryByText('{{count}}m ago')).toBeNull(); + }); +}); + +describe('objectui#7149 — the Activity card title resolves from the packs', () => { + it('titles the card in zh, ja and ar', () => { + for (const [lang, word] of [ + ['zh', '活动'], + ['ja', 'アクティビティ'], + ['ar', 'النشاط'], + ] as const) { + renderIn(lang, [entry({})]); + expect(screen.getByText(word), `${lang} card title`).toBeTruthy(); + // "Activity" was the literal; it must not be rendered as copy any more. + expect(screen.queryByText('Activity'), `${lang} kept English title`).toBeNull(); + cleanup(); + } + }); + + it('is the half objectui#7142 left English — the whole card is now zh', () => { + // The exact string the card recorded as the visible half-done state: + // `"Activity(0)暂无活动记录"`. It is now Chinese end to end. + const { container } = renderIn('zh', []); + expect(container.textContent).toBe('活动(0)暂无活动记录'); + }); +}); + +describe('objectui#7149 — formatFieldChange sentences resolve, holes and all', () => { + it('renders the zh field-change sentence with all three holes filled', () => { + renderIn('zh', [ + entry({ type: 'field_change', field: 'status_code', oldValue: 'open', newValue: 'closed' }), + ]); + + expect(screen.getByText('将 Status code 从“open”改为“closed”')).toBeTruthy(); + expect(screen.queryByText('Changed Status code from "open" to "closed"')).toBeNull(); + }); + + it('substitutes the localized (empty) placeholder for a null side', () => { + renderIn('zh', [ + entry({ type: 'field_change', field: 'owner', oldValue: null, newValue: 'Ada' }), + ]); + + // `(empty)` was a literal too, and it is INSIDE the sentence — a lookup that + // translated the sentence but not the placeholder would read "从(empty)". + expect(screen.getByText('将 Owner 从“(空)”改为“Ada”')).toBeTruthy(); + }); + + it('renders the create / delete / status / fallback sentences in zh', () => { + renderIn('zh', [ + entry({ id: 'c', type: 'create' }), + entry({ id: 'd', type: 'delete' }), + entry({ id: 's', type: 'status_change', field: 'stage', newValue: 'Won' }), + entry({ id: 'u', type: 'comment' }), + ]); + + expect(screen.getByText('创建了此记录')).toBeTruthy(); + expect(screen.getByText('删除了此记录')).toBeTruthy(); + expect(screen.getByText('将状态改为“Won”')).toBeTruthy(); + // `comment` with no description falls through to the catch-all. + expect(screen.getByText('更新了记录')).toBeTruthy(); + + for (const english of [ + 'Created this record', + 'Deleted this record', + 'Changed status to "Won"', + 'Updated record', + ]) { + expect(screen.queryByText(english), `English survived: ${english}`).toBeNull(); + } + }); + + it('renders the de sentence with German quotes, not the ASCII pair', () => { + renderIn('de', [ + entry({ type: 'field_change', field: 'stage', oldValue: 'A', newValue: 'B' }), + ]); + + // The de pack may hold no U+0022 at all (de-quote-pairing-3876), so this is + // also the render-side proof of that gate's subject. + expect(screen.getByText('Stage von „A“ zu „B“ geändert')).toBeTruthy(); + }); + + it('leaves an author-supplied description alone in every locale', () => { + // `description` short-circuits before any lookup: it is the host's own copy, + // not ours to translate. + renderIn('zh', [entry({ type: 'create', description: 'Imported from CRM' })]); + expect(screen.getByText('Imported from CRM')).toBeTruthy(); + expect(screen.queryByText('创建了此记录')).toBeNull(); + }); +}); + +describe('objectui#7149 — filter chips and the group aria-label (published-export path)', () => { + it('labels all six chips from the packs under zh', () => { + renderIn('zh', [entry({})], true); + + const group = screen.getByRole('group'); + for (const label of ['全部', '字段变更', '创建', '删除', '评论', '状态变更']) { + expect(within(group).getByText(label), `zh chip ${label}`).toBeTruthy(); + } + for (const english of ['All', 'Field Changes', 'Creates', 'Deletes', 'Comments', 'Status Changes']) { + expect(within(group).queryByText(english), `English chip survived: ${english}`).toBeNull(); + } + }); + + it('names the chip group from the pack, in zh and ar', () => { + // Reuses `detail.filterActivity` — the key `RecordActivityTimeline` already + // uses for the accessible name of ITS activity filter. That is a deliberate + // English copy change ("Activity type filter" -> "Filter activity") so one + // control does not carry two names across two components; see the PR. + renderIn('zh', [entry({})], true); + expect(screen.getByRole('group', { name: '筛选活动' })).toBeTruthy(); + cleanup(); + + renderIn('ar', [entry({})], true); + expect(screen.getByRole('group', { name: 'تصفية النشاط' })).toBeTruthy(); + }); + + it('falls back to English chip labels with no provider, never raw keys', () => { + renderIn(null, [entry({})], true); + + const group = screen.getByRole('group'); + for (const label of ['All', 'Field Changes', 'Creates', 'Deletes', 'Comments', 'Status Changes']) { + expect(within(group).getByText(label), `no-provider chip ${label}`).toBeTruthy(); + } + for (const key of ['detail.allFilter', 'detail.createsFilter', 'detail.deletesFilter']) { + expect(within(group).queryByText(key), `raw key rendered: ${key}`).toBeNull(); + } + }); +}); + +describe('objectui#7149 — no raw key reaches the DOM on any path', () => { + it('renders no `detail.` key text, provider or not, filterable or not', () => { + const activities = [ + entry({ id: 'f', type: 'field_change', field: 'stage', oldValue: 'A', newValue: 'B' }), + entry({ id: 'c', type: 'create' }), + entry({ id: 's', type: 'status_change', field: 'stage', newValue: 'Won' }), + ]; + + for (const lang of ['zh', 'ja', 'ar', 'de', 'en', null] as const) { + for (const filterable of [true, false]) { + const { container } = renderIn(lang, activities, filterable); + expect( + container.textContent, + `raw key leaked (lang=${lang}, filterable=${filterable})`, + ).not.toMatch(/detail\.[a-zA-Z]/); + // …and no unfilled interpolation hole, which the raw-key probe misses. + expect( + container.textContent, + `unfilled hole (lang=${lang}, filterable=${filterable})`, + ).not.toMatch(/\{\{[a-zA-Z]+\}\}/); + cleanup(); + } + } + }); +}); diff --git a/packages/plugin-detail/src/ActivityTimeline.tsx b/packages/plugin-detail/src/ActivityTimeline.tsx index 6bb6ce3216..29180c1456 100644 --- a/packages/plugin-detail/src/ActivityTimeline.tsx +++ b/packages/plugin-detail/src/ActivityTimeline.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import { cn, Card, CardHeader, CardTitle, CardContent, DataEmptyState } from '@object-ui/components'; -import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft, Filter } from 'lucide-react'; +import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft } from 'lucide-react'; import type { ActivityEntry } from '@object-ui/types'; import { useDetailTranslation } from './useDetailTranslation'; @@ -39,53 +39,104 @@ const ACTIVITY_COLORS: Record = { status_change: 'bg-amber-100 text-amber-600', }; -function formatTimestamp(timestamp: string): string { +/** + * The `t` the module-level formatters below take. + * + * They are plain functions, not hooks, so the component reads the hook once and + * passes `t` down — the same shape and the same reason as the sibling + * `RecordActivityTimeline`, which has always done this correctly. + */ +type ActivityTranslate = (key: string, options?: Record) => string; + +function formatTimestamp(timestamp: string, t: ActivityTranslate): string { try { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1) return t('detail.justNow'); + if (diffMins < 60) return t('detail.minutesAgo', { count: diffMins }); const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; + if (diffHours < 24) return t('detail.hoursAgo', { count: diffHours }); const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 7) return t('detail.daysAgo', { count: diffDays }); + // Past a week this is a DATE, not a relative phrase: `toLocaleDateString` + // already localizes it, so there is no literal here to key. Byte-identical + // to the sibling's own tail for the same reason. return date.toLocaleDateString(); } catch { return timestamp; } } -function formatFieldChange(entry: ActivityEntry): string { +function formatFieldChange(entry: ActivityEntry, t: ActivityTranslate): string { if (entry.description) return entry.description; + // The one value repeated across three branches, resolved once. + const emptyValue = t('detail.activityEmptyValue'); + if (entry.type === 'field_change' && entry.field) { + // `entry.field` is a schema field NAME — runtime data, not copy — so this + // stays a code-side transform and rides in through the `{{field}}` hole. const fieldLabel = entry.field.charAt(0).toUpperCase() + entry.field.slice(1).replace(/_/g, ' '); - const oldVal = entry.oldValue != null ? String(entry.oldValue) : '(empty)'; - const newVal = entry.newValue != null ? String(entry.newValue) : '(empty)'; - return `Changed ${fieldLabel} from "${oldVal}" to "${newVal}"`; + const oldVal = entry.oldValue != null ? String(entry.oldValue) : emptyValue; + const newVal = entry.newValue != null ? String(entry.newValue) : emptyValue; + // The quotes live INSIDE each pack's value, so every locale punctuates the + // quoted span its own way (de `„“`, zh `“”`, ja `「」`, fr/ru `«»`). + return t('detail.activityFieldChanged', { field: fieldLabel, old: oldVal, new: newVal }); } - if (entry.type === 'create') return 'Created this record'; - if (entry.type === 'delete') return 'Deleted this record'; + if (entry.type === 'create') return t('detail.activityCreated'); + if (entry.type === 'delete') return t('detail.activityDeleted'); if (entry.type === 'status_change' && entry.field) { - const newVal = entry.newValue != null ? String(entry.newValue) : '(empty)'; - return `Changed status to "${newVal}"`; + const newVal = entry.newValue != null ? String(entry.newValue) : emptyValue; + return t('detail.activityStatusChanged', { value: newVal }); } - return 'Updated record'; + return t('detail.activityUpdated'); } -const FILTER_LABELS: Record = { - all: 'All', - field_change: 'Field Changes', - create: 'Creates', - delete: 'Deletes', - comment: 'Comments', - status_change: 'Status Changes', -}; +/** Chip order — what `Object.keys(FILTER_LABELS)` used to supply. */ +const FILTER_ORDER: readonly ActivityFilterType[] = [ + 'all', + 'field_change', + 'create', + 'delete', + 'comment', + 'status_change', +]; + +/** + * The pack key naming each chip. + * + * Deliberately STATIC `t()` calls rather than a `type -> key` map read as + * `t(KEYS[type])`: a key that only ever appears as a map value has no call site + * `check:i18n-keys` or `check-i18n-dead-keys` can resolve, so it reads as an + * unreferenced key even while it renders. Same shape as the sibling + * `RecordActivityTimeline`'s `getFilterOptions`. + * + * `field_change` and `comment` reuse keys whose `en` values are byte-identical + * to the literals they replace, so those two are a pure lookup swap rather than + * a new spelling of copy the packs already carry. + */ +function filterLabel(type: ActivityFilterType, t: ActivityTranslate): string { + switch (type) { + case 'field_change': + return t('detail.fieldChangesFilter'); + case 'create': + return t('detail.createsFilter'); + case 'delete': + return t('detail.deletesFilter'); + case 'comment': + return t('detail.comments'); + case 'status_change': + return t('detail.statusChangesFilter'); + case 'all': + default: + return t('detail.allFilter'); + } +} export const ActivityTimeline: React.FC = ({ activities, @@ -106,7 +157,7 @@ export const ActivityTimeline: React.FC = ({ - Activity + {t('detail.activity')} ({filteredActivities.length}) @@ -115,8 +166,8 @@ export const ActivityTimeline: React.FC = ({ {/* Filter controls */} {filterable && ( -
- {(Object.keys(FILTER_LABELS) as ActivityFilterType[]).map(type => ( +
+ {FILTER_ORDER.map(type => ( ))}
@@ -165,11 +216,11 @@ export const ActivityTimeline: React.FC = ({ {entry.user} {' '} - {formatFieldChange(entry)} + {formatFieldChange(entry, t)}

- {formatTimestamp(entry.timestamp)} + {formatTimestamp(entry.timestamp, t)}

diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index bef6813589..0772ed8df0 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -186,6 +186,20 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.tasksOnly': 'Tasks Only', 'detail.leaveCommentPlaceholder': 'Leave a comment… (Ctrl+Enter to submit)', 'detail.noActivity': 'No activity recorded', + // objectui#7149 — the rest of `ActivityTimeline`'s copy. Byte-identical to + // the `en` pack rows (this map mirrors it; `defaults-maps-mirror-en-pack` + // enforces that), so a provider-less host reads the same English the console + // does instead of a raw key. + 'detail.allFilter': 'All', + 'detail.createsFilter': 'Creates', + 'detail.deletesFilter': 'Deletes', + 'detail.statusChangesFilter': 'Status Changes', + 'detail.activityEmptyValue': '(empty)', + 'detail.activityFieldChanged': 'Changed {{field}} from "{{old}}" to "{{new}}"', + 'detail.activityCreated': 'Created this record', + 'detail.activityDeleted': 'Deleted this record', + 'detail.activityStatusChanged': 'Changed status to "{{value}}"', + 'detail.activityUpdated': 'Updated record', 'detail.loadMore': 'Load more', 'detail.edited': '(edited)', 'detail.via': 'via {{source}}',