@solidxai/core-ui 0.1.12-beta.0 → 0.1.12-beta.1

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.
Files changed (88) hide show
  1. package/dist/components/auth/SolidLogin.d.ts.map +1 -1
  2. package/dist/components/auth/SolidLogin.js +6 -3
  3. package/dist/components/auth/SolidLogin.js.map +1 -1
  4. package/dist/components/auth/SolidLogin.tsx +16 -2
  5. package/dist/components/auth/SolidRegister.d.ts.map +1 -1
  6. package/dist/components/auth/SolidRegister.js +7 -4
  7. package/dist/components/auth/SolidRegister.js.map +1 -1
  8. package/dist/components/auth/SolidRegister.tsx +16 -2
  9. package/dist/components/common/SolidLoadingState.d.ts +10 -0
  10. package/dist/components/common/SolidLoadingState.d.ts.map +1 -0
  11. package/dist/components/common/SolidLoadingState.js +8 -0
  12. package/dist/components/common/SolidLoadingState.js.map +1 -0
  13. package/dist/components/common/SolidLoadingState.module.css +56 -0
  14. package/dist/components/common/SolidLoadingState.tsx +28 -0
  15. package/dist/components/core/json/SolidJsonEditor.css +195 -0
  16. package/dist/components/core/json/SolidJsonEditor.d.ts +19 -0
  17. package/dist/components/core/json/SolidJsonEditor.d.ts.map +1 -0
  18. package/dist/components/core/json/SolidJsonEditor.js +109 -0
  19. package/dist/components/core/json/SolidJsonEditor.js.map +1 -0
  20. package/dist/components/core/json/SolidJsonEditor.tsx +150 -0
  21. package/dist/components/core/module/CreateModule.css +70 -2
  22. package/dist/components/core/module/CreateModule.d.ts.map +1 -1
  23. package/dist/components/core/module/CreateModule.js +2 -2
  24. package/dist/components/core/module/CreateModule.js.map +1 -1
  25. package/dist/components/core/module/CreateModule.tsx +13 -1
  26. package/dist/components/shad-cn-ui/SolidTabs.d.ts.map +1 -1
  27. package/dist/components/shad-cn-ui/SolidTabs.js +1 -1
  28. package/dist/components/shad-cn-ui/SolidTabs.js.map +1 -1
  29. package/dist/components/shad-cn-ui/SolidTabs.tsx +1 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.ts +14 -0
  35. package/dist/redux/api/datasourceIntrospectionApi.d.ts +203 -0
  36. package/dist/redux/api/datasourceIntrospectionApi.d.ts.map +1 -0
  37. package/dist/redux/api/datasourceIntrospectionApi.js +71 -0
  38. package/dist/redux/api/datasourceIntrospectionApi.js.map +1 -0
  39. package/dist/redux/api/datasourceIntrospectionApi.ts +261 -0
  40. package/dist/redux/api/datasourceManagementApi.d.ts +46 -0
  41. package/dist/redux/api/datasourceManagementApi.d.ts.map +1 -0
  42. package/dist/redux/api/datasourceManagementApi.js +35 -0
  43. package/dist/redux/api/datasourceManagementApi.js.map +1 -0
  44. package/dist/redux/api/datasourceManagementApi.ts +86 -0
  45. package/dist/redux/store/StoreProvider.d.ts.map +1 -1
  46. package/dist/redux/store/StoreProvider.js +4 -2
  47. package/dist/redux/store/StoreProvider.js.map +1 -1
  48. package/dist/redux/store/StoreProvider.tsx +9 -1
  49. package/dist/redux/store/defaultStoreConfig.d.ts +23 -0
  50. package/dist/redux/store/defaultStoreConfig.d.ts.map +1 -1
  51. package/dist/redux/store/defaultStoreConfig.js +4 -0
  52. package/dist/redux/store/defaultStoreConfig.js.map +1 -1
  53. package/dist/redux/store/defaultStoreConfig.ts +4 -0
  54. package/dist/resources/solid-custom.css +70 -16
  55. package/dist/routes/guards/AdminGuard.d.ts.map +1 -1
  56. package/dist/routes/guards/AdminGuard.js +2 -1
  57. package/dist/routes/guards/AdminGuard.js.map +1 -1
  58. package/dist/routes/guards/AdminGuard.tsx +7 -1
  59. package/dist/routes/guards/AuthGuard.d.ts.map +1 -1
  60. package/dist/routes/guards/AuthGuard.js +2 -1
  61. package/dist/routes/guards/AuthGuard.js.map +1 -1
  62. package/dist/routes/guards/AuthGuard.tsx +8 -2
  63. package/dist/routes/guards/GuestGuard.d.ts.map +1 -1
  64. package/dist/routes/guards/GuestGuard.js +2 -1
  65. package/dist/routes/guards/GuestGuard.js.map +1 -1
  66. package/dist/routes/guards/GuestGuard.tsx +8 -2
  67. package/dist/routes/pages/admin/core/DashboardPage.d.ts.map +1 -1
  68. package/dist/routes/pages/admin/core/DashboardPage.js +7 -5
  69. package/dist/routes/pages/admin/core/DashboardPage.js.map +1 -1
  70. package/dist/routes/pages/admin/core/DashboardPage.module.css +18 -0
  71. package/dist/routes/pages/admin/core/DashboardPage.tsx +50 -32
  72. package/dist/routes/pages/admin/core/DatasourceIntrospectionPage.css +828 -0
  73. package/dist/routes/pages/admin/core/DatasourceIntrospectionPage.d.ts +3 -0
  74. package/dist/routes/pages/admin/core/DatasourceIntrospectionPage.d.ts.map +1 -0
  75. package/dist/routes/pages/admin/core/DatasourceIntrospectionPage.js +947 -0
  76. package/dist/routes/pages/admin/core/DatasourceIntrospectionPage.js.map +1 -0
  77. package/dist/routes/pages/admin/core/DatasourceIntrospectionPage.tsx +1719 -0
  78. package/dist/routes/pages/admin/core/DatasourcesPage.css +337 -0
  79. package/dist/routes/pages/admin/core/DatasourcesPage.d.ts +3 -0
  80. package/dist/routes/pages/admin/core/DatasourcesPage.d.ts.map +1 -0
  81. package/dist/routes/pages/admin/core/DatasourcesPage.js +221 -0
  82. package/dist/routes/pages/admin/core/DatasourcesPage.js.map +1 -0
  83. package/dist/routes/pages/admin/core/DatasourcesPage.tsx +540 -0
  84. package/dist/routes/solidRoutes.d.ts.map +1 -1
  85. package/dist/routes/solidRoutes.js +5 -1
  86. package/dist/routes/solidRoutes.js.map +1 -1
  87. package/dist/routes/solidRoutes.tsx +4 -0
  88. package/package.json +1 -1
