Skip to content

Commit db6b408

Browse files
fix(plugin-form): the ?form overlay for a record that does not exist shows the not-found state, not an empty editable form (#29246)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb7GJjX2XGw6A3qCEHoGcXrw)_ Fixes #29157 Clause-②: no Metadata-protocol: none Declarations as set by the claim (card comment 6573647843), which fixes the file surface: `ModalForm.tsx`, its private `loadFailure.ts`, their tests and a root changeset. DrawerForm, TabbedForm and WizardForm stay out by the claim. ## 维护者速读 - **改了什么**:控制台全局记录表单浮层(`?form=ID`)打开一条不存在的记录时,现在显示「Record not found」未找到状态(与记录页相同的文案),不再显示可编辑的空白「编辑」表单,也没有「更新」按钮。新建(`?form=new`)和编辑已存在的记录不变。 - **为什么改**:用户从已删除记录的旧链接(通知、书签、别人发来的地址)进入,会看到空白表单,填完点更新才被告知记录不存在,白填一遍;同一个 id 走 `/forms` 路由会直接拒绝,两处入口不一致。 - **风险与回滚**:低。只改 `ModalForm` 的记录读取:`findOne` 按契约对不存在的记录返回 `null`,此前被当成空记录 `{}` 填进表单,现在记为一次加载失败并画未找到状态。表单打开期间记录被删、后台重读得到 `null` 时也显示同一状态。回滚即还原本 PR。 - **先看**:`ui/packages/plugin-form/src/ModalForm.tsx` 的记录读取 effect(`data == null` 分支)与 `renderContent` 开头的 `RecordNotFoundError` 分支;`ui/packages/plugin-form/src/loadFailure.ts` 末尾新增的 `RecordNotFoundError`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-form/src/ModalForm.recordNotFound-29157.test.tsx`,预期 1 个文件、4 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **A null edit read is a failure, not an empty record.** `DataSource.findOne` is typed `T | null`, and the ObjectStack adapter's `readOne` answers `null` when the server answers 404. `ModalForm`'s record read ran `setFormData(data || {})` and committed, so the console's `?form=ID` overlay (AppContent mounts `ModalForm` with `mode: 'edit'` and the URL's id) drew a blank editable form with an enabled Update button for a record that does not exist. The read now fails with a new `RecordNotFoundError` from the package-private `loadFailure.ts`, through the same per-read failure run every other load failure uses, and the form is not seeded. - **Drawn as the record not-found state.** `renderContent` draws a `RecordNotFoundError` as the console's record not-found state (`Empty` with `EmptyTitle` and `EmptyDescription`, as `RecordDetailView` draws it), not as the red "Error loading form" box. The dialog's footer is already off while a load failure is set, so there is no Update or Cancel button; the dialog still closes by its X, Escape or the backdrop. - **Copy reused, no new string.** The title and description are the existing pack keys `empty.recordNotFound` and `empty.recordNotFoundDescription`, present in every locale pack. Their English values are added only to `ModalForm`'s private `createSafeTranslation` fallback table, so a host with no i18n provider still reads English. - **Also on a background re-read.** A bus re-read (`useRecordInvalidation`) that finds the record deleted while the dialog is open shows the same state. - **Unchanged.** Create (`?form=new`, or no id) never reads; an edit of a record that exists draws its form as before. `AppContent.tsx` is not in the diff. The master-detail arm (an object with subforms) reads through `MasterDetailForm` and is not touched. ## Tests - New `ModalForm.recordNotFound-29157.test.tsx` mounts `ModalForm` the way the console overlay does (`object-form`, `formType: 'modal'`, `mode: 'edit'`, an id, `open: true`) with the real registered fields. It pins: a `null` read draws "Record not found" and its description, no `name` input, no Update button and no "Error loading form"; a found record that a bus re-read then finds deleted switches to the same state; CONTROL a found record still draws its form and Update; CONTROL a create draws an empty form with Create and never calls `findOne`. ## Acceptance notes - **Live console not measured.** The card measured the defect in the console at `7fa9873f`. Here the chain is pinned in vitest from the `findOne` answer down; the adapter's 404 to `null` mapping is read from `ObjectStackAdapter.readOne` (`is404Error`), not re-exercised. - **The dialog title still reads "Edit CATEGORY".** The title is built by the host (`AppContent`), outside the claim's surface; the body says the record is not found. - **Sibling arms (finding, not fixed here).** `DrawerForm`, `TabbedForm` and `WizardForm` seed their forms with `data || {}` on a null record read in the same way. Reported to the seat for one follow-up card. ## Verification <details><summary>New pin · 4/4 here, 2 red against main's ModalForm</summary> `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/src/ModalForm.recordNotFound-29157.test.tsx` under `os-verify-lock` at `d68406a2`: Test Files 1 passed (1), Tests 4 passed (4). Reverse check at `64c17e66`: `ModalForm.tsx` and `loadFailure.ts` restored from the base `dedc3505` (the `RecordNotFoundError` count on disk 0), same command: Tests 2 failed, 2 passed. Both not-found cases failed with "Unable to find an element with the text: Record not found"; both CONTROLs passed. A throwaway probe on the same restored tree asserted the base behaviour directly and passed: the `name` input is present, empty and enabled, and the Update button is present and enabled. Restored with `git restore --source=HEAD --staged --worktree`; `git diff HEAD` empty, `git status --porcelain` empty, the probe file removed. </details> <details><summary>plugin-form package, overlay hosts, type check and lint</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/` (locked, code at `6733bc41`): Test Files 218 passed (218), Tests 2411 passed, 1 skipped. - The 30 ui test files outside plugin-form that name `ModalForm` in app-shell, the console, plugin-view and plugin-designer (the `AppContent.*` overlay tests among them), after a `turbo run build --filter='@object-ui/console^...'`: Test Files 30 passed (30), Tests 241 passed. - `turbo run type-check --filter=@object-ui/plugin-form` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0; `tsc -p tsconfig.test.json` again at `d68406a2`: exit 0. - `eslint` on the three changed `.ts`/`.tsx` files: 0 errors; the warnings are all on lines outside this diff's hunks. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 34 commands against merge base `dedc35051`. The 33 non-vitest gates ran in one loop starting at `6733bc41` and all exited 0, including `check-sdui-manifest.mjs` and its `--self-test`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-debt` and `check:query-options-erasure`. `pnpm --dir ui exec vitest run scripts/` at `d68406a2`: Test Files 169 passed, 2 skipped; Tests 4900 passed, 2 skipped. `dispatch-gates --ran` reconciled 34 derived, 34 run, 0 NOT-MEASURED. `d68406a2` only retypes the new test file. `origin/main` moved 12 commits since the base, none touching `ui/packages/plugin-form/`, `AppContent.tsx` or the en locale pack. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRxwsTHrYtG7dozRj9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b177920 commit db6b408

4 files changed

Lines changed: 159 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
---
4+
5+
fix(plugin-form): an edit `ModalForm` whose record does not exist shows the record not-found state instead of an empty editable form
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`DataSource.findOne` answers `null` for a record that does not exist (the ObjectStack adapter maps the server's 404 to it). The edit dialog seeded its form with `{}` from that answer, so the console's `?form=ID` overlay for a deleted or unknown record opened a blank form with an enabled Update button, and the 404 surfaced only after submit. The dialog now draws the console's record not-found state ("Record not found", the same copy the record page shows) with no fields and no footer, both on the first read and when a background re-read finds the record deleted. Create, and an edit of a record that exists, are unchanged.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* An edit `ModalForm` whose record does not exist shows the record not-found
11+
* state, never an empty editable form (objectstack#29157).
12+
*
13+
* The console's `?form=ID` overlay mounts this dialog with `mode: 'edit'` and
14+
* the id from the URL. `DataSource.findOne` answers `null` for a record that is
15+
* not there (the ObjectStack adapter maps the server's 404 to it), and the
16+
* dialog used to seed the form with `{}` from that answer: a blank form with an
17+
* enabled Update button, whose submit then failed against the missing record.
18+
*
19+
* Pinned here: a `null` edit read draws the not-found state (the console's
20+
* shared `empty.recordNotFound` copy) with no field and no Update button, both
21+
* on the first read and on a background re-read that finds the record deleted.
22+
* Controls: a create and a found record still draw the form.
23+
*/
24+
import React from 'react';
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { render, screen, waitFor, act } from '@testing-library/react';
27+
import { notifyDataChanged } from '@object-ui/react';
28+
import { registerAllFields } from '@object-ui/fields';
29+
import { ModalForm, type ModalFormProps, type ModalFormSchema } from './ModalForm';
30+
31+
registerAllFields();
32+
33+
function makeDS(record: Record<string, unknown> | null) {
34+
return {
35+
getObjectSchema: vi.fn().mockResolvedValue({
36+
name: 'showcase_category',
37+
fields: { name: { type: 'text', label: 'Name' } },
38+
}),
39+
findOne: vi.fn().mockResolvedValue(record),
40+
find: vi.fn().mockResolvedValue({ data: [] }),
41+
create: vi.fn(async (_o: string, d: Record<string, unknown>) => ({ id: 'n1', ...d })),
42+
update: vi.fn(async (_o: string, id: string, d: Record<string, unknown>) => ({ id, ...d })),
43+
};
44+
}
45+
46+
function open(ds: ReturnType<typeof makeDS>, mode: 'edit' | 'create', recordId?: string) {
47+
const schema: ModalFormSchema = {
48+
type: 'object-form',
49+
formType: 'modal',
50+
objectName: 'showcase_category',
51+
mode,
52+
recordId,
53+
title: mode === 'edit' ? 'Edit Category' : 'New Category',
54+
open: true,
55+
};
56+
render(<ModalForm schema={schema} dataSource={ds as unknown as ModalFormProps['dataSource']} />);
57+
}
58+
59+
const nameBox = () => document.body.querySelector('input[name="name"]') as HTMLInputElement | null;
60+
const submitButton = (label: string) => screen.queryByRole('button', { name: label });
61+
62+
describe('ModalForm — an edit read that finds no record (objectstack#29157)', () => {
63+
it('draws the not-found state with no field and no Update button', async () => {
64+
const ds = makeDS(null);
65+
open(ds, 'edit', 'NOPE_x');
66+
67+
expect(await screen.findByText('Record not found')).toBeInTheDocument();
68+
expect(
69+
screen.getByText(
70+
'The record you are looking for does not exist, may have been deleted, or you may not have access to it.',
71+
),
72+
).toBeInTheDocument();
73+
expect(ds.findOne).toHaveBeenCalledWith('showcase_category', 'NOPE_x');
74+
expect(nameBox()).toBeNull();
75+
expect(submitButton('Update')).toBeNull();
76+
// Not the generic load-failure screen: nothing failed to load.
77+
expect(screen.queryByText('Error loading form')).toBeNull();
78+
});
79+
80+
it('draws the not-found state when a background re-read finds the record deleted', async () => {
81+
const ds = makeDS({ id: 'c1', name: 'Books' });
82+
open(ds, 'edit', 'c1');
83+
await waitFor(() => expect(nameBox()?.value).toBe('Books'));
84+
85+
ds.findOne.mockResolvedValue(null);
86+
await act(async () => {
87+
notifyDataChanged({ objectName: 'showcase_category', recordId: 'c1' });
88+
});
89+
90+
expect(await screen.findByText('Record not found')).toBeInTheDocument();
91+
expect(nameBox()).toBeNull();
92+
expect(submitButton('Update')).toBeNull();
93+
});
94+
95+
it('control: a found record still draws its form and the Update button', async () => {
96+
const ds = makeDS({ id: 'c1', name: 'Books' });
97+
open(ds, 'edit', 'c1');
98+
99+
await waitFor(() => expect(nameBox()?.value).toBe('Books'));
100+
expect(submitButton('Update')).not.toBeNull();
101+
expect(screen.queryByText('Record not found')).toBeNull();
102+
});
103+
104+
it('control: a create draws an empty form and the Create button, with no read', async () => {
105+
const ds = makeDS(null);
106+
open(ds, 'create');
107+
108+
await waitFor(() => expect(nameBox()).not.toBeNull());
109+
expect(submitButton('Create')).not.toBeNull();
110+
expect(ds.findOne).not.toHaveBeenCalled();
111+
expect(screen.queryByText('Record not found')).toBeNull();
112+
});
113+
});

‎ui/packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import {
3333
AlertDialogDescription,
3434
AlertDialogAction,
3535
AlertDialogCancel,
36+
Empty,
37+
EmptyTitle,
38+
EmptyDescription,
3639
toast,
3740
} from '@object-ui/components';
3841
import { Loader2 } from 'lucide-react';
@@ -73,6 +76,7 @@ import { usePermissions } from '@object-ui/permissions';
7376
import { useOccSave } from './occSave';
7477
import {
7578
NO_LOAD_FAILURES,
79+
RecordNotFoundError,
7680
beginLoadRun,
7781
shownLoadFailure,
7882
type LoadFailures,
@@ -109,6 +113,10 @@ const useDiscardTranslation = createSafeTranslation(
109113
// The footer's declared Reset button (objectstack#27247).
110114
'common.reset': 'Reset',
111115
'form.errorLoading': 'Error loading form',
116+
// The record not-found state, the console's shared copy (the same keys and
117+
// values `RecordDetailView` draws), for an edit read that finds no record.
118+
'empty.recordNotFound': 'Record not found',
119+
'empty.recordNotFoundDescription': 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.',
112120
},
113121
'form.discardTitle',
114122
);
@@ -611,6 +619,12 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
611619
try {
612620
const data = await dataSource.findOne(schema.objectName, schema.recordId);
613621
if (cancelled) return;
622+
// `findOne` answers `null` for a record that does not exist: there is
623+
// nothing to edit, so the read failed, and the form is not seeded.
624+
if (data == null) {
625+
run.fail(new RecordNotFoundError(schema.objectName, schema.recordId));
626+
return;
627+
}
614628
loadedRecordIdRef.current = schema.recordId;
615629
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
616630
setFormData(data || {});
@@ -904,6 +918,14 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
904918
};
905919

906920
const renderContent = () => {
921+
if (error instanceof RecordNotFoundError) {
922+
return (
923+
<Empty data-testid="modal-form-record-not-found">
924+
<EmptyTitle>{t('empty.recordNotFound')}</EmptyTitle>
925+
<EmptyDescription>{t('empty.recordNotFoundDescription')}</EmptyDescription>
926+
</Empty>
927+
);
928+
}
907929
if (error) {
908930
return (
909931
<div className="p-4 border border-red-300 bg-red-50 rounded-md">

‎ui/packages/plugin-form/src/loadFailure.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,17 @@ export function beginLoadRun(
8383
export function shownLoadFailure(failures: LoadFailures): Error | null {
8484
return failures.schema || failures.record;
8585
}
86+
87+
/**
88+
* The record read answered "no such record": `DataSource.findOne` resolves
89+
* `null` for it (the ObjectStack adapter maps the server's 404 to that). An
90+
* edit form has nothing to edit then, so the read is a failure like any other,
91+
* drawn as the record not-found state rather than as an empty form whose
92+
* submit can only be refused.
93+
*/
94+
export class RecordNotFoundError extends Error {
95+
constructor(readonly objectName: string, readonly recordId: string) {
96+
super(`Record ${recordId} not found in ${objectName}`);
97+
this.name = 'RecordNotFoundError';
98+
}
99+
}

0 commit comments

Comments
 (0)