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
29 changes: 29 additions & 0 deletions .changeset/studio-wizard-and-p3-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@object-ui/app-shell": minor
"@object-ui/plugin-view": patch
---

Studio package-create dogfood follow-ups (framework#2615 — P2 wizard + P3 polish):

- **Package-id wizard feedback.** The three package wizards (switcher create,
landing create, landing duplicate) share a new `PackageIdInput`: illegal
characters are still normalized away, but no longer silently — a notice
says what was removed — a reverse-domain format hint shows while the id
doesn't parse, and a CJK-only name that yields no id suggestion is told to
type one manually instead of leaving the id box mysteriously empty.
- **Records-grid duplicate "Actions" column.** A field literally named
`actions` is now dropped from the Studio grid's data columns, so it no
longer collides with the always-pinned row-actions column (it stays
editable in the form designer).
- **Record-create verb consistency.** The `ObjectView` toolbar create button
resolved a hardcoded English "Create"; it now uses the same
`console.objectView.new` ("New" / 新建) key as the runtime object pages so
Studio and the running app agree.
- **Branded cold-load splash.** The console's pre-auth loading gate rendered a
bare "Loading…"; it now shows the branded, boot-safe `LoadingScreen`.
- **Picklist option editor.** Value/label inputs and CJK option labels no
longer truncate — the six controls that shared one cramped row are split
into a two-row layout so the inputs get the full panel width.
- **Draft-save confirmation.** The Data pillar's "Save draft" now shows a
success toast and a "last saved HH:MM" indicator, matching the App and
Automations pillars.
13 changes: 8 additions & 5 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ import {
useAttachUserStateAdapters,
} from '../context/UserStateAdapters';
import { ThemeProvider } from '../chrome/ThemeProvider';
import { LoadingScreen } from '../chrome/LoadingScreen';
import { RemediationOverlay } from './RemediationOverlay';

// The console's every pre-React / pre-auth gate (Suspense fallback, adapter
// not ready, org/auth loading) renders this. It used to be a bare, unbranded
// "Loading…" line — ~8s of blank chrome before the login redirect on a cold
// /_console load (framework#2615 P3). Delegate to the branded, boot-safe
// splash (logo + product name + step list) that the rest of the shell already
// uses, so the cold load looks like the product from the first frame.
export function LoadingFallback() {
return (
<div className="h-screen flex items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
);
return <LoadingScreen />;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.create': 'Create',
'engine.studio.createDraft': 'Create (save as draft)',
'engine.studio.saveDraft': 'Save draft',
'engine.studio.data.savedDraft': 'Object “{label}” saved as draft',
'engine.studio.data.lastSaved': 'Saved {time}',
'engine.studio.publish': 'Publish',
'engine.studio.loading': 'Loading…',
'engine.studio.loadFailed': 'Failed to load',
Expand Down Expand Up @@ -954,6 +956,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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)',
Expand Down Expand Up @@ -1789,6 +1794,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.create': '创建',
'engine.studio.createDraft': '创建(存为草稿)',
'engine.studio.saveDraft': '保存草稿',
'engine.studio.data.savedDraft': '对象「{label}」已存为草稿',
'engine.studio.data.lastSaved': '已于 {time} 保存',
'engine.studio.publish': '发布',
'engine.studio.loading': '加载中…',
'engine.studio.loadFailed': '加载失败',
Expand Down Expand Up @@ -1887,6 +1894,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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} 已创建(可写)',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -757,61 +757,72 @@ function OptionsEditor({
{rows.length === 0 ? (
<div className="text-[11px] italic text-muted-foreground px-1">{t('designer.field.noValues', locale)}</div>
) : (
<div className="space-y-1">
<div className="space-y-1.5">
{rows.map((o, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={o.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder={t('designer.field.optValue', locale)}
disabled={disabled}
className="h-7 text-xs font-mono flex-1"
/>
<Input
value={o.label ?? ''}
onChange={(e) => update(i, { label: e.target.value })}
placeholder={t('designer.field.optLabel', locale)}
disabled={disabled}
className="h-7 text-xs flex-1"
/>
<input
type="color"
value={o.color ?? '#cccccc'}
onChange={(e) => update(i, { color: e.target.value })}
disabled={disabled}
className="h-7 w-7 rounded border bg-background cursor-pointer p-0.5"
title={t('designer.field.optColor', locale)}
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => move(i, i - 1)}
disabled={disabled || i === 0}
aria-label={t('designer.field.moveUp', locale)}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => move(i, i + 1)}
disabled={disabled || i === rows.length - 1}
aria-label={t('designer.field.moveDown', locale)}
>
<ArrowDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive"
onClick={() => remove(i)}
disabled={disabled}
aria-label={t('designer.field.removeValue', locale)}
>
<X className="h-3 w-3" />
</Button>
// 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).
<div key={i} className="rounded-md border border-border/60 p-1.5 space-y-1">
<div className="flex items-center gap-1">
<Input
value={o.value}
onChange={(e) => 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"
/>
<Input
value={o.label ?? ''}
onChange={(e) => update(i, { label: e.target.value })}
placeholder={t('designer.field.optLabel', locale)}
disabled={disabled}
className="h-7 min-w-0 flex-1 text-xs"
/>
</div>
<div className="flex items-center gap-1">
<input
type="color"
value={o.color ?? '#cccccc'}
onChange={(e) => 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)}
/>
<span className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => move(i, i - 1)}
disabled={disabled || i === 0}
aria-label={t('designer.field.moveUp', locale)}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => move(i, i + 1)}
disabled={disabled || i === rows.length - 1}
aria-label={t('designer.field.moveDown', locale)}
>
<ArrowDown className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive"
onClick={() => remove(i)}
disabled={disabled}
aria-label={t('designer.field.removeValue', locale)}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
Expand Down
30 changes: 15 additions & 15 deletions packages/app-shell/src/views/studio-design/BuilderLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"
/>
<input
<PackageIdInput
value={dupId}
onChange={(e) => 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 && <p className="text-[10px] text-destructive">{dupErr}</p>}
<div className="flex items-center gap-1.5">
Expand Down Expand Up @@ -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"
/>
<input
<PackageIdSuggestionHint show={!idTouched && !!newName.trim() && !newId} locale={locale} />
<PackageIdInput
value={newId}
onChange={(e) => {
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"
/>
<div className="flex items-center gap-1.5">
<button
Expand Down
77 changes: 77 additions & 0 deletions packages/app-shell/src/views/studio-design/PackageIdInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { sanitizePackageId, PACKAGE_ID_RE } from './packages-io';
import { PackageIdInput, PackageIdSuggestionHint } from './PackageIdInput';

afterEach(cleanup);

describe('sanitizePackageId', () => {
it('passes a clean reverse-domain id through unflagged', () => {
expect(sanitizePackageId('com.example.repairs')).toEqual({
value: 'com.example.repairs',
stripped: false,
});
});

it('flags dropped characters (the audit repro: `bad id!!` → `badid`)', () => {
expect(sanitizePackageId('bad id!!')).toEqual({ value: 'badid', stripped: true });
});

it('lowercases without flagging — nothing was lost', () => {
expect(sanitizePackageId('Com.Example.App')).toEqual({
value: 'com.example.app',
stripped: false,
});
});

it('flags CJK input as stripped (nothing representable remains)', () => {
expect(sanitizePackageId('报修')).toEqual({ value: '', stripped: true });
});
});

/** Controlled harness — the real callers keep the value in state. */
function Harness() {
const [value, setValue] = React.useState('');
return <PackageIdInput value={value} onChange={setValue} testId="id-input" />;
}

describe('PackageIdInput', () => {
it('shows the stripped notice when illegal characters are removed, clears on a clean keystroke', () => {
render(<Harness />);
const input = screen.getByTestId('id-input');
fireEvent.change(input, { target: { value: 'bad id!!' } });
expect(input).toHaveValue('badid');
expect(screen.getByTestId('pkg-id-stripped')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'badid.app' } });
expect(screen.queryByTestId('pkg-id-stripped')).not.toBeInTheDocument();
});

it('shows the reverse-domain format hint while the id does not parse, hides once valid', () => {
render(<Harness />);
const input = screen.getByTestId('id-input');
fireEvent.change(input, { target: { value: 'badid' } });
expect(PACKAGE_ID_RE.test('badid')).toBe(false);
expect(screen.getByTestId('pkg-id-format-hint')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'com.example.app' } });
expect(screen.queryByTestId('pkg-id-format-hint')).not.toBeInTheDocument();
});

it('shows no hints for an empty value', () => {
render(<Harness />);
expect(screen.queryByTestId('pkg-id-format-hint')).not.toBeInTheDocument();
expect(screen.queryByTestId('pkg-id-stripped')).not.toBeInTheDocument();
});
});

describe('PackageIdSuggestionHint', () => {
it('renders only when shown (CJK name yielded no suggestion)', () => {
const { rerender } = render(<PackageIdSuggestionHint show={false} />);
expect(screen.queryByTestId('pkg-id-manual-hint')).not.toBeInTheDocument();
rerender(<PackageIdSuggestionHint show />);
expect(screen.getByTestId('pkg-id-manual-hint')).toBeInTheDocument();
});
});
Loading
Loading