@object-ui/app-shell 4.0.0 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Utility functions for ObjectStack Console
3
3
  */
4
+ export { resolveRecordFormTarget, } from './recordFormNavigation';
4
5
  /**
5
6
  * Resolves an I18nLabel to a plain string.
6
7
  * I18nLabel can be either a string or an object { key, defaultValue?, params? }.
@@ -28,24 +29,47 @@ export function capitalizeFirst(str) {
28
29
  return str;
29
30
  return str.charAt(0).toUpperCase() + str.slice(1);
30
31
  }
32
+ // Sentinel used to mark empty-placeholder positions inside formatRecordTitle
33
+ // so adjacent separators can be stripped in a second pass.
34
+ const EMPTY_TOKEN = '\u0000';
35
+ // Separator characters commonly placed between {fields} in titleFormat patterns
36
+ // (hyphen, em/en dashes, pipes, slashes, middle dot, comma, colon).
37
+ const SEPARATOR_CLASS = '[-\\u2013\\u2014|/·,:]';
31
38
  /**
32
- * Format a record title using the titleFormat pattern
33
- * @param titleFormat Pattern like "{name} - {email}" or "{firstName} {lastName}"
34
- * @param record The record data object
35
- * @returns Formatted title string
39
+ * Format a record title using the titleFormat pattern.
40
+ *
41
+ * Empty placeholders (missing or null/empty fields) are stripped along with
42
+ * any orphan separator they leave behind, so a template like
43
+ * "{full_name} - {company}"
44
+ * evaluated against `{ company: "Acme" }` resolves to `"Acme"` rather than
45
+ * `" - Acme"`. Returns an empty string when no placeholder resolved.
36
46
  */
