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
8 changes: 8 additions & 0 deletions .changeset/gantt-quickfilter-labels-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/plugin-gantt': patch
'@object-ui/i18n': patch
---

`ObjectGantt`'s quick-filter bar is now localized instead of pinned to Chinese. The four `QuickFilterBar` labels (`all`, `clear`, `empty`, `resultSummary`) were hardcoded as Chinese string literals at the `ObjectGantt` call site, so the bar read 全部 / 清除筛选 / 无可选项 / 显示 N / M 项任务 under an `en`, `ja`, `es` or `ar` session while the rest of the gantt toolbar localized correctly — a conspicuous mismatch, and a violation of the English-only-codebase rule. `QuickFilterBar` itself was never at fault: it is presentational and already falls back to English, so the host was the only thing pinning the copy.

The four strings moved into a new `gantt.quickFilter` namespace, added to all ten built-in locale packs, and the call site now resolves them through the gantt package's existing `useGanttTranslation` — the same per-key hook every other gantt string already uses, so a host dictionary that lags on these keys still renders the bundled English default rather than a raw key. `gantt.quickFilter.resultSummary` deliberately keeps SINGLE-brace placeholders (`{shown}` / `{total}`): the call site substitutes them with a literal `.replace`, not i18next interpolation, matching `gantt.autoScheduleDlg.body` and the placeholder convention `all-locales-key-parity` already recognises. Anyone retranslating these packs must keep that spelling — a respell to `{{shown}}` would render the raw placeholder to the user.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* `gantt.quickFilter.*` exists in all ten packs, with the SINGLE-brace
* placeholder spelling (objectstack#5427).
*
* `all-locales-key-parity` already enforces en↔pack key parity generically, so
* this file is deliberately narrow: it pins the two things that guard could not
* express on its own.
*
* 1. The keys exist in `en` at all. Full parity is symmetric — deleting the
* block from *every* pack keeps that suite green while the gantt bar
* silently reverts to raw keys.
* 2. `resultSummary` keeps SINGLE braces. The call site in `ObjectGantt.tsx`
* does a literal `.replace('{shown}', …)` rather than i18next
* interpolation, so a pack respelled to `{{shown}}` would render the raw
* placeholder to the user. The parity guard compares placeholder *shape*
* between packs, which stays consistent if someone converts all ten at
* once — this asserts the absolute form.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';

const KEYS = ['all', 'clear', 'empty', 'resultSummary'] as const;
const LANGS = Object.keys(builtInLocales);

const quickFilterOf = (lang: string) =>
(builtInLocales[lang] as any)?.gantt?.quickFilter as Record<string, string> | undefined;

describe('gantt.quickFilter.* locale coverage (objectstack#5427)', () => {
it('covers all ten built-in packs', () => {
expect(LANGS).toHaveLength(10);
});

it.each(LANGS)('%s defines every quick-filter label as a non-empty string', (lang) => {
const block = quickFilterOf(lang);
expect(block, `${lang} has no gantt.quickFilter block`).toBeTruthy();
for (const key of KEYS) {
expect(typeof block![key], `${lang}.gantt.quickFilter.${key}`).toBe('string');
expect(block![key].trim().length, `${lang}.gantt.quickFilter.${key} is empty`).toBeGreaterThan(0);
}
});

it.each(LANGS)('%s spells resultSummary with SINGLE braces, not i18next interpolation', (lang) => {
const summary = quickFilterOf(lang)!.resultSummary;
expect(summary).toContain('{shown}');
expect(summary).toContain('{total}');
expect(summary).not.toContain('{{');
});

it('the English pack is the source of the bundled defaults', () => {
// Guards against the en pack drifting from plugin-gantt's standalone
// fallback map, which would make a provider-less embed disagree with an
// `en` session.
expect(quickFilterOf('en')).toEqual({
all: 'All',
clear: 'Clear filters',
empty: 'No options',
resultSummary: 'Showing {shown} / {total} tasks',
});
});

it('no pack but zh serves the Chinese copy this issue removed', () => {
// The defect was Chinese copy served to every locale. A blanket CJK-range
// scan would false-red on `ja` (its summary is legitimately kanji:
// "件のタスクを表示"), so this pins the four
// exact literals that used to be hardcoded in ObjectGantt.tsx instead.
const REMOVED = ['全部', '清除筛选', '无可选项', '项任务'];
const leaked = LANGS.filter(
(l) => l !== 'zh' && KEYS.some((k) => REMOVED.some((s) => quickFilterOf(l)![k].includes(s))),
);
expect(leaked).toEqual([]);
});
});
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const ar = {
over: "محمّل بإفراط",
empty: "لا توجد مهام لتخصيصها.",
},
quickFilter: {
all: "الكل",
clear: "مسح عوامل التصفية",
empty: "لا توجد خيارات",
resultSummary: "عرض {shown} / {total} من المهام",
},
readOnly: "للقراءة فقط",
readOnlyHint: "التحرير معطّل في هذا العرض.",
lockedHint: "لا توجد صلاحية تحرير",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const de = {
over: "überlastet",
empty: "Keine Vorgänge zum Zuweisen.",
},
quickFilter: {
all: "Alle",
clear: "Filter zurücksetzen",
empty: "Keine Optionen",
resultSummary: "{shown} / {total} Vorgänge werden angezeigt",
},
readOnly: "Schreibgeschützt",
readOnlyHint: "Die Bearbeitung ist in dieser Ansicht deaktiviert.",
lockedHint: "Keine Bearbeitungsberechtigung",
Expand Down
9 changes: 9 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,15 @@ const en = {
over: 'overloaded',
empty: 'No tasks to allocate.',
},
quickFilter: {
all: 'All',
clear: 'Clear filters',
empty: 'No options',
// SINGLE braces on purpose: the ObjectGantt call site resolves these
// with a literal `.replace('{shown}', …)`, not i18next interpolation
// (same convention as `autoScheduleDlg.body` above).
resultSummary: 'Showing {shown} / {total} tasks',
},
readOnly: 'Read-only',
readOnlyHint: 'Editing is disabled for this view.',
lockedHint: 'No edit permission',
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,12 @@ const es = {
over: "sobrecargado",
empty: "No hay tareas que asignar.",
},
quickFilter: {
all: "Todos",
clear: "Borrar filtros",
empty: "Sin opciones",
resultSummary: "Mostrando {shown} / {total} tareas",
},
readOnly: "Solo lectura",
readOnlyHint: "La edición está deshabilitada en esta vista.",
lockedHint: "Sin permiso de edición",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const fr = {
over: "surchargée",
empty: "Aucune tâche à affecter.",
},
quickFilter: {
all: "Tous",
clear: "Effacer les filtres",
empty: "Aucune option",
resultSummary: "Affichage de {shown} / {total} tâches",
},
readOnly: "Lecture seule",
readOnlyHint: "L'édition est désactivée pour cette vue.",
lockedHint: "Aucune autorisation de modification",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const ja = {
over: "過負荷",
empty: "割り当てるタスクがありません。",
},
quickFilter: {
all: "すべて",
clear: "フィルターをクリア",
empty: "選択肢がありません",
resultSummary: "{shown} / {total} 件のタスクを表示",
},
readOnly: "読み取り専用",
readOnlyHint: "このビューでは編集できません。",
lockedHint: "編集権限がありません",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const ko = {
over: "과부하",
empty: "배정할 작업이 없습니다.",
},
quickFilter: {
all: "전체",
clear: "필터 지우기",
empty: "옵션 없음",
resultSummary: "작업 {shown} / {total}건 표시",
},
readOnly: "읽기 전용",
readOnlyHint: "이 보기에서는 편집할 수 없습니다.",
lockedHint: "편집 권한 없음",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const pt = {
over: "sobrecarregado",
empty: "Nenhuma tarefa para alocar.",
},
quickFilter: {
all: "Todos",
clear: "Limpar filtros",
empty: "Sem opções",
resultSummary: "Exibindo {shown} / {total} tarefas",
},
readOnly: "Somente leitura",
readOnlyHint: "A edição está desativada nesta visão.",
lockedHint: "Sem permissão de edição",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ const ru = {
over: "перегружен",
empty: "Нет задач для распределения.",
},
quickFilter: {
all: "Все",
clear: "Очистить фильтры",
empty: "Нет вариантов",
resultSummary: "Показано {shown} / {total} задач",
},
readOnly: "Только чтение",
readOnlyHint: "Редактирование в этом представлении отключено.",
lockedHint: "Нет прав на редактирование",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,12 @@ const zh = {
over: '超载',
empty: '没有可分配的任务。',
},
quickFilter: {
all: '全部',
clear: '清除筛选',
empty: '无可选项',
resultSummary: '显示 {shown} / {total} 项任务',
},
readOnly: '只读',
readOnlyHint: '此视图已禁用编辑。',
lockedHint: '无编辑权限',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* ObjectGantt quick-filter labels with NO I18nProvider mounted (objectstack#5427).
*
* Standalone embeds and most unit tests render ObjectGantt without a provider.
* Turning the four labels into `gantt.quickFilter.*` bundle keys moved the
* failure mode: where the old hardcoded literals always rendered *something*,
* an unregistered key renders as the raw key — the bar would read
* `gantt.quickFilter.all`. `GANTT_DEFAULT_TRANSLATIONS` is what prevents that,
* and this file is what pins it.
*
* Deliberately a separate file from ObjectGantt.quickfilter.i18n.test.tsx:
* mounting an I18nProvider leaves react-i18next's global instance on that
* language, so the un-provided case is only meaningful in a module state where
* no provider was ever constructed.
*/
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ObjectGantt } from './ObjectGantt';
import { GANTT_DEFAULT_TRANSLATIONS } from './useGanttTranslation';

vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => <div data-testid="gantt-view" data-count={tasks.length} />,
}));

