Skip to content

Commit 29c6040

Browse files
os-zhuangclaude
andauthored
fix: redo record-list "Add View" flow — empty-name 405, invisible drafts, canonical naming (#2768)
Rebuilds the record-list "Add View" / "Save as view" create path so a runtime-created view has one canonical identity and is verifiable before publish. Supersedes #2754; fixes #2767. P1 — unified identity. New `viewEnvelope(objectName, spec, { name, label })` seam emits the canonical ViewItem `{ name: '<object>.<key>', object, viewKind: 'list', label, config }` (config.data stamped), mirroring the Studio `anchors.ts:createBuildBody`. The qualified name is used as BOTH the `PUT /meta/view/:name` URL segment and `body.name`, so the sys_metadata row key, the ViewTabBar tab id, and the body identity agree and the draft → read → publish loop resolves. `ObjectView` and `ObjectDataPage` share the one helper. Empty-name 405. `MetadataClient.save()` and `createRuntimeMetadata()` throw a clear error instead of emitting `PUT /meta/view/` (empty :name). P2/P3/P4 — draft visibility. `listViews(objectName, { previewDrafts })`: in preview mode the adapter makes a single `MetadataClient.withPreviewDrafts(true) .list('view')` request and uses the server's already-overlaid list (draft wins by name, `_draft` tagged) — replacing, not appending, so a draft that edits a published view can't double-tab. No hand-rolled metadata fetch at the adapter. After a create in normal mode the console navigates to the new view with `?preview=draft` so the DraftPreviewBar is visible and Publish is one click. P5 — CJK naming. `CreateViewDialog` gains an editable machine-name field, prefilled via slugify(label) and required (submit disabled) when slugify is empty for non-Latin labels — no silent random names. New i18n keys (en/zh). Tests: viewEnvelope/deriveViewKey, empty-name guards, and the listViews preview branch (single request, no dupe, _draft passthrough). Docs updated. Claude-Session: https://claude.ai/code/session_01Y2ZKbzgbsYndkWYojr1NTc Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93a85b2 commit 29c6040

14 files changed

Lines changed: 520 additions & 31 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@object-ui/data-objectstack": patch
3+
"@object-ui/app-shell": patch
4+
"@object-ui/types": patch
5+
"@object-ui/i18n": patch
6+
---
7+
8+
fix(app-shell): redo the record-list "Add View" create flow — empty-name 405, invisible drafts, canonical naming
9+
10+
Rebuilds the record-list "Add View" / "Save as view" create path so a
11+
runtime-created view has one canonical identity and is actually verifiable
12+
before publish (supersedes #2754; fixes #2767).
13+
14+
- **Unified identity (P1).** New `viewEnvelope(objectName, spec, { name, label })`
15+
seam in `runtime-metadata-persistence.ts` emits the canonical ViewItem
16+
(`{ name: '<object>.<key>', object, viewKind: 'list', label, config }` with
17+
`config.data = { provider: 'object', object }`), mirroring the Studio
18+
`anchors.ts:createBuildBody`. The **qualified** name is passed as BOTH the
19+
`PUT /meta/view/:name` URL segment and `body.name`, so the `sys_metadata`
20+
row key, the ViewTabBar tab id, and the body identity all agree and the
21+
draft → read → publish loop resolves. `ObjectView` and `ObjectDataPage` both
22+
call the single helper — the duplicated envelope block is gone (P6).
23+
- **Empty-name guards (405).** `MetadataClient.save()` and
24+
`createRuntimeMetadata()` throw a clear contextual error instead of emitting
25+
`PUT /meta/view/` (empty `:name`, server 405).
26+
- **Draft visibility (P2/P3/P4).** `DataSource.listViews(objectName, { previewDrafts })`:
27+
in draft-preview mode the `ObjectStackAdapter` makes a **single**
28+
`MetadataClient.withPreviewDrafts(true).list('view')` request and uses the
29+
server's already-overlaid list (draft wins by name, `_draft` tagged) —
30+
replacing, not appending, so a draft that edits a published view can't
31+
double-tab. No hand-rolled `fetch` of metadata routes at the adapter layer.
32+
After a create in normal mode the console navigates to the new view with
33+
`?preview=draft`, so the DraftPreviewBar is visible and Publish is one click.
34+
- **CJK-aware naming (P5).** `CreateViewDialog` gains an editable machine-name
35+
field, prefilled via `slugify(label)` for Latin labels and required (submit
36+
disabled) when slugify yields empty for non-Latin labels — no more silent
37+
random `task_grid_mrsyt56j` names. New `console.objectView.viewName*` keys
38+
(en/zh).

content/docs/guide/designing-app-navigation.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ The key asymmetry: a page can imitate the other two, but it **loses the object
2626
shell** — and every future improvement to that shell (new actions, better view
2727
switching, permission trimming) will skip your page.
2828

29+
> **Creating a view at runtime ("Add View" / "Save as view").** Both entry
30+
> points stage the new view as a per-item **draft** (ADR-0034) — invisible to
31+
> other users until you Publish. Its identity is the canonical qualified name
32+
> `<object>.<key>` (e.g. `project.by_status`), used as the metadata row key,
33+
> the `body.name`, and the ViewTabBar tab id alike, so the draft → preview →
34+
> publish loop resolves to a single row. After creating, the console navigates
35+
> you to the new view in **draft-preview mode** (`?preview=draft`) so you can
36+
> verify it and Publish from the DraftPreviewBar in one click. The create
37+
> dialog asks for a display label **and** a machine key; the key auto-fills
38+
> from the label for Latin text, and must be typed for non-Latin (CJK, …)
39+
> labels rather than falling back to a random name.
40+
2941
## The Rule of Least Power
3042

3143
Use the least powerful construct that expresses the requirement:

packages/app-shell/src/views/CreateViewDialog.tsx

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77
* configuration fields (e.g. the group-by field for kanban, the start-date
88
* field for calendar/timeline/gantt, lat/lng for map, image for gallery).
99
* The Create button stays disabled until every required field is set.
10-
* Step 3: The user enters a name (required, defaults to "Grid 1" etc.).
10+
* Step 3: The user enters a display label (required, defaults to "Grid 1" etc.)
11+
* and a machine `name` (the metadata key). The name auto-fills from the label
12+
* via `slugify`; for non-Latin (CJK/…) labels slugify yields nothing, so the
13+
* field is left empty and the user must type a key before Create enables
14+
* (#2767 P5 — no more silent random names).
1115
*
12-
* On submit, calls `onCreate({ type, label, [type]: {...required fields} })`.
16+
* On submit, calls `onCreate({ type, label, name, [type]: {...required fields} })`.
1317
* The parent is responsible for actually persisting the view (we keep this
1418
* component pure — no dataSource coupling).
1519
*/
@@ -27,6 +31,7 @@ import {
2731
cn,
2832
} from '@object-ui/components';
2933
import { useObjectTranslation } from '@object-ui/i18n';
34+
import { slugify } from './metadata-admin/createDerive';
3035
import {
3136
deriveFieldOptions,
3237
isImageLikeField,
@@ -58,7 +63,7 @@ export interface CreateViewDialogProps {
5863
* fields are nested under their type key (e.g. `kanban.groupByField`),
5964
* matching the @objectstack/spec NamedListView shape.
6065
*/
61-
onCreate: (config: Record<string, any> & { type: string; label: string }) => void;
66+
onCreate: (config: Record<string, any> & { type: string; label: string; name: string }) => void;
6267
/** Used to suggest unique default names like "Grid 2" if "Grid 1" exists. */
6368
existingLabels?: string[];
6469
/** Restrict the available view types. Defaults to all built-in types. */
@@ -305,6 +310,10 @@ export function CreateViewDialog({
305310
const [selectedType, setSelectedType] = useState<string>(types[0]?.type ?? 'grid');
306311
const [label, setLabel] = useState<string>('');
307312
const [touched, setTouched] = useState(false);
313+
// Machine `name` (metadata key) + whether the user has typed into it. Auto-
314+
// derived from `label` via slugify until the user edits it directly.
315+
const [name, setName] = useState<string>('');
316+
const [nameTouched, setNameTouched] = useState(false);
308317
/** Map of `${type}.${fieldKey}` → selected field name. Per-type so switching
309318
* view types preserves the user's earlier choices in case they switch back. */
310319
const [requiredFieldValues, setRequiredFieldValues] = useState<Record<string, string>>({});
@@ -315,6 +324,7 @@ export function CreateViewDialog({
315324
if (open) {
316325
setSelectedType(types[0]?.type ?? 'grid');
317326
setTouched(false);
327+
setNameTouched(false);
318328
setRequiredFieldValues({});
319329
}
320330
}, [open, types]);
@@ -326,6 +336,13 @@ export function CreateViewDialog({
326336
}
327337
}, [selectedType, touched, types, existingSet]);
328338

339+
// Derive the machine name from the label until the user edits it. slugify
340+
// returns '' for non-Latin labels, so the field stays empty and the user is
341+
// prompted to fill it in (rather than a silent random key — #2767 P5).
342+
useEffect(() => {
343+
if (!nameTouched) setName(slugify(label));
344+
}, [label, nameTouched]);
345+
329346
// Required fields for the currently selected type
330347
const requiredFields = REQUIRED_FIELDS_BY_TYPE[selectedType] ?? [];
331348

@@ -358,7 +375,13 @@ export function CreateViewDialog({
358375
const trimmed = label.trim();
359376
const isDuplicate = trimmed.length > 0 && existingSet.has(trimmed);
360377
const allRequiredFilled = requiredFields.every(f => getRequiredValue(f.key).length > 0);
361-
const canSubmit = trimmed.length > 0 && !isDuplicate && allRequiredFilled;
378+
// Machine name: same snake_case shape slugify emits and the metadata `name`
379+
// pattern (`^[a-z_][a-z0-9_]*$`) accepts. Empty → prompt; malformed → hint.
380+
const NAME_RE = /^[a-z_][a-z0-9_]*$/;
381+
const nameKey = name.trim();
382+
const nameMissing = nameKey.length === 0;
383+
const nameInvalid = nameKey.length > 0 && !NAME_RE.test(nameKey);
384+
const canSubmit = trimmed.length > 0 && !isDuplicate && allRequiredFilled && !nameMissing && !nameInvalid;
362385

363386
// Auto-pick a sensible default for any required field. Runs whenever the
364387
// type or available options change, but only fills slots the user hasn't
@@ -398,9 +421,10 @@ export function CreateViewDialog({
398421
if (!v) return;
399422
subConfig[rf.key] = rf.key === 'yAxisFields' ? [v] : v;
400423
});
401-
const payload: Record<string, any> & { type: string; label: string } = {
424+
const payload: Record<string, any> & { type: string; label: string; name: string } = {
402425
type: selectedType,
403426
label: trimmed,
427+
name: nameKey,
404428
};
405429
if (Object.keys(subConfig).length > 0) {
406430
payload[selectedType] = subConfig;
@@ -549,6 +573,35 @@ export function CreateViewDialog({
549573
)}
550574
</div>
551575

576+
<div className="space-y-1">
577+
<label htmlFor="create-view-machine-name-input" className="text-xs font-medium">
578+
{t('console.objectView.viewName')}
579+
<span className="ml-1 text-destructive">*</span>
580+
</label>
581+
<Input
582+
id="create-view-machine-name-input"
583+
data-testid="create-view-machine-name-input"
584+
value={name}
585+
onChange={(e) => { setName(e.target.value); setNameTouched(true); }}
586+
onKeyDown={(e) => { if (e.key === 'Enter' && canSubmit) handleSubmit(); }}
587+
placeholder="grid_1"
588+
className="h-9 font-mono"
589+
/>
590+
{nameMissing ? (
591+
<p className="text-[11px] text-muted-foreground" data-testid="create-view-machine-name-help">
592+
{t('console.objectView.viewNameRequired')}
593+
</p>
594+
) : nameInvalid ? (
595+
<p className="text-[11px] text-destructive" data-testid="create-view-error-machine-name">
596+
{t('console.objectView.viewNameInvalid')}
597+
</p>
598+
) : (
599+
<p className="text-[11px] text-muted-foreground" data-testid="create-view-machine-name-help">
600+
{t('console.objectView.viewNameHelp')}
601+
</p>
602+
)}
603+
</div>
604+
552605
<DialogFooter className="mt-2">
553606
<Button
554607
type="button"

packages/app-shell/src/views/ObjectDataPage.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ import { RecordDetailView } from './RecordDetailView';
5858
import { PageHeader } from '../layout/PageHeader';
5959
import { getIcon } from '../utils/getIcon';
6060
import { useMetadataClient } from './metadata-admin/useMetadata';
61-
import { createRuntimeMetadata } from './runtime-metadata-persistence';
61+
import { createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence';
6262
import { CreateViewDialog } from './CreateViewDialog';
63+
import {
64+
usePreviewDrafts,
65+
PREVIEW_QUERY_FLAG,
66+
PREVIEW_QUERY_VALUE,
67+
} from '../preview/PreviewModeContext';
6368

6469
/** Field types the auto-derived user-filter bar offers as dropdowns. */
6570
const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']);
@@ -76,6 +81,9 @@ export function ObjectDataPage({ dataSource, objects }: any) {
7681
const { user } = useAuth();
7782
const isAdmin = useIsWorkspaceAdmin();
7883
const metadataClient = useMetadataClient();
84+
// ADR-0037: enter draft-preview after "Save as view" so the fresh draft is
85+
// visible; if already previewing, keep the flag off the suffix (it's sticky).
86+
const previewDrafts = usePreviewDrafts();
7987
const [showCreateViewDialog, setShowCreateViewDialog] = React.useState(false);
8088

8189
const objectDef = React.useMemo(
@@ -233,14 +241,25 @@ export function ObjectDataPage({ dataSource, objects }: any) {
233241
columns: Array.isArray(config.columns) && config.columns.length > 0 ? config.columns : columns,
234242
...(urlFilters.length ? { filter: urlFilters } : {}),
235243
};
236-
const draftName = String(config?.name ?? config?.id ?? '');
237-
const createdId = await createRuntimeMetadata('view', draftName, spec, { metadataClient });
238-
if (createdId) navigate(`../view/${createdId}`, { relative: 'path' });
244+
// #2767 P1: unified identity — the qualified `<object>.<key>` name is the
245+
// URL segment AND the body identity. #2767 P4: land on the new draft in
246+
// preview mode so it's visible and one click from Publish.
247+
const env = viewEnvelope(objectName ?? '', spec, {
248+
name: config.name,
249+
label: config.label,
250+
});
251+
const createdId = await createRuntimeMetadata('view', env.name, env, { metadataClient });
252+
if (createdId) {
253+
const previewSuffix = previewDrafts
254+
? ''
255+
: `?${PREVIEW_QUERY_FLAG}=${PREVIEW_QUERY_VALUE}`;
256+
navigate(`../view/${createdId}${previewSuffix}`, { relative: 'path' });
257+
}
239258
} catch (err) {
240259
console.error('[ObjectDataPage] Failed to save view:', err);
241260
}
242261
},
243-
[columns, urlFilters, metadataClient, navigate],
262+
[columns, urlFilters, metadataClient, navigate, objectName, previewDrafts],
244263
);
245264

246265
if (!objectDef) {

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ import { detectStatusField } from '@object-ui/types';
4141
import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
4242
import { ViewConfigPanel } from './ViewConfigPanel';
4343
import { useMetadataClient } from './metadata-admin/useMetadata';
44-
import { persistRuntimeMetadata, createRuntimeMetadata } from './runtime-metadata-persistence';
44+
import { persistRuntimeMetadata, createRuntimeMetadata, viewEnvelope } from './runtime-metadata-persistence';
4545
import { CreateViewDialog } from './CreateViewDialog';
46+
import {
47+
usePreviewDrafts,
48+
PREVIEW_QUERY_FLAG,
49+
PREVIEW_QUERY_VALUE,
50+
} from '../preview/PreviewModeContext';
4651
import { PageHeader } from '../layout/PageHeader';
4752
import { useMobileViewSwitcherRegistration } from '../layout/MobileViewSwitcherContext';
4853
import type { MobileViewSwitcherItem } from '../layout/MobileViewSwitcherContext';
@@ -169,6 +174,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
169174
// model (the `sys_view` table is retired).
170175
const metadataClient = useMetadataClient();
171176

177+
// ADR-0037: in draft-preview mode (`?preview=draft`), `listViews()` reads
178+
// the draft-overlaid world so a view just created via "Add View" shows up
179+
// in the ViewTabBar before it is published. Normal mode sees published
180+
// views only (the publish gate stays intact).
181+
const previewDrafts = usePreviewDrafts();
182+
172183
// Inline view config panel state (Airtable-style right sidebar)
173184
const [showViewConfigPanel, setShowViewConfigPanel] = useState(false);
174185
const [viewConfigPanelMode, setViewConfigPanelMode] = useState<'create' | 'edit'>('edit');
@@ -276,31 +287,45 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
276287
// ADR-0034: a new view is created as an invisible per-item
277288
// draft via the metadata seam; an explicit Publish promotes it.
278289
// UI-layer concerns (default columns, kanban/gallery massaging
279-
// above, and the auto-activation below) stay here.
280-
const draftName = String(
281-
(config as any)?.name ?? (config as any)?.id ?? (spec as any)?.id ?? '',
282-
);
283-
createdId = await createRuntimeMetadata('view', draftName, spec, {
290+
// above, and the auto-activation below) stay here; the canonical
291+
// ViewItem envelope + qualified-name identity live in the seam.
292+
//
293+
// #2767 P1: the qualified name `<object>.<key>` is used as BOTH
294+
// the URL segment and `body.name`, so the sys_metadata row key,
295+
// the ViewTabBar tab id, and the body identity all agree and the
296+
// draft → read → publish loop resolves.
297+
const env = viewEnvelope(objectName, spec, {
298+
name: (config as any)?.name,
299+
label: config.label,
300+
});
301+
createdId = await createRuntimeMetadata('view', env.name, env, {
284302
metadataClient,
285303
});
286304
}
287305
setShowViewConfigPanel(false);
288306
setViewConfigPanelMode('edit');
289307
setRefreshKey(k => k + 1);
290-
// Auto-activate the newly created view (Airtable parity).
291-
// Routing falls back to the default view if `createdId` doesn't
292-
// resolve yet — re-render after refresh will pick it up.
308+
// Auto-activate the newly created view (Airtable parity), and close
309+
// the main-path loop (#2767 P4): a fresh view is a DRAFT, invisible
310+
// to a normal (published-only) read, so navigating there plainly
311+
// would land on "view not found". Enter draft-preview mode
312+
// (`?preview=draft`) instead — the DraftPreviewBar then lets the
313+
// user verify and Publish in one click. If already in preview, the
314+
// sticky provider keeps the flag, so no suffix is needed.
293315
if (createdId) {
316+
const previewSuffix = previewDrafts
317+
? ''
318+
: `?${PREVIEW_QUERY_FLAG}=${PREVIEW_QUERY_VALUE}`;
294319
if (viewId) {
295-
navigate(`../${createdId}`, { relative: 'path' });
320+
navigate(`../${createdId}${previewSuffix}`, { relative: 'path' });
296321
} else {
297-
navigate(`view/${createdId}`);
322+
navigate(`view/${createdId}${previewSuffix}`);
298323
}
299324
}
300325
} catch (err) {
301326
console.error('[ViewConfigPanel] Failed to create view:', err);
302327
}
303-
}, [dataSource, objectName, objects, navigate, viewId, metadataClient]);
328+
}, [dataSource, objectName, objects, navigate, viewId, metadataClient, previewDrafts]);
304329

305330
// Record count tracking for footer
306331
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
@@ -418,7 +443,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
418443
// Read saved views from the metadata overlay (`/meta/view`) via the
419444
// adapter's `listViews`. Adapters without it surface no saved views.
420445
if (typeof (dataSource as any)?.listViews === 'function') {
421-
(dataSource as any).listViews(objectName)
446+
(dataSource as any).listViews(objectName, { previewDrafts })
422447
.then((rows: any[]) => {
423448
if (cancelled) return;
424449
// Normalize: ensure each view has an `id` for ViewTabBar
@@ -445,7 +470,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
445470
// saved views. The retired `sys_view` table is no longer read.
446471
setSavedViews([]);
447472
return () => { cancelled = true; };
448-
}, [dataSource, objectName, refreshKey]);
473+
}, [dataSource, objectName, refreshKey, previewDrafts]);
449474

450475
// Persisted per-view config overrides (e.g. density toggle). Saved
451476
// separately from `objectDef.listViews` (the embedded definition) via

0 commit comments

Comments
 (0)