From dd00aecd58330fda7f6f3982dff1b8aa72238028 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:01:41 +0800 Subject: [PATCH 1/9] fix(app-shell,i18n): the cloud home's primary button reads "Open workspace", not "Open Production" (objectui#11659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new customer has exactly one environment, and "production" is control-plane vocabulary. The `cloud:onboarding-next` ready-state button now resolves `cloudOnboarding.openWorkspace` (zh 进入工作区) in all ten packs, and the hint under it says "workspace" instead of "production environment". The destination is unchanged: the page's sso-open URL. Claude-Session: https://claude.ai/code/session_64cb68a6-01c7-4dc6-acc6-ccb459c90ef2 Co-authored-by: Claude --- .changeset/11659-console-record-ux.md | 8 +++ .../src/console/home/CloudOnboardingNext.tsx | 15 +++-- .../__tests__/CloudOnboardingNext.test.tsx | 61 +++++++++++++++---- packages/i18n/src/locales/ar.ts | 4 +- packages/i18n/src/locales/de.ts | 4 +- packages/i18n/src/locales/en.ts | 4 +- packages/i18n/src/locales/es.ts | 4 +- packages/i18n/src/locales/fr.ts | 4 +- packages/i18n/src/locales/ja.ts | 4 +- packages/i18n/src/locales/ko.ts | 4 +- packages/i18n/src/locales/pt.ts | 4 +- packages/i18n/src/locales/ru.ts | 4 +- packages/i18n/src/locales/zh.ts | 4 +- 13 files changed, 87 insertions(+), 37 deletions(-) create mode 100644 .changeset/11659-console-record-ux.md diff --git a/.changeset/11659-console-record-ux.md b/.changeset/11659-console-record-ux.md new file mode 100644 index 0000000000..65cff3fcf7 --- /dev/null +++ b/.changeset/11659-console-record-ux.md @@ -0,0 +1,8 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). + +**The cloud home's primary button reads "Open workspace" (zh 「进入工作区」), not "Open Production".** A new customer has exactly one environment, and "production" is control-plane vocabulary. The `cloud:onboarding-next` widget's ready-state button now resolves `cloudOnboarding.openWorkspace`, which replaces `cloudOnboarding.openProduction` in all ten language packs, and the hint under it (`cloudOnboarding.hintReady`) says "workspace" instead of "production environment". The button still navigates to the page's `openProductionUrl`; the page-metadata contract is unchanged. diff --git a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx index 1bb0c2d179..71733d2b7f 100644 --- a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx +++ b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx @@ -13,13 +13,18 @@ * the same `/cloud/environment-entitlements` endpoint the environment list uses * and renders the right primary action: * - * • has production env → "Open Production" (the doorway into the env, where + * • has production env → "Open workspace" (the doorway into the env, where * building happens) + a "Manage environments" link. + * The label names the place in the customer's words, + * not the operator's: a new customer has exactly one + * environment, and "production" is control-plane + * vocabulary (objectui#11659). The destination is + * still the org's production environment. * • no production env → "Create your environment" (the real first step). * • loading → a neutral skeleton (no CTA flashes / layout jump). * • unknown / error → degrade to BOTH actions, so the button always works. * - * Routes and the "Open Production" endpoint come from the page metadata + * Routes and the "Open workspace" endpoint come from the page metadata * (`properties`) so the Cloud app owns its URLs; this component owns only the * state logic. It mirrors `useEnvironmentEntitlements`' authoritative summary * fetch (org-scoped GET) without its list-only gating. @@ -53,7 +58,7 @@ interface CloudOnboardingNextProps { * Optional backend pre-warm endpoint. When set and the caller already has * a production env, the widget fires a best-effort GET the moment it knows * the env exists — nudging a possibly-asleep container awake WHILE the user - * reads the hint, so the later "Open Production" click lands in an + * reads the hint, so the later "Open workspace" click lands in an * already-waking env instead of paying the full cold-start. No-op when * unset (older page metadata), so this is safe to ship ahead of the page. */ @@ -118,7 +123,7 @@ function useProductionEnvState(warmUrl?: string): Resolved { setState({ phase: 'ready', hasProductionEnv }); // Pre-warm the prod env the instant we know it exists — fire-and-forget, // best-effort, once per resolve. The user is now reading the hint and - // will click "Open Production" seconds later; by then the container is + // will click "Open workspace" seconds later; by then the container is // already waking. The sso-open click warms again, so a failed warm here // costs nothing. Only when the page passes a warmUrl (else no-op). if (hasProductionEnv && warmUrl) { @@ -194,7 +199,7 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) { ) : ( )} - - - - - ); -} - export function HomePage() { const navigate = useNavigate(); const { t } = useObjectTranslation(); diff --git a/packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx b/packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx new file mode 100644 index 0000000000..3811aef696 --- /dev/null +++ b/packages/app-shell/src/console/home/RecoveryPasswordReminder.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * RecoveryPasswordReminder + * + * Dismissible nudge on the environment home to set a local recovery password — + * shown when the user signed in via SSO and has no local credential yet. We no + * longer force this before the first session (it walled off the magic moment); + * this gentle, one-time reminder preserves instance self-sufficiency without the + * friction. + * + * @module + */ + +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@object-ui/auth'; +import { Button } from '@object-ui/components'; +import { ShieldAlert, X } from 'lucide-react'; + +/** Set once the user dismisses the reminder; it never shows again on this device. */ +export const RECOVERY_REMINDER_DISMISSED_KEY = 'os:recovery-pw-dismissed'; + +/** Epoch milliseconds of the user's first home visit on this device. */ +export const RECOVERY_REMINDER_FIRST_SEEN_KEY = 'os:recovery-pw-first-seen'; + +/** + * How long after the first home visit the reminder stays quiet: longer than any + * single sitting, short enough that the next day's visit shows it. + */ +export const FIRST_SESSION_GRACE_MS = 12 * 60 * 60 * 1000; + +/** + * Below this a stored value is not an epoch-milliseconds timestamp (it is + * 2001-09-09). The legacy flag this key used to hold, `'1'`, sits below it. + */ +const EPOCH_MS_FLOOR = 1_000_000_000_000; + +type ReminderStorage = Pick; + +/** + * Is the user past their FIRST SESSION on this device (objectui#11659)? + * + * The first call records the time of the first home visit and answers `false`; + * later calls answer `true` once `FIRST_SESSION_GRACE_MS` has passed since it. + * + * Why elapsed time, and not a count of home visits or a per-tab marker: + * + * - A visit count is what this replaced, and it is the defect the card + * measured. The flag was set on the first home mount and the reminder shown + * on the second; a new user lands on home, builds their first app, comes back + * to home — the second mount, inside the first sitting — and the reminder sat + * on top of 「我的应用」 right after the first app was built. + * - `sessionStorage` is per tab. A "new session" read from it fires in any + * second tab the user opens during that same first sitting. + * - The client holds no session id to compare: `AuthClientSession` carries the + * bearer token, and an SSO re-entry can mint a new one within one sitting. + * + * Elapsed time is the one signal that cannot fire inside the first sitting. + * The ruling allows the reminder from the second session or the second day; a + * second session on the same day may still find it quiet, which errs on the + * side the ruling protects. + * + * A stored value that is not a timestamp — the legacy `'1'` flag, or anything + * unreadable — is re-recorded as now, so the grace period starts over rather + * than reading as "long ago". No storage, or storage that throws (a private + * window), answers `false`: when it cannot tell, it does not nag. + */ +export function isPastFirstSession(storage: ReminderStorage | undefined, now: number): boolean { + if (!storage) return false; + let raw: string | null; + try { + raw = storage.getItem(RECOVERY_REMINDER_FIRST_SEEN_KEY); + } catch { + return false; + } + const firstSeen = raw === null ? NaN : Number(raw); + if (!Number.isFinite(firstSeen) || firstSeen < EPOCH_MS_FLOOR) { + try { + storage.setItem(RECOVERY_REMINDER_FIRST_SEEN_KEY, String(now)); + } catch { + /* ignore */ + } + return false; + } + return now - firstSeen >= FIRST_SESSION_GRACE_MS; +} + +function deviceStorage(): Storage | undefined { + try { + return typeof localStorage !== 'undefined' ? localStorage : undefined; + } catch { + return undefined; + } +} + +function isDismissed(storage: ReminderStorage | undefined): boolean { + try { + return storage?.getItem(RECOVERY_REMINDER_DISMISSED_KEY) === '1'; + } catch { + return false; + } +} + +export function RecoveryPasswordReminder({ t }: { t: (key: string, opts?: any) => string }) { + const navigate = useNavigate(); + const { hasLocalPassword, getAuthConfig } = useAuth(); + const [show, setShow] = useState(false); + useEffect(() => { + const storage = deviceStorage(); + if (isDismissed(storage)) return; + // Not in the user's first session: let a brand-new SSO user reach their + // magic moment (build their first app) before asking them to set a + // recovery password. See `isPastFirstSession` for why "first session" is + // measured in elapsed time. + if (!isPastFirstSession(storage, Date.now())) return; + let cancelled = false; + Promise.all([ + Promise.resolve(hasLocalPassword?.()), + Promise.resolve(getAuthConfig?.()).catch(() => null), + ]) + .then(([has, config]: [unknown, any]) => { + if (cancelled) return; + // Skip on SSO-enforced envs: password login is disabled there, so a + // recovery password can't be used — nudging for one is misleading and + // gives a false sense of security. Same signal LoginForm uses. + const passwordUnavailable = + config?.features?.ssoEnforced === true || config?.emailPassword?.enabled === false; + if (has === false && !passwordUnavailable) setShow(true); + }) + .catch(() => { /* unknown → don't nag */ }); + return () => { cancelled = true; }; + }, [hasLocalPassword, getAuthConfig]); + const dismiss = () => { + try { localStorage.setItem(RECOVERY_REMINDER_DISMISSED_KEY, '1'); } catch { /* ignore */ } + setShow(false); + }; + if (!show) return null; + return ( +
+
+
+ +