const SCHEMA: any = {
type: 'gantt',
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
data: {
provider: 'value',
items: [
{ id: '1', name: 'Alpha task', start: '2024-01-01', end: '2024-01-05', status: 'todo' },
{ id: '2', name: 'Beta task', start: '2024-02-01', end: '2024-02-10', status: 'doing' },
{ id: '3', name: 'Gamma task', start: '2024-03-01', end: '2024-03-20', status: 'done' },
],
},
quickFilters: [
{ field: 'status', label: 'Status', options: ['todo', 'doing', 'done'] },
// No record carries `owner` → zero options → renders the `empty` placeholder.
{ field: 'owner', label: 'Owner' },
],
};

describe('ObjectGantt quick-filter labels without an I18nProvider (objectstack#5427)', () => {
it('renders the bundled English defaults — never a raw bundle key', async () => {
const { container, getByTestId } = render(<ObjectGantt schema={SCHEMA} />);
await waitFor(() => expect(container.querySelector('[data-testid="gantt-view"]')).toBeTruthy());

fireEvent.click(getByTestId('quick-filter-trigger-owner'));
const empty = getByTestId('quick-filter-panel-owner').textContent ?? '';
fireEvent.click(getByTestId('quick-filter-trigger-owner'));

fireEvent.click(getByTestId('quick-filter-trigger-status'));
const all = getByTestId('quick-filter-all-status').textContent ?? '';

fireEvent.click(getByTestId('quick-filter-option-status-doing'));
await waitFor(() => expect(getByTestId('gantt-view').getAttribute('data-count')).toBe('1'));

const clear = getByTestId('quick-filter-clear').textContent ?? '';
const summary = getByTestId('quick-filter-summary').textContent ?? '';

expect(all).toBe(GANTT_DEFAULT_TRANSLATIONS['gantt.quickFilter.all']);
expect(empty).toBe(GANTT_DEFAULT_TRANSLATIONS['gantt.quickFilter.empty']);
expect(clear).toBe(GANTT_DEFAULT_TRANSLATIONS['gantt.quickFilter.clear']);
expect(summary).toBe('Showing 1 / 3 tasks');

const rendered = [all, empty, clear, summary].join(' ');
// The regression this file exists for.
expect(rendered).not.toContain('gantt.quickFilter');
// Placeholders must be substituted by the call site's literal `.replace`,
// not rendered to the user.
expect(rendered).not.toContain('{shown}');
expect(rendered).not.toContain('{total}');
// And the Chinese literals this issue removed must not come back.
expect(rendered).not.toContain('全部');
expect(rendered).not.toContain('清除筛选');
expect(rendered).not.toContain('无可选项');
expect(rendered).not.toMatch(/显示 .* 项任务/);
});
});
Loading
Loading