@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.
- package/CHANGELOG.md +127 -0
- package/README.md +54 -0
- package/dist/console/AppContent.js +93 -37
- package/dist/console/ConsoleShell.js +4 -1
- package/dist/console/home/HomeLayout.js +12 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/layout/AppHeader.js +8 -1
- package/dist/layout/ConsoleFloatingChatbot.d.ts +8 -1
- package/dist/layout/ConsoleFloatingChatbot.js +79 -10
- package/dist/layout/ConsoleLayout.js +5 -1
- package/dist/utils/index.d.ts +9 -4
- package/dist/utils/index.js +45 -12
- package/dist/utils/recordFormNavigation.d.ts +130 -0
- package/dist/utils/recordFormNavigation.js +96 -0
- package/dist/views/CreateViewDialog.js +10 -1
- package/dist/views/ObjectView.js +32 -6
- package/dist/views/RecordDetailView.js +8 -11
- package/dist/views/RecordFormPage.d.ts +42 -0
- package/dist/views/RecordFormPage.js +171 -0
- package/dist/views/index.d.ts +2 -0
- package/dist/views/index.js +1 -0
- package/package.json +24 -24
package/dist/utils/index.js
CHANGED
|
@@ -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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
82
|
+
const formatted = formatRecordTitle(objectDef.titleFormat, record);
|
|
83
|
+
if (formatted)
|
|
84
|
+
return formatted;
|
|
59
85
|
}
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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('');
|
package/dist/views/ObjectView.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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;
|