+ {t('home.recoveryReminder.message', { defaultValue: 'Set a recovery password so you can still sign in if single sign-on is ever unavailable.' })} +

+ + +
+
+
+ ); +} diff --git a/packages/app-shell/src/console/home/__tests__/RecoveryPasswordReminder.firstSession-11659.test.tsx b/packages/app-shell/src/console/home/__tests__/RecoveryPasswordReminder.firstSession-11659.test.tsx new file mode 100644 index 0000000000..670a1b16f5 --- /dev/null +++ b/packages/app-shell/src/console/home/__tests__/RecoveryPasswordReminder.firstSession-11659.test.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The backup-password reminder does not appear in the user's first session + * (objectui#11659 ruling 3). + * + * The defect the cloud acceptance run measured: a new zh user built their first + * app and came back to the environment home, and 「建议设置一个备用密码」 sat on + * top of 「我的应用」. The reminder used to be gated on a home-VISIT count — the + * first home mount set a flag, the second showed the banner — and "come back to + * home after building" is the second mount inside the first sitting. + * + * The gate now reads elapsed time since the first home visit + * (`isPastFirstSession`, which states why). These cases drive the real + * component over a real `localStorage`, with the clock moved by hand. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => navigateMock, +})); + +const hasLocalPassword = vi.fn(); +const getAuthConfig = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ hasLocalPassword, getAuthConfig }), +})); + +import { + FIRST_SESSION_GRACE_MS, + RECOVERY_REMINDER_DISMISSED_KEY, + RECOVERY_REMINDER_FIRST_SEEN_KEY, + RecoveryPasswordReminder, + isPastFirstSession, +} from '../RecoveryPasswordReminder'; + +const t = (key: string) => key; +const SIGNUP = Date.UTC(2026, 9, 5, 9, 0, 0); +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; + +/** A plain in-memory Storage double — the gate's whole contract with storage. */ +function memoryStorage(seed: Record = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null), + setItem: (k: string, v: string) => void map.set(k, v), + map, + }; +} + +/** Mount the reminder at `now` and let its auth promises settle. */ +async function mountAt(now: number) { + vi.setSystemTime(now); + const view = render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + return view; +} + +describe('isPastFirstSession (objectui#11659)', () => { + it('records the first home visit and answers false', () => { + const storage = memoryStorage(); + expect(isPastFirstSession(storage, SIGNUP)).toBe(false); + expect(storage.map.get(RECOVERY_REMINDER_FIRST_SEEN_KEY)).toBe(String(SIGNUP)); + }); + + it('stays false for the rest of the first sitting, however many home visits it holds', () => { + const storage = memoryStorage(); + isPastFirstSession(storage, SIGNUP); + for (const later of [1 * MINUTE, 30 * MINUTE, 3 * HOUR, FIRST_SESSION_GRACE_MS - 1]) { + expect(isPastFirstSession(storage, SIGNUP + later)).toBe(false); + } + // The first visit's time is not moved by the later ones. + expect(storage.map.get(RECOVERY_REMINDER_FIRST_SEEN_KEY)).toBe(String(SIGNUP)); + }); + + it('answers true from the next day on', () => { + const storage = memoryStorage({ [RECOVERY_REMINDER_FIRST_SEEN_KEY]: String(SIGNUP) }); + expect(isPastFirstSession(storage, SIGNUP + FIRST_SESSION_GRACE_MS)).toBe(true); + expect(isPastFirstSession(storage, SIGNUP + 24 * HOUR)).toBe(true); + }); + + it('re-records the legacy visit flag instead of reading it as long ago', () => { + const storage = memoryStorage({ [RECOVERY_REMINDER_FIRST_SEEN_KEY]: '1' }); + expect(isPastFirstSession(storage, SIGNUP)).toBe(false); + expect(storage.map.get(RECOVERY_REMINDER_FIRST_SEEN_KEY)).toBe(String(SIGNUP)); + }); + + it('does not nag when it cannot tell (no storage, or storage that throws)', () => { + expect(isPastFirstSession(undefined, SIGNUP)).toBe(false); + const throwing = { + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => { + throw new Error('SecurityError'); + }, + }; + expect(isPastFirstSession(throwing, SIGNUP + 48 * HOUR)).toBe(false); + }); +}); + +describe('RecoveryPasswordReminder is quiet in the first session (objectui#11659)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + localStorage.removeItem(RECOVERY_REMINDER_FIRST_SEEN_KEY); + localStorage.removeItem(RECOVERY_REMINDER_DISMISSED_KEY); + hasLocalPassword.mockReset().mockResolvedValue(false); + getAuthConfig.mockReset().mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + localStorage.removeItem(RECOVERY_REMINDER_FIRST_SEEN_KEY); + localStorage.removeItem(RECOVERY_REMINDER_DISMISSED_KEY); + }); + + it('home → build the first app → home again: no reminder on either visit', async () => { + const first = await mountAt(SIGNUP); + expect(screen.queryByTestId('recovery-pw-reminder')).toBeNull(); + first.unmount(); + + // The user builds their first app and returns to home in the same sitting. + // This second home mount is where the reminder used to appear. + await mountAt(SIGNUP + 20 * MINUTE); + expect(screen.queryByTestId('recovery-pw-reminder')).toBeNull(); + }); + + it('shows from the next day on, for an SSO user with no local password', async () => { + const first = await mountAt(SIGNUP); + first.unmount(); + + await mountAt(SIGNUP + 20 * HOUR); + expect(screen.getByTestId('recovery-pw-reminder')).toBeInTheDocument(); + expect(screen.getByText('home.recoveryReminder.message')).toBeInTheDocument(); + }); + + it('still never shows to a user who already has a local password', async () => { + hasLocalPassword.mockResolvedValue(true); + const first = await mountAt(SIGNUP); + first.unmount(); + + await mountAt(SIGNUP + 20 * HOUR); + expect(screen.queryByTestId('recovery-pw-reminder')).toBeNull(); + }); +}); From 98bb8bea83409c35fc807e53e813d7f97879fcf5 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:14:34 +0800 Subject: [PATCH 4/9] fix(plugin-detail): the record header's highlight row shows a whole phone number (objectui#11659) The phone cell draws its icon, number and copy button in one inline-flex box, which the chip's `truncate` span clips without an ellipsis: a 9rem chip read 0574-876 for 0574-8765-4321. A `phone` chip now takes the wide basis `email` and `url` already take. Claude-Session: https://claude.ai/code/session_64cb68a6-01c7-4dc6-acc6-ccb459c90ef2 Co-authored-by: Claude --- .changeset/11659-console-record-ux.md | 3 + .../plugin-detail/src/HeaderHighlight.tsx | 9 ++- ...ghlightsRenderer.phoneWidth-11659.test.tsx | 80 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-detail/src/__tests__/RecordHighlightsRenderer.phoneWidth-11659.test.tsx diff --git a/.changeset/11659-console-record-ux.md b/.changeset/11659-console-record-ux.md index 926189963f..7c79f573b4 100644 --- a/.changeset/11659-console-record-ux.md +++ b/.changeset/11659-console-record-ux.md @@ -1,6 +1,7 @@ --- '@object-ui/app-shell': patch '@object-ui/i18n': patch +'@object-ui/plugin-detail': patch --- Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). @@ -10,3 +11,5 @@ Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (o **The create-workspace dialog asks for the name only.** `CreateWorkspaceDialog` no longer shows the "URL slug" field: the customer never sees the slug take effect at this step. The slug is still generated from the name, by the same rule as before, and sent with the create call; the owner can change it later in organization settings. Because the slug is no longer the user's to fix in the dialog, a slug collision (`ORGANIZATION_ALREADY_EXISTS` or `ORGANIZATION_SLUG_ALREADY_TAKEN`) is retried with a short random suffix, up to three attempts in all; any other refusal is shown as before. The `workspace.slugLabel` and `workspace.slugHint` pack keys are left in place, now unread. **The backup-password reminder no longer appears in the user's first session.** 「建议设置一个备用密码」 showed on the environment home right after a new user built their first app: the reminder was gated on a home-visit count (quiet on the first home mount, shown on the second), and coming back to home after building is the second mount inside the first sitting. It now stays quiet for 12 hours after the first home visit on the device, so it first shows on a later day's visit. The first-visit record (`os:recovery-pw-first-seen`) now holds a timestamp; a device that holds the old `'1'` flag starts the 12 hours over rather than reading it as long ago. When storage is unavailable it stays quiet. `RecoveryPasswordReminder` moves out of `HomePage.tsx` into its own module; it is not exported from the package entry, and its other conditions (dismissed, SSO-enforced, has a local password) are unchanged. + +**The record header's highlight row no longer cuts a phone number.** A drawer's highlight row read 「0574-876」 for the stored `0574-8765-4321`, with no ellipsis. The phone cell draws a dial icon, the number and a copy button in one `inline-flex` box, and the chip's single-line clip cannot put an ellipsis on such a box, so a 9rem chip cut the number mid-way. `HeaderHighlight` now gives a `phone` chip the wide basis that `email` and `url` already take, so a whole number shows; the full value stays in the chip's hover title. Other field types keep their chip width. diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index e37547242a..86089cadcf 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -170,7 +170,13 @@ export const HeaderHighlight: React.FC = ({ // Two independent reasons a chip needs the wide basis, kept // separate because only one of them is a shared table: // 1. long-text DISPLAY types, whose value simply does not fit a - // 9rem column — this surface's own list, unchanged here; + // 9rem column — this surface's own list. `phone` joined it in + // objectui#11659: its cell draws a dial icon and a copy button + // around the number inside an `inline-flex` box, and the + // `truncate` span below cannot put an ellipsis on an + // inline-flex child — it clips it. A 9rem chip cut + // `0574-8765-4321` to `0574-876` with no ellipsis; the wide + // basis shows a whole number; // 2. REFERENCE-BEARING types, whose inline editor is a record // picker (see the `dataSource` prop doc above, which already // names `lookup` / `user` together as the reference editors). @@ -191,6 +197,7 @@ export const HeaderHighlight: React.FC = ({ const isWide = resolvedType === 'email' || resolvedType === 'url' || + resolvedType === 'phone' || resolvedType === 'textarea' || (!!resolvedType && EXPANDABLE_FIELD_TYPES.has(resolvedType)); const isBoolean = resolvedType === 'boolean'; diff --git a/packages/plugin-detail/src/__tests__/RecordHighlightsRenderer.phoneWidth-11659.test.tsx b/packages/plugin-detail/src/__tests__/RecordHighlightsRenderer.phoneWidth-11659.test.tsx new file mode 100644 index 0000000000..2c86efc050 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordHighlightsRenderer.phoneWidth-11659.test.tsx @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * objectui#11659 ruling 4 — the record header does not cut a phone number. + * + * The cloud acceptance run read 「0574-876」 in the highlight row of a record + * drawer for the stored `0574-8765-4321`, with no ellipsis. The phone cell + * draws a dial icon, the number and a copy button inside an `inline-flex` box; + * the chip's `truncate` span cannot put an ellipsis on an inline-flex child, so + * a 9rem chip clipped the number mid-way. A phone chip now takes the wide basis, + * as `email` and `url` already did, so a whole number fits. + * + * happy-dom has no layout engine, so this is a class-semantics pin, not a pixel + * measurement (same approach as `RecordHighlightsRenderer.percentClip.test.tsx`). + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import * as React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { RecordHighlightsRenderer } from '../renderers/record-highlights'; + +const PHONE = '0574-8765-4321'; + +const objectSchema = { + fields: { + phone: { type: 'phone', label: 'Phone' }, + status: { type: 'text', label: 'Status' }, + }, +}; + +const renderStrip = () => + render( + + + , + ); + +/** The chip column (`group flex flex-col …`) that holds a given value. */ +const chipOf = (text: string) => screen.getByText(text).closest('div.group') as HTMLElement; + +describe('record:highlights — a phone chip shows the whole number (objectui#11659)', () => { + it('gives the phone chip the wide basis, not the 9rem one', () => { + renderStrip(); + + const chip = chipOf(PHONE); + expect(chip).not.toBeNull(); + expect(chip).toHaveClass('basis-[16rem]'); + expect(chip).not.toHaveClass('basis-[9rem]'); + }); + + it('keeps a short text chip on the 9rem basis (the change is phone-only)', () => { + renderStrip(); + + const chip = chipOf('Active'); + expect(chip).toHaveClass('basis-[9rem]'); + expect(chip).not.toHaveClass('basis-[16rem]'); + }); + + it('carries the whole number in the DOM and as the hover title', () => { + const { container } = renderStrip(); + + expect(screen.getByText(PHONE)).toBeInTheDocument(); + const clipBox = chipOf(PHONE).querySelector('span.truncate'); + expect(clipBox).not.toBeNull(); + expect(clipBox).toHaveAttribute('title', PHONE); + expect(container.querySelector(`a[href="tel:${PHONE}"]`)).not.toBeNull(); + }); +}); From ae1ec24dc06b281caa3b9e6491cf96dd6ec80d7a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:15:28 +0800 Subject: [PATCH 5/9] fix(app-shell): Studio's dashboard widgets list names each widget's kind, not its type id (objectui#11659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DashboardDefaultInspector` printed the stored `type` beside each widget title (按客户状态统计数量 · bar). It now prints the name the add-widget picker shows for that kind, in the designer's language, with the id on hover; an uncatalogued type still prints its id. Claude-Session: https://claude.ai/code/session_64cb68a6-01c7-4dc6-acc6-ccb459c90ef2 Co-authored-by: Claude --- .changeset/11659-console-record-ux.md | 2 + .../DashboardDefaultInspector.test.tsx | 56 +++++++++++++++++++ .../inspectors/DashboardDefaultInspector.tsx | 13 ++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.changeset/11659-console-record-ux.md b/.changeset/11659-console-record-ux.md index 7c79f573b4..d833ce1c75 100644 --- a/.changeset/11659-console-record-ux.md +++ b/.changeset/11659-console-record-ux.md @@ -13,3 +13,5 @@ Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (o **The backup-password reminder no longer appears in the user's first session.** 「建议设置一个备用密码」 showed on the environment home right after a new user built their first app: the reminder was gated on a home-visit count (quiet on the first home mount, shown on the second), and coming back to home after building is the second mount inside the first sitting. It now stays quiet for 12 hours after the first home visit on the device, so it first shows on a later day's visit. The first-visit record (`os:recovery-pw-first-seen`) now holds a timestamp; a device that holds the old `'1'` flag starts the 12 hours over rather than reading it as long ago. When storage is unavailable it stays quiet. `RecoveryPasswordReminder` moves out of `HomePage.tsx` into its own module; it is not exported from the package entry, and its other conditions (dismissed, SSO-enforced, has a local password) are unchanged. **The record header's highlight row no longer cuts a phone number.** A drawer's highlight row read 「0574-876」 for the stored `0574-8765-4321`, with no ellipsis. The phone cell draws a dial icon, the number and a copy button in one `inline-flex` box, and the chip's single-line clip cannot put an ellipsis on such a box, so a 9rem chip cut the number mid-way. `HeaderHighlight` now gives a `phone` chip the wide basis that `email` and `url` already take, so a whole number shows; the full value stays in the chip's hover title. Other field types keep their chip width. + +**Studio's dashboard widgets list names each widget's kind in the designer's language.** The list read 「按客户状态统计数量 · bar」: beside each title, `DashboardDefaultInspector` printed the stored `type` id. It now prints the name the add-widget picker shows for that kind (`engine.widgetPicker.type.*`, zh 「柱状图」, en "Bar chart"), with the id on hover. A type the catalogue does not know still prints its id. The stored `type` is unchanged. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx index 0f6f965816..0ab2e12d51 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import { DashboardDefaultInspector } from './DashboardDefaultInspector'; +import { t } from '../i18n'; afterEach(cleanup); @@ -108,3 +109,58 @@ describe('DashboardDefaultInspector — basics', () => { expect(labelledInput('Description')).toBeDisabled(); }); }); + +// objectui#11659 ruling 5: the widgets list names each widget's kind in the +// designer's language — the name the add-widget picker shows for it — not the +// stored `type` id. The acceptance run read 「按客户状态统计数量 · bar」. +describe('DashboardDefaultInspector — the widgets list names each kind (objectui#11659)', () => { + const draftWithChart = { + name: 'crm', + label: 'CRM', + widgets: [ + { id: 'w_bar', type: 'bar', title: '按客户状态统计数量' }, + { id: 'w_kpi', type: 'metric', title: 'Revenue' }, + { id: 'w_odd', type: 'not-a-catalogued-type', title: 'Legacy' }, + ], + }; + const kindOf = (id: string) => + document.querySelector(`[data-widget-kind="${id}"]`) as HTMLElement | null; + + for (const locale of ['zh-CN', 'en-US'] as const) { + it(`${locale}: a bar widget reads the picker's name for a bar chart, and keeps its id on hover`, () => { + render( + , + ); + const bar = kindOf('bar'); + expect(bar).not.toBeNull(); + const name = t('engine.widgetPicker.type.bar', locale); + // A real row, not the key echoed back. + expect(name).not.toBe('engine.widgetPicker.type.bar'); + expect(bar!.textContent).toBe(name); + expect(bar!.textContent).not.toBe('bar'); + expect(bar).toHaveAttribute('title', 'bar'); + expect(kindOf('metric')!.textContent).toBe(t('engine.widgetPicker.type.metric', locale)); + // No row prints a bare stored id where the catalogue has a name for it. + expect(screen.queryByText('bar', { selector: 'code' })).toBeNull(); + }); + } + + it('a type the catalogue does not know still prints its id', () => { + render( + , + ); + expect(kindOf('not-a-catalogued-type')!.textContent).toBe('not-a-catalogued-type'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx index 2ba1707080..a987facbbf 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardDefaultInspector.tsx @@ -221,7 +221,18 @@ export function DashboardDefaultInspector({ w?.id || tFormat('engine.inspector.widget.untitledN', locale, { n: i + 1 })} - {w?.type} + {/* The widget's KIND in the designer's language — the name + the add-widget picker shows for it (objectui#11659), not + the stored `type` id (`bar`). The id stays on hover for + authors. A type the catalogue does not know has no name + to show, so it prints its id as before. */} + + {meta ? tr(meta.labelKey) : w?.type} + {!readOnly && (