37
47
  export function formatRecordTitle(titleFormat, record) {
38
48
  if (!titleFormat || !record) {
39
49
  return record?.id || record?._id || 'Record';
40
50
  }
41
- // Replace {fieldName} patterns with actual values
42
- return titleFormat.replace(/\{(\w+)\}/g, (_match, fieldName) => {
43
- const value = record[fieldName];
44
- if (value === null || value === undefined) {
45
- return '';
51
+ let anyResolved = false;
52
+ let out = titleFormat.replace(/\{([^{}]+)\}/g, (_match, fieldName) => {
53
+ const value = record[fieldName.trim()];
54
+ if (value === null || value === undefined || value === '') {
55
+ return EMPTY_TOKEN;
46
56
  }
57
+ anyResolved = true;
47
58
  return String(value);
48
59
  });
60
+ if (!anyResolved)
61
+ return '';
62
+ // Drop separators on either side of an empty token, then any leftover
63
+ // tokens, then collapse runs of whitespace.
64
+ const sepBefore = new RegExp(`\\s*${SEPARATOR_CLASS}\\s*${EMPTY_TOKEN}`, 'g');
65
+ const sepAfter = new RegExp(`${EMPTY_TOKEN}\\s*${SEPARATOR_CLASS}\\s*`, 'g');
66
+ out = out
67
+ .replace(sepBefore, '')
68
+ .replace(sepAfter, '')
69
+ .replace(new RegExp(EMPTY_TOKEN, 'g'), '')
70
+ .replace(/\s+/g, ' ')
71
+ .trim();
72
+ return out;
49
73
  }
50
74
  /**
51
75
  * Get display name for a record using titleFormat or fallback
@@ -55,8 +79,17 @@ export function formatRecordTitle(titleFormat, record) {
55
79
  */
56
80
  export function getRecordDisplayName(objectDef, record) {
57
81
  if (objectDef?.titleFormat) {
58
- return formatRecordTitle(objectDef.titleFormat, record);
82
+ const formatted = formatRecordTitle(objectDef.titleFormat, record);
83
+ if (formatted)
84
+ return formatted;
59
85
  }
60
- // Fallback: Try common name fields
61
- return record?.name || record?.title || record?.label || record?.id || record?._id || 'Untitled';
86
+ return (record?.name ||
87
+ record?.full_name ||
88
+ record?.fullName ||
89
+ record?.title ||
90
+ record?.label ||
91
+ record?.subject ||
92
+ record?.id ||
93
+ record?._id ||
94
+ 'Untitled');
62
95
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Resolvers for record-form navigation targets.
3
+ *
4
+ * Pure helpers extracted from `AppContent.tsx` so the routing rules behind
5
+ * the modal-vs-page decision are unit-testable in isolation. Keep this file
6
+ * free of React / router imports — it must run in a node test environment.
7
+ *
8
+ * @module utils/recordFormNavigation
9
+ */
10
+ /**
11
+ * Subset of the object metadata shape consumed by the resolver. Mirrors the
12
+ * fields read from the runtime objects list (`useMetadata().objects`).
13
+ */
14
+ export interface ObjectDefinitionForNavigation {
15
+ /** API name used in URLs. */
16
+ name: string;
17
+ /** Optional UI mode override for create/edit interactions. */
18
+ editMode?: 'modal' | 'page';
19
+ }
20
+ /**
21
+ * Result of the modal-vs-page decision. `kind: 'modal'` means the caller
22
+ * should open the global `<ModalForm>`; `kind: 'page'` means the caller
23
+ * should `navigate(url)` to the full-screen create/edit route.
24
+ */
25
+ export type RecordFormTarget = {
26
+ kind: 'modal';
27
+ } | {
28
+ kind: 'page';
29
+ url: string;
30
+ };
31
+ /**
32
+ * Build a record-form navigation target.
33
+ *
34
+ * Returns:
35
+ * - `{ kind: 'modal' }` when the object metadata does not opt in to page
36
+ * mode (default behavior — preserves backward compatibility).
37
+ * - `{ kind: 'page', url }` when `objectDef.editMode === 'page'`. The
38
+ * URL points at `/{baseUrl}/{objectName}/new` for create, or
39
+ * `/{baseUrl}/{objectName}/record/{recordId}/edit` for edit. The
40
+ * `recordId` is derived from `record.id` or `record._id`, falling
41
+ * back to create-mode if neither is present.
42
+ *
43
+ * Notes:
44
+ * - Returns `{ kind: 'modal' }` when `objectDef` is missing or has no
45
+ * `name` — the caller (AppContent) treats this as "no actionable
46
+ * state" and falls back to the existing modal flow.
47
+ * - `recordId` is URL-encoded so non-ASCII / reserved characters in
48
+ * object IDs (e.g. UUIDs with `:` separators) are safe.
49
+ */
50
+ export declare function resolveRecordFormTarget(opts: {
51
+ objectDef: ObjectDefinitionForNavigation | null | undefined;
52
+ baseUrl: string;
53
+ record: {
54
+ id?: string | number;
55
+ _id?: string | number;
56
+ } | null | undefined;
57
+ }): RecordFormTarget;
58
+ /**
59
+ * Action descriptor accepted by the navigate-create / navigate-edit
60
+ * handlers. Loose-typed because the same shape is constructed dynamically
61
+ * from JSON metadata at runtime and we want the helpers to be tolerant of
62
+ * legacy or hand-authored inputs.
63
+ */
64
+ export interface NavigationActionDef {
65
+ /** Optional explicit object name (overrides any context). */
66
+ objectName?: string;
67
+ /** Optional explicit record id (only meaningful for `navigate_edit`). */
68
+ recordId?: string | number;
69
+ /** Standard params bag — preferred location for objectName / recordId. */
70
+ params?: {
71
+ objectName?: string;
72
+ recordId?: string | number;
73
+ [key: string]: any;
74
+ };
75
+ }
76
+ /**
77
+ * Optional context typically supplied by an `ActionRunner` (e.g. when the
78
+ * action button is mounted inside an `ObjectView`, the view registers its
79
+ * `objectName` and `baseUrl` on the runner so action JSON can omit them).
80
+ *
81
+ * Tolerant of additional unknown keys because the upstream
82
+ * `ActionRunner.getContext()` returns a generic `ActionContext` with an
83
+ * open index signature; we only read the two fields we care about.
84
+ */
85
+ export interface NavigationActionContext {
86
+ objectName?: string;
87
+ baseUrl?: string;
88
+ [key: string]: unknown;
89
+ }
90
+ export type NavigationActionResult = {
91
+ success: true;
92
+ url: string;
93
+ } | {
94
+ success: false;
95
+ error: string;
96
+ };
97
+ /**
98
+ * Resolve the URL for a `navigate_create` action.
99
+ *
100
+ * Order of precedence for `objectName`:
101
+ * 1. `action.params.objectName`
102
+ * 2. `action.objectName`
103
+ * 3. `context.objectName`
104
+ *
105
+ * `baseUrl` falls back to `context.baseUrl`, then to the supplied
106
+ * `defaultBaseUrl` (typically `/apps/{appName}`).
107
+ *
108
+ * Returns `{ success: false }` with a descriptive error when `objectName`
109
+ * cannot be resolved — the caller (`ActionRunner`) surfaces this to the UI.
110
+ */
111
+ export declare function resolveNavigateCreateUrl(opts: {
112
+ action: NavigationActionDef;
113
+ context?: NavigationActionContext;
114
+ defaultBaseUrl: string;
115
+ }): NavigationActionResult;
116
+ /**
117
+ * Resolve the URL for a `navigate_edit` action.
118
+ *
119
+ * Same `objectName` precedence as {@link resolveNavigateCreateUrl}.
120
+ * `recordId` is read from `action.params.recordId` or `action.recordId`
121
+ * (no context fallback — record ids are intrinsically per-action).
122
+ *
123
+ * Returns `{ success: false }` with a descriptive error when either
124
+ * `objectName` or `recordId` is missing.
125
+ */
126
+ export declare function resolveNavigateEditUrl(opts: {
127
+ action: NavigationActionDef;
128
+ context?: NavigationActionContext;
129
+ defaultBaseUrl: string;
130
+ }): NavigationActionResult;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Resolvers for record-form navigation targets.
3
+ *
4
+ * Pure helpers extracted from `AppContent.tsx` so the routing rules behind
5
+ * the modal-vs-page decision are unit-testable in isolation. Keep this file
6
+ * free of React / router imports — it must run in a node test environment.
7
+ *
8
+ * @module utils/recordFormNavigation
9
+ */
10
+ /**
11
+ * Build a record-form navigation target.
12
+ *
13
+ * Returns:
14
+ * - `{ kind: 'modal' }` when the object metadata does not opt in to page
15
+ * mode (default behavior — preserves backward compatibility).
16
+ * - `{ kind: 'page', url }` when `objectDef.editMode === 'page'`. The
17
+ * URL points at `/{baseUrl}/{objectName}/new` for create, or
18
+ * `/{baseUrl}/{objectName}/record/{recordId}/edit` for edit. The
19
+ * `recordId` is derived from `record.id` or `record._id`, falling
20
+ * back to create-mode if neither is present.
21
+ *
22
+ * Notes:
23
+ * - Returns `{ kind: 'modal' }` when `objectDef` is missing or has no
24
+ * `name` — the caller (AppContent) treats this as "no actionable
25
+ * state" and falls back to the existing modal flow.
26
+ * - `recordId` is URL-encoded so non-ASCII / reserved characters in
27
+ * object IDs (e.g. UUIDs with `:` separators) are safe.
28
+ */
29
+ export function resolveRecordFormTarget(opts) {
30
+ const { objectDef, baseUrl, record } = opts;
31
+ if (!objectDef?.name || objectDef.editMode !== 'page') {
32
+ return { kind: 'modal' };
33
+ }
34
+ const rawId = record?.id ?? record?._id;
35
+ if (record && rawId != null && rawId !== '') {
36
+ const encoded = encodeURIComponent(String(rawId));
37
+ return {
38
+ kind: 'page',
39
+ url: `${baseUrl}/${objectDef.name}/record/${encoded}/edit`,
40
+ };
41
+ }
42
+ return { kind: 'page', url: `${baseUrl}/${objectDef.name}/new` };
43
+ }
44
+ /**
45
+ * Resolve the URL for a `navigate_create` action.
46
+ *
47
+ * Order of precedence for `objectName`:
48
+ * 1. `action.params.objectName`
49
+ * 2. `action.objectName`
50
+ * 3. `context.objectName`
51
+ *
52
+ * `baseUrl` falls back to `context.baseUrl`, then to the supplied
53
+ * `defaultBaseUrl` (typically `/apps/{appName}`).
54
+ *
55
+ * Returns `{ success: false }` with a descriptive error when `objectName`
56
+ * cannot be resolved — the caller (`ActionRunner`) surfaces this to the UI.
57
+ */
58
+ export function resolveNavigateCreateUrl(opts) {
59
+ const { action, context = {}, defaultBaseUrl } = opts;
60
+ const objectName = action.params?.objectName ?? action.objectName ?? context.objectName;
61
+ const baseUrl = context.baseUrl ?? defaultBaseUrl;
62
+ if (!objectName) {
63
+ return {
64
+ success: false,
65
+ error: 'navigate_create: objectName is required',
66
+ };
67
+ }
68
+ return { success: true, url: `${baseUrl}/${objectName}/new` };
69
+ }
70
+ /**
71
+ * Resolve the URL for a `navigate_edit` action.
72
+ *
73
+ * Same `objectName` precedence as {@link resolveNavigateCreateUrl}.
74
+ * `recordId` is read from `action.params.recordId` or `action.recordId`
75
+ * (no context fallback — record ids are intrinsically per-action).
76
+ *
77
+ * Returns `{ success: false }` with a descriptive error when either
78
+ * `objectName` or `recordId` is missing.
79
+ */
80
+ export function resolveNavigateEditUrl(opts) {
81
+ const { action, context = {}, defaultBaseUrl } = opts;
82
+ const objectName = action.params?.objectName ?? action.objectName ?? context.objectName;
83
+ const recordId = action.params?.recordId ?? action.recordId;
84
+ const baseUrl = context.baseUrl ?? defaultBaseUrl;
85
+ if (!objectName || recordId == null || recordId === '') {
86
+ return {
87
+ success: false,
88
+ error: 'navigate_edit: objectName and recordId are required',
89
+ };
90
+ }
91
+ const encoded = encodeURIComponent(String(recordId));
92
+ return {
93
+ success: true,
94
+ url: `${baseUrl}/${objectName}/record/${encoded}/edit`,
95
+ };
96
+ }
@@ -58,7 +58,16 @@ export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels,
58
58
  const types = useMemo(() => (availableTypes && availableTypes.length > 0
59
59
  ? allTypes.filter(v => availableTypes.includes(v.type))
60
60
  : allTypes), [allTypes, availableTypes]);
61
- const existingSet = useMemo(() => new Set(existingLabels ?? []), [existingLabels]);
61
+ // Stabilise the existing-labels list across renders so we don't churn the
62
+ // `existingSet` memo (and the dependent useEffects) on every parent render.
63
+ // Callers commonly pass `views.map(v => v.label)` inline, which is a fresh
64
+ // array each render — without this normalisation, the name-suggest effect
65
+ // below would re-fire indefinitely and could trigger "Maximum update depth"
66
+ // when the array contents are stable but the reference is not.
67
+ const existingKey = (existingLabels ?? []).join('\u0000');
68
+ const existingSet = useMemo(() => new Set(existingLabels ?? []),
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ [existingKey]);
62
71
  const fieldOptions = useMemo(() => (objectDef ? deriveFieldOptions(objectDef) : []), [objectDef]);
63
72
  const [selectedType, setSelectedType] = useState(types[0]?.type ?? 'grid');
64
73
  const [label, setLabel] = useState('');
@@ -634,14 +634,17 @@ export function ObjectView({ dataSource, objects, onEdit }) {
634
634
  const handleSetDefaultView = useCallback(async (vid) => {
635
635
  if (!dataSource?.update)
636
636
  return;
637
+ if (!isSavedView(vid)) {
638
+ toast.error(t('console.objectView.cannotEditMetaView')
639
+ || 'System view — duplicate it to mark a default.');
640
+ return;
641
+ }
637
642
  try {
638
643
  // Clear `isDefault` on all other saved views, then set this one.
639
644
  const updates = savedViews
640
645
  .filter((sv) => (sv.id || sv._id) !== vid && sv.isDefault)
641
646
  .map((sv) => dataSource.update('sys_view', sv.id || sv._id, { isDefault: false }));
642
- if (isSavedView(vid)) {
643
- updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
644
- }
647
+ updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
645
648
  await Promise.all(updates);
646
649
  setRefreshKey(k => k + 1);
647
650
  }
@@ -649,7 +652,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
649
652
  console.error('[ViewTabBar] Failed to set default view:', err);
650
653
  toast.error('Failed to set default view');
651
654
  }
652
- }, [dataSource, savedViews, isSavedView]);
655
+ }, [dataSource, savedViews, isSavedView, t]);
653
656
  const handleReorderViews = useCallback(async (orderedIds) => {
654
657
  // Persist order for ALL views (incl. metadata) in localStorage so the
655
658
  // UI immediately reflects the new ordering, including reorderings
@@ -677,11 +680,19 @@ export function ObjectView({ dataSource, objects, onEdit }) {
677
680
  setRefreshKey(k => k + 1);
678
681
  }, [dataSource, savedViews, objectName]);
679
682
  const handleConfigView = useCallback((vid) => {
683
+ // System (metadata-defined) views are read-only — opening the
684
+ // ViewConfigPanel against one would let the user save changes that
685
+ // never persist. Steer them to "Duplicate view" instead.
686
+ if (!isSavedView(vid)) {
687
+ toast.error(t('console.objectView.cannotEditMetaView')
688
+ || 'System view — duplicate it to make changes.');
689
+ return;
690
+ }
680
691
  if (vid !== activeViewId)
681
692
  handleViewChange(vid);
682
693
  setViewConfigPanelMode('edit');
683
694
  setShowViewConfigPanel(true);
684
- }, [activeViewId, handleViewChange]);
695
+ }, [activeViewId, handleViewChange, isSavedView, t]);
685
696
  const handleAddView = useCallback(() => {
686
697
  setShowCreateViewDialog(true);
687
698
  }, []);
@@ -1001,7 +1012,13 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1001
1012
  center: viewDef.map?.center,
1002
1013
  },
