-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathMetadataFieldsPage.tsx
More file actions
252 lines (233 loc) · 7.53 KB
/
Copy pathMetadataFieldsPage.tsx
File metadata and controls
252 lines (233 loc) · 7.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* MetadataFieldsPage
*
* Setup-app container that renders {@link FieldDesigner} bound to one
* object's `fields` map, loaded from `GET /api/v1/meta/object/:name`
* and persisted by issuing `PUT /api/v1/meta/object/:name` with the
* merged-back fields. Mirrors {@link MetadataObjectsPage}.
*
* Why we save the *parent object* instead of `/meta/field/:name`:
* In the ObjectStack data protocol, fields live INSIDE an object's
* `fields: Record<string, FieldSchema>` map — there is no per-field
* document in the canonical Zod source. The metadata type registry
* does expose `type: 'field'` for cases where a field is shipped as
* a stand-alone artifact (third-party extension), but the normal
* path used by the Setup app is to mutate the parent object so the
* round-trip stays consistent with the artifact format the CLI dump
* produces (`*.object.ts`).
*
* The container preserves any object-schema attribute it doesn't
* know about (indexes, hooks, permissions, lifecycle, …) by deep
* cloning the loaded raw payload and only swapping in the new
* `fields` map on save.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
import type { DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types';
import { MetadataClient, type MetadataClientConfig } from '@object-ui/data-objectstack';
import { FieldDesigner } from './FieldDesigner';
/** Subset of the framework FieldSchema shape we render. */
interface ServerFieldSchema {
/** Field type (framework field-type enum). */
type?: string;
label?: string;
description?: string;
required?: boolean;
unique?: boolean;
readonly?: boolean;
hidden?: boolean;
defaultValue?: unknown;
placeholder?: string;
group?: string;
indexed?: boolean;
externalId?: boolean;
trackHistory?: boolean;
referenceTo?: string;
formula?: string;
// The framework also stores `select` field options as `options: string[] |
// {label, value}[]`; we passthrough the raw structure for now.
options?: unknown;
// Marker used by the framework's system-field injection (organization_id).
isSystem?: boolean;
[key: string]: unknown;
}
interface ServerObjectSchema {
name: string;
label?: string;
fields?: Record<string, ServerFieldSchema>;
[key: string]: unknown;
}
// Derived from the canonical vocabulary rather than restated (objectui#3017).
const KNOWN_FIELD_TYPES: ReadonlySet<DesignerFieldType> = new Set(DESIGNER_FIELD_TYPES);
function toDesignerType(raw: string | undefined): DesignerFieldType {
if (raw && KNOWN_FIELD_TYPES.has(raw as DesignerFieldType)) {
return raw as DesignerFieldType;
}
return 'text';
}
function toDesignerField(name: string, raw: ServerFieldSchema): DesignerFieldDefinition {
return {
id: name,
name,
label: raw.label ?? name,
type: toDesignerType(raw.type),
group: raw.group,
description: raw.description,
required: raw.required,
unique: raw.unique,
readonly: raw.readonly,
hidden: raw.hidden,
defaultValue: raw.defaultValue,
placeholder: raw.placeholder,
isSystem: raw.isSystem,
externalId: raw.externalId,
trackHistory: raw.trackHistory,
indexed: raw.indexed,
referenceTo: raw.referenceTo,
formula: raw.formula,
};
}
function fromDesignerField(
designed: DesignerFieldDefinition,
prev?: ServerFieldSchema,
): ServerFieldSchema {
return {
...(prev ?? {}),
type: designed.type,
label: designed.label,
description: designed.description,
required: designed.required,
unique: designed.unique,
readonly: designed.readonly,
hidden: designed.hidden,
defaultValue: designed.defaultValue,
placeholder: designed.placeholder,
group: designed.group,
indexed: designed.indexed,
externalId: designed.externalId,
trackHistory: designed.trackHistory,
referenceTo: designed.referenceTo,
formula: designed.formula,
};
}
export interface MetadataFieldsPageProps {
/** Object name to edit fields for (e.g. `account`, `sys_permission_set`). */
objectName: string;
/** Pre-built metadata client (preferred for auth-decorated instances). */
client?: MetadataClient;
/** Used when `client` is omitted. */
clientConfig?: MetadataClientConfig;
/** Read-only mode. */
readOnly?: boolean;
/** Optional CSS class for the wrapper. */
className?: string;
}
interface ObjectState {
loading: boolean;
error: string | null;
raw: ServerObjectSchema | null;
}
export function MetadataFieldsPage({
objectName,
client: clientProp,
clientConfig,
readOnly = false,
className,
}: MetadataFieldsPageProps) {
const client = useMemo(() => {
if (clientProp) return clientProp;
if (!clientConfig) {
throw new Error('MetadataFieldsPage: provide either `client` or `clientConfig`.');
}
return new MetadataClient(clientConfig);
}, [clientProp, clientConfig]);
const [state, setState] = useState<ObjectState>({
loading: true,
error: null,
raw: null,
});
const reload = useCallback(async () => {
setState({ loading: true, error: null, raw: null });
try {
const raw = await client.get<ServerObjectSchema>('object', objectName);
if (!raw) {
setState({
loading: false,
error: `Object "${objectName}" not found.`,
raw: null,
});
return;
}
setState({ loading: false, error: null, raw });
} catch (err) {
setState({
loading: false,
error: err instanceof Error ? err.message : String(err),
raw: null,
});
}
}, [client, objectName]);
useEffect(() => {
void reload();
}, [reload]);
const fields = useMemo<DesignerFieldDefinition[]>(() => {
if (!state.raw?.fields) return [];
return Object.entries(state.raw.fields).map(([name, f]) => toDesignerField(name, f));
}, [state.raw]);
const handleFieldsChange = useCallback(async (next: DesignerFieldDefinition[]) => {
if (!state.raw) return;
// Rebuild the fields map preserving prior unknown keys per field, and
// dropping anything the designer removed.
const prevFields = state.raw.fields ?? {};
const nextFields: Record<string, ServerFieldSchema> = {};
for (const f of next) {
nextFields[f.name] = fromDesignerField(f, prevFields[f.name]);
}
const mergedObject: ServerObjectSchema = {
...state.raw,
fields: nextFields,
};
try {
await client.save('object', objectName, mergedObject);
await reload();
} catch (err) {
setState((s) => ({
...s,
error: err instanceof Error ? err.message : String(err),
}));
}
}, [client, objectName, reload, state.raw]);
if (state.loading) {
return (
<div className={className} data-testid="metadata-fields-page-loading">
Loading fields…
</div>
);
}
return (
<div className={className} data-testid="metadata-fields-page">
{state.error && (
<pre
data-testid="metadata-fields-page-error"
className="mb-2 whitespace-pre-wrap rounded bg-red-50 p-2 text-xs text-red-700"
>
{state.error}
</pre>
)}
<FieldDesigner
objectName={objectName}
fields={fields}
onFieldsChange={(next) => { void handleFieldsChange(next); }}
readOnly={readOnly}
/>
</div>
);
}
export default MetadataFieldsPage;