@@ -0,0 +1,1719 @@
1
+ import {
2
+ Cog,
3
+ Database,
4
+ ExternalLink,
5
+ RefreshCw,
6
+ Save,
7
+ Search,
8
+ ShieldAlert,
9
+ Sparkles,
10
+ } from "lucide-react";
11
+ import { camelCase, startCase } from "lodash";
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
+ import { useDispatch } from "react-redux";
14
+ import { useParams } from "react-router-dom";
15
+ import { showToast } from "../../../../redux/features/toastSlice";
16
+ import { SolidJsonEditor } from "../../../../components/core/json/SolidJsonEditor";
17
+ import { Column, SolidDataTable } from "../../../../components/core/list/SolidDataTable";
18
+ import {
19
+ SolidButton,
20
+ SolidCheckbox,
21
+ SolidDialog,
22
+ SolidDialogBody,
23
+ SolidDialogClose,
24
+ SolidDialogDescription,
25
+ SolidDialogFooter,
26
+ SolidDialogHeader,
27
+ SolidDialogSeparator,
28
+ SolidDialogTitle,
29
+ SolidInput,
30
+ SolidSelect,
31
+ SolidSpinner,
32
+ SolidSwitch,
33
+ SolidTabGroup,
34
+ SolidTextarea,
35
+ } from "../../../../components/shad-cn-ui";
36
+ import {
37
+ type DatasourceIntrospectionTableDetail,
38
+ type DatasourceIntrospectionMappingPreview,
39
+ type DatasourceIntrospectionMappingRequest,
40
+ type DatasourceIntrospectionTableRecord,
41
+ useApplyDatasourceIntrospectionMappingMutation,
42
+ useGetDatasourceIntrospectionBootstrapQuery,
43
+ useGetDatasourceIntrospectionTableDetailQuery,
44
+ useGetDatasourceIntrospectionTablesQuery,
45
+ usePreviewDatasourceIntrospectionMappingMutation,
46
+ } from "../../../../redux/api/datasourceIntrospectionApi";
47
+ import { useGetmodelByIdQuery } from "../../../../redux/api/modelApi";
48
+ import "./DatasourceIntrospectionPage.css";
49
+
50
+ type SessionSettings = {
51
+ relationInferenceEnabled: boolean;
52
+ prioritizeUnmappedTables: boolean;
53
+ };
54
+
55
+ type DrawerColumnDraft = {
56
+ columnName: string;
57
+ fieldName: string;
58
+ dataType: string;
59
+ solidFieldType: string;
60
+ ormType: string;
61
+ nullable: boolean;
62
+ defaultValue: string | null;
63
+ characterMaximumLength: number | null;
64
+ numericPrecision: number | null;
65
+ numericScale: number | null;
66
+ ordinalPosition: number | null;
67
+ isAutoGenerated: boolean;
68
+ handledBySuperclass: boolean;
69
+ superclassFieldName: string | null;
70
+ fieldId: number | null;
71
+ include: boolean;
72
+ matched: boolean;
73
+ isPrimaryKey: boolean;
74
+ };
75
+
76
+ type DrawerDraft = {
77
+ mapped: boolean;
78
+ modelId: number | null;
79
+ mappedModuleLabel: string | null;
80
+ singularName: string;
81
+ pluralName: string;
82
+ displayName: string;
83
+ description: string;
84
+ tableName: string;
85
+ schema: string | null;
86
+ dataSource: string;
87
+ dataSourceType: string;
88
+ legacyTableType: "none" | "existing_id" | "generated_id";
89
+ baseClassName: string;
90
+ userKeyField: string;
91
+ enableAuditTracking: boolean;
92
+ enableSoftDelete: boolean;
93
+ draftPublishWorkflow: boolean;
94
+ internationalisation: boolean;
95
+ columns: DrawerColumnDraft[];
96
+ preservedFields: any[];
97
+ };
98
+
99
+ function providerLabel(type: string) {
100
+ if (type === "postgres") return "PostgreSQL";
101
+ if (type === "mysql") return "MySQL";
102
+ if (type === "mssql") return "MS SQL Server";
103
+ return type;
104
+ }
105
+
106
+ const scalarFieldTypeOptions = [
107
+ { value: "shortText", label: "Short Text" },
108
+ { value: "longText", label: "Long Text" },
109
+ { value: "richText", label: "Rich Text" },
110
+ { value: "boolean", label: "Boolean" },
111
+ { value: "int", label: "Integer" },
112
+ { value: "bigint", label: "Big Integer" },
113
+ { value: "decimal", label: "Decimal" },
114
+ { value: "date", label: "Date" },
115
+ { value: "datetime", label: "Date Time" },
116
+ { value: "time", label: "Time" },
117
+ { value: "json", label: "JSON" },
118
+ { value: "uuid", label: "UUID" },
119
+ { value: "email", label: "Email" },
120
+ { value: "password", label: "Password" },
121
+ ];
122
+
123
+ const scalarFieldTypeValueSet = new Set(scalarFieldTypeOptions.map((option) => option.value));
124
+
125
+ function getDefaultOrmTypeForSolidFieldType(dataSourceType: string, solidFieldType: string) {
126
+ const normalizedDatasourceType = dataSourceType.toLowerCase();
127
+
128
+ const ormTypesByDatasource: Record<string, Record<string, string>> = {
129
+ mssql: {
130
+ shortText: "varchar",
131
+ longText: "nvarchar",
132
+ richText: "nvarchar",
133
+ boolean: "bit",
134
+ int: "int",
135
+ bigint: "bigint",
136
+ decimal: "numeric",
137
+ date: "date",
138
+ datetime: "datetime",
139
+ time: "time",
140
+ json: "simple-json",
141
+ uuid: "uniqueidentifier",
142
+ email: "nvarchar",
143
+ password: "nvarchar",
144
+ },
145
+ mysql: {
146
+ shortText: "varchar",
147
+ longText: "longtext",
148
+ richText: "longtext",
149
+ boolean: "boolean",
150
+ int: "int",
151
+ bigint: "int",
152
+ decimal: "decimal",
153
+ date: "date",
154
+ datetime: "datetime",
155
+ time: "time",
156
+ json: "json",
157
+ uuid: "varchar",
158
+ email: "varchar",
159
+ password: "varchar",
160
+ },
161
+ postgres: {
162
+ shortText: "varchar",
163
+ longText: "text",
164
+ richText: "text",
165
+ boolean: "boolean",
166
+ int: "integer",
167
+ bigint: "bigint",
168
+ decimal: "decimal",
169
+ date: "date",
170
+ datetime: "timestamp",
171
+ time: "time",
172
+ json: "json",
173
+ uuid: "varchar",
174
+ email: "varchar",
175
+ password: "varchar",
176
+ },
177
+ };
178
+
179
+ return ormTypesByDatasource[normalizedDatasourceType]?.[solidFieldType] ?? "varchar";
180
+ }
181
+
182
+ function legacyTypeLabel(value: string) {
183
+ if (value === "generated_id") return "Legacy generated id";
184
+ if (value === "existing_id") return "Legacy existing id";
185
+ return "Legacy generated id";
186
+ }
187
+
188
+ function statusLabel(record: DatasourceIntrospectionTableRecord) {
189
+ return record.mapped ? "Mapped" : "Ready to map";
190
+ }
191
+
192
+ function readQueryError(error: any) {
193
+ return (
194
+ error?.data?.message
195
+ || error?.error
196
+ || error?.message
197
+ || "Something went wrong."
198
+ );
199
+ }
200
+
201
+ function normalizeName(value: string) {
202
+ return value.trim().toLowerCase();
203
+ }
204
+
205
+ function findMatchingModelField(column: DatasourceIntrospectionTableDetail["columns"][number], modelFields: any[]) {
206
+ return modelFields.find((field) => {
207
+ if (field?.isSystem) return false;
208
+
209
+ const sameColumnName = normalizeName(field?.columnName ?? "") === normalizeName(column.columnName);
210
+ if (sameColumnName) return true;
211
+
212
+ return Boolean(column.isAutoGenerated && column.fieldName === "id" && field?.name === "id");
213
+ }) ?? null;
214
+ }
215
+
216
+ function sanitizeExistingFieldPayload(field: any) {
217
+ const {
218
+ createdAt,
219
+ updatedAt,
220
+ deletedAt,
221
+ model,
222
+ mediaStorageProvider,
223
+ identifier,
224
+ ...rest
225
+ } = field ?? {};
226
+
227
+ if (mediaStorageProvider?.id && !rest.mediaStorageProviderId) {
228
+ rest.mediaStorageProviderId = mediaStorageProvider.id;
229
+ }
230
+
231
+ return rest;
232
+ }
233
+
234
+ function buildDrawerDraft(
235
+ tableDetail: DatasourceIntrospectionTableDetail,
236
+ selectedTable: DatasourceIntrospectionTableRecord,
237
+ mappedModelData?: any,
238
+ ): DrawerDraft {
239
+ const modelData = selectedTable.mapped ? (mappedModelData?.data ?? null) : null;
240
+ const modelFields = modelData?.fields ?? [];
241
+ const preservedFields = modelFields.map((field: any) => sanitizeExistingFieldPayload(field));
242
+ const modelUserKeyField =
243
+ modelData?.userKeyField?.name
244
+ || modelFields.find((field: any) => field?.isUserKey)?.name
245
+ || "";
246
+
247
+ const columns: DrawerColumnDraft[] = tableDetail.columns.map((column) => {
248
+ const matchingField = findMatchingModelField(column, modelFields);
249
+ return {
250
+ columnName: column.columnName,
251
+ fieldName: matchingField?.name ?? column.fieldName,
252
+ dataType: column.dataType,
253
+ solidFieldType: matchingField?.type ?? column.solidFieldType,
254
+ ormType: matchingField?.ormType ?? column.ormType,
255
+ nullable: column.nullable,
256
+ defaultValue: matchingField?.defaultValue ?? column.defaultValue,
257
+ characterMaximumLength: column.characterMaximumLength,
258
+ numericPrecision: column.numericPrecision,
259
+ numericScale: column.numericScale,
260
+ ordinalPosition: column.ordinalPosition,
261
+ isAutoGenerated: column.isAutoGenerated,
262
+ handledBySuperclass: column.handledBySuperclass,
263
+ superclassFieldName: column.superclassFieldName,
264
+ fieldId: matchingField?.id ?? null,
265
+ include: column.handledBySuperclass ? false : Boolean(matchingField) || !selectedTable.mapped,
266
+ matched: Boolean(matchingField),
267
+ isPrimaryKey: Boolean(matchingField?.isPrimaryKey) || (column.isAutoGenerated && column.fieldName === "id"),
268
+ };
269
+ });
270
+
271
+ const draftUserKeyField = modelUserKeyField || tableDetail.suggestedMetadata.userKeyField;
272
+
273
+ return {
274
+ mapped: selectedTable.mapped,
275
+ modelId: selectedTable.mappedModelId,
276
+ mappedModuleLabel: selectedTable.mappedModuleDisplayName || selectedTable.mappedModuleName || null,
277
+ singularName: modelData?.singularName ?? tableDetail.suggestedMetadata.singularName,
278
+ pluralName: modelData?.pluralName ?? tableDetail.suggestedMetadata.pluralName,
279
+ displayName: modelData?.displayName ?? tableDetail.suggestedMetadata.displayName,
280
+ description: modelData?.description ?? tableDetail.suggestedMetadata.displayName,
281
+ tableName: modelData?.tableName ?? tableDetail.suggestedMetadata.tableName,
282
+ schema: tableDetail.suggestedMetadata.schema,
283
+ dataSource: modelData?.dataSource ?? tableDetail.suggestedMetadata.dataSource,
284
+ dataSourceType: modelData?.dataSourceType ?? tableDetail.suggestedMetadata.dataSourceType,
285
+ legacyTableType: modelData?.legacyTableType ?? tableDetail.suggestedMetadata.legacyTableType,
286
+ baseClassName: tableDetail.suggestedMetadata.baseClassName,
287
+ userKeyField: draftUserKeyField,
288
+ enableAuditTracking: modelData?.enableAuditTracking ?? true,
289
+ enableSoftDelete: modelData?.enableSoftDelete ?? false,
290
+ draftPublishWorkflow: modelData?.draftPublishWorkflow ?? false,
291
+ internationalisation: modelData?.internationalisation ?? false,
292
+ columns,
293
+ preservedFields,
294
+ };
295
+ }
296
+
297
+ function getDrawerValidationErrors(draft: DrawerDraft) {
298
+ const errors: string[] = [];
299
+ const includedColumns = draft.columns.filter((column) => !column.handledBySuperclass && column.include);
300
+ const seenFieldNames = new Set<string>();
301
+
302
+ if (!draft.singularName.trim()) {
303
+ errors.push("Singular name is required.");
304
+ }
305
+ if (!draft.pluralName.trim()) {
306
+ errors.push("Plural name is required.");
307
+ }
308
+ if (!draft.displayName.trim()) {
309
+ errors.push("Display name is required.");
310
+ }
311
+ if (!includedColumns.length) {
312
+ errors.push("Select at least one physical column to include in the mapping.");
313
+ }
314
+
315
+ for (const column of includedColumns) {
316
+ const normalizedFieldName = column.fieldName.trim();
317
+ if (!normalizedFieldName) {
318
+ errors.push(`Field name is required for column "${column.columnName}".`);
319
+ continue;
320
+ }
321
+
322
+ if (!scalarFieldTypeValueSet.has(column.solidFieldType)) {
323
+ errors.push(`SolidX type "${column.solidFieldType}" is not allowed in step 1 for column "${column.columnName}".`);
324
+ continue;
325
+ }
326
+
327
+ if (seenFieldNames.has(normalizedFieldName.toLowerCase())) {
328
+ errors.push(`Field name "${normalizedFieldName}" is duplicated in the mapping draft.`);
329
+ continue;
330
+ }
331
+
332
+ seenFieldNames.add(normalizedFieldName.toLowerCase());
333
+ }
334
+
335
+ if (!draft.userKeyField.trim()) {
336
+ errors.push("Choose a user key field before saving the mapping.");
337
+ } else {
338
+ const userKeyExists = includedColumns.some((column) => column.fieldName === draft.userKeyField);
339
+ if (!userKeyExists) {
340
+ errors.push("The chosen user key field must remain part of the mapped columns.");
341
+ }
342
+ }
343
+
344
+ if (draft.legacyTableType === "existing_id") {
345
+ const hasPrimaryKey = includedColumns.some((column) => column.isPrimaryKey);
346
+ if (!hasPrimaryKey) {
347
+ errors.push("Legacy existing-id mappings must keep the generated id column mapped to field name \"id\".");
348
+ }
349
+ }
350
+
351
+ return errors;
352
+ }
353
+
354
+ function buildMappingRequest(
355
+ draft: DrawerDraft,
356
+ plannedMigrationTimestamp?: number | null,
357
+ ): DatasourceIntrospectionMappingRequest {
358
+ return {
359
+ mapped: draft.mapped,
360
+ modelId: draft.modelId,
361
+ singularName: draft.singularName.trim(),
362
+ pluralName: draft.pluralName.trim(),
363
+ displayName: draft.displayName.trim(),
364
+ description: draft.description.trim() || draft.displayName.trim(),
365
+ tableName: draft.tableName,
366
+ schema: draft.schema ?? null,
367
+ dataSource: draft.dataSource,
368
+ dataSourceType: draft.dataSourceType,
369
+ legacyTableType: draft.legacyTableType,
370
+ baseClassName: draft.baseClassName,
371
+ userKeyField: draft.userKeyField,
372
+ enableAuditTracking: draft.enableAuditTracking,
373
+ enableSoftDelete: draft.enableSoftDelete,
374
+ draftPublishWorkflow: draft.draftPublishWorkflow,
375
+ internationalisation: draft.internationalisation,
376
+ plannedMigrationTimestamp: plannedMigrationTimestamp ?? null,
377
+ columns: draft.columns.map((column) => ({
378
+ columnName: column.columnName,
379
+ fieldName: column.fieldName,
380
+ dataType: column.dataType,
381
+ solidFieldType: column.solidFieldType,
382
+ ormType: column.ormType,
383
+ nullable: column.nullable,
384
+ defaultValue: column.defaultValue,
385
+ characterMaximumLength: column.characterMaximumLength,
386
+ numericPrecision: column.numericPrecision,
387
+ numericScale: column.numericScale,
388
+ ordinalPosition: column.ordinalPosition,
389
+ isAutoGenerated: column.isAutoGenerated,
390
+ handledBySuperclass: column.handledBySuperclass,
391
+ superclassFieldName: column.superclassFieldName,
392
+ fieldId: column.fieldId,
393
+ include: column.include,
394
+ matched: column.matched,
395
+ isPrimaryKey: column.isPrimaryKey,
396
+ })),
397
+ };
398
+ }
399
+
400
+ function parseAndValidateReviewedModelJson(
401
+ metadataJsonText: string,
402
+ draft: DrawerDraft,
403
+ ) {
404
+ let parsed: any;
405
+
406
+ try {
407
+ parsed = JSON.parse(metadataJsonText);
408
+ } catch (error: any) {
409
+ return {
410
+ error: `Metadata JSON is not valid JSON: ${error?.message ?? "Unknown parse error."}`,
411
+ parsed: null,
412
+ };
413
+ }
414
+
415
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
416
+ return {
417
+ error: "Metadata JSON must be an object.",
418
+ parsed: null,
419
+ };
420
+ }
421
+
422
+ const requiredStringKeys = [
423
+ "singularName",
424
+ "pluralName",
425
+ "displayName",
426
+ "description",
427
+ "dataSource",
428
+ "dataSourceType",
429
+ "tableName",
430
+ "userKeyFieldUserKey",
431
+ ];
432
+
433
+ for (const key of requiredStringKeys) {
434
+ if (typeof parsed[key] !== "string" || !parsed[key].trim()) {
435
+ return {
436
+ error: `Metadata JSON must include a non-empty "${key}" string.`,
437
+ parsed: null,
438
+ };
439
+ }
440
+ }
441
+
442
+ const immutableChecks: Array<{ key: string; expected: string }> = [
443
+ { key: "dataSource", expected: draft.dataSource },
444
+ { key: "dataSourceType", expected: draft.dataSourceType },
445
+ { key: "tableName", expected: draft.tableName },
446
+ { key: "legacyTableType", expected: draft.legacyTableType },
447
+ ];
448
+
449
+ for (const check of immutableChecks) {
450
+ if (`${parsed[check.key] ?? ""}` !== `${check.expected}`) {
451
+ return {
452
+ error: `Metadata JSON cannot change immutable binding key "${check.key}".`,
453
+ parsed: null,
454
+ };
455
+ }
456
+ }
457
+
458
+ const requiredBooleanKeys = [
459
+ "enableAuditTracking",
460
+ "enableSoftDelete",
461
+ "draftPublishWorkflow",
462
+ "internationalisation",
463
+ ];
464
+
465
+ for (const key of requiredBooleanKeys) {
466
+ if (typeof parsed[key] !== "boolean") {
467
+ return {
468
+ error: `Metadata JSON must include a boolean "${key}" value.`,
469
+ parsed: null,
470
+ };
471
+ }
472
+ }
473
+
474
+ if (!Array.isArray(parsed.fields)) {
475
+ return {
476
+ error: "Metadata JSON must include a fields array.",
477
+ parsed: null,
478
+ };
479
+ }
480
+
481
+ const seenFieldNames = new Set<string>();
482
+ for (const [index, field] of parsed.fields.entries()) {
483
+ if (!field || typeof field !== "object" || Array.isArray(field)) {
484
+ return {
485
+ error: `Field at index ${index} is not a valid object.`,
486
+ parsed: null,
487
+ };
488
+ }
489
+
490
+ if (typeof field.name !== "string" || !field.name.trim()) {
491
+ return {
492
+ error: `Field at index ${index} must include a non-empty "name".`,
493
+ parsed: null,
494
+ };
495
+ }
496
+
497
+ if (typeof field.type !== "string" || !field.type.trim()) {
498
+ return {
499
+ error: `Field "${field.name}" must include a non-empty "type".`,
500
+ parsed: null,
501
+ };
502
+ }
503
+
504
+ if (typeof field.ormType !== "string" || !field.ormType.trim()) {
505
+ return {
506
+ error: `Field "${field.name}" must include a non-empty "ormType".`,
507
+ parsed: null,
508
+ };
509
+ }
510
+
511
+ if (typeof field.columnName !== "string" || !field.columnName.trim()) {
512
+ return {
513
+ error: `Field "${field.name}" must include a non-empty "columnName".`,
514
+ parsed: null,
515
+ };
516
+ }
517
+
518
+ const normalizedFieldName = field.name.trim().toLowerCase();
519
+ if (seenFieldNames.has(normalizedFieldName)) {
520
+ return {
521
+ error: `Field name "${field.name}" is duplicated in metadata JSON.`,
522
+ parsed: null,
523
+ };
524
+ }
525
+
526
+ seenFieldNames.add(normalizedFieldName);
527
+ }
528
+
529
+ if (!parsed.fields.some((field: any) => field?.name === parsed.userKeyFieldUserKey && !field?.isMarkedForRemoval)) {
530
+ return {
531
+ error: "Metadata JSON must keep userKeyFieldUserKey present in the fields array.",
532
+ parsed: null,
533
+ };
534
+ }
535
+
536
+ return {
537
+ error: null,
538
+ parsed,
539
+ };
540
+ }
541
+
542
+ export function DatasourceIntrospectionPage() {
543
+ const params = useParams();
544
+ const dispatch = useDispatch();
545
+ const moduleId = Number(params.moduleId ?? 0);
546
+ const isValidModuleId = Number.isFinite(moduleId) && moduleId > 0;
547
+
548
+ const {
549
+ data: bootstrap,
550
+ isLoading: isBootstrapLoading,
551
+ isFetching: isBootstrapFetching,
552
+ error: bootstrapError,
553
+ refetch: refetchBootstrap,
554
+ } = useGetDatasourceIntrospectionBootstrapQuery(moduleId, {
555
+ skip: !isValidModuleId,
556
+ });
557
+
558
+ const [selectedDatasource, setSelectedDatasource] = useState("");
559
+ const [selectedTableKey, setSelectedTableKey] = useState("");
560
+ const [tableSearch, setTableSearch] = useState("");
561
+ const [settingsOpen, setSettingsOpen] = useState(false);
562
+ const [settingsTab, setSettingsTab] = useState("datasource-info");
563
+ const [drawerOpen, setDrawerOpen] = useState(false);
564
+ const [drawerDraft, setDrawerDraft] = useState<DrawerDraft | null>(null);
565
+ const [drawerTab, setDrawerTab] = useState("general-info");
566
+ const [drawerStep, setDrawerStep] = useState<"edit" | "review">("edit");
567
+ const [reviewTab, setReviewTab] = useState("metadata-json");
568
+ const [mappingPreview, setMappingPreview] = useState<DatasourceIntrospectionMappingPreview | null>(null);
569
+ const [reviewedMetadataJsonValue, setReviewedMetadataJsonValue] = useState<any>(null);
570
+ const [metadataJsonText, setMetadataJsonText] = useState("");
571
+ const [metadataJsonValidationError, setMetadataJsonValidationError] = useState<string | null>(null);
572
+ const [metadataJsonResetToken, setMetadataJsonResetToken] = useState("initial");
573
+ const [drawerBootstrapping, setDrawerBootstrapping] = useState(false);
574
+ const [sessionSettings, setSessionSettings] = useState<SessionSettings>({
575
+ relationInferenceEnabled: false,
576
+ prioritizeUnmappedTables: true,
577
+ });
578
+ const [draftSessionSettings, setDraftSessionSettings] = useState<SessionSettings>(sessionSettings);
579
+ const drawerBootstrapTimerRef = useRef<number | null>(null);
580
+
581
+ const scrollDrawerBodyToTop = () => {
582
+ if (typeof document === "undefined") return;
583
+
584
+ window.requestAnimationFrame(() => {
585
+ const drawerBody = document.querySelector<HTMLDivElement>(".sdix-drawer-body");
586
+ drawerBody?.scrollTo({ top: 0, left: 0 });
587
+ });
588
+ };
589
+
590
+ const [
591
+ previewMapping,
592
+ { isLoading: isPreviewMappingLoading },
593
+ ] = usePreviewDatasourceIntrospectionMappingMutation();
594
+
595
+ const [
596
+ applyMapping,
597
+ { isLoading: isApplyMappingLoading },
598
+ ] = useApplyDatasourceIntrospectionMappingMutation();
599
+
600
+ const isSavingMapping = isApplyMappingLoading;
601
+
602
+ useEffect(() => {
603
+ if (!bootstrap) return;
604
+ if (selectedDatasource) return;
605
+ if (bootstrap.recommendedDatasourceName) {
606
+ setSelectedDatasource(bootstrap.recommendedDatasourceName);
607
+ }
608
+ }, [bootstrap, selectedDatasource]);
609
+
610
+ useEffect(() => {
611
+ setSelectedTableKey("");
612
+ setDrawerDraft(null);
613
+ setDrawerOpen(false);
614
+ setTableSearch("");
615
+ setDrawerStep("edit");
616
+ setReviewTab("metadata-json");
617
+ setMappingPreview(null);
618
+ setReviewedMetadataJsonValue(null);
619
+ setMetadataJsonText("");
620
+ setMetadataJsonValidationError(null);
621
+ setMetadataJsonResetToken(`datasource-${Date.now()}`);
622
+ }, [selectedDatasource]);
623
+
624
+ useEffect(() => () => {
625
+ if (drawerBootstrapTimerRef.current) {
626
+ window.clearTimeout(drawerBootstrapTimerRef.current);
627
+ }
628
+ }, []);
629
+
630
+ useEffect(() => {
631
+ if (!drawerOpen) return;
632
+ scrollDrawerBodyToTop();
633
+ }, [drawerOpen, drawerStep, drawerTab, reviewTab, selectedTableKey]);
634
+
635
+ const datasourceOptions = useMemo(
636
+ () => (bootstrap?.datasources ?? []).map((datasource) => ({
637
+ label: `${datasource.displayName} (${providerLabel(datasource.type)})`,
638
+ value: datasource.name,
639
+ })),
640
+ [bootstrap],
641
+ );
642
+
643
+ const {
644
+ data: tablesResponse,
645
+ isLoading: isTablesLoading,
646
+ isFetching: isTablesFetching,
647
+ error: tablesError,
648
+ refetch: refetchTables,
649
+ } = useGetDatasourceIntrospectionTablesQuery(
650
+ { moduleId, datasource: selectedDatasource },
651
+ {
652
+ skip: !isValidModuleId || !selectedDatasource,
653
+ },
654
+ );
655
+
656
+ const selectedTable = useMemo(
657
+ () => tablesResponse?.records?.find((record) => `${record.schema ?? ""}::${record.tableName}` === selectedTableKey) ?? null,
658
+ [tablesResponse, selectedTableKey],
659
+ );
660
+
661
+ const {
662
+ data: tableDetail,
663
+ isLoading: isTableDetailLoading,
664
+ isFetching: isTableDetailFetching,
665
+ error: tableDetailError,
666
+ refetch: refetchTableDetail,
667
+ } = useGetDatasourceIntrospectionTableDetailQuery(
668
+ {
669
+ moduleId,
670
+ datasource: selectedDatasource,
671
+ table: selectedTable?.tableName ?? "",
672
+ schema: selectedTable?.schema ?? undefined,
673
+ },
674
+ {
675
+ skip: !drawerOpen || !isValidModuleId || !selectedDatasource || !selectedTable || !!tablesResponse?.synchronizeBlocked,
676
+ },
677
+ );
678
+
679
+ const {
680
+ data: mappedModelResponse,
681
+ isLoading: isMappedModelLoading,
682
+ isFetching: isMappedModelFetching,
683
+ error: mappedModelError,
684
+ } = useGetmodelByIdQuery(selectedTable?.mappedModelId ?? 0, {
685
+ skip: !drawerOpen || !selectedTable?.mapped || !selectedTable?.mappedModelId,
686
+ });
687
+
688
+ const mappedModelData = mappedModelResponse?.data;
689
+ const selectedDatasourceRecord = bootstrap?.datasources?.find((datasource) => datasource.name === selectedDatasource) ?? null;
690
+ const drawerLoadingError = selectedTable?.mapped ? (tableDetailError || mappedModelError) : tableDetailError;
691
+ const isDrawerBlockingLoading = Boolean(
692
+ selectedTable
693
+ && (
694
+ drawerBootstrapping
695
+ || !drawerDraft
696
+ || isTableDetailLoading
697
+ || isTableDetailFetching
698
+ || (selectedTable.mapped && (isMappedModelLoading || isMappedModelFetching))
699
+ ),
700
+ );
701
+
702
+ useEffect(() => {
703
+ if (!drawerOpen || !selectedTable || !tableDetail) return;
704
+ if (selectedTable.mapped && (!mappedModelData || mappedModelData.id !== selectedTable.mappedModelId)) return;
705
+
706
+ if (drawerBootstrapTimerRef.current) {
707
+ window.clearTimeout(drawerBootstrapTimerRef.current);
708
+ }
709
+
710
+ setDrawerDraft(buildDrawerDraft(
711
+ tableDetail,
712
+ selectedTable,
713
+ selectedTable.mapped ? mappedModelResponse : undefined,
714
+ ));
715
+ drawerBootstrapTimerRef.current = window.setTimeout(() => {
716
+ setDrawerBootstrapping(false);
717
+ }, 150);
718
+ }, [drawerOpen, selectedTable, tableDetail, mappedModelData, mappedModelResponse]);
719
+
720
+ const filteredRecords = useMemo(() => {
721
+ const records = [...(tablesResponse?.records ?? [])];
722
+ const searchNeedle = tableSearch.trim().toLowerCase();
723
+
724
+ const filtered = searchNeedle
725
+ ? records.filter((record) => {
726
+ const haystack = `${record.tableName} ${record.schema ?? ""} ${record.mappedModelSingularName ?? ""} ${record.mappedModelDisplayName ?? ""}`.toLowerCase();
727
+ return haystack.includes(searchNeedle);
728
+ })
729
+ : records;
730
+
731
+ if (sessionSettings.prioritizeUnmappedTables) {
732
+ filtered.sort((left, right) => {
733
+ if (left.mapped !== right.mapped) {
734
+ return left.mapped ? 1 : -1;
735
+ }
736
+
737
+ const leftLabel = `${left.schema ?? ""}.${left.tableName}`.toLowerCase();
738
+ const rightLabel = `${right.schema ?? ""}.${right.tableName}`.toLowerCase();
739
+ return leftLabel.localeCompare(rightLabel);
740
+ });
741
+ }
742
+
743
+ return filtered;
744
+ }, [sessionSettings.prioritizeUnmappedTables, tableSearch, tablesResponse?.records]);
745
+
746
+ const drawerUserKeyOptions = useMemo(() => {
747
+ const options = new Map<string, { label: string; value: string }>();
748
+
749
+ tableDetail?.userKeyCandidates?.forEach((candidate) => {
750
+ options.set(candidate.name, {
751
+ label: `${candidate.label} · ${candidate.reason}`,
752
+ value: candidate.name,
753
+ });
754
+ });
755
+
756
+ drawerDraft?.columns
757
+ .filter((column) => !column.handledBySuperclass && column.include)
758
+ .forEach((column) => {
759
+ if (!options.has(column.fieldName)) {
760
+ options.set(column.fieldName, {
761
+ label: `${column.fieldName} · mapped from ${column.columnName}`,
762
+ value: column.fieldName,
763
+ });
764
+ }
765
+ });
766
+
767
+ return Array.from(options.values());
768
+ }, [drawerDraft?.columns, tableDetail?.userKeyCandidates]);
769
+
770
+ const settingsTabs = [
771
+ {
772
+ value: "datasource-info",
773
+ label: "Datasource Info",
774
+ content: (
775
+ <div className="sdix-settings-grid">
776
+ <div className="sdix-settings-static">
777
+ <span>Target module</span>
778
+ <strong>{bootstrap?.module?.displayName}</strong>
779
+ </div>
780
+ <div className="sdix-settings-static">
781
+ <span>Default datasource</span>
782
+ <strong>{bootstrap?.module?.defaultDataSource || "Not set"}</strong>
783
+ </div>
784
+ <div className="sdix-settings-static">
785
+ <span>Datasource name</span>
786
+ <strong>{selectedDatasourceRecord?.displayName || "Not selected"}</strong>
787
+ </div>
788
+ <div className="sdix-settings-static">
789
+ <span>Datasource type</span>
790
+ <strong>{selectedDatasourceRecord ? providerLabel(selectedDatasourceRecord.type) : "-"}</strong>
791
+ </div>
792
+ <div className="sdix-settings-static">
793
+ <span>Host</span>
794
+ <strong>{selectedDatasourceRecord?.host || "-"}</strong>
795
+ </div>
796
+ <div className="sdix-settings-static">
797
+ <span>Port</span>
798
+ <strong>{selectedDatasourceRecord?.port ?? "-"}</strong>
799
+ </div>
800
+ <div className="sdix-settings-static">
801
+ <span>Database</span>
802
+ <strong>{selectedDatasourceRecord?.database || "-"}</strong>
803
+ </div>
804
+ <div className="sdix-settings-static">
805
+ <span>Env Prefix</span>
806
+ <strong>{selectedDatasourceRecord?.envPrefix || "-"}</strong>
807
+ </div>
808
+ <div className="sdix-settings-static">
809
+ <span>Synchronize</span>
810
+ <strong>{selectedDatasourceRecord ? (selectedDatasourceRecord.synchronize === true ? "true" : "false") : "-"}</strong>
811
+ </div>
812
+ </div>
813
+ ),
814
+ },
815
+ {
816
+ value: "session-settings",
817
+ label: "Session Settings",
818
+ content: (
819
+ <div className="sdix-settings-grid">
820
+ <div className="sdix-settings-row">
821
+ <div>
822
+ <label>Relation inference</label>
823
+ <p>Keep this off until we deliberately introduce relation discovery across engines.</p>
824
+ </div>
825
+ <SolidSwitch
826
+ checked={draftSessionSettings.relationInferenceEnabled}
827
+ onChange={(checked) => setDraftSessionSettings((current) => ({ ...current, relationInferenceEnabled: checked }))}
828
+ />
829
+ </div>
830
+
831
+ <div className="sdix-settings-row">
832
+ <div>
833
+ <label>Prioritize unmapped tables</label>
834
+ <p>Bubble unmapped tables to the top of the inventory so the first pass stays focused.</p>
835
+ </div>
836
+ <SolidSwitch
837
+ checked={draftSessionSettings.prioritizeUnmappedTables}
838
+ onChange={(checked) => setDraftSessionSettings((current) => ({ ...current, prioritizeUnmappedTables: checked }))}
839
+ />
840
+ </div>
841
+ </div>
842
+ ),
843
+ },
844
+ ];
845
+
846
+ const handleRefresh = async () => {
847
+ await refetchBootstrap();
848
+ if (selectedDatasource) {
849
+ await refetchTables();
850
+ }
851
+ if (selectedTable) {
852
+ await refetchTableDetail();
853
+ }
854
+ };
855
+
856
+ const openTableDrawer = (record: DatasourceIntrospectionTableRecord) => {
857
+ if (drawerBootstrapTimerRef.current) {
858
+ window.clearTimeout(drawerBootstrapTimerRef.current);
859
+ }
860
+
861
+ setSelectedTableKey(`${record.schema ?? ""}::${record.tableName}`);
862
+ setDrawerDraft(null);
863
+ setDrawerTab("general-info");
864
+ setDrawerStep("edit");
865
+ setReviewTab("metadata-json");
866
+ setMappingPreview(null);
867
+ setReviewedMetadataJsonValue(null);
868
+ setMetadataJsonText("");
869
+ setMetadataJsonValidationError(null);
870
+ setMetadataJsonResetToken(`table-${record.schema ?? "default"}-${record.tableName}-${Date.now()}`);
871
+ setDrawerBootstrapping(true);
872
+ setDrawerOpen(true);
873
+ };
874
+
875
+ const applyDraftSettings = () => {
876
+ setSessionSettings(draftSessionSettings);
877
+ setSettingsOpen(false);
878
+ };
879
+
880
+ const updateDrawerDraft = (updater: (current: DrawerDraft) => DrawerDraft) => {
881
+ setMappingPreview(null);
882
+ setDrawerDraft((current) => (current ? updater(current) : current));
883
+ };
884
+
885
+ const updateGeneralField = (field: keyof DrawerDraft, value: any) => {
886
+ updateDrawerDraft((current) => ({ ...current, [field]: value }));
887
+ };
888
+
889
+ const updateColumnFieldName = (columnName: string, nextFieldName: string) => {
890
+ updateDrawerDraft((current) => ({
891
+ ...current,
892
+ columns: current.columns.map((column) => {
893
+ if (column.columnName !== columnName) return column;
894
+ return { ...column, fieldName: camelCase(nextFieldName) };
895
+ }),
896
+ userKeyField: current.userKeyField === current.columns.find((column) => column.columnName === columnName)?.fieldName
897
+ ? camelCase(nextFieldName)
898
+ : current.userKeyField,
899
+ }));
900
+ };
901
+
902
+ const updateColumnSolidFieldType = (columnName: string, nextSolidFieldType: string) => {
903
+ updateDrawerDraft((current) => ({
904
+ ...current,
905
+ columns: current.columns.map((column) => {
906
+ if (column.columnName !== columnName) return column;
907
+ if (column.handledBySuperclass) return column;
908
+
909
+ return {
910
+ ...column,
911
+ solidFieldType: nextSolidFieldType,
912
+ ormType: getDefaultOrmTypeForSolidFieldType(current.dataSourceType, nextSolidFieldType),
913
+ };
914
+ }),
915
+ }));
916
+ };
917
+
918
+ const setColumnIncluded = (columnName: string, include: boolean) => {
919
+ updateDrawerDraft((current) => ({
920
+ ...current,
921
+ columns: current.columns.map((column) => {
922
+ if (column.columnName !== columnName) return column;
923
+ if (column.handledBySuperclass || column.isPrimaryKey) return column;
924
+ return { ...column, include };
925
+ }),
926
+ }));
927
+ };
928
+
929
+ const changeUserKeyField = (fieldName: string) => {
930
+ updateDrawerDraft((current) => ({
931
+ ...current,
932
+ userKeyField: fieldName,
933
+ columns: current.columns.map((column) => (
934
+ column.fieldName === fieldName
935
+ ? { ...column, include: column.handledBySuperclass ? false : true }
936
+ : column
937
+ )),
938
+ }));
939
+ };
940
+
941
+ const setUnmappedColumnsIncluded = (include: boolean) => {
942
+ updateDrawerDraft((current) => ({
943
+ ...current,
944
+ columns: current.columns.map((column) => {
945
+ if (column.handledBySuperclass || column.matched || column.isPrimaryKey) {
946
+ return column;
947
+ }
948
+ return { ...column, include };
949
+ }),
950
+ }));
951
+ };
952
+
953
+ const openMappedModelForm = () => {
954
+ const targetModelId = drawerDraft?.modelId ?? selectedTable?.mappedModelId;
955
+ if (!targetModelId) return;
956
+ window.open(`/admin/core/solid-core/model-metadata/form/${targetModelId}`, "_blank", "noopener,noreferrer");
957
+ };
958
+
959
+ const viewChanges = async () => {
960
+ if (!drawerDraft || !bootstrap?.module?.id) return;
961
+
962
+ const validationErrors = getDrawerValidationErrors(drawerDraft);
963
+ if (validationErrors.length) {
964
+ validationErrors.forEach((detail) => {
965
+ dispatch(showToast({ severity: "error", summary: "Validation", detail }));
966
+ });
967
+ return;
968
+ }
969
+
970
+ try {
971
+ const preview = await previewMapping({
972
+ moduleId: bootstrap.module.id,
973
+ payload: buildMappingRequest(drawerDraft),
974
+ }).unwrap();
975
+
976
+ setMappingPreview(preview);
977
+ setReviewedMetadataJsonValue(preview.metadataJson.model);
978
+ setMetadataJsonText(JSON.stringify(preview.metadataJson.model, null, 2));
979
+ setMetadataJsonValidationError(null);
980
+ setMetadataJsonResetToken(`preview-${preview.metadataJson.filePath}-${Date.now()}`);
981
+ setReviewTab("metadata-json");
982
+ setDrawerStep("review");
983
+ } catch (error: any) {
984
+ dispatch(showToast({
985
+ severity: "error",
986
+ summary: "Preview failed",
987
+ detail: readQueryError(error),
988
+ }));
989
+ }
990
+ };
991
+
992
+ const saveMapping = async () => {
993
+ if (!drawerDraft || !bootstrap?.module?.id) return;
994
+
995
+ const validationErrors = getDrawerValidationErrors(drawerDraft);
996
+ if (validationErrors.length) {
997
+ validationErrors.forEach((detail) => {
998
+ dispatch(showToast({ severity: "error", summary: "Validation", detail }));
999
+ });
1000
+ setDrawerStep("edit");
1001
+ return;
1002
+ }
1003
+
1004
+ const reviewedModelValidation = parseAndValidateReviewedModelJson(metadataJsonText, drawerDraft);
1005
+ if (reviewedModelValidation.error || !reviewedModelValidation.parsed) {
1006
+ setMetadataJsonValidationError(reviewedModelValidation.error ?? "Metadata JSON is invalid.");
1007
+ setReviewTab("metadata-json");
1008
+ setDrawerStep("review");
1009
+ dispatch(showToast({
1010
+ severity: "error",
1011
+ summary: "Validation",
1012
+ detail: reviewedModelValidation.error ?? "Metadata JSON is invalid.",
1013
+ }));
1014
+ return;
1015
+ }
1016
+
1017
+ setMetadataJsonValidationError(null);
1018
+
1019
+ try {
1020
+ const response = await applyMapping({
1021
+ moduleId: bootstrap.module.id,
1022
+ payload: {
1023
+ ...buildMappingRequest(drawerDraft, mappingPreview?.migration.timestamp ?? null),
1024
+ reviewedModel: reviewedModelValidation.parsed,
1025
+ },
1026
+ }).unwrap();
1027
+
1028
+ dispatch(showToast({
1029
+ severity: "success",
1030
+ summary: "Saved",
1031
+ detail: response.migration.written
1032
+ ? "The mapping, metadata JSON, and migration template were written successfully."
1033
+ : drawerDraft.mapped
1034
+ ? "The model mapping was updated and written back to metadata JSON."
1035
+ : "The model mapping was created and written to metadata JSON.",
1036
+ }));
1037
+
1038
+ setDrawerOpen(false);
1039
+ setDrawerDraft(null);
1040
+ setMappingPreview(null);
1041
+ setReviewedMetadataJsonValue(null);
1042
+ setMetadataJsonText("");
1043
+ setMetadataJsonValidationError(null);
1044
+ setMetadataJsonResetToken(`saved-${Date.now()}`);
1045
+ setDrawerStep("edit");
1046
+ await refetchTables();
1047
+ await refetchBootstrap();
1048
+ } catch (error: any) {
1049
+ dispatch(showToast({
1050
+ severity: "error",
1051
+ summary: "Save failed",
1052
+ detail: readQueryError(error),
1053
+ }));
1054
+ }
1055
+ };
1056
+
1057
+ if (!isValidModuleId) {
1058
+ return (
1059
+ <div className="sdix-page">
1060
+ <div className="sdix-empty-state">
1061
+ <ShieldAlert size={24} />
1062
+ <h2>Module not found</h2>
1063
+ <p>Open datasource introspection from a saved module so SolidX knows which module should receive the mapped models.</p>
1064
+ </div>
1065
+ </div>
1066
+ );
1067
+ }
1068
+
1069
+ const selectableUnmappedColumns = drawerDraft?.columns.filter(
1070
+ (column) => !column.handledBySuperclass && !column.matched && !column.isPrimaryKey,
1071
+ ) ?? [];
1072
+ const allSelectableUnmappedIncluded = selectableUnmappedColumns.length > 0
1073
+ && selectableUnmappedColumns.every((column) => column.include);
1074
+ const drawerRawDdl = tableDetail?.rawDdl ?? null;
1075
+ const drawerTabs = drawerDraft ? [
1076
+ {
1077
+ value: "general-info",
1078
+ label: "General Info",
1079
+ content: (
1080
+ <div className="sdix-drawer-form">
1081
+ <section className="sdix-form-section">
1082
+ <h4 className="solid-form-layout-label">Model Info</h4>
1083
+ <div className="sdix-form-grid">
1084
+ <div className="sdix-form-field">
1085
+ <label htmlFor="sdix-singular-name">Singular Name</label>
1086
+ <SolidInput
1087
+ id="sdix-singular-name"
1088
+ value={drawerDraft.singularName}
1089
+ onChange={(event) => updateGeneralField("singularName", event.target.value)}
1090
+ />
1091
+ </div>
1092
+ <div className="sdix-form-field">
1093
+ <label htmlFor="sdix-plural-name">Plural Name</label>
1094
+ <SolidInput
1095
+ id="sdix-plural-name"
1096
+ value={drawerDraft.pluralName}
1097
+ onChange={(event) => updateGeneralField("pluralName", event.target.value)}
1098
+ />
1099
+ </div>
1100
+ <div className="sdix-form-field">
1101
+ <label htmlFor="sdix-display-name">Display Name</label>
1102
+ <SolidInput
1103
+ id="sdix-display-name"
1104
+ value={drawerDraft.displayName}
1105
+ onChange={(event) => updateGeneralField("displayName", event.target.value)}
1106
+ />
1107
+ </div>
1108
+ <div className="sdix-form-field">
1109
+ <label htmlFor="sdix-user-key-field">User Key Field</label>
1110
+ <SolidSelect
1111
+ id="sdix-user-key-field"
1112
+ value={drawerDraft.userKeyField}
1113
+ options={drawerUserKeyOptions}
1114
+ placeholder="Select user key field"
1115
+ onChange={(event) => changeUserKeyField(event.value)}
1116
+ />
1117
+ </div>
1118
+ <div className="sdix-form-field sdix-form-field--full">
1119
+ <label htmlFor="sdix-description">Description</label>
1120
+ <SolidTextarea
1121
+ id="sdix-description"
1122
+ rows={3}
1123
+ value={drawerDraft.description}
1124
+ onChange={(event) => updateGeneralField("description", event.target.value)}
1125
+ />
1126
+ </div>
1127
+ </div>
1128
+ </section>
1129
+
1130
+ <section className="sdix-form-section">
1131
+ <h4 className="solid-form-layout-label">Datasource Binding</h4>
1132
+ <div className="sdix-form-grid">
1133
+ <div className="sdix-form-field">
1134
+ <label htmlFor="sdix-table-name">Table Name</label>
1135
+ <SolidInput
1136
+ id="sdix-table-name"
1137
+ value={drawerDraft.tableName}
1138
+ onChange={(event) => updateGeneralField("tableName", event.target.value)}
1139
+ />
1140
+ </div>
1141
+ <div className="sdix-form-field">
1142
+ <label htmlFor="sdix-schema-name">Schema</label>
1143
+ <SolidInput
1144
+ id="sdix-schema-name"
1145
+ value={drawerDraft.schema ?? "default"}
1146
+ disabled
1147
+ />
1148
+ </div>
1149
+ <div className="sdix-form-field">
1150
+ <label htmlFor="sdix-datasource-name">Datasource</label>
1151
+ <SolidInput
1152
+ id="sdix-datasource-name"
1153
+ value={drawerDraft.dataSource}
1154
+ disabled
1155
+ />
1156
+ </div>
1157
+ <div className="sdix-form-field">
1158
+ <label htmlFor="sdix-datasource-type">Provider</label>
1159
+ <SolidInput
1160
+ id="sdix-datasource-type"
1161
+ value={providerLabel(drawerDraft.dataSourceType)}
1162
+ disabled
1163
+ />
1164
+ </div>
1165
+ <div className="sdix-form-field">
1166
+ <label htmlFor="sdix-legacy-type">Legacy Strategy</label>
1167
+ <SolidInput
1168
+ id="sdix-legacy-type"
1169
+ value={legacyTypeLabel(drawerDraft.legacyTableType)}
1170
+ disabled
1171
+ />
1172
+ </div>
1173
+ <div className="sdix-form-field">
1174
+ <label htmlFor="sdix-base-class">Superclass</label>
1175
+ <SolidInput
1176
+ id="sdix-base-class"
1177
+ value={drawerDraft.baseClassName}
1178
+ disabled
1179
+ />
1180
+ </div>
1181
+ </div>
1182
+ </section>
1183
+ </div>
1184
+ ),
1185
+ },
1186
+ {
1187
+ value: "columns-mapping",
1188
+ label: "Columns Mapping",
1189
+ content: (
1190
+ <div className="sdix-columns">
1191
+ <div className="sdix-columns__toolbar">
1192
+ <div className="sdix-columns__legend" aria-label="Column legend">
1193
+ <span className="sdix-status-badge is-superclass">System column</span>
1194
+ <span className="sdix-status-badge is-muted">Application column</span>
1195
+ </div>
1196
+ </div>
1197
+ <div className="sdix-column-table">
1198
+ <div className="sdix-column-row sdix-column-row--head sdix-column-row--mapping">
1199
+ <span>Column</span>
1200
+ <span>Field</span>
1201
+ <span>Database</span>
1202
+ <span>SolidX</span>
1203
+ <span className="sdix-column-select-header">
1204
+ {selectableUnmappedColumns.length ? (
1205
+ <SolidCheckbox
1206
+ checked={allSelectableUnmappedIncluded}
1207
+ onChange={(event) => setUnmappedColumnsIncluded(event.target.checked)}
1208
+ aria-label={allSelectableUnmappedIncluded ? "Deselect all unmapped columns" : "Select all unmapped columns"}
1209
+ />
1210
+ ) : (
1211
+ "Include"
1212
+ )}
1213
+ </span>
1214
+ <span>Status</span>
1215
+ <span>Action</span>
1216
+ </div>
1217
+ {drawerDraft.columns.map((column) => {
1218
+ const isUserKey = drawerDraft.userKeyField === column.fieldName;
1219
+ const statusClassName = column.handledBySuperclass
1220
+ ? "is-superclass"
1221
+ : column.include
1222
+ ? column.matched
1223
+ ? "is-mapped"
1224
+ : "is-unmapped"
1225
+ : "is-muted";
1226
+
1227
+ const statusText = column.handledBySuperclass
1228
+ ? "System"
1229
+ : column.include
1230
+ ? column.matched
1231
+ ? "Mapped"
1232
+ : "Queued for save"
1233
+ : column.matched
1234
+ ? "Currently omitted"
1235
+ : "Skipped for now";
1236
+
1237
+ return (
1238
+ <div key={column.columnName} className="sdix-column-row sdix-column-row--mapping">
1239
+ <span className="sdix-column-meta">
1240
+ <strong>{column.columnName}</strong>
1241
+ {column.isAutoGenerated ? <small>Generated id column</small> : null}
1242
+ </span>
1243
+ <span>
1244
+ <SolidInput
1245
+ value={column.fieldName}
1246
+ disabled={column.handledBySuperclass}
1247
+ onChange={(event) => updateColumnFieldName(column.columnName, event.target.value)}
1248
+ />
1249
+ {isUserKey ? <small className="sdix-inline-note">User key</small> : null}
1250
+ </span>
1251
+ <span className="sdix-column-meta">
1252
+ <strong>{column.dataType}</strong>
1253
+ </span>
1254
+ <span>
1255
+ {column.handledBySuperclass ? (
1256
+ <span>{column.solidFieldType}</span>
1257
+ ) : (
1258
+ <SolidSelect
1259
+ value={column.solidFieldType}
1260
+ options={scalarFieldTypeOptions}
1261
+ onChange={(event) => updateColumnSolidFieldType(column.columnName, event.value)}
1262
+ />
1263
+ )}
1264
+ </span>
1265
+ <span className="sdix-column-include">
1266
+ {column.handledBySuperclass ? (
1267
+ <span className="sdix-column-placeholder">-</span>
1268
+ ) : column.matched ? (
1269
+ <span className="sdix-column-placeholder">-</span>
1270
+ ) : column.isPrimaryKey ? (
1271
+ <SolidCheckbox checked disabled label="Required" />
1272
+ ) : (
1273
+ <SolidCheckbox
1274
+ checked={column.include}
1275
+ onChange={(event) => setColumnIncluded(column.columnName, event.target.checked)}
1276
+ />
1277
+ )}
1278
+ </span>
1279
+ <span>
1280
+ <span className={`sdix-status-badge ${statusClassName}`}>{statusText}</span>
1281
+ </span>
1282
+ <span className="sdix-column-action">
1283
+ {column.handledBySuperclass ? (
1284
+ <SolidButton size="small" variant="outline" disabled>
1285
+ Managed
1286
+ </SolidButton>
1287
+ ) : column.isPrimaryKey ? (
1288
+ <SolidButton size="small" variant="outline" disabled>
1289
+ Required
1290
+ </SolidButton>
1291
+ ) : column.matched ? (
1292
+ <SolidButton size="small" variant="outline" onClick={() => setColumnIncluded(column.columnName, false)}>
1293
+ Remove mapping
1294
+ </SolidButton>
1295
+ ) : (
1296
+ <span className="sdix-column-placeholder">-</span>
1297
+ )}
1298
+ </span>
1299
+ </div>
1300
+ );
1301
+ })}
1302
+ </div>
1303
+ </div>
1304
+ ),
1305
+ },
1306
+ {
1307
+ value: "raw-ddl",
1308
+ label: "Raw DDL",
1309
+ content: (
1310
+ <div className="sdix-ddl-tab">
1311
+ {drawerRawDdl?.available && drawerRawDdl.statement ? (
1312
+ <pre className="sdix-ddl-code"><code>{drawerRawDdl.statement}</code></pre>
1313
+ ) : (
1314
+ <div className="sdix-empty-state is-inline sdix-ddl-empty">
1315
+ <Database size={18} />
1316
+ <p>{drawerRawDdl?.note || "DDL preview is not available for this table."}</p>
1317
+ </div>
1318
+ )}
1319
+ </div>
1320
+ ),
1321
+ },
1322
+ ] : [];
1323
+
1324
+ const reviewTabs = mappingPreview ? [
1325
+ {
1326
+ value: "metadata-json",
1327
+ label: "Metadata JSON",
1328
+ content: (
1329
+ <div className="sdix-review-panel sdix-review-panel--metadata">
1330
+ <div className="sdix-review-meta sdix-review-meta--compact">
1331
+ <span>Target file</span>
1332
+ <strong>{mappingPreview.metadataJson.filePath}</strong>
1333
+ </div>
1334
+ <SolidJsonEditor
1335
+ className="sdix-json-editor"
1336
+ value={reviewedMetadataJsonValue ?? mappingPreview.metadataJson.model}
1337
+ resetToken={metadataJsonResetToken}
1338
+ onValueChange={(nextValue) => {
1339
+ setReviewedMetadataJsonValue(nextValue);
1340
+ if (metadataJsonValidationError) {
1341
+ setMetadataJsonValidationError(null);
1342
+ }
1343
+ }}
1344
+ onTextChange={(nextText) => {
1345
+ setMetadataJsonText(nextText);
1346
+ }}
1347
+ onErrorChange={setMetadataJsonValidationError}
1348
+ />
1349
+ {metadataJsonValidationError ? (
1350
+ <div className="sdix-json-validation-error">
1351
+ <span>{metadataJsonValidationError}</span>
1352
+ </div>
1353
+ ) : null}
1354
+ </div>
1355
+ ),
1356
+ },
1357
+ {
1358
+ value: "migration",
1359
+ label: "Migration",
1360
+ content: (
1361
+ <div className="sdix-review-panel sdix-review-panel--migration">
1362
+ {mappingPreview.migration.willGenerate && mappingPreview.migration.filePath ? (
1363
+ <>
1364
+ <div className="sdix-review-meta sdix-review-meta--compact">
1365
+ <span>Target file</span>
1366
+ <strong>{mappingPreview.migration.filePath}</strong>
1367
+ </div>
1368
+ <p className="sdix-review-note">
1369
+ This migration preview is generated from the current mapping and is read-only here. Saving the mapping will write a new template migration file to the target location.
1370
+ </p>
1371
+ {mappingPreview.migration.content ? (
1372
+ <pre className="sdix-ddl-code sdix-ddl-code--fill"><code>{mappingPreview.migration.content}</code></pre>
1373
+ ) : null}
1374
+ </>
1375
+ ) : (
1376
+ <div className="sdix-empty-state is-inline sdix-ddl-empty">
1377
+ <Database size={18} />
1378
+ <p>{mappingPreview.migration.note}</p>
1379
+ </div>
1380
+ )}
1381
+ </div>
1382
+ ),
1383
+ },
1384
+ ] : [];
1385
+
1386
+ return (
1387
+ <div className="sdix-page">
1388
+ <div className="sdix-toolbar">
1389
+ <div className="sdix-toolbar__left">
1390
+ <div className="sdix-toolbar-search">
1391
+ <Search size={15} />
1392
+ <SolidInput
1393
+ value={tableSearch}
1394
+ onChange={(event) => setTableSearch(event.target.value)}
1395
+ placeholder="Search tables"
1396
+ disabled={!selectedDatasource}
1397
+ />
1398
+ </div>
1399
+ </div>
1400
+
1401
+ <div className="sdix-toolbar__actions">
1402
+ {isBootstrapFetching || isTablesFetching || isTableDetailFetching || isMappedModelFetching ? (
1403
+ <div className="sdix-inline-status">
1404
+ <SolidSpinner size={16} />
1405
+ <span>Refreshing</span>
1406
+ </div>
1407
+ ) : null}
1408
+ <SolidSelect
1409
+ value={selectedDatasource}
1410
+ options={datasourceOptions}
1411
+ placeholder="Datasource"
1412
+ className="sdix-toolbar-select sdix-toolbar-select--compact"
1413
+ onChange={(event) => setSelectedDatasource(event.value)}
1414
+ />
1415
+ <SolidButton
1416
+ size="small"
1417
+ className="sdix-icon-button"
1418
+ leftIcon={<RefreshCw size={16} />}
1419
+ onClick={() => void handleRefresh()}
1420
+ tooltip="Refresh"
1421
+ aria-label="Refresh"
1422
+ />
1423
+ <SolidButton
1424
+ size="small"
1425
+ className="sdix-icon-button"
1426
+ leftIcon={<Cog size={16} />}
1427
+ onClick={() => {
1428
+ setDraftSessionSettings(sessionSettings);
1429
+ setSettingsTab("datasource-info");
1430
+ setSettingsOpen(true);
1431
+ }}
1432
+ tooltip="Session settings"
1433
+ aria-label="Session settings"
1434
+ />
1435
+ </div>
1436
+ </div>
1437
+
1438
+ {isBootstrapLoading ? (
1439
+ <div className="sdix-loading">
1440
+ <SolidSpinner size={28} />
1441
+ <p>Loading introspection workspace...</p>
1442
+ </div>
1443
+ ) : bootstrapError ? (
1444
+ <div className="sdix-error">
1445
+ <span>{readQueryError(bootstrapError)}</span>
1446
+ <SolidButton size="small" variant="outline" onClick={() => void refetchBootstrap()}>
1447
+ Retry
1448
+ </SolidButton>
1449
+ </div>
1450
+ ) : (
1451
+ <>
1452
+ {selectedDatasource && tablesResponse?.synchronizeBlocked ? (
1453
+ <div className="sdix-blocker">
1454
+ <ShieldAlert size={20} />
1455
+ <div>
1456
+ <h3>Synchronize must be disabled before introspection</h3>
1457
+ <p>{tablesResponse.synchronizeMessage}</p>
1458
+ </div>
1459
+ </div>
1460
+ ) : null}
1461
+
1462
+ <div className="sdix-workbench">
1463
+ <div className="sdix-table-shell">
1464
+ {!selectedDatasource ? (
1465
+ <div className="sdix-empty-state is-inline">
1466
+ <Database size={22} />
1467
+ <p>Select a datasource to load its tables.</p>
1468
+ </div>
1469
+ ) : isTablesLoading ? (
1470
+ <div className="sdix-loading is-inline">
1471
+ <SolidSpinner size={24} />
1472
+ <p>Inspecting datasource tables...</p>
1473
+ </div>
1474
+ ) : tablesError ? (
1475
+ <div className="sdix-error is-inline">
1476
+ <span>{readQueryError(tablesError)}</span>
1477
+ <SolidButton size="small" variant="outline" onClick={() => void refetchTables()}>
1478
+ Retry
1479
+ </SolidButton>
1480
+ </div>
1481
+ ) : !filteredRecords.length ? (
1482
+ <div className="sdix-empty-state is-inline">
1483
+ <Database size={22} />
1484
+ <p>{tableSearch ? "No tables match the current quick filter." : "No database tables were discovered for this datasource."}</p>
1485
+ </div>
1486
+ ) : (
1487
+ <div className="sdix-table-area">
1488
+ <SolidDataTable
1489
+ value={filteredRecords}
1490
+ dataKey="tableName"
1491
+ viewportHeight="calc(100vh - 162px)"
1492
+ size="small"
1493
+ onRowClick={({ data }) => openTableDrawer(data)}
1494
+ rowClassName={(rowData) => `${rowData.schema ?? ""}::${rowData.tableName}` === selectedTableKey && drawerOpen ? "sdix-table-row-selected" : ""}
1495
+ emptyMessage="No datasource tables found"
1496
+ >
1497
+ <Column
1498
+ field="tableName"
1499
+ header="Table"
1500
+ body={(rowData: DatasourceIntrospectionTableRecord) => (
1501
+ <div className="sdix-table-primary-cell">
1502
+ <strong>{rowData.tableName}</strong>
1503
+ </div>
1504
+ )}
1505
+ style={{ minWidth: 240 }}
1506
+ />
1507
+ <Column field="schema" header="Schema" body={(rowData: DatasourceIntrospectionTableRecord) => rowData.schema || "dbo/default"} style={{ minWidth: 120 }} />
1508
+ <Column
1509
+ header="Status"
1510
+ body={(rowData: DatasourceIntrospectionTableRecord) => (
1511
+ <span
1512
+ className={`sdix-status-badge ${rowData.mapped ? "is-mapped" : "is-unmapped"}`}
1513
+ >
1514
+ {statusLabel(rowData)}
1515
+ </span>
1516
+ )}
1517
+ style={{ minWidth: 150 }}
1518
+ />
1519
+ <Column
1520
+ header="Superclass"
1521
+ body={(rowData: DatasourceIntrospectionTableRecord) => rowData.suggestedBaseClassName}
1522
+ style={{ minWidth: 220 }}
1523
+ />
1524
+ <Column field="suggestedUserKeyField" header="User Key" style={{ minWidth: 160 }} />
1525
+ <Column
1526
+ header="Mapped Model"
1527
+ body={(rowData: DatasourceIntrospectionTableRecord) => (
1528
+ rowData.mapped
1529
+ ? (rowData.mappedModelDisplayName || rowData.mappedModelSingularName || "-")
1530
+ : <span className="sdix-muted-cell">Not mapped</span>
1531
+ )}
1532
+ style={{ minWidth: 200 }}
1533
+ />
1534
+ <Column
1535
+ header="System Columns"
1536
+ body={(rowData: DatasourceIntrospectionTableRecord) => Math.max(rowData.columnCount - rowData.physicalColumnCount, 0)}
1537
+ style={{ minWidth: 130 }}
1538
+ />
1539
+ <Column
1540
+ header="Columns"
1541
+ body={(rowData: DatasourceIntrospectionTableRecord) => rowData.physicalColumnCount}
1542
+ style={{ minWidth: 110 }}
1543
+ />
1544
+ </SolidDataTable>
1545
+ </div>
1546
+ )}
1547
+ </div>
1548
+ </div>
1549
+
1550
+ <SolidDialog
1551
+ open={settingsOpen}
1552
+ onOpenChange={setSettingsOpen}
1553
+ style={{ width: "min(560px, calc(100vw - 32px))" }}
1554
+ >
1555
+ <SolidDialogHeader>
1556
+ <div>
1557
+ <SolidDialogTitle>Session settings</SolidDialogTitle>
1558
+ <SolidDialogDescription>
1559
+ Review the selected datasource and adjust the session-specific introspection flags.
1560
+ </SolidDialogDescription>
1561
+ </div>
1562
+ <SolidDialogClose />
1563
+ </SolidDialogHeader>
1564
+ <SolidDialogSeparator />
1565
+ <SolidDialogBody>
1566
+ <SolidTabGroup
1567
+ tabs={settingsTabs}
1568
+ value={settingsTab}
1569
+ onValueChange={setSettingsTab}
1570
+ className="sdix-settings-tabs"
1571
+ panelClassName="sdix-settings-tab-panel"
1572
+ />
1573
+ </SolidDialogBody>
1574
+ <SolidDialogFooter>
1575
+ <SolidButton variant="outline" onClick={() => setSettingsOpen(false)}>
1576
+ Cancel
1577
+ </SolidButton>
1578
+ <SolidButton onClick={applyDraftSettings}>
1579
+ Apply
1580
+ </SolidButton>
1581
+ </SolidDialogFooter>
1582
+ </SolidDialog>
1583
+
1584
+ <SolidDialog
1585
+ open={drawerOpen}
1586
+ onOpenChange={setDrawerOpen}
1587
+ className="sdix-drawer"
1588
+ overlayClassName="sdix-drawer-overlay"
1589
+ dismissible
1590
+ >
1591
+ <SolidDialogHeader className="sdix-drawer-header">
1592
+ <div className="sdix-drawer-header__identity">
1593
+ <SolidDialogTitle>{selectedTable?.tableName || "Table details"}</SolidDialogTitle>
1594
+ <SolidDialogDescription className="sdix-drawer-header__meta">
1595
+ {selectedTable
1596
+ ? `${selectedTable.schema || "default"} · ${selectedDatasourceRecord?.database || selectedDatasourceRecord?.displayName || "Datasource"}${selectedDatasourceRecord ? ` · ${providerLabel(selectedDatasourceRecord.type)}` : ""}`
1597
+ : "Review mapping details"}
1598
+ </SolidDialogDescription>
1599
+ </div>
1600
+ <SolidDialogClose />
1601
+ </SolidDialogHeader>
1602
+ <SolidDialogSeparator />
1603
+ <SolidDialogBody className="sdix-drawer-body">
1604
+ {!selectedTable ? (
1605
+ <div className="sdix-empty-state is-inline">
1606
+ <Sparkles size={22} />
1607
+ <p>Select a table row to review its structure.</p>
1608
+ </div>
1609
+ ) : drawerLoadingError ? (
1610
+ <div className="sdix-error is-inline">
1611
+ <span>{readQueryError(drawerLoadingError)}</span>
1612
+ <SolidButton size="small" variant="outline" onClick={() => void refetchTableDetail()}>
1613
+ Retry
1614
+ </SolidButton>
1615
+ </div>
1616
+ ) : isDrawerBlockingLoading ? (
1617
+ <div className="sdix-loading is-inline">
1618
+ <SolidSpinner size={24} />
1619
+ <p>Preparing column mapping preview...</p>
1620
+ </div>
1621
+ ) : (
1622
+ <div className="sdix-drawer-stack">
1623
+ {drawerDraft && !drawerDraft.mapped && drawerStep === "edit" ? (
1624
+ <div className="sdix-detail-callout sdix-system-fields-callout">
1625
+ <h3>SolidX system fields are added later</h3>
1626
+ <p>
1627
+ This preview only lists the legacy table columns that already exist today.
1628
+ {" "}
1629
+ Framework system fields from the selected superclass, including the namespaced
1630
+ {" "}
1631
+ <code>ss_*</code>
1632
+ {" "}
1633
+ columns, are introduced when you save the mapping and then generate/apply the follow-up migrations.
1634
+ </p>
1635
+ </div>
1636
+ ) : null}
1637
+ {drawerStep === "edit" ? (
1638
+ <SolidTabGroup
1639
+ tabs={drawerTabs}
1640
+ value={drawerTab}
1641
+ onValueChange={setDrawerTab}
1642
+ className="sdix-drawer-tabs"
1643
+ panelClassName="sdix-drawer-tab-panel"
1644
+ />
1645
+ ) : (
1646
+ <SolidTabGroup
1647
+ tabs={reviewTabs}
1648
+ value={reviewTab}
1649
+ onValueChange={setReviewTab}
1650
+ className="sdix-drawer-tabs"
1651
+ panelClassName="sdix-drawer-tab-panel"
1652
+ />
1653
+ )}
1654
+ </div>
1655
+ )}
1656
+ </SolidDialogBody>
1657
+ {drawerDraft && !isDrawerBlockingLoading ? (
1658
+ <>
1659
+ <SolidDialogSeparator />
1660
+ <SolidDialogFooter className="sdix-drawer-footer">
1661
+ <div className="sdix-drawer-footer__meta">
1662
+ {drawerStep === "review" && mappingPreview ? (
1663
+ <span>
1664
+ Review the metadata update first, then save to apply
1665
+ {" "}
1666
+ <strong>{mappingPreview.summary.includedColumnNames.length}</strong>
1667
+ {" "}
1668
+ mapped columns
1669
+ {mappingPreview.summary.generatedSystemColumnCount
1670
+ ? ` and ${mappingPreview.summary.generatedSystemColumnCount} SolidX system column migration changes`
1671
+ : ""}
1672
+ .
1673
+ </span>
1674
+ ) : drawerDraft.mapped ? (
1675
+ <span>
1676
+ Editing mapped model
1677
+ {" "}
1678
+ <strong>{selectedTable?.mappedModelDisplayName || selectedTable?.mappedModelSingularName}</strong>
1679
+ {drawerDraft.mappedModuleLabel ? ` in ${drawerDraft.mappedModuleLabel}` : ""}
1680
+ </span>
1681
+ ) : (
1682
+ <span>Saving will create the model metadata and write it to the module JSON.</span>
1683
+ )}
1684
+ </div>
1685
+ <div className="sdix-drawer-footer__actions">
1686
+ {drawerDraft.mapped && drawerDraft.modelId ? (
1687
+ <SolidButton size="small" variant="outline" leftIcon={<ExternalLink size={14} />} onClick={openMappedModelForm}>
1688
+ Open model form
1689
+ </SolidButton>
1690
+ ) : null}
1691
+ {drawerStep === "review" ? (
1692
+ <>
1693
+ <SolidButton size="small" variant="outline" onClick={() => setDrawerStep("edit")}>
1694
+ Back to editing
1695
+ </SolidButton>
1696
+ <SolidButton size="small" leftIcon={<Save size={14} />} loading={isSavingMapping} onClick={() => void saveMapping()}>
1697
+ Save mapping
1698
+ </SolidButton>
1699
+ </>
1700
+ ) : (
1701
+ <SolidButton
1702
+ size="small"
1703
+ leftIcon={<Save size={14} />}
1704
+ loading={isPreviewMappingLoading}
1705
+ onClick={() => void viewChanges()}
1706
+ >
1707
+ View Changes
1708
+ </SolidButton>
1709
+ )}
1710
+ </div>
1711
+ </SolidDialogFooter>
1712
+ </>
1713
+ ) : null}
1714
+ </SolidDialog>
1715
+ </>
1716
+ )}
1717
+ </div>
1718
+ );
1719
+ }