1003
1014
  gallery: {
1004
- imageField: viewDef.gallery?.imageField || 'image',
1015
+ // Spread the full view-defined gallery first so spec
1016
+ // fields (cardSize, visibleFields, coverField, coverFit)
1017
+ // make it through; then layer legacy field aliases that
1018
+ // ObjectGallery still consults.
1019
+ ...(viewDef.gallery || {}),
1020
+ imageField: viewDef.gallery?.imageField || viewDef.gallery?.coverField || 'image',
1021
+ coverField: viewDef.gallery?.coverField || viewDef.gallery?.imageField,
1005
1022
  titleField: viewDef.gallery?.titleField || objectDef.titleField || 'name',
1006
1023
  subtitleField: viewDef.gallery?.subtitleField,
1007
1024
  },
@@ -1072,6 +1089,10 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1072
1089
  } }))] })] }), views.length >= 1 && (() => {
1073
1090
  const viewTabItems = views.map((view) => {
1074
1091
  const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
1092
+ // System views (loaded from objectDef.listViews / metadata) are
1093
+ // *read-only*. Only sys_view-backed records can be mutated by
1094
+ // the user; admins must duplicate a system view to customize it.
1095
+ const isSystem = !saved;
1075
1096
  return {
1076
1097
  id: view.id,
1077
1098
  label: view.label,
@@ -1081,6 +1102,11 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1081
1102
  isDefault: !!(saved?.isDefault ?? view.isDefault),
1082
1103
  isPinned: !!(saved?.isPinned ?? view.isPinned),
1083
1104
  visibility: saved?.visibility ?? view.visibility,
1105
+ readonly: isSystem,
1106
+ readonlyReason: isSystem
1107
+ ? (t('console.objectView.systemViewReadonly')
1108
+ || 'System view defined in code — duplicate to customize.')
1109
+ : undefined,
1084
1110
  };
1085
1111
  });
1086
1112
  return (_jsxs("div", { className: "border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: [_jsx(ViewTabBar, { views: viewTabItems, activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: {
@@ -20,6 +20,7 @@ import { SkeletonDetail } from '../skeletons';
20
20
  import { ActionConfirmDialog } from './ActionConfirmDialog';
21
21
  import { ActionParamDialog } from './ActionParamDialog';
22
22
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
23
+ import { getRecordDisplayName } from '../utils';
23
24
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
24
25
  export function RecordDetailView({ dataSource, objects, onEdit }) {
25
26
  const { objectName, recordId } = useParams();
@@ -477,17 +478,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
477
478
  return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: "Users viewing this record", children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectDef.label, onDataLoaded: (record) => {
478
479
  if (!record || typeof record !== 'object')
479
480
  return;
480
- const primary = detailSchema.primaryField;
481
- const candidates = [
482
- primary && record[primary],
483
- record.name,
484
- record.title,
485
- record.label,
486
- record.subject,
487
- ];
488
- const next = candidates.find((v) => typeof v === 'string' && v.trim().length > 0);
489
- if (next && next !== recordTitle)
490
- setRecordTitle(next);
481
+ // Resolve the same way DetailView's header does, so the
482
+ // breadcrumb matches the on-page title (e.g. "David Kim"
483
+ // instead of "#lead-1778…").
484
+ const resolved = getRecordDisplayName(objectDef, record);
485
+ if (resolved && resolved !== recordTitle && resolved !== 'Untitled') {
486
+ setRecordTitle(resolved);
487
+ }
491
488
  }, onEdit: () => {
492
489
  onEdit({ id: pureRecordId });
493
490
  }, discussionSlot: _jsx(RecordChatterPanel, { config: {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * RecordFormPage Component
3
+ *
4
+ * Renders a full-screen create or edit page for a record. This is the
5
+ * page-mode counterpart to the global `ModalForm` mounted by `AppContent`
6
+ * for objects whose metadata declares `editMode: 'page'`.
7
+ *
8
+ * Routes (mounted by `AppContent`):
9
+ * - `/apps/:appName/:objectName/new` — create mode
10
+ * - `/apps/:appName/:objectName/record/:recordId/edit` — edit mode
11
+ *
12
+ * Behavior:
13
+ * - Resolves the object definition via `useMetadata()`. While metadata is
14
+ * still loading the page renders a `SkeletonDetail` to avoid a flash of
15
+ * "Object Not Found".
16
+ * - Delegates form rendering and data fetching to `<ObjectForm>` from
17
+ * `@object-ui/plugin-form` with `formType: 'simple'`. ObjectForm itself
18
+ * fetches the existing record (in edit mode) via `dataSource.findOne`,
19
+ * so this page does not need to manage its own loading state for
20
+ * record data.
21
+ * - On success / cancel, navigates back if the user has history, otherwise
22
+ * falls back to a sensible parent route (record detail in edit mode,
23
+ * object list in create mode). Cancel always navigates back without a
24
+ * toast.
25
+ * - Wraps the form in a sticky page header (back button + title) for a
26
+ * consistent full-screen chrome.
27
+ *
28
+ * @module views/RecordFormPage
29
+ */
30
+ export interface RecordFormPageProps {
31
+ /** Form mode — `'create'` for the `/new` route, `'edit'` for the `/edit` route. */
32
+ mode: 'create' | 'edit';
33
+ }
34
+ /**
35
+ * Full-screen record create/edit page.
36
+ *
37
+ * Reads `:objectName` (and `:recordId` when editing) from the URL and
38
+ * resolves the object definition. Renders an `<ObjectForm>` configured with
39
+ * `formType: 'simple'` (i.e. a flat in-page form), wrapped in a page header
40
+ * that mirrors the look of `RecordDetailView`.
41
+ */
42
+ export declare function RecordFormPage({ mode }: RecordFormPageProps): import("react/jsx-runtime").JSX.Element;