From 9d98cd4af57d730d7e17d6dfc32c202e8affc70d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:56:51 +0000 Subject: [PATCH 01/10] wip(app-shell): Setup suggestion rows 12, 18, 22 Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .../src/console/RemediationOverlay.tsx | 17 ++- ...ionOverlay.forcedResetNewPassword.test.tsx | 9 +- ...iationOverlay.impersonation-13208.test.tsx | 8 +- ...erlay.passwordResetRequired-12365.test.tsx | 45 ++++++- ...dUsersSection.pickerColumns-27433.test.tsx | 118 ++++++++++++++++++ .../metadata-admin/AssignedUsersSection.tsx | 27 ++++ ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + 16 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.pickerColumns-27433.test.tsx diff --git a/ui/packages/app-shell/src/console/RemediationOverlay.tsx b/ui/packages/app-shell/src/console/RemediationOverlay.tsx index ca94db57876..06c55e63820 100644 --- a/ui/packages/app-shell/src/console/RemediationOverlay.tsx +++ b/ui/packages/app-shell/src/console/RemediationOverlay.tsx @@ -178,14 +178,23 @@ function ChangePasswordForm({ forcedReset, message }: { forcedReset: boolean; me

{forcedReset ? t('auth.remediation.password.resetTitle') : t('auth.remediation.password.title')}

- {/* `message` is server-authored (localized upstream or not at all); only - the empty-message fallback is ours to translate. */} + {/* An expiry's `message` is server-authored (localized upstream or not + at all); only the empty-message fallback is ours to translate. A + forced reset's body is ours, as its title is: the code alone says + what happened, and the server's sentence only repeats the title in + the server's language. */}

- {message || t('auth.remediation.password.fallbackMessage')} + {forcedReset + ? t('auth.remediation.password.resetBody') + : message || t('auth.remediation.password.fallbackMessage')}

- + {/* After a forced reset the password in hand is the one an + administrator issued, so the field is named for it. */} + setCurrent(e.target.value)} required />
diff --git a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx index 34ff0e0d577..01c9f5a8e59 100644 --- a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx @@ -46,8 +46,11 @@ function submit(current: string, next: string, labels: { current: string; next: fireEvent.submit(screen.getByLabelText(labels.next).closest('form') as HTMLFormElement); } -const EN = { current: 'Current password', next: 'New password', confirm: 'Confirm new password' }; -const ZH = { current: '当前密码', next: '新密码', confirm: '确认新密码' }; +// A forced reset names the field for the password an administrator issued; +// an expiry keeps "Current password". +const EN = { current: 'Temporary password', next: 'New password', confirm: 'Confirm new password' }; +const EN_EXPIRED = { ...EN, current: 'Current password' }; +const ZH = { current: '临时密码', next: '新密码', confirm: '确认新密码' }; const FORCED: Gate = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' }; beforeEach(() => { @@ -81,7 +84,7 @@ describe('RemediationOverlay — a forced reset asks for a new password', () => it('leaves an expiry to the server: the same password is sent on', async () => { renderWith({ code: 'PASSWORD_EXPIRED', message: '' }, 'en'); - submit('Same!Passw0rd-1', 'Same!Passw0rd-1', EN); + submit('Same!Passw0rd-1', 'Same!Passw0rd-1', EN_EXPIRED); await vi.waitFor(() => expect(changePassword).toHaveBeenCalledWith('Same!Passw0rd-1', 'Same!Passw0rd-1')); expect(screen.queryByText('Choose a new password that is different from your temporary password.')).toBeNull(); }); diff --git a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.impersonation-13208.test.tsx b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.impersonation-13208.test.tsx index fee36f62aee..e22057b94b6 100644 --- a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.impersonation-13208.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.impersonation-13208.test.tsx @@ -150,7 +150,7 @@ describe('RemediationOverlay on an impersonation session (objectui#13208)', () = expect(screen.getByText(/only they can do this themselves/)).toBeTruthy(); expect(screen.getByTestId('remediation-impersonation-stop').textContent).toBe('Stop impersonating'); // Not the target's credential form, and not the message written to them. - expect(screen.queryByLabelText('Current password')).toBeNull(); + expect(screen.queryByLabelText('Temporary password')).toBeNull(); expect(screen.queryByLabelText('New password')).toBeNull(); expect(screen.queryByText(HELD_MESSAGE)).toBeNull(); // The other way out stays. @@ -196,7 +196,7 @@ describe('RemediationOverlay on an impersonation session (objectui#13208)', () = const stranded = await screen.findByTestId('remediation-impersonation-not-restored'); expect(stranded.textContent).toMatch(/still impersonating RT8 Dave/); expect(screen.getByTestId('remediation-impersonation-exit')).toBeTruthy(); - expect(screen.queryByLabelText('Current password')).toBeNull(); + expect(screen.queryByLabelText('Temporary password')).toBeNull(); expect(reload).not.toHaveBeenCalled(); }); @@ -227,9 +227,9 @@ describe('RemediationOverlay on an impersonation session (objectui#13208)', () = await act(async () => {}); await raiseGate(); - expect(await screen.findByLabelText('Current password')).toBeTruthy(); + expect(await screen.findByLabelText('Temporary password')).toBeTruthy(); expect(screen.getByRole('heading', { name: 'You must change your password' })).toBeTruthy(); - expect(screen.getByText(HELD_MESSAGE)).toBeTruthy(); + expect(screen.getByText(/An administrator set a temporary password for you/)).toBeTruthy(); expect(screen.queryByTestId('remediation-impersonation-exit')).toBeNull(); expect(screen.queryByTestId('remediation-impersonation-stop')).toBeNull(); }); diff --git a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.passwordResetRequired-12365.test.tsx b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.passwordResetRequired-12365.test.tsx index a497f58c1ef..f968a635354 100644 --- a/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.passwordResetRequired-12365.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/RemediationOverlay.passwordResetRequired-12365.test.tsx @@ -38,7 +38,7 @@ function renderWith(next: Gate) { describe('RemediationOverlay — PASSWORD_RESET_REQUIRED (objectui#12365)', () => { it('shows the change-password form for a forced reset, under a reset title', () => { renderWith({ code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' }); - expect(screen.getByLabelText('Current password')).toBeTruthy(); + expect(screen.getByLabelText('Temporary password')).toBeTruthy(); expect(screen.getByLabelText('New password')).toBeTruthy(); expect(screen.getByLabelText('Confirm new password')).toBeTruthy(); expect(screen.getByRole('heading', { name: 'You must change your password' })).toBeTruthy(); @@ -53,3 +53,46 @@ describe('RemediationOverlay — PASSWORD_RESET_REQUIRED (objectui#12365)', () = expect(screen.getByRole('heading', { name: 'Your password has expired' })).toBeTruthy(); }); }); + +/** + * objectstack#27521 rows 12 and 22 — a forced reset's body and its first + * field are the console's own words, in the user's language: the body says + * what happened instead of repeating the title in the server's language, and + * the field is named for the temporary password the user holds. An expiry + * keeps the server's sentence and "Current password". + */ +describe('RemediationOverlay — a forced reset speaks in the console’s words (objectstack#27521)', () => { + const SERVER_SENTENCE = 'You must change your password before continuing.'; + + function renderIn(language: string, next: Gate) { + gate = next; + return render( + + + , + ); + } + + it('says why in its own body and names the temporary password, not the server sentence', () => { + renderIn('en', { code: 'PASSWORD_RESET_REQUIRED', message: SERVER_SENTENCE }); + expect(screen.getByText(/An administrator set a temporary password for you/)).toBeTruthy(); + expect(screen.queryByText(SERVER_SENTENCE)).toBeNull(); + expect(screen.getByLabelText('Temporary password')).toBeTruthy(); + expect(screen.queryByLabelText('Current password')).toBeNull(); + }); + + it('reads in the user’s language (fr): title, body and fields together', async () => { + renderIn('fr', { code: 'PASSWORD_RESET_REQUIRED', message: SERVER_SENTENCE }); + expect(await screen.findByRole('heading', { name: 'Vous devez changer votre mot de passe' })).toBeTruthy(); + expect(screen.getByText(/Un administrateur vous a attribué un mot de passe temporaire/)).toBeTruthy(); + expect(screen.queryByText(SERVER_SENTENCE)).toBeNull(); + expect(screen.getByLabelText('Mot de passe temporaire')).toBeTruthy(); + }); + + it('CONTROL: an expiry keeps the server sentence and "Current password"', () => { + renderIn('en', { code: 'PASSWORD_EXPIRED', message: 'Your password is older than 90 days.' }); + expect(screen.getByText('Your password is older than 90 days.')).toBeTruthy(); + expect(screen.getByLabelText('Current password')).toBeTruthy(); + expect(screen.queryByLabelText('Temporary password')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.pickerColumns-27433.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.pickerColumns-27433.test.tsx new file mode 100644 index 00000000000..c5556bb1c66 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.pickerColumns-27433.test.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the MIT license. + +/** + * objectstack#27433 — a permission set's Add user picker shows each person's + * email beside their name, as the assigned rows do, so two people of one name + * can be told apart before either is granted the set (the pattern the + * position Add holder picker follows). + * + * Pinned against the real `RecordPickerDialog` (not a stub), so what is read + * is the table the admin sees: + * + * - the picker draws a name column and an email column; + * - CONTROL: choosing a person still grants the set BY NAME; + * - CONTROL: under a loaded policy that denies `sys_user.email`, the picker's + * own read rule leaves the name column alone — the section adds no check of + * its own and reads the email no other way. + */ +import * as React from 'react'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import type { ObjectPermissionConfig } from '@object-ui/types'; + +const users = [ + { id: 'u_1', name: 'Ada Auditor', email: 'ada@example.com' }, + { id: 'u_2', name: 'Sam Lee', email: 'sam.lee@example.com' }, + { id: 'u_3', name: 'Sam Lee', email: 'slee@example.com' }, +]; +const grants = [{ id: 'grant_1', user_id: 'u_1', permission_set: 'showcase_auditor' }]; + +const adapter = { + find: vi.fn(async (object: string, query: any) => { + if (object === 'sys_user_permission_set') { + return { data: grants.filter((g) => g.permission_set === query?.$filter?.permission_set) }; + } + if (object === 'sys_user') { + const ids: string[] | undefined = query?.$filter?.id?.$in; + const data = ids ? users.filter((u) => ids.includes(u.id)) : users; + return { data, total: data.length }; + } + return { data: [] }; + }), + create: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), +}; +const metadataStore = { ensureType: vi.fn(async () => []) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => adapter, + useMetadata: () => metadataStore, +})); + +import { AssignedUsersSection } from './AssignedUsersSection'; + +/** A policy under which `viewer` may not read `sys_user.email`. */ +const EMAIL_DENIED: ObjectPermissionConfig[] = [ + { + object: 'sys_user', + roles: { viewer: { actions: ['read'], fieldPermissions: [{ field: 'email', read: false }] } }, + } as ObjectPermissionConfig, +]; + +function cells(field: string): string[] { + return Array.from(document.querySelectorAll(`[data-lookup-cell="${field}"]`)).map((el) => el.textContent ?? ''); +} + +async function openPicker(): Promise { + await waitFor(() => expect(screen.getByText('Ada Auditor')).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: /Add user/ })); + await waitFor(() => expect(screen.getByTestId('record-row-u_3')).toBeTruthy()); +} + +beforeEach(() => { + adapter.find.mockClear(); + adapter.create.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('AssignedUsersSection — the picker shows email beside the name (objectstack#27433)', () => { + it('draws a name column and an email column', async () => { + render(); + await openPicker(); + + const dialog = screen.getByTestId('record-picker-dialog'); + expect(dialog).toHaveTextContent('Email'); + expect(cells('name')).toEqual(['Ada Auditor', 'Sam Lee', 'Sam Lee']); + expect(cells('email')).toEqual(['ada@example.com', 'sam.lee@example.com', 'slee@example.com']); + }); + + it('CONTROL: choosing a person still grants the set by name', async () => { + render(); + await openPicker(); + + fireEvent.click(screen.getByTestId('record-row-u_3')); + fireEvent.click(screen.getByTestId('record-picker-confirm')); + await waitFor(() => expect(adapter.create).toHaveBeenCalledTimes(1)); + expect(adapter.create).toHaveBeenCalledWith('sys_user_permission_set', { + user_id: 'u_3', + permission_set: 'showcase_auditor', + }); + }); + + it('CONTROL: a viewer who may not read email gets the name column alone', async () => { + render( + + + , + ); + await openPicker(); + + expect(cells('name')).toEqual(['Ada Auditor', 'Sam Lee', 'Sam Lee']); + expect(cells('email')).toEqual([]); + expect(screen.getByTestId('record-picker-dialog')).not.toHaveTextContent('slee@example.com'); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx index 0110fa30ddd..d71cf0d8703 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -142,6 +142,8 @@ function useCopy() { sentenceGap: '', loading: '加载中…', pickTitle: '选择要分配的组织成员', + nameColumn: '姓名', + emailColumn: '邮箱', noMembers: '本组织还没有可分配的成员。', membersFailed: '无法读取本组织的成员。', removeTitle: (who: string) => `将 ${who} 从此权限集移除?`, @@ -168,6 +170,8 @@ function useCopy() { sentenceGap: ' ', loading: 'Loading…', pickTitle: 'Select organization members to assign', + nameColumn: 'Name', + emailColumn: 'Email', noMembers: 'This organization has no members to assign yet.', membersFailed: 'Could not read the members of this organization.', removeTitle: (who: string) => `Remove ${who} from this permission set?`, @@ -219,6 +223,18 @@ const asArray = (res: any): any[] => */ const refusalSentence = (err: unknown): string => extractWriteErrorMessage(err) ?? ''; +/** + * The picker shows what a holder row shows: the person's name and, beside it, + * their email, so two people of one name can be told apart before either is + * granted the set, as `PositionHoldersSection`'s picker does. `name` is the + * field `sys_user` declares as its `nameField`, and `email` is its login + * identity. The picker draws only the columns the caller may read + * (`RecordPickerDialog`'s own read rule), so for a caller who may not read + * `email` the name column stands alone. + */ +const PICKER_NAME_FIELD = 'name'; +const PICKER_EMAIL_FIELD = 'email'; + const personLabel = (u: any): string => u?.full_name || u?.name || u?.display_name || u?.email || String(u?.id ?? ''); @@ -321,6 +337,12 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig const canAdd = resolveAffordance('relatedNew', grantSource).allowed; const canRemove = resolveAffordance('relatedRowDelete', grantSource).allowed; const memberOrganization = membershipOrganization(activeOrganizationId, metadataStore.objects); + /** + * `sys_user` as the registry serves it, read from the metadata this section + * already holds: the picker's name column resolves each person's title by it. + */ + const userSchema = ((metadataStore.objects ?? []).find((o) => (o as { name?: unknown } | null)?.name === 'sys_user') ?? + null) as Record | null; const load = React.useCallback(async () => { setLoading(true); @@ -615,6 +637,11 @@ export function AssignedUsersSection({ permissionSetName, onHoldersRead }: Assig multiple dataSource={adapter} objectName="sys_user" + objectSchema={userSchema} + columns={[ + { field: PICKER_NAME_FIELD, label: c.nameColumn }, + { field: PICKER_EMAIL_FIELD, label: c.emailColumn }, + ]} title={c.pickTitle} baseFilter={memberFilter} onSelect={() => {}} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 93204d42cd3..c82058c38d3 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -3016,7 +3016,9 @@ const ar = { title: "انتهت صلاحية كلمة المرور", resetTitle: "يجب عليك تغيير كلمة المرور", fallbackMessage: "يرجى تعيين كلمة مرور جديدة للمتابعة.", + resetBody: "عيّن لك أحد المسؤولين كلمة مرور مؤقتة. اختر كلمة مرور جديدة خاصة بك للمتابعة.", current: "كلمة المرور الحالية", + temporary: "كلمة المرور المؤقتة", next: "كلمة المرور الجديدة", confirm: "تأكيد كلمة المرور الجديدة", submit: "تغيير كلمة المرور والمتابعة", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 6e42688b142..7a2c88df31b 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2786,7 +2786,9 @@ const de = { title: "Ihr Passwort ist abgelaufen", resetTitle: "Sie müssen Ihr Passwort ändern", fallbackMessage: "Bitte legen Sie ein neues Passwort fest, um fortzufahren.", + resetBody: "Ein Administrator hat ein temporäres Passwort für Sie festgelegt. Wählen Sie ein eigenes neues Passwort, um fortzufahren.", current: "Aktuelles Passwort", + temporary: "Temporäres Passwort", next: "Neues Passwort", confirm: "Neues Passwort bestätigen", submit: "Passwort ändern & fortfahren", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 87a2437601b..d8e59ea5874 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3340,7 +3340,9 @@ const en = { title: 'Your password has expired', resetTitle: 'You must change your password', fallbackMessage: 'Please set a new password to continue.', + resetBody: 'An administrator set a temporary password for you. Choose a new password of your own to continue.', current: 'Current password', + temporary: 'Temporary password', next: 'New password', confirm: 'Confirm new password', submit: 'Change password & continue', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index b54edfb9a83..5f1ef5d456b 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2845,7 +2845,9 @@ const es = { title: "Su contraseña ha caducado", resetTitle: "Debe cambiar su contraseña", fallbackMessage: "Establezca una nueva contraseña para continuar.", + resetBody: "Un administrador le asignó una contraseña temporal. Elija una contraseña nueva propia para continuar.", current: "Contraseña actual", + temporary: "Contraseña temporal", next: "Nueva contraseña", confirm: "Confirmar nueva contraseña", submit: "Cambiar contraseña y continuar", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 4d2fded8f2e..3c80b823926 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2841,7 +2841,9 @@ const fr = { title: "Votre mot de passe a expiré", resetTitle: "Vous devez changer votre mot de passe", fallbackMessage: "Veuillez définir un nouveau mot de passe pour continuer.", + resetBody: "Un administrateur vous a attribué un mot de passe temporaire. Choisissez votre propre nouveau mot de passe pour continuer.", current: "Mot de passe actuel", + temporary: "Mot de passe temporaire", next: "Nouveau mot de passe", confirm: "Confirmer le nouveau mot de passe", submit: "Changer le mot de passe et continuer", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 1b6f5c2cfef..6b716412ba0 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2788,7 +2788,9 @@ const ja = { title: "パスワードの有効期限が切れました", resetTitle: "パスワードの変更が必要です", fallbackMessage: "続行するには新しいパスワードを設定してください。", + resetBody: "管理者が一時パスワードを設定しました。続行するには、ご自身の新しいパスワードを設定してください。", current: "現在のパスワード", + temporary: "一時パスワード", next: "新しいパスワード", confirm: "新しいパスワード(確認)", submit: "パスワードを変更して続行", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 6a415327261..8fc54d6d091 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2786,7 +2786,9 @@ const ko = { title: "비밀번호가 만료되었습니다", resetTitle: "비밀번호를 변경해야 합니다", fallbackMessage: "계속하려면 새 비밀번호를 설정하세요.", + resetBody: "관리자가 임시 비밀번호를 설정했습니다. 계속하려면 본인의 새 비밀번호를 설정하세요.", current: "현재 비밀번호", + temporary: "임시 비밀번호", next: "새 비밀번호", confirm: "새 비밀번호 확인", submit: "비밀번호 변경 후 계속", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 06b841e6f53..565bd3ed38f 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2840,7 +2840,9 @@ const pt = { title: "Sua senha expirou", resetTitle: "Você precisa alterar sua senha", fallbackMessage: "Defina uma nova senha para continuar.", + resetBody: "Um administrador definiu uma senha temporária para você. Escolha uma nova senha sua para continuar.", current: "Senha atual", + temporary: "Senha temporária", next: "Nova senha", confirm: "Confirmar nova senha", submit: "Alterar senha e continuar", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 7ae453f4ce4..40851334f8b 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2909,7 +2909,9 @@ const ru = { title: "Срок действия пароля истёк", resetTitle: "Необходимо сменить пароль", fallbackMessage: "Задайте новый пароль, чтобы продолжить.", + resetBody: "Администратор задал для вас временный пароль. Чтобы продолжить, задайте собственный новый пароль.", current: "Текущий пароль", + temporary: "Временный пароль", next: "Новый пароль", confirm: "Подтвердите новый пароль", submit: "Изменить пароль и продолжить", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 83704909f90..7b30fc6f5c0 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2863,7 +2863,9 @@ const zh = { title: '您的密码已过期', resetTitle: '您必须修改密码', fallbackMessage: '请设置新密码后继续。', + resetBody: '管理员为您设置了临时密码。请设置您自己的新密码后继续。', current: '当前密码', + temporary: '临时密码', next: '新密码', confirm: '确认新密码', submit: '修改密码并继续', From 4b226a304f41f8085b8fd0e40e0b721cadbbb04b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:58:48 +0000 Subject: [PATCH 02/10] wip(app-shell): Setup row 21, package sheet reasons and Disable confirm Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- ...ackagesPage.adoptLooseItems-13339.test.tsx | 3 +- .../PackagesPage.confirmFlows-11784.test.tsx | 10 +- ...ackagesPage.readOnlyActions-13157.test.tsx | 2 +- ...OnlyReasonAndDisableConfirm-27521.test.tsx | 143 ++++++++++++++++++ .../src/views/metadata-admin/PackagesPage.tsx | 64 +++++++- ...gesPage.walledCatalogRefusal-7611.test.tsx | 4 +- ui/packages/i18n/src/namespaces/studio/en.ts | 9 +- ui/packages/i18n/src/namespaces/studio/zh.ts | 9 +- 8 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyReasonAndDisableConfirm-27521.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.adoptLooseItems-13339.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.adoptLooseItems-13339.test.tsx index 99055ac223a..2201c63201f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.adoptLooseItems-13339.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.adoptLooseItems-13339.test.tsx @@ -207,7 +207,8 @@ describe('PackageDetailSheet — starting an action clears the previous message // it is where the action starts. it.each([ ['Duplicate'], - ['Delete app'], + ['Delete package'], + ['Disable'], ['Adopt loose items'], [`Discard changes (${DRAFTS.length})`], ['View info'], diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx index 9ad7bf289e3..cfbe5ad16de 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx @@ -134,14 +134,14 @@ async function settle() { } async function openDeleteDialog() { - fireEvent.click(await screen.findByRole('button', { name: 'Delete app' })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete package' })); return screen.findByTestId('pkg-detail-delete-dialog'); } -describe('PackageDetailSheet — Delete app is one in-app dialog (objectui#11784)', () => { +describe('PackageDetailSheet — Delete package is one in-app dialog (objectui#11784)', () => { it('Cancel deletes nothing, however the question is asked', async () => { renderSheet(); - fireEvent.click(await screen.findByRole('button', { name: 'Delete app' })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete package' })); await settle(); // If a dialog is up, answer it with Cancel; a native confirm was already // answered by the spy (OK, then Cancel). @@ -367,7 +367,7 @@ describe('PackageDetailSheet — Duplicate is the landing inline form (objectui# describe('PackageDetailSheet — no native dialog on these three paths (objectui#11784)', () => { it('Delete, Discard and Duplicate never call window.confirm or window.prompt', async () => { renderSheet(); - fireEvent.click(await screen.findByRole('button', { name: 'Delete app' })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete package' })); await settle(); const del = screen.queryByTestId('pkg-detail-delete-dialog'); if (del) fireEvent.click(within(del).getByTestId('pkg-detail-delete-cancel')); @@ -422,7 +422,7 @@ describe('Console Packages page — a delete there stays on the page (objectui#1 await waitFor(() => expect(deletes()).toHaveLength(1)); await waitFor(() => expect(listReads()).toHaveLength(2)); - await waitFor(() => expect(screen.queryByRole('button', { name: 'Delete app' })).toBeNull()); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Delete package' })).toBeNull()); expect(screen.getByTestId('path').textContent).toBe(START); expect(screen.queryByTestId('studio-landing')).toBeNull(); expect(screen.queryByTestId('elsewhere')).toBeNull(); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyActions-13157.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyActions-13157.test.tsx index 0e3831eb970..3e1229e3434 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyActions-13157.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyActions-13157.test.tsx @@ -35,7 +35,7 @@ import { t } from './i18n'; const label = (key: string) => t(`engine.packages.detail.${key}`, 'en'); /** Every write action the sheet offers on a writable package. */ -const WRITES = ['edit', 'publish', 'revert', 'disable', 'duplicate', 'adoptOrphans', 'deleteApp']; +const WRITES = ['edit', 'publish', 'revert', 'disable', 'duplicate', 'adoptOrphans', 'deletePackage']; /** The read actions, offered on every package. */ const READS = ['viewInfo', 'export']; diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyReasonAndDisableConfirm-27521.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyReasonAndDisableConfirm-27521.test.tsx new file mode 100644 index 00000000000..92edf214d97 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyReasonAndDisableConfirm-27521.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27521 row 21 — the package sheet says why its write actions are + * missing, confirms Disable, and calls the package a package. + * + * Pinned through the real `PackageDetailSheet`: + * (a) a read-only package states its reason above the actions: a + * project-scoped code package that it is loaded from code, a scope-less + * read-only one that it is read-only here, a platform package that it is + * part of the platform; a writable package states none; + * (b) Disable opens a confirm and sends nothing until it is confirmed; + * Cancel sends nothing; Confirm sends the one `PATCH …/disable`; + * (c) CONTROL: Enable on a disabled package runs at once, with no dialog; + * (d) the delete action is named for the package. + */ + +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { PackageDetailSheet, type InstalledPackageRow } from './PackagesPage'; +import { t } from './i18n'; + +const label = (key: string) => t(`engine.packages.detail.${key}`, 'en'); + +function row( + writable: boolean, + extra: { scope?: 'system' | 'cloud' | 'project'; enabled?: boolean } = {}, +): InstalledPackageRow { + return { + manifest: { + id: 'com.acme.crm', + name: 'Acme CRM', + version: '1.0.0', + type: 'app', + ...(extra.scope ? { scope: extra.scope } : {}), + }, + enabled: extra.enabled ?? true, + source: 'code', + writable, + }; +} + +function respond(body: unknown, status = 200) { + const text = JSON.stringify(body); + return { + ok: status >= 200 && status < 300, + status, + text: async () => text, + json: async () => JSON.parse(text), + } as unknown as Response; +} + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(async (input: string) => { + if (input.startsWith('/api/v1/meta/_drafts')) { + return respond({ success: true, data: { drafts: [] } }); + } + return respond({ success: true, data: {} }); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function renderSheet(pkg: InstalledPackageRow) { + render( + + {}} onChanged={() => {}} /> + , + ); + await screen.findByTestId('pkg-scope'); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +/** The lifecycle PATCHes the sheet sent: `enable` / `disable`. */ +function lifecycleCalls(): string[] { + return fetchMock.mock.calls + .map(([url, init]) => ({ url: String(url), method: (init as RequestInit | undefined)?.method })) + .filter((c) => c.method === 'PATCH' && /\/(enable|disable)$/.test(c.url)) + .map((c) => c.url.split('/').pop() as string); +} + +describe('PackageDetailSheet — the read-only reason (objectstack#27521 row 21)', () => { + it.each([ + ['a project-scoped code package', row(false, { scope: 'project' }), 'codeReadOnly'], + ['a scope-less read-only package', row(false), 'readOnly'], + ['a platform package', row(false, { scope: 'system' }), 'kernelReadOnly'], + ])('(a) %s states why it offers no write action', async (_name, pkg, key) => { + await renderSheet(pkg); + expect(screen.getByTestId('pkg-detail-readonly-reason')).toHaveTextContent(label(key)); + }); + + it('(a) CONTROL: a writable package states no read-only reason', async () => { + await renderSheet(row(true)); + expect(screen.queryByTestId('pkg-detail-readonly-reason')).toBeNull(); + }); +}); + +describe('PackageDetailSheet — Disable is confirmed (objectstack#27521 row 21)', () => { + it('(b) Disable opens a confirm and sends nothing; Cancel keeps it that way', async () => { + await renderSheet(row(true)); + fireEvent.click(screen.getByRole('button', { name: label('disable') })); + const dialog = await screen.findByTestId('pkg-detail-disable-dialog'); + expect(within(dialog).getByText(/Acme CRM/)).toBeTruthy(); + expect(lifecycleCalls()).toEqual([]); + + fireEvent.click(within(dialog).getByTestId('pkg-detail-disable-cancel')); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(screen.queryByTestId('pkg-detail-disable-dialog')).toBeNull(); + expect(lifecycleCalls()).toEqual([]); + }); + + it('(b) Confirm sends the one disable', async () => { + await renderSheet(row(true)); + fireEvent.click(screen.getByRole('button', { name: label('disable') })); + fireEvent.click(within(await screen.findByTestId('pkg-detail-disable-dialog')).getByTestId('pkg-detail-disable-confirm')); + await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['disable'])); + }); + + it('(c) CONTROL: Enable on a disabled package runs at once, with no dialog', async () => { + await renderSheet(row(true, { enabled: false })); + fireEvent.click(screen.getByRole('button', { name: label('enable') })); + await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['enable'])); + expect(screen.queryByTestId('pkg-detail-disable-dialog')).toBeNull(); + }); + + it('(d) the delete action is named for the package', async () => { + await renderSheet(row(true)); + expect(screen.getByRole('button', { name: label('deletePackage') })).toBeTruthy(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index e5852c68b6e..032c769d5b0 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -522,6 +522,9 @@ export function PackageDetailSheet({ // the fourth. const [adoptOpen, setAdoptOpen] = React.useState(false); const [deleteOpen, setDeleteOpen] = React.useState(false); + // Disable takes a package's metadata out of service for every user, so it + // is confirmed first; Enable restores and runs at once. + const [disableOpen, setDisableOpen] = React.useState(false); const [deleteMode, setDeleteMode] = React.useState(''); const [deleteTyped, setDeleteTyped] = React.useState(''); const [discardOpen, setDiscardOpen] = React.useState(false); @@ -1005,11 +1008,20 @@ export function PackageDetailSheet({
- {isKernel && ( -

+ {/* Why the write actions are missing, said once above them: a + read-only package offers only its reads (and Enable when it is + off), and an absent button explains nothing on its own. */} + {isKernel ? ( +

{t('engine.packages.detail.kernelReadOnly', locale)}

- )} + ) : pkg.writable === false ? ( +

+ {pkg.manifest.scope === 'project' + ? t('engine.packages.detail.codeReadOnly', locale) + : t('engine.packages.detail.readOnly', locale)} +

+ ) : null}

{t('engine.packages.detail.actions', locale)}

@@ -1040,7 +1052,12 @@ export function PackageDetailSheet({ server's enable door has no writability gate), so a disabled read-only package can still be switched back on. */} {(writable || !enabled) && ( - )}
@@ -1185,7 +1202,7 @@ export function PackageDetailSheet({ setDeleteMode(v === 'structure' || v === 'all' ? v : '')} - aria-label={t('engine.packages.detail.deleteApp', locale)} + aria-label={t('engine.packages.detail.deletePackage', locale)} className="gap-3" >
@@ -1246,7 +1263,34 @@ export function PackageDetailSheet({ > {busy === 'delete' ? t('engine.packages.detail.deleting', locale) - : t('engine.packages.detail.deleteApp', locale)} + : t('engine.packages.detail.deletePackage', locale)} + + + + + + + + + + {tFormat('engine.packages.detail.disableTitle', locale, { name: displayName })} + + + {t('engine.packages.detail.disableDescription', locale)} + + + + + {t('engine.cancel', locale)} + + { + setDisableOpen(false); + void toggleEnable(); + }} + > + {t('engine.packages.detail.disable', locale)} @@ -1543,7 +1587,11 @@ export function PackagesPage() { ? t('engine.packages.installedApps.title', locale) : t('engine.packages.title', locale) } - subtitle={t('engine.packages.description', locale)} + subtitle={ + installedAppsView + ? t('engine.packages.installedApps.description', locale) + : t('engine.packages.description', locale) + } icon={} refresh={{ onRefresh: () => void load(), label: t('engine.packages.refresh', locale), busy: loading }} actions={ diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx index a782fcec2fd..36a23568120 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx @@ -113,7 +113,7 @@ afterEach(() => { describe.each(['group', 'isolated'])( 'Packages page — a walled catalog-package refusal shows its reason, under `%s` (objectui#7611)', (posture) => { - it('Delete app: the `DELETE /api/v1/packages/:id` refusal is shown with its code', async () => { + it('Delete package: the `DELETE /api/v1/packages/:id` refusal is shown with its code', async () => { serve(() => json(403, { success: false, error: { code: 'PERMISSION_DENIED', message: deleteRefusal(posture), httpStatus: 403 } }, 'Forbidden'), ); @@ -122,7 +122,7 @@ describe.each(['group', 'isolated'])( {}} onChanged={() => {}} /> , ); - fireEvent.click(await screen.findByRole('button', { name: 'Delete app' })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete package' })); const dialog = await screen.findByTestId('pkg-detail-delete-dialog'); fireEvent.click(within(dialog).getByTestId('pkg-detail-delete-mode-all')); fireEvent.change(within(dialog).getByTestId('pkg-detail-delete-name-input'), { target: { value: PKG_NAME } }); diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 66c729b6602..a4d2600a5cf 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -1753,6 +1753,7 @@ const STUDIO_EN: Record = { 'engine.packages.emptyCreate': 'Create your first package to start authoring metadata.', 'engine.packages.emptyFiltered': 'No packages match your filters.', 'engine.packages.installedApps.title': 'Installed Apps', + 'engine.packages.installedApps.description': 'The apps installed from the marketplace into this system.', 'engine.packages.installedApps.empty': 'No apps installed from the marketplace yet', 'engine.packages.installedApps.emptyHint': 'Apps you install from the marketplace are listed here.', 'engine.packages.loadFailed': 'Failed to load packages', @@ -1829,7 +1830,9 @@ const STUDIO_EN: Record = { 'engine.packages.detail.pendingHint': 'Drafted, not yet published. Publish the whole app, or review each below.', 'engine.packages.detail.publishApp': 'Publish app ({count})', 'engine.packages.detail.discardChanges': 'Discard changes ({count})', - 'engine.packages.detail.kernelReadOnly': 'This is a platform kernel package. Authoring actions are disabled.', + 'engine.packages.detail.kernelReadOnly': 'This package is part of the platform. It cannot be edited, disabled or deleted.', + 'engine.packages.detail.codeReadOnly': 'This package is loaded from code, so it is read-only here: it cannot be edited, disabled or deleted in the console. Change it in its source and deploy again.', + 'engine.packages.detail.readOnly': 'This package is read-only here: it cannot be edited, disabled or deleted in the console.', 'engine.packages.detail.actions': 'Actions', 'engine.packages.detail.edit': 'Edit', 'engine.packages.edit.title': 'Edit package', @@ -1842,9 +1845,11 @@ const STUDIO_EN: Record = { 'engine.packages.detail.revert': 'Revert', 'engine.packages.detail.enable': 'Enable', 'engine.packages.detail.disable': 'Disable', + 'engine.packages.detail.disableTitle': 'Disable "{name}"?', + 'engine.packages.detail.disableDescription': 'The package is taken out of service for every user until it is enabled again. No records are deleted, and you can enable it again at any time.', 'engine.packages.detail.export': 'Export', 'engine.packages.detail.exporting': 'Exporting…', - 'engine.packages.detail.deleteApp': 'Delete app', + 'engine.packages.detail.deletePackage': 'Delete package', 'engine.packages.detail.deleting': 'Deleting…', 'engine.packages.detail.discarding': 'Discarding…', 'engine.packages.detail.actionFailed': 'Action failed', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 0ad91d22553..1e6acb547c0 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1561,6 +1561,7 @@ const STUDIO_ZH: Record = { 'engine.packages.emptyCreate': '创建第一个软件包以开始编写元数据。', 'engine.packages.emptyFiltered': '没有匹配筛选条件的软件包。', 'engine.packages.installedApps.title': '已安装的应用', + 'engine.packages.installedApps.description': '从应用市场安装到本系统的应用。', 'engine.packages.installedApps.empty': '尚未从应用市场安装任何应用', 'engine.packages.installedApps.emptyHint': '从应用市场安装的应用会列在这里。', 'engine.packages.loadFailed': '加载软件包失败', @@ -1657,7 +1658,9 @@ const STUDIO_ZH: Record = { 'engine.packages.detail.pendingHint': '草稿尚未发布。可以发布整个应用,也可以逐项查看。', 'engine.packages.detail.publishApp': '发布应用({count})', 'engine.packages.detail.discardChanges': '丢弃更改({count})', - 'engine.packages.detail.kernelReadOnly': '这是平台内核软件包,已禁用编写操作。', + 'engine.packages.detail.kernelReadOnly': '此软件包是平台的一部分,不能编辑、禁用或删除。', + 'engine.packages.detail.codeReadOnly': '此软件包从代码加载,在这里是只读的:不能在控制台中编辑、禁用或删除。请在其源代码中修改后重新部署。', + 'engine.packages.detail.readOnly': '此软件包在这里是只读的:不能在控制台中编辑、禁用或删除。', 'engine.packages.detail.actions': '操作', 'engine.packages.detail.edit': '编辑', 'engine.packages.edit.title': '编辑软件包', @@ -1670,9 +1673,11 @@ const STUDIO_ZH: Record = { 'engine.packages.detail.revert': '还原', 'engine.packages.detail.enable': '启用', 'engine.packages.detail.disable': '禁用', + 'engine.packages.detail.disableTitle': '禁用「{name}」?', + 'engine.packages.detail.disableDescription': '在重新启用之前,此软件包将对所有用户停止服务。不会删除任何记录,您可以随时重新启用。', 'engine.packages.detail.export': '导出', 'engine.packages.detail.exporting': '导出中…', - 'engine.packages.detail.deleteApp': '删除应用', + 'engine.packages.detail.deletePackage': '删除软件包', 'engine.packages.detail.deleting': '删除中…', 'engine.packages.detail.discarding': '丢弃中…', 'engine.packages.detail.actionFailed': '操作失败', From 0feed7d25879fcbc5500b65c7050fca9acd40e49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:00:57 +0000 Subject: [PATCH 03/10] wip(app-shell): Setup row 20, packaged automation confirm, toast and object label Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .../setup/PackagedActionsSection.test.tsx | 83 +++++++++++++++++-- .../views/setup/PackagedActionsSection.tsx | 76 +++++++++++++++-- .../views/setup/PackagedAutomationPage.tsx | 9 ++ .../src/views/setup/packagedActions.ts | 8 ++ ui/packages/i18n/src/locales/ar.ts | 5 ++ ui/packages/i18n/src/locales/de.ts | 5 ++ ui/packages/i18n/src/locales/en.ts | 7 ++ ui/packages/i18n/src/locales/es.ts | 5 ++ ui/packages/i18n/src/locales/fr.ts | 5 ++ ui/packages/i18n/src/locales/ja.ts | 5 ++ ui/packages/i18n/src/locales/ko.ts | 5 ++ ui/packages/i18n/src/locales/pt.ts | 5 ++ ui/packages/i18n/src/locales/ru.ts | 5 ++ ui/packages/i18n/src/locales/zh.ts | 5 ++ 14 files changed, 215 insertions(+), 13 deletions(-) diff --git a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx index a3cb60e1e69..3f325dfbf16 100644 --- a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx +++ b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx @@ -37,8 +37,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; +import { toast } from 'sonner'; import { PackagedActionsSection } from './PackagedActionsSection'; +vi.mock('sonner', () => ({ toast: { success: vi.fn() } })); + /* -------------------------------------------------------------------------- */ /* The server's own refusal strings */ /* -------------------------------------------------------------------------- */ @@ -256,6 +259,15 @@ describe('activation state as the ledger reports it', () => { /* 2) Activation round-trip */ /* -------------------------------------------------------------------------- */ +/** + * Switching a packaged action OFF asks a confirm first (objectstack#27521 row + * 20): click its switch, then the confirm. Switching one ON runs at once. + */ +async function switchOff(name: string): Promise { + fireEvent.click(screen.getByRole('switch', { name })); + fireEvent.click(await screen.findByTestId('packaged-action-off-confirm')); +} + describe('activation round-trip', () => { it('disables and re-enables a packaged action, showing what the server reports', async () => { await renderSection(); @@ -263,7 +275,7 @@ describe('activation round-trip', () => { const invoice = screen.getByRole('switch', { name: 'Activation for Send invoice on account' }); expect(invoice).toBeChecked(); - fireEvent.click(invoice); + await switchOff('Activation for Send invoice on account'); await waitFor(() => expect(flipCalls()).toHaveLength(1)); expect(flipCalls()[0].method).toBe('POST'); expect(flipCalls()[0].url).toContain('/actions/_activation/account/send_invoice'); @@ -290,7 +302,7 @@ describe('activation round-trip', () => { it('moves only the flipped row, even when a sibling shares the object', async () => { await renderSection(); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await switchOff('Activation for Send invoice on account'); await waitFor(() => expect( screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), @@ -309,7 +321,7 @@ describe('activation round-trip', () => { render(); await screen.findByText('Nightly sync'); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Nightly sync on global' })); + await switchOff('Activation for Nightly sync on global'); await waitFor(() => expect(flipCalls()).toHaveLength(1)); expect(flipCalls()[0].url).toContain('/actions/_activation/global/nightly_sync'); }); @@ -324,7 +336,7 @@ describe('server refusals reach the operator verbatim', () => { fixture.flip = [{ status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }]; await renderSection(); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await switchOff('Activation for Send invoice on account'); const shown = await screen.findByText(POSTURE_REFUSAL); expect(shown).toBeInTheDocument(); @@ -349,7 +361,7 @@ describe('server refusals reach the operator verbatim', () => { // conflict with a client-side guess about which one is "real". expect(screen.getByRole('switch', { name: 'Activation for Export on contact' })).toBeInTheDocument(); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Export on account' })); + await switchOff('Activation for Export on account'); const shown = await screen.findByText(AMBIGUITY_REFUSAL); expect(shown).toBeInTheDocument(); @@ -361,7 +373,7 @@ describe('server refusals reach the operator verbatim', () => { fixture.flip = [{ status: 503, body: errorEnvelope('SERVICE_UNAVAILABLE', NO_LEDGER_REFUSAL) }]; await renderSection(); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await switchOff('Activation for Send invoice on account'); expect(await screen.findByText(NO_LEDGER_REFUSAL)).toBeInTheDocument(); // An outage is not a verdict, and it is not a hiccup to paper over either: @@ -374,7 +386,7 @@ describe('server refusals reach the operator verbatim', () => { fixture.flip = [{ status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }]; await renderSection(); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await switchOff('Activation for Send invoice on account'); await screen.findByText(POSTURE_REFUSAL); const invoiceRow = screen.getByTestId('packaged-action-account-send_invoice'); @@ -455,7 +467,7 @@ describe('no ancestry or drift surface', () => { ]; await renderSection(); - fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + await switchOff('Activation for Send invoice on account'); await waitFor(() => expect( screen.getByRole('switch', { name: 'Activation for Send invoice on account' }), @@ -470,3 +482,58 @@ describe('no ancestry or drift surface', () => { expect(section.textContent).not.toContain('v3'); }); }); + +/* -------------------------------------------------------------------------- */ +/* 6) The switch confirms off, says what happened, names the object */ +/* (objectstack#27521 row 20) */ +/* -------------------------------------------------------------------------- */ + +describe('the switch confirms off, says what happened, and names the object', () => { + beforeEach(() => { + vi.mocked(toast.success).mockClear(); + }); + + it('switching off asks first: Cancel sends nothing and leaves the switch on', async () => { + await renderSection(); + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + + const dialog = await screen.findByTestId('packaged-action-off-dialog'); + expect(dialog).toHaveTextContent('Send invoice'); + expect(flipCalls()).toHaveLength(0); + + fireEvent.click(within(dialog).getByTestId('packaged-action-off-cancel')); + await waitFor(() => expect(screen.queryByTestId('packaged-action-off-dialog')).toBeNull()); + expect(flipCalls()).toHaveLength(0); + expect(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })).toBeChecked(); + }); + + it('an accepted switch says so in a toast, off and then on; switching on asks nothing', async () => { + await renderSection(); + await switchOff('Activation for Send invoice on account'); + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(1)); + expect(vi.mocked(toast.success).mock.calls[0][0]).toContain('Send invoice'); + + fireEvent.click(screen.getByRole('switch', { name: 'Activation for Send invoice on account' })); + expect(screen.queryByTestId('packaged-action-off-dialog')).toBeNull(); + await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(2)); + expect(flipCalls().map((c) => (c.body as { enabled: boolean }).enabled)).toEqual([false, true]); + }); + + it('CONTROL: a refused switch shows the refusal and no toast', async () => { + fixture.flip = [{ status: 403, body: errorEnvelope('PERMISSION_DENIED', POSTURE_REFUSAL) }]; + await renderSection(); + await switchOff('Activation for Send invoice on account'); + await screen.findByText(POSTURE_REFUSAL); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('draws the object by its label, with the machine name beside it', async () => { + fixture.objects = [ + { ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: 'Account' }, + ]; + render(); + const row = await screen.findByTestId('packaged-action-account-send_invoice'); + expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('Account'); + expect(within(row).getByText('account')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx index 585738fb7e9..11d27eb4d8f 100644 --- a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx +++ b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx @@ -62,7 +62,16 @@ */ import * as React from 'react'; +import { toast } from 'sonner'; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Badge, Empty, EmptyDescription, @@ -76,7 +85,7 @@ import { TableHeader, TableRow, } from '@object-ui/components'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { actionErrorDetail } from '@object-ui/core'; // The LEAF module, for the reason its own header gives: this section joins the @@ -191,6 +200,7 @@ function rowKey(row: Pick): string { export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProps) { const { t } = useObjectTranslation(); + const { objectLabel } = useObjectLabel(); const [rows, setRows] = React.useState(null); const [loadError, setLoadError] = React.useState(null); @@ -198,6 +208,12 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp const [busyKey, setBusyKey] = React.useState(null); /** Per-action server refusal, keyed by `:`. Verbatim. */ const [refusals, setRefusals] = React.useState>({}); + /** + * The action whose switch-off waits on its confirm. Off stops the action + * everywhere it is offered, for every user, so it is asked first; on + * restores it and runs at once. + */ + const [pendingOff, setPendingOff] = React.useState(null); React.useEffect(() => { const controller = new AbortController(); @@ -283,6 +299,14 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp setRows( (prev) => prev?.map((r) => (rowKey(r) === key ? { ...r, enabled: next } : r)) ?? prev, ); + toast.success( + next + ? t('packagedAutomation.switchedOn', { defaultValue: '"{{label}}" is switched on.', label: row.label }) + : t('packagedAutomation.switchedOff', { + defaultValue: '"{{label}}" is switched off for this deployment.', + label: row.label, + }), + ); } catch (e) { setRefusals((prev) => ({ ...prev, @@ -368,9 +392,15 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp )} - {/* The machine name, which is also the `:object` segment - the flip addresses — `global` for an object-less - action, the platform's own spelling. */} + {/* The object's label in the reader's language, over the + machine name, which is also the `:object` segment the + flip addresses — `global` for an object-less action, + the platform's own spelling, drawn alone. */} + {row.objectLabel && ( +
+ {objectLabel({ name: row.objectName, label: row.objectLabel })} +
+ )} {row.objectName}
@@ -378,7 +408,9 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp void onToggle(row, next)} + onCheckedChange={(next: boolean) => + next ? void onToggle(row, true) : setPendingOff(row) + } // The object is part of the accessible name because it // is part of the identity: two objects may declare the // same action name, and a label without the object @@ -402,6 +434,40 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp )} + + !open && setPendingOff(null)}> + + + + {t('packagedAutomation.actionOffTitle', { + defaultValue: 'Switch "{{label}}" off?', + label: pendingOff?.label ?? '', + })} + + + {t('packagedAutomation.actionOffBody', { + defaultValue: + 'It stops running everywhere it is offered on this deployment, for every user, until it is switched on again.', + })} + + + + + {t('packagedAutomation.cancel', { defaultValue: 'Cancel' })} + + { + const target = pendingOff; + setPendingOff(null); + if (target) void onToggle(target, false); + }} + > + {t('packagedAutomation.actionOffConfirm', { defaultValue: 'Switch off' })} + + + + ); } diff --git a/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx b/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx index 821b47b0152..69250d94aff 100644 --- a/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx +++ b/ui/packages/app-shell/src/views/setup/PackagedAutomationPage.tsx @@ -110,6 +110,7 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; +import { toast } from 'sonner'; import { Info, Loader2, Workflow } from 'lucide-react'; import { Badge, @@ -424,6 +425,14 @@ export function PackagedAutomationPage() { return; } applyToggled(row.name, outcome.enabled); + toast.success( + outcome.enabled + ? t('packagedAutomation.switchedOn', { defaultValue: '"{{label}}" is switched on.', label: row.label }) + : t('packagedAutomation.switchedOff', { + defaultValue: '"{{label}}" is switched off for this deployment.', + label: row.label, + }), + ); } finally { setBusyFlow(null); } diff --git a/ui/packages/app-shell/src/views/setup/packagedActions.ts b/ui/packages/app-shell/src/views/setup/packagedActions.ts index 1858b4a68e3..1ec9bb07ec7 100644 --- a/ui/packages/app-shell/src/views/setup/packagedActions.ts +++ b/ui/packages/app-shell/src/views/setup/packagedActions.ts @@ -87,6 +87,12 @@ export interface PackagedActionRow { objectName: string; /** Display name; falls back to the machine name when the item has none. */ label: string; + /** + * The owning object's label as `GET /meta/object` serves it, when the + * object is in that list and declares one; the page resolves it in the + * reader's language and keeps the machine name beside it. + */ + objectLabel?: string; /** Activation as the ledger reports it — `true` when no row says otherwise. */ enabled: boolean; } @@ -241,10 +247,12 @@ export function collectPackagedActions( seen.add(key); if (!isPackagedFlowItem(packagedActionSource(action, obj))) return; const label = a?.label; + const objectLabel = (obj as { label?: unknown } | null | undefined)?.label; rows.push({ name, objectName, label: typeof label === 'string' && label ? label : name, + ...(typeof objectLabel === 'string' && objectLabel ? { objectLabel } : {}), enabled: activation.get(name) ?? true, }); }; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index c82058c38d3..20f0137357c 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -5166,6 +5166,11 @@ const ar = { actionsEmptyTitle: "لا توجد إجراءات من الحزم", actionsEmptyBody: "لا توجد حزمة مثبّتة تعلن عن إجراء في هذا النشر. الإجراءات التي تنشئها بنفسك موجودة في Studio.", actionsLoadFailed: "تعذّر تحميل إجراءات الحزم.", + switchedOn: "تم تشغيل \"{{label}}\".", + switchedOff: "تم إيقاف \"{{label}}\" في هذا النشر.", + actionOffTitle: "إيقاف \"{{label}}\"؟", + actionOffBody: "سيتوقف عن العمل في كل مكان يُعرض فيه ضمن هذا النشر، لجميع المستخدمين، حتى تتم إعادة تشغيله.", + actionOffConfirm: "إيقاف", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 7a2c88df31b..469190c0ce3 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4688,6 +4688,11 @@ const de = { actionsEmptyTitle: "Keine Paket-Aktionen", actionsEmptyBody: "In dieser Installation deklariert kein installiertes Paket eine Aktion. Selbst erstellte Aktionen finden Sie im Studio.", actionsLoadFailed: "Paket-Aktionen konnten nicht geladen werden.", + switchedOn: "„{{label}}“ ist eingeschaltet.", + switchedOff: "„{{label}}“ ist für diese Bereitstellung ausgeschaltet.", + actionOffTitle: "„{{label}}“ ausschalten?", + actionOffBody: "Es wird überall, wo es in dieser Bereitstellung angeboten wird, für alle Benutzer nicht mehr ausgeführt, bis es wieder eingeschaltet wird.", + actionOffConfirm: "Ausschalten", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index d8e59ea5874..1d9d1da1d8a 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -5495,6 +5495,13 @@ const en = { actionsEmptyBody: 'No installed package declares an action on this deployment. Actions you author yourself live in Studio.', actionsLoadFailed: 'Could not load packaged actions.', + // The toast after a switch the server accepted, in both sections, and + // the confirm a packaged action's switch-off asks first (objectstack#27521). + switchedOn: '"{{label}}" is switched on.', + switchedOff: '"{{label}}" is switched off for this deployment.', + actionOffTitle: 'Switch "{{label}}" off?', + actionOffBody: 'It stops running everywhere it is offered on this deployment, for every user, until it is switched on again.', + actionOffConfirm: 'Switch off', }, element: { // objectui#10951 — `element:number` authored with an aggregate and no diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 5f1ef5d456b..109e04db532 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4807,6 +4807,11 @@ const es = { actionsEmptyTitle: "Sin acciones de paquete", actionsEmptyBody: "Ningún paquete instalado declara una acción en esta implementación. Las acciones que creas tú están en Studio.", actionsLoadFailed: "No se pudieron cargar las acciones de paquete.", + switchedOn: "\"{{label}}\" está activado.", + switchedOff: "\"{{label}}\" está desactivado en esta implementación.", + actionOffTitle: "¿Desactivar \"{{label}}\"?", + actionOffBody: "Dejará de ejecutarse en todos los lugares donde se ofrece en esta implementación, para todos los usuarios, hasta que se vuelva a activar.", + actionOffConfirm: "Desactivar", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 3c80b823926..26ef5e81455 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4803,6 +4803,11 @@ const fr = { actionsEmptyTitle: "Aucune action de package", actionsEmptyBody: "Aucun package installé ne déclare d'action sur ce déploiement. Les actions que vous créez vous-même se trouvent dans Studio.", actionsLoadFailed: "Impossible de charger les actions des packages.", + switchedOn: "« {{label}} » est activé.", + switchedOff: "« {{label}} » est désactivé pour ce déploiement.", + actionOffTitle: "Désactiver « {{label}} » ?", + actionOffBody: "Il cesse de s’exécuter partout où il est proposé dans ce déploiement, pour tous les utilisateurs, jusqu’à ce qu’il soit réactivé.", + actionOffConfirm: "Désactiver", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 6b716412ba0..9bff55f72a4 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4689,6 +4689,11 @@ const ja = { actionsEmptyTitle: "パッケージアクションはありません", actionsEmptyBody: "このデプロイには、アクションを宣言するインストール済みパッケージがありません。自分で作成したアクションは Studio にあります。", actionsLoadFailed: "パッケージアクションを読み込めませんでした。", + switchedOn: "「{{label}}」をオンにしました。", + switchedOff: "「{{label}}」をこのデプロイでオフにしました。", + actionOffTitle: "「{{label}}」をオフにしますか?", + actionOffBody: "再びオンにするまで、このデプロイで提供されているすべての場所で、すべてのユーザーに対して実行されなくなります。", + actionOffConfirm: "オフにする", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 8fc54d6d091..c9e603aaac8 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4686,6 +4686,11 @@ const ko = { actionsEmptyTitle: "패키지 액션 없음", actionsEmptyBody: "이 배포에는 액션을 선언하는 설치된 패키지가 없습니다. 직접 작성한 액션은 Studio에 있습니다.", actionsLoadFailed: "패키지 액션을 불러오지 못했습니다.", + switchedOn: "\"{{label}}\"을(를) 켰습니다.", + switchedOff: "\"{{label}}\"을(를) 이 배포에서 껐습니다.", + actionOffTitle: "\"{{label}}\"을(를) 끌까요?", + actionOffBody: "다시 켤 때까지 이 배포에서 제공되는 모든 곳에서 모든 사용자에게 실행되지 않습니다.", + actionOffConfirm: "끄기", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 565bd3ed38f..050567bf39d 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4802,6 +4802,11 @@ const pt = { actionsEmptyTitle: "Nenhuma ação de pacote", actionsEmptyBody: "Nenhum pacote instalado declara uma ação nesta implantação. As ações que você mesmo cria ficam no Studio.", actionsLoadFailed: "Não foi possível carregar as ações de pacote.", + switchedOn: "\"{{label}}\" está ativado.", + switchedOff: "\"{{label}}\" está desativado nesta implantação.", + actionOffTitle: "Desativar \"{{label}}\"?", + actionOffBody: "Ele deixa de ser executado em todos os lugares onde é oferecido nesta implantação, para todos os usuários, até ser ativado novamente.", + actionOffConfirm: "Desativar", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 40851334f8b..4ea105ed150 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4935,6 +4935,11 @@ const ru = { actionsEmptyTitle: "Нет действий из пакетов", actionsEmptyBody: "В этой установке ни один установленный пакет не объявляет действие. Действия, которые вы создаёте сами, находятся в Studio.", actionsLoadFailed: "Не удалось загрузить действия из пакетов.", + switchedOn: "«{{label}}» включено.", + switchedOff: "«{{label}}» выключено для этого развёртывания.", + actionOffTitle: "Выключить «{{label}}»?", + actionOffBody: "Оно перестанет выполняться везде, где предлагается в этом развёртывании, для всех пользователей, пока его снова не включат.", + actionOffConfirm: "Выключить", }, element: { number: { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 7b30fc6f5c0..397c5fe419b 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4746,6 +4746,11 @@ const zh = { actionsEmptyTitle: '没有打包动作', actionsEmptyBody: '本部署中没有任何已安装软件包声明动作。你自己编写的动作在 Studio 中。', actionsLoadFailed: '无法加载打包动作。', + switchedOn: '「{{label}}」已开启。', + switchedOff: '「{{label}}」已在本部署中关闭。', + actionOffTitle: '关闭「{{label}}」?', + actionOffBody: '在重新开启之前,它将在本部署中所有提供它的地方对所有用户停止运行。', + actionOffConfirm: '关闭', }, element: { number: { From efbe0a1482b239cd849405ddcceab61cbac3cfd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:19:54 +0000 Subject: [PATCH 04/10] wip(app-shell): Setup row 1, Home link and switcher use the Setup app's label Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .../app-shell/src/console/home/HomeAppsStrip.tsx | 9 ++++++++- ui/packages/app-shell/src/console/home/HomePage.tsx | 5 +++++ .../__tests__/HomePage.greetingAndApps-12601.test.tsx | 7 +++++-- ui/packages/app-shell/src/layout/AppSwitcher.tsx | 7 +++++-- .../__tests__/AppSwitcher.homeMenu-12605.test.tsx | 11 ++++++++++- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx b/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx index 379346eb6e8..e681c6c6690 100644 --- a/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx +++ b/ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx @@ -58,6 +58,7 @@ export function HomeAppsStrip({ canAuthorMetadata = true, marketplaceEnabled = true, settingsHref, + settingsLabel, lastOpened, }: { apps: any[]; @@ -91,6 +92,12 @@ export function HomeAppsStrip({ * case. */ settingsHref?: string; + /** + * The Setup app's own label, as its header and the app switcher draw it, so + * the link names the app it opens in the reader's language. Omitted, the + * link falls back to the `home.systemSettings` string. + */ + settingsLabel?: string; /** * [objectui#12601] When the user last had each app open, by app `name` (an * ISO timestamp), drawn under the tile's name as "2 hr. ago" (zh "2小时前打开"; @@ -153,7 +160,7 @@ export function HomeAppsStrip({ data-testid="home-system-settings-link" > - {t('home.systemSettings', { defaultValue: 'System Settings' })} + {settingsLabel || t('home.systemSettings', { defaultValue: 'System Settings' })} )} diff --git a/ui/packages/app-shell/src/console/home/HomePage.tsx b/ui/packages/app-shell/src/console/home/HomePage.tsx index 062c332fc67..07e099a3c06 100644 --- a/ui/packages/app-shell/src/console/home/HomePage.tsx +++ b/ui/packages/app-shell/src/console/home/HomePage.tsx @@ -790,6 +790,11 @@ export function HomePage() { apps={launcherApps} favorites={favorites} settingsHref={setupApp ? setupPath : undefined} + settingsLabel={ + setupApp + ? appLabel({ name: setupApp.name, label: resolveKeyedI18nLabel(setupApp.label, t) }) + : undefined + } lastOpened={lastOpened} /* Deliberately NOT resolved through `hostAppSegment` (objectui#4074 scope guard): the marketplace is an admin-only surface that lives diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx index f35199cdfa6..716a0ae4f59 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx @@ -298,13 +298,16 @@ describe('the builder chips (objectui#12601)', () => { }); describe('the apps strip (objectui#12601)', () => { - it('Setup is not among the tiles: it is the heading’s System Settings link, on the /setup alias’s route', () => { + it('Setup is not among the tiles: it is the heading’s link, named for the app, on the /setup alias’s route', () => { render(); expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument(); expect(screen.queryByTestId('app-tile-setup')).toBeNull(); const link = screen.getByTestId('home-system-settings-link'); - expect(link).toHaveTextContent('«home.systemSettings»'); + // objectstack#27521 row 1 — named by the Setup app's own label, the name + // its header and the app switcher show. + expect(link).toHaveTextContent('Setup'); + expect(link).not.toHaveTextContent('«home.systemSettings»'); expect(link).toHaveAttribute('href', '/apps/com.objectstack.setup'); // The count pill counts the tiles, so Setup is not in it. expect(screen.getByTestId('home-apps-count')).toHaveTextContent(/^1$/); diff --git a/ui/packages/app-shell/src/layout/AppSwitcher.tsx b/ui/packages/app-shell/src/layout/AppSwitcher.tsx index d4b6058ebce..b75f5ce3611 100644 --- a/ui/packages/app-shell/src/layout/AppSwitcher.tsx +++ b/ui/packages/app-shell/src/layout/AppSwitcher.tsx @@ -188,14 +188,17 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: - {setupPath ? ( + {setupApp && setupPath ? ( - {t('layout.appSwitcher.settings', { defaultValue: 'Settings' })} + {/* The Setup app's own label, as its header draws it once it + is open, so the entry and the app it opens share one name. */} + {appLabel({ name: setupApp.name, label: resolveKeyedI18nLabel(setupApp.label, t) }) || + t('layout.appSwitcher.settings', { defaultValue: 'Settings' })} {activeApp === setupApp && } diff --git a/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.homeMenu-12605.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.homeMenu-12605.test.tsx index 21fcd0fed56..c5a7e5715c3 100644 --- a/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.homeMenu-12605.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.homeMenu-12605.test.tsx @@ -110,7 +110,16 @@ describe('the app pill\'s menu (objectui#12605)', () => { ]); expect(menu().getByTestId('app-switcher-home')).toHaveTextContent('Back to home'); expect(menu().getByTestId('app-switcher-all-apps')).toHaveTextContent('All apps'); - expect(menu().getByTestId('app-switcher-settings')).toHaveTextContent('Settings'); + // objectstack#27521 row 1 — the entry carries the Setup app's own label, + // the name its header shows once it is open. + expect(menu().getByTestId('app-switcher-settings')).toHaveTextContent('Setup'); + }); + + it('the Setup entry follows the app’s served label, whatever it is (objectstack#27521 row 1)', () => { + apps = [HOTCRM, SERVICE, { ...SETUP, label: 'Administration' }]; + renderSwitcher('hotcrm'); + expect(menu().getByTestId('app-switcher-settings')).toHaveTextContent('Administration'); + expect(menu().getByTestId('app-switcher-settings')).not.toHaveTextContent('Settings'); }); it('Setup is not in the switch group; the current app is checked', () => { From 92450f4f7fb2a77243a34d470f523be430c5f5c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:31:42 +0000 Subject: [PATCH 05/10] wip(app-shell,fields): Setup rows 4 and 5, severity colours and focusable header tags Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .../src/components/ManagedByBadge.tsx | 3 ++ .../ManagedByBadge.keyboard-27521.test.tsx | 28 +++++++++++++++++++ .../__tests__/badge-severity-27521.test.ts | 20 +++++++++++++ ui/packages/fields/src/index.tsx | 3 ++ 4 files changed, 54 insertions(+) create mode 100644 ui/packages/app-shell/src/components/__tests__/ManagedByBadge.keyboard-27521.test.tsx create mode 100644 ui/packages/fields/src/__tests__/badge-severity-27521.test.ts diff --git a/ui/packages/app-shell/src/components/ManagedByBadge.tsx b/ui/packages/app-shell/src/components/ManagedByBadge.tsx index 108af3abb1e..31d7526050d 100644 --- a/ui/packages/app-shell/src/components/ManagedByBadge.tsx +++ b/ui/packages/app-shell/src/components/ManagedByBadge.tsx @@ -214,8 +214,11 @@ export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgePr + {/* Focusable, so the explanation a hover opens is reachable from + the keyboard too: the tooltip opens on focus. */} { + it('Tab focuses the tag and opens its explanation', async () => { + render(); + const badge = screen.getByTestId('managed-by-badge'); + expect(screen.queryByRole('tooltip')).toBeNull(); + + await userEvent.tab(); + expect(badge).toHaveFocus(); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Administrator configuration'); + }); + + it('CONTROL: a platform object draws no tag, so there is nothing to focus', async () => { + render(); + expect(screen.queryByTestId('managed-by-badge')).toBeNull(); + }); +}); diff --git a/ui/packages/fields/src/__tests__/badge-severity-27521.test.ts b/ui/packages/fields/src/__tests__/badge-severity-27521.test.ts new file mode 100644 index 00000000000..aa564e72d5b --- /dev/null +++ b/ui/packages/fields/src/__tests__/badge-severity-27521.test.ts @@ -0,0 +1,20 @@ +/** + * objectstack#27521 row 4 — a severity value with no declared colour reads in + * the severity colours, not in a hashed one: `info` was drawn orange, the + * colour of an alarm, for being the quietest level. + */ +import { describe, it, expect } from 'vitest'; +import { getBadgeColorClasses, getSemanticColorName } from '../index'; + +describe('severity values with no declared colour (objectstack#27521)', () => { + it('info is blue and warning is yellow', () => { + expect(getBadgeColorClasses(undefined, 'info')).toContain('bg-blue-50'); + expect(getBadgeColorClasses(undefined, 'warning')).toContain('bg-yellow-50'); + expect(getSemanticColorName(undefined, 'info')).toBe('blue'); + }); + + it('CONTROL: critical stays red, and a declared colour still wins', () => { + expect(getBadgeColorClasses(undefined, 'critical')).toContain('bg-red-50'); + expect(getBadgeColorClasses('green', 'info')).toContain('bg-green-50'); + }); +}); diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index d8e292c1253..1d683cfc478 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -1437,6 +1437,9 @@ const SEMANTIC_COLOR_MAP: Record = { normal: 'blue', low: 'gray', none: 'gray', + // Severity values (en): info is the quiet level, not an alarm colour + info: 'blue', + warning: 'yellow', // Status values (en) paid: 'green', completed: 'green', From d35f5bddfda094496fdf343caca23a58374f4df0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:44:39 +0000 Subject: [PATCH 06/10] wip(components): Setup row 13, record tab strip scrolls with an edge cue Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .../page-tabs-overflow-cue-27521.test.tsx | 72 +++++++++++++++++++ .../src/hooks/use-horizontal-overflow.ts | 47 ++++++++++++ .../src/renderers/layout/containers.tsx | 23 +++++- 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 ui/packages/components/src/__tests__/page-tabs-overflow-cue-27521.test.tsx create mode 100644 ui/packages/components/src/hooks/use-horizontal-overflow.ts diff --git a/ui/packages/components/src/__tests__/page-tabs-overflow-cue-27521.test.tsx b/ui/packages/components/src/__tests__/page-tabs-overflow-cue-27521.test.tsx new file mode 100644 index 00000000000..91189dc8858 --- /dev/null +++ b/ui/packages/components/src/__tests__/page-tabs-overflow-cue-27521.test.tsx @@ -0,0 +1,72 @@ +/** + * 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. + * + * objectstack#27521 row 13 — a horizontal `page:tabs` strip with more tabs + * than fit scrolls sideways and says which edge hides tabs (`data-overflow`, + * which the strip's edge fade reads), re-read on scroll. jsdom lays nothing + * out, so the strip's widths are set on the element, as a browser would + * measure them. + * + * Module-scope registration import, not a hook (AGENTS.md §测试纪律). + */ + +import { describe, it, expect } from 'vitest'; +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer } from '@object-ui/react'; +import '../renderers'; + +const TABS = ['Details', 'Positions', 'Permission Sets', 'Sessions'].map((label) => ({ + label, + value: label.toLowerCase().replace(/\s+/g, '_'), + children: [], +})); + +function strip(position?: 'left') { + const { container } = render( + , + ); + return container.querySelector('[role="tablist"]') as HTMLElement; +} + +/** Give the strip a layout: `scrollWidth` of content in a `clientWidth` box. */ +function layOut(el: HTMLElement, scrollWidth: number, clientWidth: number) { + Object.defineProperty(el, 'scrollWidth', { configurable: true, value: scrollWidth }); + Object.defineProperty(el, 'clientWidth', { configurable: true, value: clientWidth }); +} + +function scrollTo(el: HTMLElement, left: number) { + el.scrollLeft = left; + fireEvent.scroll(el); +} + +describe('page:tabs — the strip scrolls and cues its hidden edge (objectstack#27521)', () => { + it('a strip wider than its box reads end, then both, then start as it scrolls', () => { + const el = strip(); + expect(el.className).toContain('overflow-x-auto'); + layOut(el, 1269, 1088); + act(() => scrollTo(el, 0)); + expect(el.dataset.overflow).toBe('end'); + act(() => scrollTo(el, 80)); + expect(el.dataset.overflow).toBe('both'); + act(() => scrollTo(el, 181)); + expect(el.dataset.overflow).toBe('start'); + }); + + it('CONTROL: a strip that fits reads none', () => { + const el = strip(); + layOut(el, 600, 1088); + act(() => scrollTo(el, 0)); + expect(el.dataset.overflow).toBe('none'); + }); + + it('CONTROL: a vertical strip is a sidebar and carries no cue', () => { + const el = strip('left'); + expect(el.dataset.overflow).toBeUndefined(); + expect(el.className).not.toContain('overflow-x-auto'); + }); +}); diff --git a/ui/packages/components/src/hooks/use-horizontal-overflow.ts b/ui/packages/components/src/hooks/use-horizontal-overflow.ts new file mode 100644 index 00000000000..fa8185fea7f --- /dev/null +++ b/ui/packages/components/src/hooks/use-horizontal-overflow.ts @@ -0,0 +1,47 @@ +/** + * 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. + */ + +import * as React from 'react'; + +/** Which edges of a horizontal scroller hide content past them. */ +export type HorizontalOverflow = 'none' | 'start' | 'end' | 'both'; + +/** + * Where a horizontally scrolling element has content out of view, re-read on + * scroll and on resize, so the element can draw a cue at each edge that hides + * something. Measured in the element's own box: `scrollLeft` against + * `scrollWidth - clientWidth`, with a one-pixel tolerance for sub-pixel + * widths. Returns a callback ref and the current answer. + */ +export function useHorizontalOverflow(): [ + (node: T | null) => void, + HorizontalOverflow, +] { + const [node, setNode] = React.useState(null); + const [overflow, setOverflow] = React.useState('none'); + + React.useEffect(() => { + if (!node) return; + const measure = () => { + const max = node.scrollWidth - node.clientWidth; + const start = node.scrollLeft > 1; + const end = max - node.scrollLeft > 1; + setOverflow(start && end ? 'both' : start ? 'start' : end ? 'end' : 'none'); + }; + measure(); + node.addEventListener('scroll', measure, { passive: true }); + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure); + observer?.observe(node); + return () => { + node.removeEventListener('scroll', measure); + observer?.disconnect(); + }; + }, [node]); + + return [setNode, overflow]; +} diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index a52cde46797..a00180d68ab 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -28,6 +28,7 @@ import { renderChildren, renderNodeSlot, cn } from '../../lib/utils'; import { LazyIcon } from '../../lib/lazy-icon'; import { RelatedCountStore, useRelatedCountVersion } from '../../hooks/related-count-store'; import { useIsMobile } from '../../hooks/use-mobile'; +import { useHorizontalOverflow } from '../../hooks/use-horizontal-overflow'; import { Tabs, TabsList, @@ -1114,6 +1115,10 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { ? selectedValue : undefined) ?? itemsWithValue[0]?.value ?? ''; + // A horizontal strip with more tabs than fit scrolls sideways, and fades at + // each edge that hides a tab, so the reader can tell the strip goes on. + const [stripRef, stripOverflow] = useHorizontalOverflow(); + const listClass = cn( isVertical && 'flex-col h-auto items-stretch p-1', type === 'card' && 'bg-transparent gap-1', @@ -1122,7 +1127,14 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { // primitive defaults to a pill-card look (bg-muted, rounded-md) that // floats unmoored on long record pages — override it so the strip reads // as a section anchor with a bottom border + per-trigger underline. - type === 'line' && !isVertical && 'h-auto rounded-none bg-transparent p-0 gap-4 border-b border-border w-full justify-start', + // The bottom rule is an inset shadow, not a border, so a strip that + // scrolls (`overflow-x-auto` clips at the padding box) keeps the active + // trigger's underline drawn over it. + type === 'line' && !isVertical && 'h-auto rounded-none bg-transparent p-0 gap-4 shadow-[inset_0_-1px_0_var(--color-border)] w-full justify-start', + !isVertical && 'max-w-full overflow-x-auto overflow-y-hidden', + !isVertical && 'data-[overflow=end]:[mask-image:linear-gradient(to_right,#000_calc(100%-2.5rem),transparent)]', + !isVertical && 'data-[overflow=start]:[mask-image:linear-gradient(to_left,#000_calc(100%-2.5rem),transparent)]', + !isVertical && 'data-[overflow=both]:[mask-image:linear-gradient(to_right,transparent,#000_2.5rem,#000_calc(100%-2.5rem),transparent)]', // Pin the horizontal tab strip to the top of the scroll container so // users keep their bearings on long record pages. Skipped for vertical // layouts where the strip is a sidebar, not a header. @@ -1133,7 +1145,8 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { isVertical && 'justify-start', type === 'card' && 'data-[state=active]:bg-background data-[state=active]:border data-[state=active]:shadow-sm rounded-md', type === 'pill' && 'rounded-full data-[state=active]:bg-background', - type === 'line' && !isVertical && 'rounded-none border-b-2 border-transparent bg-transparent px-1 pb-2.5 -mb-px shadow-none data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:text-foreground', + !isVertical && 'shrink-0', + type === 'line' && !isVertical && 'rounded-none border-b-2 border-transparent bg-transparent px-1 pb-2.5 shadow-none data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:text-foreground', ); return ( @@ -1174,7 +1187,11 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { {(itemsWithValue.length > 1 || schema?.alwaysShowStrip === true || schema?.properties?.alwaysShowStrip === true) && ( - + {itemsWithValue.map((item) => ( {item.icon && ( From a92bfad44a227657c9e8b9ceaa95fd58c22e188f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:49:33 +0000 Subject: [PATCH 07/10] wip(app-shell): Setup row 20, packaged automation fits a phone Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .../src/views/setup/PackagedActionsSection.tsx | 14 ++++++++------ .../src/views/setup/PackagedAutomationPage.tsx | 13 ++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx index 11d27eb4d8f..70601a26104 100644 --- a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx +++ b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx @@ -362,14 +362,16 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp )} {rows !== null && rows.length > 0 && ( - +
{t('packagedAutomation.colAction', { defaultValue: 'Action' })} - + {/* Fixed column widths from `sm` up only, as the flows table + above: on a phone the columns share the width. */} + {t('packagedAutomation.colObject', { defaultValue: 'Object' })} - + {t('packagedAutomation.colActivation', { defaultValue: 'Activation' })} @@ -382,7 +384,7 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp
{row.label}
- {row.name} + {row.name} {refusal && ( // VERBATIM server refusal, next to the control that // caused it. `role="alert"` so it is announced. @@ -401,10 +403,10 @@ export function PackagedActionsSection({ nonce = 0 }: PackagedActionsSectionProp {objectLabel({ name: row.objectName, label: row.objectLabel })} )} - {row.objectName} + {row.objectName}
-
+
0 && ( -
+
{t('packagedAutomation.colFlow', { defaultValue: 'Flow' })} - + {/* Fixed column widths from `sm` up only: on a phone the columns + share the width and the activation cell wraps, so the switch + and Clone stay on screen without a sideways scroll. */} + {t('packagedAutomation.colActivation', { defaultValue: 'Activation' })} - + {t('packagedAutomation.colActions', { defaultValue: 'Actions' })} @@ -669,7 +672,7 @@ export function PackagedAutomationPage() { sent; the chip reads the same whatever it says. */} {row.reason && } - {row.name} + {row.name} {refusal && ( // VERBATIM server refusal, next to the control that // caused it. `role="alert"` so it is announced. @@ -679,7 +682,7 @@ export function PackagedAutomationPage() { )} -
+
Date: Fri, 30 Oct 2026 00:50:30 +0000 Subject: [PATCH 08/10] chore: changeset for the Setup walkthrough rows Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .changeset/27521-setup-walkthrough-rows.md | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/27521-setup-walkthrough-rows.md diff --git a/.changeset/27521-setup-walkthrough-rows.md b/.changeset/27521-setup-walkthrough-rows.md new file mode 100644 index 00000000000..851f3bfd289 --- /dev/null +++ b/.changeset/27521-setup-walkthrough-rows.md @@ -0,0 +1,23 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/components': patch +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): Setup reads clearer — one name for the app, a confirm before Disable and before switching a packaged action off, the reason a package is read-only, email in the Add user picker, and a forced password change in the user's words + +Clause-②: no +Metadata-protocol: none + +Suggestions from the 18.3.0 RC Setup walkthrough, each in the console: + +- **One name for Setup.** Home's link and the app switcher's entry now carry the Setup app's own label, the name its header shows, instead of "System Settings" and "Settings". +- **A permission set's Add user picker** shows each person's email beside their name, as the Add holder picker on a position does. +- **A forced password change** reads in the console's own words, in the user's language: the body says an administrator set a temporary password, and the first field is "Temporary password". An expired password keeps the server's sentence and "Current password". +- **The package sheet** says why a read-only package offers no write action (loaded from code, read-only here, or part of the platform), asks before Disable, and calls its delete action "Delete package". Installed Apps has its own subtitle. +- **Packaged automation** asks before switching a packaged action off, says in a toast when a switch was accepted, shows each action's object by its label beside the machine name, and fits a phone screen without a sideways scroll. +- **Record tab strips** that hold more tabs than fit scroll sideways and fade at the edge that hides a tab. +- **A select value of `info` or `warning`** with no declared colour reads blue or yellow, not a hashed alarm orange. +- **Setup's header tags** (Admin config, Platform schema, System-managed, Identity) take keyboard focus, which opens their explanation as hover does. +- `@object-ui/i18n` adds `auth.remediation.password.resetBody`, `auth.remediation.password.temporary` and five `packagedAutomation.*` keys in all ten locales, and five `engine.packages.*` keys in the Studio namespace (en, zh); `engine.packages.detail.deleteApp` becomes `engine.packages.detail.deletePackage`. From ed05c2ac58ac30245a18708d33bd26576813b6cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:16:09 +0000 Subject: [PATCH 09/10] fix(i18n): de packaged-automation strings follow the pack's unquoted label form Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- ui/packages/i18n/src/locales/de.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 469190c0ce3..04239c589e0 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4688,9 +4688,9 @@ const de = { actionsEmptyTitle: "Keine Paket-Aktionen", actionsEmptyBody: "In dieser Installation deklariert kein installiertes Paket eine Aktion. Selbst erstellte Aktionen finden Sie im Studio.", actionsLoadFailed: "Paket-Aktionen konnten nicht geladen werden.", - switchedOn: "„{{label}}“ ist eingeschaltet.", - switchedOff: "„{{label}}“ ist für diese Bereitstellung ausgeschaltet.", - actionOffTitle: "„{{label}}“ ausschalten?", + switchedOn: "{{label}} ist eingeschaltet.", + switchedOff: "{{label}} ist für diese Bereitstellung ausgeschaltet.", + actionOffTitle: "{{label}} ausschalten?", actionOffBody: "Es wird überall, wo es in dieser Bereitstellung angeboten wird, für alle Benutzer nicht mehr ausgeführt, bis es wieder eingeschaltet wird.", actionOffConfirm: "Ausschalten", }, From 5a54fd9875ae7fe97a162217f2a60276d29064f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:21:11 +0000 Subject: [PATCH 10/10] chore: changeset drops the Add user picker, landed on main by #27808 Claude-Session: https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB Co-authored-by: Claude --- .changeset/27521-setup-walkthrough-rows.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/27521-setup-walkthrough-rows.md b/.changeset/27521-setup-walkthrough-rows.md index 851f3bfd289..893783b6bb4 100644 --- a/.changeset/27521-setup-walkthrough-rows.md +++ b/.changeset/27521-setup-walkthrough-rows.md @@ -5,7 +5,7 @@ '@object-ui/i18n': patch --- -fix(app-shell): Setup reads clearer — one name for the app, a confirm before Disable and before switching a packaged action off, the reason a package is read-only, email in the Add user picker, and a forced password change in the user's words +fix(app-shell): Setup reads clearer — one name for the app, a confirm before Disable and before switching a packaged action off, the reason a package is read-only, and a forced password change in the user's words Clause-②: no Metadata-protocol: none @@ -13,7 +13,6 @@ Metadata-protocol: none Suggestions from the 18.3.0 RC Setup walkthrough, each in the console: - **One name for Setup.** Home's link and the app switcher's entry now carry the Setup app's own label, the name its header shows, instead of "System Settings" and "Settings". -- **A permission set's Add user picker** shows each person's email beside their name, as the Add holder picker on a position does. - **A forced password change** reads in the console's own words, in the user's language: the body says an administrator set a temporary password, and the first field is "Temporary password". An expired password keeps the server's sentence and "Current password". - **The package sheet** says why a read-only package offers no write action (loaded from code, read-only here, or part of the platform), asks before Disable, and calls its delete action "Delete package". Installed Apps has its own subtitle. - **Packaged automation** asks before switching a packaged action off, says in a toast when a switch was accepted, shows each action's object by its label beside the machine name, and fits a phone screen without a sideways scroll.