Skip to content

Commit c92ae76

Browse files
fix(plugin-form): drawer, tabbed and wizard edit forms for a record that does not exist show the not-found state, not an empty editable form (#29351)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb3Z5yLX6trsrar5zLPSuFd8)_ Fixes #29247 Clause-②: no Metadata-protocol: none Declarations as set by the claim (card comment 6576081714), which fixes the file surface: `DrawerForm.tsx`, `TabbedForm.tsx`, `WizardForm.tsx`, `loadFailure.ts` (a module-private shared helper only), `ModalForm.tsx` only because its not-found drawing moved into that helper, their tests and a root changeset. ## 维护者速读 - **改了什么**:抽屉表单、分页签表单和向导表单在编辑一条不存在的记录时,现在显示「Record not found」未找到状态(与记录页和 `?form=` 弹窗相同的文案),不再显示可编辑的空白表单,也没有「更新」按钮。四种记录表单共用 `loadFailure.ts` 里同一个私有组件来画这个状态。新建和编辑已存在的记录不变。 - **为什么改**:用户从已删除记录的旧链接进入这三种表单,会看到空白表单,填完点更新才被告知记录不存在;PR #29246 只修了弹窗表单。 - **风险与回滚**:低。三处记录读取 effect 在 `findOne` 返回 `null` 时记一次 `RecordNotFoundError` 加载失败、不再把 `{}` 填进表单;原有的「加载失败时不画表单和底栏」路径接管其余。表单打开期间后台重读发现记录已删,也显示同一状态。`ModalForm` 只是改为调用共享组件,行为不变。回滚即还原本 PR。 - **先看**:`ui/packages/plugin-form/src/loadFailure.ts` 末尾的 `RecordNotFoundState`;`DrawerForm.tsx` 记录读取 effect 的 `data == null` 分支与 `renderContent` 开头的分支;`WizardForm.tsx` 同一处(它还设置 `persistedRecord`)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-form/src/recordNotFound-29247.test.tsx packages/plugin-form/src/ModalForm.recordNotFound-29157.test.tsx`,预期 2 个文件、16 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **A null edit read is a failure, not an empty record, in every record form.** `DataSource.findOne` is typed `T | null`, and the ObjectStack adapter's `readOne` answers `null` when the server answers 404. `DrawerForm`, `TabbedForm` and `WizardForm` ran `setFormData(data || {})` (the wizard also `setPersistedRecord(data || {})`) and committed, so an edit form for a deleted or unknown record drew a blank editable form with an enabled Update button. Each now fails the read with the package-private `RecordNotFoundError`, through the per-read failure run every other load failure uses, and the form is not seeded. This is the branch PR #29246 gave `ModalForm`. - **One drawing for the state.** `loadFailure.ts` gains a module-private `RecordNotFoundState`: `Empty` with `EmptyTitle` and `EmptyDescription`, reading the existing pack keys `empty.recordNotFound` and `empty.recordNotFoundDescription` through its own `createSafeTranslation` table (the `en` values, so a provider-less host still reads English). Each of the four layouts draws it ahead of the generic "Error loading form" box when the shown failure is a `RecordNotFoundError`; the drawer keeps its footer off while a load failure is shown, as before, and the tabbed and wizard layouts return the state in place of the form. `ModalForm`'s inline copy of the same drawing and its two table rows move into the helper; its test id is kept. - **Also on a background re-read.** A bus re-read (`useRecordInvalidation`) that finds the record deleted while the form is open shows the same state. - **Unchanged.** Create (or no id) never reads; an edit of a record that exists draws its form as before. No new string, no new export from the package entry, no prop or schema change. ## Why A user who opens an old link to a deleted record in a drawer, tabbed or wizard form saw a blank form, filled it in, and learned the record did not exist only on save. ## Tests - New `recordNotFound-29247.test.tsx`, one `describe.each` over `DrawerForm`, `TabbedForm` and `WizardForm` (each an `object-form` in edit mode with one section, the real registered fields): a `null` read draws "Record not found", its description and the layout's test id, with no `title` 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`. - `ModalForm.recordNotFound-29157.test.tsx` (unchanged) holds `ModalForm` after its drawing moved into the helper. ## Acceptance notes - **Live console not measured.** The chain is pinned in vitest from the `findOne` answer down; the adapter's 404 to `null` mapping is read from `ObjectStackAdapter.readOne`, not re-exercised. - **The helper is built with `createElement`** because `loadFailure.ts` stays a `.ts` module as the claim names it; the `data-testid` prop is typed onto `Empty`'s props instead of cast. - **`setFormData(data)`** in the three layouts: after the `null` branch the `|| {}` fallback could no longer fire, so it is dropped. `ModalForm`'s own line is left as it is. - **One lint warning in the new test** (`no-explicit-any` on the parametrised layout type), the same documented shape and reason as `recordSwapLoading.test.tsx`: the four layouts share no props base. ## Verification <details><summary>New pin · 12/12 here, the 6 not-found cases red against main's three layouts</summary> `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/src/recordNotFound-29247.test.tsx packages/plugin-form/src/ModalForm.recordNotFound-29157.test.tsx` under `os-verify-lock` at `c4c659f5`: Test Files 2 passed (2), Tests 16 passed (16). Reverse check at `35a69884`: `DrawerForm.tsx`, `TabbedForm.tsx` and `WizardForm.tsx` restored from the base `c347d22d` with `git restore --source` (on disk: `RecordNotFoundError` count 0 in each; `data || {}` count 1, 1 and 2), `loadFailure.ts` kept at HEAD, new test file alone: Tests 6 failed, 6 passed (12). All six failures were the two not-found cases of each layout, each "Unable to find an element with the text: Record not found"; all six CONTROLs passed. Restored with `git restore --source=HEAD --staged --worktree` under an EXIT trap; `git diff HEAD` empty, `git status --porcelain` empty, and each file's `git hash-object` equal to its HEAD blob. At the final head `56a7a05d` (after the second merge of `main`): the two not-found files plus `ModalForm.untypedRepeater.test.tsx` (new on main) and `recordSwapLoading.test.tsx`: Test Files 4 passed (4), Tests 33 passed (33). </details> <details><summary>plugin-form package, type check and lint</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/` (locked) at `903d4662`: Test Files 220 passed (220), Tests 2435 passed, 1 skipped. Same counts at `35a69884` before the first merge. - `turbo run type-check --filter=@object-ui/plugin-form` (`tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 at `903d4662` and again at `56a7a05d`. The first run, at `35a69884`, before `815181c6` typed the test id prop, was red with TS2769 on `createElement(Empty, { 'data-testid' … })`; fixed by naming the attribute in the props type, no cast. - `eslint` on the six changed `.ts`/`.tsx` files: 0 errors; the one warning in a changed hunk is the documented `any` in the new test. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. `node scripts/check-i18n-call-site-keys.mjs` (from `ui/`, at `35a69884`): exit 0, the new `createSafeTranslation` table repeats the `en` values. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands (the same 34 against merge bases `c347d22d` and `d9885f5d`). At `903d4662`, the 33 non-vitest gates ran in one loop and all exited 0, `check-sdui-manifest.mjs` and its `--self-test`, `check:nul-bytes`, `check:query-options-erasure`, `check:type-check-debt` and `check:test-source-alias` among them; `pnpm --dir ui exec vitest run scripts/`: Test Files 169 passed, 2 skipped; Tests 4903 passed, 2 skipped. `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED. `main` then moved 21 commits, none touching the changed files (one new `ModalForm` test file and one `en` key in plugin-form and i18n). After merging it, at `56a7a05d`: `check-sdui-manifest`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset`, `check:nul-bytes`, `check:type-check-debt`, `check:type-check-coverage`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:undeclared-dep-imports` and `check:published-files` all exit 0. The other 23 derived gates were last measured at `903d4662`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no spec change) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the changed files: 0 errors) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTeZsth6vzGnL4eoaN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea025d3 commit c92ae76

7 files changed

Lines changed: 237 additions & 18 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 `DrawerForm`, `TabbedForm` or `WizardForm` 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 drawer, tabbed and wizard form layouts seeded their form with `{}` from that answer, so editing a deleted or unknown record opened a blank form with an enabled Update button, and the 404 surfaced only after submit. They now draw the record not-found state ("Record not found", the same copy the record page and the `ModalForm` overlay show) with no fields and no submit, both on the first read and when a background re-read finds the record deleted. All four record form layouts draw that state from one shared private component. Create, and an edit of a record that exists, are unchanged.

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import { usePermissions } from '@object-ui/permissions';
6969
import { useOccSave } from './occSave';
7070
import {
7171
NO_LOAD_FAILURES,
72+
RecordNotFoundError,
73+
RecordNotFoundState,
7274
beginLoadRun,
7375
shownLoadFailure,
7476
type LoadFailures,
@@ -443,9 +445,15 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
443445
try {
444446
const data = await dataSource.findOne(schema.objectName, schema.recordId);
445447
if (cancelled) return;
448+
// `findOne` answers `null` for a record that does not exist: there is
449+
// nothing to edit, so the read failed, and the form is not seeded.
450+
if (data == null) {
451+
run.fail(new RecordNotFoundError(schema.objectName, schema.recordId));
452+
return;
453+
}
446454
loadedRecordIdRef.current = schema.recordId;
447455
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
448-
setFormData(data || {});
456+
setFormData(data);
449457
// The record on screen is the one this run read, so an earlier record
450458
// read's failure no longer describes it. A schema failure stays: this
451459
// read says nothing about the object's fields.
@@ -775,6 +783,9 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
775783
};
776784

777785
const renderContent = () => {
786+
if (error instanceof RecordNotFoundError) {
787+
return <RecordNotFoundState testId="drawer-form-record-not-found" />;
788+
}
778789
if (error) {
779790
return (
780791
<div className="p-4 border border-red-300 bg-red-50 rounded-md">

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

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@ import {
3333
AlertDialogDescription,
3434
AlertDialogAction,
3535
AlertDialogCancel,
36-
Empty,
37-
EmptyTitle,
38-
EmptyDescription,
3936
toast,
4037
} from '@object-ui/components';
4138
import { Loader2 } from 'lucide-react';
@@ -77,6 +74,7 @@ import { useOccSave } from './occSave';
7774
import {
7875
NO_LOAD_FAILURES,
7976
RecordNotFoundError,
77+
RecordNotFoundState,
8078
beginLoadRun,
8179
shownLoadFailure,
8280
type LoadFailures,
@@ -113,10 +111,6 @@ const useDiscardTranslation = createSafeTranslation(
113111
// The footer's declared Reset button (objectstack#27247).
114112
'common.reset': 'Reset',
115113
'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.',
120114
},
121115
'form.discardTitle',
122116
);
@@ -919,12 +913,7 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
919913

920914
const renderContent = () => {
921915
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-
);
916+
return <RecordNotFoundState testId="modal-form-record-not-found" />;
928917
}
929918
if (error) {
930919
return (

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { sectionedGridLayout } from './autoLayout';
3030
import { useOccSave } from './occSave';
3131
import {
3232
NO_LOAD_FAILURES,
33+
RecordNotFoundError,
34+
RecordNotFoundState,
3335
beginLoadRun,
3436
shownLoadFailure,
3537
type LoadFailures,
@@ -410,9 +412,15 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
410412
try {
411413
const data = await dataSource.findOne(schema.objectName, schema.recordId);
412414
if (cancelled) return;
415+
// `findOne` answers `null` for a record that does not exist: there is
416+
// nothing to edit, so the read failed, and the form is not seeded.
417+
if (data == null) {
418+
run.fail(new RecordNotFoundError(schema.objectName, schema.recordId));
419+
return;
420+
}
413421
loadedRecordIdRef.current = schema.recordId;
414422
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
415-
setFormData(data || {});
423+
setFormData(data);
416424
// The record on screen is the one this run read, so an earlier record
417425
// read's failure no longer describes it. A schema failure stays: this
418426
// read says nothing about the object's fields.
@@ -581,6 +589,9 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
581589
return section.name || section.label || `tab-${index}`;
582590
};
583591

592+
if (error instanceof RecordNotFoundError) {
593+
return <RecordNotFoundState testId="tabbed-form-record-not-found" />;
594+
}
584595
if (error) {
585596
return (
586597
<div className="p-4 border border-red-300 bg-red-50 rounded-md">

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
import { useOccSave } from './occSave';
4040
import {
4141
NO_LOAD_FAILURES,
42+
RecordNotFoundError,
43+
RecordNotFoundState,
4244
beginLoadRun,
4345
shownLoadFailure,
4446
type LoadFailures,
@@ -673,10 +675,16 @@ export const WizardForm: React.FC<WizardFormProps> = ({
673675
try {
674676
const data = await dataSource.findOne(schema.objectName, schema.recordId);
675677
if (cancelled) return;
678+
// `findOne` answers `null` for a record that does not exist: there is
679+
// nothing to edit, so the read failed, and the form is not seeded.
680+
if (data == null) {
681+
run.fail(new RecordNotFoundError(schema.objectName, schema.recordId));
682+
return;
683+
}
676684
loadedRecordIdRef.current = schema.recordId;
677685
loadedRecordRef.current = snapshotLoadedRecord(schema, data);
678-
setFormData(data || {});
679-
setPersistedRecord(data || {});
686+
setFormData(data);
687+
setPersistedRecord(data);
680688
// What the wizard holds is now the record as read: no step's answer is
681689
// unsaved (objectui#10715).
682690
unsavedStepValuesRef.current = false;
@@ -1198,6 +1206,9 @@ export const WizardForm: React.FC<WizardFormProps> = ({
11981206
}
11991207
}, [canOpenStep, goToStep]);
12001208

1209+
if (error instanceof RecordNotFoundError) {
1210+
return <RecordNotFoundState testId="wizard-form-record-not-found" />;
1211+
}
12011212
if (error) {
12021213
return (
12031214
<div className="p-4 border border-red-300 bg-red-50 rounded-md">

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@
3131
*
3232
* Private to this package: no form's props or schema change.
3333
*/
34-
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
34+
import {
35+
createElement,
36+
type ComponentProps,
37+
type Dispatch,
38+
type MutableRefObject,
39+
type ReactElement,
40+
type SetStateAction,
41+
} from 'react';
42+
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
43+
import { createSafeTranslation } from '@object-ui/i18n';
3544

3645
/** The two reads a record form makes before it can draw. */
3746
export type LoadSource = 'schema' | 'record';
@@ -97,3 +106,36 @@ export class RecordNotFoundError extends Error {
97106
this.name = 'RecordNotFoundError';
98107
}
99108
}
109+
110+
// The record not-found copy: the console's shared keys, with the values
111+
// `RecordDetailView` draws. A provider-less host (a standalone embed, this
112+
// package's own tests) reads this table; `pnpm check:i18n-keys` holds it to the
113+
// `en` pack.
114+
const useRecordNotFoundTranslation = createSafeTranslation(
115+
{
116+
'empty.recordNotFound': 'Record not found',
117+
'empty.recordNotFoundDescription': 'The record you are looking for does not exist, may have been deleted, or you may not have access to it.',
118+
},
119+
'empty.recordNotFound',
120+
);
121+
122+
/**
123+
* What a record form draws in place of its form for a `RecordNotFoundError`:
124+
* the record not-found state, never the "Error loading form" box (nothing
125+
* failed to load) and never an empty form. One drawing for every layout that
126+
* reads a record (`ModalForm`, `DrawerForm`, `TabbedForm`, `WizardForm`), so
127+
* each host shows the same state. The caller draws no footer or submit beside
128+
* it: each already keeps those off while a load failure is shown.
129+
*/
130+
export function RecordNotFoundState({ testId }: { testId: string }): ReactElement {
131+
const { t } = useRecordNotFoundTranslation();
132+
// A `data-*` attribute is legal on the `div` that `Empty` renders, but only
133+
// JSX spells it without a declared prop: named in the props type here.
134+
const props: ComponentProps<typeof Empty> & { 'data-testid': string } = { 'data-testid': testId };
135+
return createElement(
136+
Empty,
137+
props,
138+
createElement(EmptyTitle, null, t('empty.recordNotFound')),
139+
createElement(EmptyDescription, null, t('empty.recordNotFoundDescription')),
140+
);
141+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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 `DrawerForm`, `TabbedForm` or `WizardForm` whose record does not
11+
* exist shows the record not-found state, never an empty editable form
12+
* (objectstack#29247; `ModalForm` got the same state in objectstack#29157).
13+
*
14+
* `DataSource.findOne` answers `null` for a record that is not there (the
15+
* ObjectStack adapter maps the server's 404 to it). Each of these layouts used
16+
* to seed its form with `{}` from that answer: a blank form with an enabled
17+
* Update button, whose submit then failed against the missing record.
18+
*
19+
* Pinned per layout: a `null` edit read draws the not-found state (the
20+
* console's shared `empty.recordNotFound` copy) with no field and no Update
21+
* button, on the first read and on a background re-read that finds the record
22+
* deleted. Controls: a found record and a create still draw the form.
23+
*/
24+
import React from 'react';
25+
import { describe, it, expect, vi, afterEach } from 'vitest';
26+
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
27+
import { notifyDataChanged } from '@object-ui/react';
28+
import { registerAllFields } from '@object-ui/fields';
29+
import type { DataSource } from '@object-ui/types';
30+
import { DrawerForm } from './DrawerForm';
31+
import { TabbedForm } from './TabbedForm';
32+
import { WizardForm } from './WizardForm';
33+
34+
registerAllFields();
35+
36+
afterEach(() => cleanup());
37+
38+
const NOT_FOUND_TITLE = 'Record not found';
39+
const NOT_FOUND_DESCRIPTION =
40+
'The record you are looking for does not exist, may have been deleted, or you may not have access to it.';
41+
42+
function makeDS(record: Record<string, unknown> | null) {
43+
return {
44+
getObjectSchema: vi.fn().mockResolvedValue({
45+
name: 'task',
46+
fields: { title: { type: 'text', label: 'Title' } },
47+
}),
48+
findOne: vi.fn().mockResolvedValue(record),
49+
find: vi.fn().mockResolvedValue({ data: [] }),
50+
create: vi.fn(async (_o: string, d: Record<string, unknown>) => ({ id: 'n1', ...d })),
51+
update: vi.fn(async (_o: string, id: string, d: Record<string, unknown>) => ({ id, ...d })),
52+
};
53+
}
54+
55+
/**
56+
* The layouts under test, as one type. They share no props base (each schema
57+
* is pinned to its own `formType` literal), so the parametrised surface is
58+
* named here: the same two props against each, `schema` deliberately `any`,
59+
* with the `formType` each case passes checked by the tuple below. The same
60+
* shape and reason as `recordSwapLoading.test.tsx`.
61+
*/
62+
type RecordFormLayout = React.ComponentType<{ schema: any; dataSource?: DataSource }>;
63+
64+
const LAYOUTS: ReadonlyArray<readonly [string, RecordFormLayout, 'drawer' | 'tabbed' | 'wizard']> = [
65+
['DrawerForm', DrawerForm, 'drawer'],
66+
['TabbedForm', TabbedForm, 'tabbed'],
67+
// A wizard step is a section, so the one section is its one step.
68+
['WizardForm', WizardForm, 'wizard'],
69+
];
70+
71+
function open(
72+
Form: RecordFormLayout,
73+
formType: 'drawer' | 'tabbed' | 'wizard',
74+
ds: ReturnType<typeof makeDS>,
75+
mode: 'edit' | 'create',
76+
recordId?: string,
77+
) {
78+
const schema = {
79+
type: 'object-form',
80+
formType,
81+
objectName: 'task',
82+
mode,
83+
recordId,
84+
sections: [{ name: 'basics', label: 'Basics', fields: ['title'] }],
85+
// The drawer only.
86+
open: true,
87+
onOpenChange: vi.fn(),
88+
};
89+
render(<Form schema={schema} dataSource={ds as unknown as DataSource} />);
90+
}
91+
92+
const titleBox = () =>
93+
document.body.querySelector<HTMLInputElement>('[data-field="title"] input');
94+
const button = (label: string) => screen.queryByRole('button', { name: label });
95+
96+
describe.each(LAYOUTS)('%s — an edit read that finds no record (objectstack#29247)', (_name, Form, formType) => {
97+
it('draws the not-found state with no field and no Update button', async () => {
98+
const ds = makeDS(null);
99+
open(Form, formType, ds, 'edit', 'NOPE_x');
100+
101+
expect(await screen.findByText(NOT_FOUND_TITLE)).toBeInTheDocument();
102+
expect(screen.getByText(NOT_FOUND_DESCRIPTION)).toBeInTheDocument();
103+
expect(screen.getByTestId(`${formType}-form-record-not-found`)).toBeInTheDocument();
104+
expect(ds.findOne).toHaveBeenCalledWith('task', 'NOPE_x');
105+
expect(titleBox()).toBeNull();
106+
expect(button('Update')).toBeNull();
107+
// Not the generic load-failure screen: nothing failed to load.
108+
expect(screen.queryByText('Error loading form')).toBeNull();
109+
});
110+
111+
it('draws the not-found state when a background re-read finds the record deleted', async () => {
112+
const ds = makeDS({ id: 't1', title: 'Write report' });
113+
open(Form, formType, ds, 'edit', 't1');
114+
await waitFor(() => expect(titleBox()?.value).toBe('Write report'));
115+
116+
ds.findOne.mockResolvedValue(null);
117+
await act(async () => {
118+
notifyDataChanged({ objectName: 'task', recordId: 't1' });
119+
});
120+
121+
expect(await screen.findByText(NOT_FOUND_TITLE)).toBeInTheDocument();
122+
expect(titleBox()).toBeNull();
123+
expect(button('Update')).toBeNull();
124+
});
125+
126+
it('control: a found record still draws its form and the Update button', async () => {
127+
const ds = makeDS({ id: 't1', title: 'Write report' });
128+
open(Form, formType, ds, 'edit', 't1');
129+
130+
await waitFor(() => expect(titleBox()?.value).toBe('Write report'));
131+
expect(button('Update')).not.toBeNull();
132+
expect(screen.queryByText(NOT_FOUND_TITLE)).toBeNull();
133+
});
134+
135+
it('control: a create draws an empty form, with no read', async () => {
136+
const ds = makeDS(null);
137+
open(Form, formType, ds, 'create');
138+
139+
await waitFor(() => expect(titleBox()).not.toBeNull());
140+
expect(titleBox()?.value).toBe('');
141+
expect(button('Create')).not.toBeNull();
142+
expect(ds.findOne).not.toHaveBeenCalled();
143+
expect(screen.queryByText(NOT_FOUND_TITLE)).toBeNull();
144+
});
145+
});

0 commit comments

Comments
 (0)