diff --git a/.changeset/27521-setup-walkthrough-rows.md b/.changeset/27521-setup-walkthrough-rows.md new file mode 100644 index 00000000000..893783b6bb4 --- /dev/null +++ b/.changeset/27521-setup-walkthrough-rows.md @@ -0,0 +1,22 @@ +--- +'@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, 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 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`. 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/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/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', () => { 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/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..70601a26104 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, @@ -338,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' })} @@ -358,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. @@ -368,17 +394,25 @@ 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. */} - {row.objectName} + {/* 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}
-
+
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 +436,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..babedc97618 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); } @@ -632,14 +641,17 @@ export function PackagedAutomationPage() { )} {rows !== null && rows.length > 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' })} @@ -660,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. @@ -670,7 +682,7 @@ export function PackagedAutomationPage() { )} -
+
({ + 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 && ( 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', diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 93204d42cd3..20f0137357c 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: "تغيير كلمة المرور والمتابعة", @@ -5164,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 6e42688b142..04239c589e0 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", @@ -4686,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 87a2437601b..1d9d1da1d8a 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', @@ -5493,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 b54edfb9a83..109e04db532 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", @@ -4805,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 4d2fded8f2e..26ef5e81455 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", @@ -4801,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 1b6f5c2cfef..9bff55f72a4 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: "パスワードを変更して続行", @@ -4687,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 6a415327261..c9e603aaac8 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: "비밀번호 변경 후 계속", @@ -4684,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 06b841e6f53..050567bf39d 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", @@ -4800,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 7ae453f4ce4..4ea105ed150 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: "Изменить пароль и продолжить", @@ -4933,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 83704909f90..397c5fe419b 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: '修改密码并继续', @@ -4744,6 +4746,11 @@ const zh = { actionsEmptyTitle: '没有打包动作', actionsEmptyBody: '本部署中没有任何已安装软件包声明动作。你自己编写的动作在 Studio 中。', actionsLoadFailed: '无法加载打包动作。', + switchedOn: '「{{label}}」已开启。', + switchedOff: '「{{label}}」已在本部署中关闭。', + actionOffTitle: '关闭「{{label}}」?', + actionOffBody: '在重新开启之前,它将在本部署中所有提供它的地方对所有用户停止运行。', + actionOffConfirm: '关闭', }, element: { number: { diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 7cf22122e59..2c3cb94656d 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 9d71ff9bd23..90714f0b3ad 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': '操作失败',