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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/27521-setup-walkthrough-rows.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions ui/packages/app-shell/src/components/ManagedByBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,11 @@ export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgePr
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
{/* Focusable, so the explanation a hover opens is reachable from
the keyboard too: the tooltip opens on focus. */}
<Badge
variant="outline"
tabIndex={0}
data-testid="managed-by-badge"
data-bucket={managedBy}
className={cn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* objectstack#27521 row 5 — a Setup header tag (Admin config, Platform
* schema, System-managed, Identity) explains itself in a tooltip, and that
* explanation is reachable from the keyboard: the tag takes focus, and focus
* opens the tooltip as hover does.
*/
import '@testing-library/jest-dom/vitest';
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ManagedByBadge } from '../ManagedByBadge';

describe('ManagedByBadge — the explanation is reachable from the keyboard (objectstack#27521)', () => {
it('Tab focuses the tag and opens its explanation', async () => {
render(<ManagedByBadge managedBy="config" />);
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(<ManagedByBadge managedBy="platform" />);
expect(screen.queryByTestId('managed-by-badge')).toBeNull();
});
});
17 changes: 13 additions & 4 deletions ui/packages/app-shell/src/console/RemediationOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,23 @@ function ChangePasswordForm({ forcedReset, message }: { forcedReset: boolean; me
<h2 className="text-lg font-semibold">
{forcedReset ? t('auth.remediation.password.resetTitle') : t('auth.remediation.password.title')}
</h2>
{/* `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. */}
<p className="mt-1 text-sm text-muted-foreground">
{message || t('auth.remediation.password.fallbackMessage')}
{forcedReset
? t('auth.remediation.password.resetBody')
: message || t('auth.remediation.password.fallbackMessage')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rem-cur">{t('auth.remediation.password.current')}</Label>
{/* After a forced reset the password in hand is the one an
administrator issued, so the field is named for it. */}
<Label htmlFor="rem-cur">
{forcedReset ? t('auth.remediation.password.temporary') : t('auth.remediation.password.current')}
</Label>
<Input id="rem-cur" type="password" autoComplete="current-password" value={current}
onChange={(e) => setCurrent(e.target.value)} required />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RemediationOverlay />
</I18nProvider>,
);
}

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();
});
});
9 changes: 8 additions & 1 deletion ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function HomeAppsStrip({
canAuthorMetadata = true,
marketplaceEnabled = true,
settingsHref,
settingsLabel,
lastOpened,
}: {
apps: any[];
Expand Down Expand Up @@ -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小时前打开";
Expand Down Expand Up @@ -153,7 +160,7 @@ export function HomeAppsStrip({
data-testid="home-system-settings-link"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
{t('home.systemSettings', { defaultValue: 'System Settings' })}
{settingsLabel || t('home.systemSettings', { defaultValue: 'System Settings' })}
</Link>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<HomePage />);
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$/);
Expand Down
7 changes: 5 additions & 2 deletions ui/packages/app-shell/src/layout/AppSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,17 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }:
</span>
</Link>
</DropdownMenuItem>
{setupPath ? (
{setupApp && setupPath ? (
<DropdownMenuItem asChild className="flex items-center gap-2.5 py-2">
<Link to={setupPath} data-testid="app-switcher-settings">
<span className="flex size-[22px] shrink-0 items-center justify-center text-muted-foreground" aria-hidden>
<Settings className="size-4" />
</span>
<span className="flex-1 truncate">
{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' })}
</span>
{activeApp === setupApp && <Check className="size-3.5 text-primary shrink-0" />}
</Link>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
Loading
Loading