From e8752ff635d53f8bf08e45e5060274d37d9390b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 08:34:49 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(studio):=20package-id=20wizard=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20stripped-chars=20notice,=20format=20hint,=20C?= =?UTF-8?q?JK=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 wizard-ergonomics finding from the Studio package-create dogfood (objectstack-ai/framework#2615): the three package wizards (switcher create, landing create, landing duplicate) silently stripped illegal id characters (`bad id!!` -> `badid`), gave no hint about the reverse-domain format while the create button sat disabled, and a CJK-only name produced no id suggestion with no explanation. Shared PackageIdInput now sanitizes with an explicit "unsupported characters removed" notice, shows the reverse-domain format hint while the id doesn't parse, and PackageIdSuggestionHint tells CJK-named packages to type an id manually. sanitizePackageId extracted to packages-io (unit-tested); en+zh strings added. --- .../src/views/metadata-admin/i18n.ts | 6 ++ .../views/studio-design/BuilderLanding.tsx | 30 +++--- .../studio-design/PackageIdInput.test.tsx | 77 +++++++++++++++ .../views/studio-design/PackageIdInput.tsx | 94 +++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 20 ++-- .../src/views/studio-design/packages-io.ts | 13 +++ 6 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 packages/app-shell/src/views/studio-design/PackageIdInput.test.tsx create mode 100644 packages/app-shell/src/views/studio-design/PackageIdInput.tsx diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index eb2a74ece..d989fec28 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -954,6 +954,9 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.pkg.none': 'No app packages yet', 'engine.studio.pkg.namePlaceholder': 'Name (e.g. Repair Center)', 'engine.studio.pkg.idPlaceholder': 'Package ID (e.g. com.example.repairs)', + 'engine.studio.pkg.idStrippedNotice': 'Unsupported characters were removed — allowed: lowercase letters, digits, dots, hyphens, underscores', + 'engine.studio.pkg.idFormatHint': 'Use a reverse-domain identifier with at least one dot, e.g. com.example.myapp', + 'engine.studio.pkg.idFromNameUnavailable': 'No identifier could be suggested from this name — type one below (e.g. com.example.myapp)', 'engine.studio.pkg.createWritable': 'Create writable package', 'engine.studio.pkg.new': 'New package (writable base)', 'engine.studio.pkg.created': 'Package {name} created (writable)', @@ -1887,6 +1890,9 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.pkg.none': '暂无应用软件包', 'engine.studio.pkg.namePlaceholder': '名称(如:维修中心)', 'engine.studio.pkg.idPlaceholder': '包 ID(如:com.example.repairs)', + 'engine.studio.pkg.idStrippedNotice': '已移除不支持的字符——仅允许小写字母、数字、点、连字符和下划线', + 'engine.studio.pkg.idFormatHint': '请使用带至少一个点的反向域名标识,如 com.example.myapp', + 'engine.studio.pkg.idFromNameUnavailable': '无法从该名称生成标识,请在下方手动输入(如 com.example.myapp)', 'engine.studio.pkg.createWritable': '创建可写软件包', 'engine.studio.pkg.new': '新建软件包(可写 base)', 'engine.studio.pkg.created': '软件包 {name} 已创建(可写)', diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx index caba0f7c4..2239f0369 100644 --- a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -21,6 +21,7 @@ import { toast } from 'sonner'; import { toFieldNameLoose } from '../metadata-admin/previews/object-fields-io'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n'; import { fetchPackages, createBasePackage, duplicatePackage, PACKAGE_ID_RE, type PkgEntry } from './packages-io'; +import { PackageIdInput, PackageIdSuggestionHint } from './PackageIdInput'; export function BuilderLanding(): React.ReactElement { const navigate = useNavigate(); @@ -168,15 +169,14 @@ export function BuilderLanding(): React.ReactElement { placeholder={t('engine.studio.landing.dupNamePlaceholder', locale)} className="h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" /> - setDupId(e.target.value.toLowerCase().replace(/[^a-z0-9_.-]/g, ''))} - onKeyDown={(e) => { - if (e.key === 'Enter') void doDup(); - if (e.key === 'Escape') setDupFor(null); - }} + onChange={setDupId} + onEnter={() => void doDup()} + onEscape={() => setDupFor(null)} placeholder={t('engine.studio.landing.dupIdPlaceholder', locale)} - className="h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" + locale={locale} + testId="pkg-dup-id-input" /> {dupErr &&

{dupErr}

}
@@ -222,18 +222,18 @@ export function BuilderLanding(): React.ReactElement { placeholder={t('engine.studio.pkg.namePlaceholder', locale)} className="h-7 w-full rounded-md border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-primary" /> - + { + onChange={(v) => { setIdTouched(true); - setNewId(e.target.value.toLowerCase().replace(/[^a-z0-9_.-]/g, '')); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') void doCreate(); - if (e.key === 'Escape') setCreating(false); + setNewId(v); }} + onEnter={() => void doCreate()} + onEscape={() => setCreating(false)} placeholder={t('engine.studio.pkg.idPlaceholder', locale)} - className="h-7 w-full rounded-md border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-primary" + locale={locale} + testId="pkg-landing-id-input" />
- - + // Two rows per option: the value/label inputs get the full panel + // width (min-w-0 lets them shrink cleanly instead of clipping their + // own placeholders), while the color swatch and reorder/remove + // controls sit on a compact strip below — previously all six + // controls shared one line, squeezing the inputs until "Value" / + // "Label" and CJK option labels truncated (framework#2615 P3). +
+
+ update(i, { value: e.target.value })} + placeholder={t('designer.field.optValue', locale)} + disabled={disabled} + className="h-7 min-w-0 flex-1 text-xs font-mono" + /> + update(i, { label: e.target.value })} + placeholder={t('designer.field.optLabel', locale)} + disabled={disabled} + className="h-7 min-w-0 flex-1 text-xs" + /> +
+
+ update(i, { color: e.target.value })} + disabled={disabled} + className="h-6 w-6 rounded border bg-background cursor-pointer p-0.5" + title={t('designer.field.optColor', locale)} + /> + + + + +
))}
diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 940869cd9..a6f09fef3 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1450,6 +1450,10 @@ function DataPillar({ const [dirty, setDirty] = React.useState(false); const [hasDraft, setHasDraft] = React.useState(false); const [saving, setSaving] = React.useState(false); + // Timestamp of the last successful draft save — renders a "last saved HH:MM" + // hint next to the Save button (framework#2615 P3: nothing confirmed a draft + // save persisted, unlike the sibling pillars which toast). + const [savedAt, setSavedAt] = React.useState(null); const [gridVer, setGridVer] = React.useState(0); // Records grid ⇄ Form ⇄ Validations ⇄ Settings — four views of the SAME // object. Grid/Form are the runtime renderer (same-renderer principle); @@ -1615,13 +1619,15 @@ function DataPillar({ await client.save('object', current.name, objDraft, { mode: 'draft', packageId }); setHasDraft(true); setDirty(false); + setSavedAt(new Date()); + toast.success(tFormat('engine.studio.data.savedDraft', locale, { label: current.label })); onDraftSaved?.(); } catch (e) { setError(formatMetadataError(e)); } finally { setSaving(false); } - }, [client, current, objDraft, onDraftSaved]); + }, [client, current, objDraft, onDraftSaved, packageId, locale]); // Drag-reorder columns → reorder the object's `fields` metadata (field display // order follows metadata order), saved as a DRAFT. Published later via the @@ -1676,11 +1682,21 @@ function DataPillar({ {t('engine.studio.unpublishedDraft', locale)} )} + {savedAt && !dirty && ( + + {tFormat('engine.studio.data.lastSaved', locale, { + time: savedAt.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }), + })} + + )} )}
diff --git a/packages/plugin-view/src/__tests__/ObjectView.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.test.tsx index 72a60051a..5b7f9f4b2 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.test.tsx @@ -138,7 +138,10 @@ describe('ObjectView', () => { render(); - expect(screen.getByText('Create')).toBeDefined(); + // The record-create verb now matches the runtime object pages ("New" / + // console.objectView.new); with no I18nProvider mounted it falls back to + // the English default "New" (framework#2615 P3 — verb consistency). + expect(screen.getByText('New')).toBeDefined(); }); it('should hide create button when showCreate is false', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e8c73207..01f0e730f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2405,6 +2405,9 @@ importers: '@object-ui/core': specifier: workspace:* version: link:../core + '@object-ui/i18n': + specifier: workspace:* + version: link:../i18n '@object-ui/plugin-form': specifier: workspace:* version: link:../plugin-form