@r2digisolutions/components 0.1.5 → 0.1.7
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/README.md +6 -1
- package/dist/components/atoms/Checkbox/Checkbox.svelte +10 -3
- package/dist/components/atoms/Checkbox/Checkbox.svelte.d.ts +3 -0
- package/dist/components/atoms/Input/Input.svelte +17 -2
- package/dist/components/atoms/Input/Input.svelte.d.ts +6 -1
- package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte +21 -0
- package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte.d.ts +8 -0
- package/dist/components/atoms/Textarea/Textarea.svelte +4 -1
- package/dist/components/atoms/Textarea/Textarea.svelte.d.ts +1 -0
- package/dist/components/atoms/Toggle/Toggle.svelte +1 -1
- package/dist/components/molecules/BulkActionBar/BulkActionBar.svelte +19 -5
- package/dist/components/molecules/CodeEditor/CodeEditor.svelte +13 -10
- package/dist/components/molecules/ColorPicker/ColorPicker.svelte +166 -96
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.d.ts +9 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.js +8 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte +412 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte.d.ts +14 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte +27 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte.d.ts +3 -0
- package/dist/components/molecules/ContextMenu/ContextMenu.svelte +73 -21
- package/dist/components/molecules/ContextMenu/ContextMenu.svelte.d.ts +8 -1
- package/dist/components/molecules/DateTimePicker/DateTimePicker.svelte.d.ts +1 -1
- package/dist/components/molecules/FormDateTimePicker/FormDateTimePicker.svelte.d.ts +1 -1
- package/dist/components/molecules/Gauge/Gauge.stories.d.ts +1 -1
- package/dist/components/molecules/InfiniteScroll/InfiniteScroll.stories.d.ts +1 -1
- package/dist/components/organisms/AssetPickerPage/AssetPickerPage.svelte.d.ts +1 -1
- package/dist/components/organisms/BacklogPage/BacklogPage.svelte.d.ts +1 -1
- package/dist/components/organisms/CalendarApp/CalendarApp.svelte.d.ts +1 -1
- package/dist/components/organisms/DataGrid/DataGrid.stories.d.ts +65 -0
- package/dist/components/organisms/DataGrid/DataGrid.stories.js +95 -0
- package/dist/components/organisms/DataGrid/DataGrid.svelte +2264 -0
- package/dist/components/organisms/DataGrid/DataGrid.svelte.d.ts +99 -0
- package/dist/components/organisms/DataGrid/DataGridStory.svelte +659 -0
- package/dist/components/organisms/DataGrid/DataGridStory.svelte.d.ts +11 -0
- package/dist/components/organisms/DataGrid/breakpoints.d.ts +10 -0
- package/dist/components/organisms/DataGrid/breakpoints.js +20 -0
- package/dist/components/organisms/DataGrid/cellEditors.d.ts +8 -0
- package/dist/components/organisms/DataGrid/cellEditors.js +59 -0
- package/dist/components/organisms/DataGrid/conditionalFormat.d.ts +11 -0
- package/dist/components/organisms/DataGrid/conditionalFormat.js +98 -0
- package/dist/components/organisms/DataGrid/filterRows.d.ts +5 -0
- package/dist/components/organisms/DataGrid/filterRows.js +60 -0
- package/dist/components/organisms/DataGrid/marks.d.ts +9 -0
- package/dist/components/organisms/DataGrid/marks.js +91 -0
- package/dist/components/organisms/DataGrid/types.d.ts +126 -0
- package/dist/components/organisms/DataGrid/types.js +21 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.d.ts +33 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.js +56 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte +830 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte.d.ts +68 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte +600 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte.d.ts +7 -0
- package/dist/components/organisms/ResourceStudio/fieldsToColumns.d.ts +4 -0
- package/dist/components/organisms/ResourceStudio/fieldsToColumns.js +20 -0
- package/dist/components/organisms/ResourceStudio/fieldsToSchema.d.ts +6 -0
- package/dist/components/organisms/ResourceStudio/fieldsToSchema.js +53 -0
- package/dist/components/organisms/ResourceStudio/types.d.ts +69 -0
- package/dist/components/organisms/ResourceStudio/types.js +15 -0
- package/dist/components/organisms/Scheduler/Scheduler.svelte.d.ts +1 -1
- package/dist/components/organisms/SchemaForm/SchemaForm.svelte +10 -3
- package/dist/components/organisms/SchemaForm/SchemaForm.svelte.d.ts +1 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.d.ts +25 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.js +18 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte +192 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte.d.ts +62 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte +129 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte.d.ts +7 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/utils/marqueeSelect.d.ts +48 -0
- package/dist/utils/marqueeSelect.js +189 -0
- package/package.json +1 -1
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type {
|
|
3
|
+
FieldType,
|
|
4
|
+
ResourceField,
|
|
5
|
+
ResourceCapabilities,
|
|
6
|
+
ResourceDefinition,
|
|
7
|
+
ResourceStudioMode,
|
|
8
|
+
ResourceStudioLayout,
|
|
9
|
+
ResourceStudioLabels,
|
|
10
|
+
ResourceStudioView
|
|
11
|
+
} from './types.js';
|
|
12
|
+
export { DEFAULT_CAPABILITIES, resolveCapabilities } from './types.js';
|
|
13
|
+
export { fieldsToColumns } from './fieldsToColumns.js';
|
|
14
|
+
export { fieldsToSchema } from './fieldsToSchema.js';
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<script lang="ts">
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
19
|
+
import DataGrid from '../DataGrid/DataGrid.svelte';
|
|
20
|
+
import type {
|
|
21
|
+
CellNote,
|
|
22
|
+
ConditionalFormatRule,
|
|
23
|
+
DataGridColumn,
|
|
24
|
+
GridSelection
|
|
25
|
+
} from '../DataGrid/types.js';
|
|
26
|
+
import { EMPTY_SELECTION } from '../DataGrid/types.js';
|
|
27
|
+
import ConditionalFormatPanel from '../../molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte';
|
|
28
|
+
import SearchInput from '../../molecules/SearchInput/SearchInput.svelte';
|
|
29
|
+
import Pagination from '../../molecules/Pagination/Pagination.svelte';
|
|
30
|
+
import EmptyState from '../../molecules/EmptyState/EmptyState.svelte';
|
|
31
|
+
import ConfirmDelete from '../../molecules/ConfirmDelete/ConfirmDelete.svelte';
|
|
32
|
+
import Drawer from '../Drawer/Drawer.svelte';
|
|
33
|
+
import SchemaForm from '../SchemaForm/SchemaForm.svelte';
|
|
34
|
+
import type { SchemaFormValues } from '../SchemaForm/SchemaForm.svelte';
|
|
35
|
+
import Badge from '../../atoms/Badge/Badge.svelte';
|
|
36
|
+
import Button from '../../atoms/Button/Button.svelte';
|
|
37
|
+
import Skeleton from '../../atoms/Skeleton/Skeleton.svelte';
|
|
38
|
+
import DropZone from '../../molecules/DropZone/DropZone.svelte';
|
|
39
|
+
import type { BulkAction } from '../../molecules/BulkActionBar/BulkActionBar.svelte';
|
|
40
|
+
import {
|
|
41
|
+
type ResourceDefinition,
|
|
42
|
+
type ResourceStudioMode,
|
|
43
|
+
type ResourceStudioLayout,
|
|
44
|
+
type ResourceStudioLabels,
|
|
45
|
+
type ResourceStudioView,
|
|
46
|
+
resolveCapabilities
|
|
47
|
+
} from './types.js';
|
|
48
|
+
import { fieldsToColumns } from './fieldsToColumns.js';
|
|
49
|
+
import { fieldsToSchema } from './fieldsToSchema.js';
|
|
50
|
+
|
|
51
|
+
interface ResourceStudioProps {
|
|
52
|
+
resources?: ResourceDefinition[];
|
|
53
|
+
activeResourceId?: string;
|
|
54
|
+
records?: Record<string, unknown>[];
|
|
55
|
+
totalRecords?: number;
|
|
56
|
+
loading?: boolean;
|
|
57
|
+
page?: number;
|
|
58
|
+
pageSize?: number;
|
|
59
|
+
query?: string;
|
|
60
|
+
selection?: GridSelection;
|
|
61
|
+
notes?: CellNote[];
|
|
62
|
+
formatRules?: ConditionalFormatRule[];
|
|
63
|
+
modes?: ResourceStudioMode[];
|
|
64
|
+
activeMode?: ResourceStudioMode;
|
|
65
|
+
layout?: ResourceStudioLayout;
|
|
66
|
+
labels?: ResourceStudioLabels;
|
|
67
|
+
views?: ResourceStudioView[];
|
|
68
|
+
activeViewId?: string;
|
|
69
|
+
/** Filter `records` locally by the search box. Set false for server-side search. */
|
|
70
|
+
clientSearch?: boolean;
|
|
71
|
+
/** Open / bind the conditional format rules panel. */
|
|
72
|
+
formatPanelOpen?: boolean;
|
|
73
|
+
/** Override bulk dock actions (defaults from capabilities). */
|
|
74
|
+
actions?: BulkAction[];
|
|
75
|
+
class?: string;
|
|
76
|
+
toolbar?: Snippet;
|
|
77
|
+
sidebarItem?: Snippet<[ResourceDefinition, boolean]>;
|
|
78
|
+
sidebarFooter?: Snippet;
|
|
79
|
+
detail?: Snippet<[record: Record<string, unknown> | null]>;
|
|
80
|
+
schemaPanel?: Snippet<[ResourceDefinition]>;
|
|
81
|
+
queryPanel?: Snippet;
|
|
82
|
+
activityPanel?: Snippet;
|
|
83
|
+
recordForm?: Snippet<[mode: 'create' | 'edit', record: Record<string, unknown> | null]>;
|
|
84
|
+
empty?: Snippet;
|
|
85
|
+
dockExtra?: Snippet;
|
|
86
|
+
cell?: Snippet<[Record<string, unknown>, DataGridColumn]>;
|
|
87
|
+
onresourcechange?: (id: string) => void;
|
|
88
|
+
onsearch?: (q: string) => void;
|
|
89
|
+
onpagechange?: (page: number, pageSize: number) => void;
|
|
90
|
+
onsort?: (columnId: string, dir: 'asc' | 'desc') => void;
|
|
91
|
+
oncreate?: () => void;
|
|
92
|
+
oninsert?: (record: Record<string, unknown>) => void;
|
|
93
|
+
onupdate?: (id: string, patch: Record<string, unknown>) => void;
|
|
94
|
+
ondelete?: (ids: string[]) => void;
|
|
95
|
+
onduplicate?: (ids: string[]) => void;
|
|
96
|
+
onexport?: (ids: string[] | 'all') => void;
|
|
97
|
+
onimport?: (file: File) => void;
|
|
98
|
+
oncelledit?: (id: string, fieldId: string, value: unknown) => void;
|
|
99
|
+
/** Fired for dock actions not handled built-in (e.g. custom bulk workflow). */
|
|
100
|
+
onaction?: (id: string, rowIds: string[]) => void;
|
|
101
|
+
onselectionchange?: (s: GridSelection) => void;
|
|
102
|
+
onnoteschange?: (notes: CellNote[]) => void;
|
|
103
|
+
onformatruleschange?: (rules: ConditionalFormatRule[]) => void;
|
|
104
|
+
onmodechange?: (mode: ResourceStudioMode) => void;
|
|
105
|
+
onviewchange?: (viewId: string) => void;
|
|
106
|
+
onrefresh?: () => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let {
|
|
110
|
+
resources = [],
|
|
111
|
+
activeResourceId = $bindable(''),
|
|
112
|
+
records = [],
|
|
113
|
+
totalRecords,
|
|
114
|
+
loading = false,
|
|
115
|
+
page = $bindable(1),
|
|
116
|
+
pageSize = 25,
|
|
117
|
+
query = $bindable(''),
|
|
118
|
+
selection = $bindable<GridSelection>(EMPTY_SELECTION),
|
|
119
|
+
notes = $bindable<CellNote[]>([]),
|
|
120
|
+
formatRules = $bindable<ConditionalFormatRule[]>([]),
|
|
121
|
+
modes = ['browse', 'detail', 'schema'] as ResourceStudioMode[],
|
|
122
|
+
activeMode = $bindable<ResourceStudioMode>('browse'),
|
|
123
|
+
layout = 'sidebar-main-detail',
|
|
124
|
+
labels = {},
|
|
125
|
+
views = [],
|
|
126
|
+
activeViewId = $bindable(''),
|
|
127
|
+
clientSearch = true,
|
|
128
|
+
formatPanelOpen = $bindable(false),
|
|
129
|
+
actions,
|
|
130
|
+
class: className = '',
|
|
131
|
+
toolbar,
|
|
132
|
+
sidebarItem,
|
|
133
|
+
sidebarFooter,
|
|
134
|
+
detail,
|
|
135
|
+
schemaPanel,
|
|
136
|
+
queryPanel,
|
|
137
|
+
activityPanel,
|
|
138
|
+
recordForm,
|
|
139
|
+
empty,
|
|
140
|
+
dockExtra,
|
|
141
|
+
cell,
|
|
142
|
+
onresourcechange,
|
|
143
|
+
onsearch,
|
|
144
|
+
onpagechange,
|
|
145
|
+
onsort,
|
|
146
|
+
oncreate,
|
|
147
|
+
oninsert,
|
|
148
|
+
onupdate,
|
|
149
|
+
ondelete,
|
|
150
|
+
onduplicate,
|
|
151
|
+
onexport,
|
|
152
|
+
onimport,
|
|
153
|
+
oncelledit,
|
|
154
|
+
onaction,
|
|
155
|
+
onselectionchange,
|
|
156
|
+
onnoteschange,
|
|
157
|
+
onformatruleschange,
|
|
158
|
+
onmodechange,
|
|
159
|
+
onviewchange,
|
|
160
|
+
onrefresh
|
|
161
|
+
}: ResourceStudioProps = $props();
|
|
162
|
+
|
|
163
|
+
let sidebarQuery = $state('');
|
|
164
|
+
let formOpen = $state(false);
|
|
165
|
+
let formMode = $state<'create' | 'edit'>('create');
|
|
166
|
+
let formRecord = $state<Record<string, unknown> | null>(null);
|
|
167
|
+
let deleteOpen = $state(false);
|
|
168
|
+
let pendingDeleteIds = $state<string[]>([]);
|
|
169
|
+
let importOpen = $state(false);
|
|
170
|
+
let focusedRecord = $state<Record<string, unknown> | null>(null);
|
|
171
|
+
let draftForm = $state<SchemaFormValues>({});
|
|
172
|
+
|
|
173
|
+
const formSchema = $derived(
|
|
174
|
+
activeResource ? fieldsToSchema(activeResource.fields, { mode: formMode }) : []
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const L = $derived({
|
|
178
|
+
resources: labels.resources ?? 'Resources',
|
|
179
|
+
records: labels.records ?? 'records',
|
|
180
|
+
addRecord: labels.addRecord ?? 'Add record',
|
|
181
|
+
schema: labels.schema ?? 'Schema',
|
|
182
|
+
runQuery: labels.runQuery ?? 'Run query',
|
|
183
|
+
searchPlaceholder: labels.searchPlaceholder ?? 'Search records…',
|
|
184
|
+
detail: labels.detail ?? 'Detail',
|
|
185
|
+
activity: labels.activity ?? 'Activity',
|
|
186
|
+
query: labels.query ?? 'Query'
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
$effect(() => {
|
|
190
|
+
if (!activeResourceId && resources[0]) {
|
|
191
|
+
activeResourceId = resources[0].id;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const activeResource = $derived(
|
|
196
|
+
resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const caps = $derived(resolveCapabilities(activeResource?.capabilities));
|
|
200
|
+
|
|
201
|
+
const primaryKey = $derived(activeResource?.primaryKey ?? 'id');
|
|
202
|
+
|
|
203
|
+
const columns = $derived(activeResource ? fieldsToColumns(activeResource.fields) : []);
|
|
204
|
+
|
|
205
|
+
const groups = $derived.by(() => {
|
|
206
|
+
const map = new Map<string, ResourceDefinition[]>();
|
|
207
|
+
const q = sidebarQuery.trim().toLowerCase();
|
|
208
|
+
for (const r of resources) {
|
|
209
|
+
if (q && !r.name.toLowerCase().includes(q) && !r.id.toLowerCase().includes(q)) continue;
|
|
210
|
+
const g = r.group ?? 'General';
|
|
211
|
+
if (!map.has(g)) map.set(g, []);
|
|
212
|
+
map.get(g)!.push(r);
|
|
213
|
+
}
|
|
214
|
+
return [...map.entries()];
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const filteredRecords = $derived.by(() => {
|
|
218
|
+
const q = query.trim().toLowerCase();
|
|
219
|
+
if (!clientSearch || !q) return records;
|
|
220
|
+
const fieldIds =
|
|
221
|
+
activeResource?.fields.filter((f) => !f.hidden).map((f) => f.id) ??
|
|
222
|
+
(records[0] ? Object.keys(records[0]) : []);
|
|
223
|
+
return records.filter((row) =>
|
|
224
|
+
fieldIds.some((id) => String(row[id] ?? '').toLowerCase().includes(q))
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const visibleCount = $derived(
|
|
229
|
+
clientSearch ? filteredRecords.length : (totalRecords ?? records.length)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const totalPages = $derived(
|
|
233
|
+
Math.max(
|
|
234
|
+
1,
|
|
235
|
+
Math.ceil(
|
|
236
|
+
(clientSearch ? filteredRecords.length : (totalRecords ?? records.length)) /
|
|
237
|
+
Math.max(pageSize, 1)
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const showSidebar = $derived(layout !== 'main-only');
|
|
243
|
+
/** Companion inspector while browsing (large screens / stacked on small). */
|
|
244
|
+
const showInspector = $derived(
|
|
245
|
+
layout === 'sidebar-main-detail' && activeMode === 'browse' && modes.includes('detail')
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
function selectResource(id: string) {
|
|
249
|
+
if (activeResourceId === id) return;
|
|
250
|
+
activeResourceId = id;
|
|
251
|
+
selection = EMPTY_SELECTION;
|
|
252
|
+
focusedRecord = null;
|
|
253
|
+
page = 1;
|
|
254
|
+
query = '';
|
|
255
|
+
if (activeMode !== 'browse' && modes.includes('browse')) activeMode = 'browse';
|
|
256
|
+
onresourcechange?.(id);
|
|
257
|
+
const res = resources.find((r) => r.id === id);
|
|
258
|
+
if (res?.defaultFormatRules) {
|
|
259
|
+
formatRules = res.defaultFormatRules.map((r) => ({ ...r }));
|
|
260
|
+
onformatruleschange?.(formatRules);
|
|
261
|
+
} else {
|
|
262
|
+
formatRules = [];
|
|
263
|
+
onformatruleschange?.([]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function setMode(mode: ResourceStudioMode) {
|
|
268
|
+
if (!modes.includes(mode)) return;
|
|
269
|
+
activeMode = mode;
|
|
270
|
+
onmodechange?.(mode);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Keep inspector in sync when exactly one row is selected via checkbox/dock. */
|
|
274
|
+
$effect(() => {
|
|
275
|
+
const ids =
|
|
276
|
+
selection.type === 'rows'
|
|
277
|
+
? selection.ids
|
|
278
|
+
: selection.type === 'cells'
|
|
279
|
+
? [...new Set(selection.cells.map((c) => c.rowId))]
|
|
280
|
+
: [];
|
|
281
|
+
if (ids.length !== 1) return;
|
|
282
|
+
const row = records.find((r) => String(r[primaryKey]) === ids[0]);
|
|
283
|
+
if (row) focusedRecord = { ...row };
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
function openCreate() {
|
|
287
|
+
formMode = 'create';
|
|
288
|
+
formRecord = null;
|
|
289
|
+
draftForm = Object.fromEntries(
|
|
290
|
+
fieldsToSchema(activeResource?.fields ?? [], { mode: 'create' }).map((f) => [
|
|
291
|
+
f.name,
|
|
292
|
+
f.defaultValue ?? (f.type === 'toggle' || f.type === 'checkbox' ? false : '')
|
|
293
|
+
])
|
|
294
|
+
);
|
|
295
|
+
formOpen = true;
|
|
296
|
+
oncreate?.();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function openEdit(record: Record<string, unknown>) {
|
|
300
|
+
formMode = 'edit';
|
|
301
|
+
formRecord = record;
|
|
302
|
+
const schema = fieldsToSchema(activeResource?.fields ?? [], { mode: 'edit' });
|
|
303
|
+
draftForm = Object.fromEntries(
|
|
304
|
+
schema.map((f) => {
|
|
305
|
+
const raw = record[f.name];
|
|
306
|
+
if (f.type === 'toggle' || f.type === 'checkbox') return [f.name, Boolean(raw)];
|
|
307
|
+
if (f.type === 'number') return [f.name, raw == null || raw === '' ? '' : Number(raw)];
|
|
308
|
+
return [f.name, raw == null ? '' : String(raw)];
|
|
309
|
+
})
|
|
310
|
+
);
|
|
311
|
+
formOpen = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function submitForm(values: SchemaFormValues = draftForm) {
|
|
315
|
+
const record: Record<string, unknown> = { ...values };
|
|
316
|
+
for (const f of activeResource?.fields ?? []) {
|
|
317
|
+
if (f.type === 'number' && record[f.id] != null && record[f.id] !== '') {
|
|
318
|
+
record[f.id] = Number(record[f.id]);
|
|
319
|
+
}
|
|
320
|
+
if (f.type === 'boolean') {
|
|
321
|
+
record[f.id] = record[f.id] === true || record[f.id] === 'true';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (formMode === 'create') {
|
|
325
|
+
if (!record[primaryKey]) record[primaryKey] = crypto.randomUUID?.() ?? String(Date.now());
|
|
326
|
+
oninsert?.(record);
|
|
327
|
+
} else if (formRecord) {
|
|
328
|
+
const id = String(formRecord[primaryKey]);
|
|
329
|
+
onupdate?.(id, record);
|
|
330
|
+
}
|
|
331
|
+
formOpen = false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function selectedRowIds(): string[] {
|
|
335
|
+
if (selection.type === 'rows') return selection.ids;
|
|
336
|
+
if (selection.type === 'cells') return [...new Set(selection.cells.map((c) => c.rowId))];
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function askDelete(ids: string[]) {
|
|
341
|
+
pendingDeleteIds = ids;
|
|
342
|
+
deleteOpen = true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function confirmDelete() {
|
|
346
|
+
ondelete?.(pendingDeleteIds);
|
|
347
|
+
deleteOpen = false;
|
|
348
|
+
pendingDeleteIds = [];
|
|
349
|
+
selection = EMPTY_SELECTION;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function handleDockAction(id: string, sel: GridSelection) {
|
|
353
|
+
const ids = selectedRowIds();
|
|
354
|
+
if (id === 'delete' && caps.delete) {
|
|
355
|
+
askDelete(ids);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (id === 'duplicate' && caps.duplicate) {
|
|
359
|
+
onduplicate?.(ids);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (id === 'export' && caps.export) {
|
|
363
|
+
onexport?.(ids.length ? ids : 'all');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (id === 'format' && caps.conditionalFormat) {
|
|
367
|
+
formatPanelOpen = true;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (id === 'edit' && ids[0]) {
|
|
371
|
+
const row = records.find((r) => String(r[primaryKey]) === ids[0]);
|
|
372
|
+
if (row) openEdit(row);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
onaction?.(id, ids);
|
|
376
|
+
void sel;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const dockActions = $derived.by((): BulkAction[] => {
|
|
380
|
+
if (actions) return actions;
|
|
381
|
+
const list: BulkAction[] = [];
|
|
382
|
+
if (caps.notes) list.push({ id: 'note', label: 'Note', variant: 'secondary' });
|
|
383
|
+
if (caps.update) list.push({ id: 'edit', label: 'Edit', variant: 'secondary' });
|
|
384
|
+
if (caps.duplicate) list.push({ id: 'duplicate', label: 'Duplicate', variant: 'secondary' });
|
|
385
|
+
if (caps.export) list.push({ id: 'export', label: 'Export', variant: 'secondary' });
|
|
386
|
+
if (caps.conditionalFormat) list.push({ id: 'format', label: 'Rules', variant: 'secondary' });
|
|
387
|
+
list.push({ id: 'copy', label: 'Copy', variant: 'ghost' });
|
|
388
|
+
if (caps.delete)
|
|
389
|
+
list.push({ id: 'delete', label: 'Delete', variant: 'destructive', confirm: true });
|
|
390
|
+
return list;
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
function onRowClick(row: Record<string, unknown>) {
|
|
394
|
+
focusedRecord = { ...row };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function onRowDblClick(row: Record<string, unknown>) {
|
|
398
|
+
// Focus only — Detail mode is opened from the mode tab, never from row clicks.
|
|
399
|
+
focusedRecord = { ...row };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function modeLabel(m: ResourceStudioMode): string {
|
|
403
|
+
if (m === 'browse') return 'Browse';
|
|
404
|
+
if (m === 'detail') return L.detail;
|
|
405
|
+
if (m === 'schema') return L.schema;
|
|
406
|
+
if (m === 'query') return L.query;
|
|
407
|
+
return L.activity;
|
|
408
|
+
}
|
|
409
|
+
</script>
|
|
410
|
+
|
|
411
|
+
{#snippet schemaBody(resource: ResourceDefinition)}
|
|
412
|
+
{#if schemaPanel}
|
|
413
|
+
{@render schemaPanel(resource)}
|
|
414
|
+
{:else}
|
|
415
|
+
<ul class="space-y-2">
|
|
416
|
+
{#each resource.fields as field (field.id)}
|
|
417
|
+
<li class="rounded-lg border border-border px-2.5 py-2">
|
|
418
|
+
<div class="flex items-center justify-between gap-2">
|
|
419
|
+
<span class="text-xs font-medium">{field.label}</span>
|
|
420
|
+
<Badge size="sm" variant="secondary">{field.type}</Badge>
|
|
421
|
+
</div>
|
|
422
|
+
<p class="mt-0.5 font-mono text-[10px] text-muted">{field.id}</p>
|
|
423
|
+
{#if field.required || field.unique || field.readonly}
|
|
424
|
+
<div class="mt-1.5 flex flex-wrap gap-1">
|
|
425
|
+
{#if field.required}<Badge size="sm" variant="warning">required</Badge>{/if}
|
|
426
|
+
{#if field.unique}<Badge size="sm" variant="info">unique</Badge>{/if}
|
|
427
|
+
{#if field.readonly}<Badge size="sm" variant="default">readonly</Badge>{/if}
|
|
428
|
+
</div>
|
|
429
|
+
{/if}
|
|
430
|
+
{#if field.relation}
|
|
431
|
+
<p class="mt-1 text-[10px] text-secondary">
|
|
432
|
+
→ {field.relation.resourceId}.{field.relation.fieldId}
|
|
433
|
+
</p>
|
|
434
|
+
{/if}
|
|
435
|
+
</li>
|
|
436
|
+
{/each}
|
|
437
|
+
</ul>
|
|
438
|
+
{/if}
|
|
439
|
+
{/snippet}
|
|
440
|
+
|
|
441
|
+
{#snippet detailBody(record: Record<string, unknown> | null)}
|
|
442
|
+
{#if detail}
|
|
443
|
+
{@render detail(record)}
|
|
444
|
+
{:else if record && activeResource}
|
|
445
|
+
<div class="space-y-3">
|
|
446
|
+
<div class="flex items-center justify-between gap-2">
|
|
447
|
+
<p class="text-xs font-semibold">{L.detail}</p>
|
|
448
|
+
{#if caps.update}
|
|
449
|
+
<Button size="xs" variant="secondary" onclick={() => openEdit(record)}>Edit</Button>
|
|
450
|
+
{/if}
|
|
451
|
+
</div>
|
|
452
|
+
<dl class="space-y-2">
|
|
453
|
+
{#each activeResource.fields.filter((f) => !f.hidden) as field (field.id)}
|
|
454
|
+
<div>
|
|
455
|
+
<dt class="text-[10px] font-semibold tracking-wide text-muted uppercase">
|
|
456
|
+
{field.label}
|
|
457
|
+
</dt>
|
|
458
|
+
<dd class="mt-0.5 break-all text-sm">
|
|
459
|
+
{record[field.id] == null ? '—' : String(record[field.id])}
|
|
460
|
+
</dd>
|
|
461
|
+
</div>
|
|
462
|
+
{/each}
|
|
463
|
+
</dl>
|
|
464
|
+
</div>
|
|
465
|
+
{:else}
|
|
466
|
+
<p class="py-8 text-center text-xs text-muted">Select a row to inspect.</p>
|
|
467
|
+
{/if}
|
|
468
|
+
{/snippet}
|
|
469
|
+
|
|
470
|
+
<div
|
|
471
|
+
class={[
|
|
472
|
+
'flex h-full min-h-0 w-full overflow-hidden rounded-xl border border-border bg-surface text-primary',
|
|
473
|
+
className
|
|
474
|
+
]}
|
|
475
|
+
>
|
|
476
|
+
{#if showSidebar}
|
|
477
|
+
<aside class="flex w-56 shrink-0 flex-col border-r border-border bg-surface-elevated">
|
|
478
|
+
<div class="space-y-2 border-b border-border p-3">
|
|
479
|
+
<p class="text-[11px] font-semibold tracking-wide text-secondary uppercase">{L.resources}</p>
|
|
480
|
+
<SearchInput
|
|
481
|
+
size="sm"
|
|
482
|
+
bind:value={sidebarQuery}
|
|
483
|
+
placeholder="Filter…"
|
|
484
|
+
class="w-full"
|
|
485
|
+
/>
|
|
486
|
+
</div>
|
|
487
|
+
<nav class="flex-1 overflow-y-auto p-2" aria-label={L.resources}>
|
|
488
|
+
{#each groups as [group, items] (group)}
|
|
489
|
+
<div class="mb-3">
|
|
490
|
+
<p class="px-2 py-1 text-[10px] font-semibold tracking-wide text-muted uppercase">
|
|
491
|
+
{group}
|
|
492
|
+
</p>
|
|
493
|
+
<ul class="space-y-0.5">
|
|
494
|
+
{#each items as resource (resource.id)}
|
|
495
|
+
{@const active = resource.id === activeResource?.id}
|
|
496
|
+
<li>
|
|
497
|
+
{#if sidebarItem}
|
|
498
|
+
<button
|
|
499
|
+
type="button"
|
|
500
|
+
class="w-full text-left"
|
|
501
|
+
onclick={() => selectResource(resource.id)}
|
|
502
|
+
>
|
|
503
|
+
{@render sidebarItem(resource, active)}
|
|
504
|
+
</button>
|
|
505
|
+
{:else}
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
class={[
|
|
509
|
+
'flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors',
|
|
510
|
+
active
|
|
511
|
+
? 'bg-brand-500/10 font-medium text-brand-700 dark:text-brand-300'
|
|
512
|
+
: 'text-secondary hover:bg-surface-overlay hover:text-primary'
|
|
513
|
+
]}
|
|
514
|
+
onclick={() => selectResource(resource.id)}
|
|
515
|
+
>
|
|
516
|
+
<span class="truncate">{resource.name}</span>
|
|
517
|
+
{#if resource.recordCount != null}
|
|
518
|
+
<span class="text-[10px] tabular-nums text-muted"
|
|
519
|
+
>{resource.recordCount}</span
|
|
520
|
+
>
|
|
521
|
+
{/if}
|
|
522
|
+
</button>
|
|
523
|
+
{/if}
|
|
524
|
+
</li>
|
|
525
|
+
{/each}
|
|
526
|
+
</ul>
|
|
527
|
+
</div>
|
|
528
|
+
{:else}
|
|
529
|
+
<p class="px-2 py-6 text-center text-xs text-muted">No resources</p>
|
|
530
|
+
{/each}
|
|
531
|
+
</nav>
|
|
532
|
+
{#if sidebarFooter}
|
|
533
|
+
<div class="border-t border-border p-2">
|
|
534
|
+
{@render sidebarFooter()}
|
|
535
|
+
</div>
|
|
536
|
+
{/if}
|
|
537
|
+
</aside>
|
|
538
|
+
{/if}
|
|
539
|
+
|
|
540
|
+
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
541
|
+
<header class="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
|
542
|
+
<div class="min-w-0 flex-1">
|
|
543
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
544
|
+
<h2 class="truncate text-sm font-semibold">
|
|
545
|
+
{activeResource?.name ?? 'Select a resource'}
|
|
546
|
+
</h2>
|
|
547
|
+
{#if activeResource?.description}
|
|
548
|
+
<p class="truncate text-xs text-muted">{activeResource.description}</p>
|
|
549
|
+
{/if}
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<div class="flex flex-wrap items-center gap-1 rounded-lg bg-surface-overlay/60 p-0.5">
|
|
554
|
+
{#each modes as mode (mode)}
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
class={[
|
|
558
|
+
'rounded-md px-2 py-1 text-[11px] font-medium transition-colors',
|
|
559
|
+
activeMode === mode
|
|
560
|
+
? 'bg-surface-elevated text-primary shadow-sm'
|
|
561
|
+
: 'text-secondary hover:text-primary'
|
|
562
|
+
]}
|
|
563
|
+
onclick={() => setMode(mode)}
|
|
564
|
+
>
|
|
565
|
+
{modeLabel(mode)}
|
|
566
|
+
</button>
|
|
567
|
+
{/each}
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
{#if caps.create}
|
|
571
|
+
<Button size="sm" variant="primary" onclick={openCreate}>{L.addRecord}</Button>
|
|
572
|
+
{/if}
|
|
573
|
+
{#if caps.conditionalFormat}
|
|
574
|
+
<Button
|
|
575
|
+
size="sm"
|
|
576
|
+
variant={formatPanelOpen ? 'secondary' : 'ghost'}
|
|
577
|
+
onclick={() => (formatPanelOpen = !formatPanelOpen)}
|
|
578
|
+
>
|
|
579
|
+
Rules{formatRules.length ? ` (${formatRules.length})` : ''}
|
|
580
|
+
</Button>
|
|
581
|
+
{/if}
|
|
582
|
+
{#if caps.import}
|
|
583
|
+
<Button size="sm" variant="secondary" onclick={() => (importOpen = true)}>Import</Button>
|
|
584
|
+
{/if}
|
|
585
|
+
{#if caps.export}
|
|
586
|
+
<Button size="sm" variant="ghost" onclick={() => onexport?.('all')}>Export</Button>
|
|
587
|
+
{/if}
|
|
588
|
+
{#if onrefresh}
|
|
589
|
+
<Button size="sm" variant="ghost" onclick={() => onrefresh?.()}>Refresh</Button>
|
|
590
|
+
{/if}
|
|
591
|
+
</header>
|
|
592
|
+
|
|
593
|
+
{#if views.length}
|
|
594
|
+
<div class="flex shrink-0 flex-wrap gap-1.5 border-b border-border px-3 py-2">
|
|
595
|
+
{#each views as view (view.id)}
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
class={[
|
|
599
|
+
'rounded-full px-2.5 py-0.5 text-[11px] font-medium ring-1 transition-colors',
|
|
600
|
+
activeViewId === view.id
|
|
601
|
+
? 'bg-brand-500/10 text-brand-700 ring-brand-500/30 dark:text-brand-300'
|
|
602
|
+
: 'text-secondary ring-border hover:bg-surface-overlay'
|
|
603
|
+
]}
|
|
604
|
+
onclick={() => {
|
|
605
|
+
activeViewId = view.id;
|
|
606
|
+
onviewchange?.(view.id);
|
|
607
|
+
}}
|
|
608
|
+
>
|
|
609
|
+
{view.label}
|
|
610
|
+
</button>
|
|
611
|
+
{/each}
|
|
612
|
+
</div>
|
|
613
|
+
{/if}
|
|
614
|
+
|
|
615
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
|
616
|
+
{#if toolbar}
|
|
617
|
+
{@render toolbar()}
|
|
618
|
+
{:else}
|
|
619
|
+
<SearchInput
|
|
620
|
+
size="sm"
|
|
621
|
+
bind:value={query}
|
|
622
|
+
placeholder={L.searchPlaceholder}
|
|
623
|
+
class="min-w-[12rem] flex-1"
|
|
624
|
+
onsubmit={(q) => onsearch?.(q)}
|
|
625
|
+
oninput={() => {
|
|
626
|
+
page = 1;
|
|
627
|
+
onsearch?.(query);
|
|
628
|
+
}}
|
|
629
|
+
/>
|
|
630
|
+
<span class="text-[11px] text-muted tabular-nums">
|
|
631
|
+
{visibleCount}
|
|
632
|
+
{L.records}
|
|
633
|
+
</span>
|
|
634
|
+
{/if}
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
638
|
+
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden p-3">
|
|
639
|
+
{#if activeMode === 'query' && queryPanel}
|
|
640
|
+
<div
|
|
641
|
+
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface-elevated p-3"
|
|
642
|
+
>
|
|
643
|
+
{@render queryPanel()}
|
|
644
|
+
</div>
|
|
645
|
+
{:else if activeMode === 'activity' && activityPanel}
|
|
646
|
+
<div class="min-h-0 flex-1 overflow-auto">
|
|
647
|
+
{@render activityPanel()}
|
|
648
|
+
</div>
|
|
649
|
+
{:else if activeMode === 'schema' && activeResource}
|
|
650
|
+
<div class="min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface-elevated p-3">
|
|
651
|
+
<p class="mb-3 text-xs font-semibold text-secondary">{L.schema}</p>
|
|
652
|
+
{@render schemaBody(activeResource)}
|
|
653
|
+
</div>
|
|
654
|
+
{:else if activeMode === 'detail'}
|
|
655
|
+
<div class="min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface-elevated p-3">
|
|
656
|
+
{#key focusedRecord ? String(focusedRecord[primaryKey] ?? '') : 'none'}
|
|
657
|
+
{@render detailBody(focusedRecord)}
|
|
658
|
+
{/key}
|
|
659
|
+
</div>
|
|
660
|
+
{:else if !activeResource}
|
|
661
|
+
<EmptyState title="No resource selected" description="Pick one from the sidebar." />
|
|
662
|
+
{:else if loading}
|
|
663
|
+
<div class="space-y-2">
|
|
664
|
+
{#each Array.from({ length: 6 }) as _, i (i)}
|
|
665
|
+
<Skeleton class="h-9 w-full" />
|
|
666
|
+
{/each}
|
|
667
|
+
</div>
|
|
668
|
+
{:else if filteredRecords.length === 0}
|
|
669
|
+
{#if empty}
|
|
670
|
+
{@render empty()}
|
|
671
|
+
{:else}
|
|
672
|
+
<EmptyState
|
|
673
|
+
title={`No ${L.records}`}
|
|
674
|
+
description={query.trim()
|
|
675
|
+
? 'No matches for your search.'
|
|
676
|
+
: 'Create a record or adjust your search.'}
|
|
677
|
+
>
|
|
678
|
+
{#snippet action()}
|
|
679
|
+
{#if caps.create}
|
|
680
|
+
<Button size="sm" variant="primary" onclick={openCreate}>{L.addRecord}</Button>
|
|
681
|
+
{/if}
|
|
682
|
+
{/snippet}
|
|
683
|
+
</EmptyState>
|
|
684
|
+
{/if}
|
|
685
|
+
{:else}
|
|
686
|
+
<div class="flex min-h-0 flex-1 flex-col gap-3">
|
|
687
|
+
<DataGrid
|
|
688
|
+
class="min-h-0 flex-1"
|
|
689
|
+
{columns}
|
|
690
|
+
rows={filteredRecords}
|
|
691
|
+
rowKey={primaryKey}
|
|
692
|
+
bind:selection
|
|
693
|
+
bind:notes
|
|
694
|
+
bind:formatRules
|
|
695
|
+
editable={caps.editable && caps.update}
|
|
696
|
+
selectable={
|
|
697
|
+
caps.delete ||
|
|
698
|
+
caps.export ||
|
|
699
|
+
caps.notes ||
|
|
700
|
+
caps.duplicate ||
|
|
701
|
+
caps.update ||
|
|
702
|
+
caps.editable
|
|
703
|
+
}
|
|
704
|
+
selectCells={false}
|
|
705
|
+
selectColumns={false}
|
|
706
|
+
selectOnClick={false}
|
|
707
|
+
marqueeSelect
|
|
708
|
+
contextMenu
|
|
709
|
+
actions={dockActions}
|
|
710
|
+
compact
|
|
711
|
+
{cell}
|
|
712
|
+
{dockExtra}
|
|
713
|
+
onselectionchange={onselectionchange}
|
|
714
|
+
onnoteschange={onnoteschange}
|
|
715
|
+
onformatruleschange={onformatruleschange}
|
|
716
|
+
oncelledit={(rowId, columnId, value) => oncelledit?.(rowId, columnId, value)}
|
|
717
|
+
onaction={handleDockAction}
|
|
718
|
+
onsort={(id, dir) => onsort?.(id, dir)}
|
|
719
|
+
onrowclick={onRowClick}
|
|
720
|
+
onrowdblclick={onRowDblClick}
|
|
721
|
+
/>
|
|
722
|
+
|
|
723
|
+
{#if visibleCount > pageSize || totalPages > 1}
|
|
724
|
+
<div class="flex shrink-0 justify-end">
|
|
725
|
+
<Pagination
|
|
726
|
+
bind:page
|
|
727
|
+
{totalPages}
|
|
728
|
+
onchange={(p) => onpagechange?.(p, pageSize)}
|
|
729
|
+
/>
|
|
730
|
+
</div>
|
|
731
|
+
{/if}
|
|
732
|
+
</div>
|
|
733
|
+
{/if}
|
|
734
|
+
|
|
735
|
+
{#if formatPanelOpen && caps.conditionalFormat && activeMode === 'browse'}
|
|
736
|
+
<div class="shrink-0 space-y-2">
|
|
737
|
+
<ConditionalFormatPanel
|
|
738
|
+
bind:rules={formatRules}
|
|
739
|
+
columns={columns.map((c) => ({ id: c.id, label: c.header }))}
|
|
740
|
+
onchange={(r) => onformatruleschange?.(r)}
|
|
741
|
+
/>
|
|
742
|
+
<div class="flex justify-end">
|
|
743
|
+
<Button size="sm" variant="ghost" onclick={() => (formatPanelOpen = false)}
|
|
744
|
+
>Hide rules</Button
|
|
745
|
+
>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
{/if}
|
|
749
|
+
</div>
|
|
750
|
+
|
|
751
|
+
{#if showInspector && activeResource}
|
|
752
|
+
<aside
|
|
753
|
+
class="flex max-h-72 min-h-0 w-full shrink-0 flex-col border-t border-border bg-surface-elevated lg:max-h-none lg:w-72 lg:border-t-0 lg:border-l"
|
|
754
|
+
>
|
|
755
|
+
<div class="flex items-center gap-1 border-b border-border p-2">
|
|
756
|
+
{#each (['detail', 'schema'] as ResourceStudioMode[]).filter((m) => modes.includes(m)) as m (m)}
|
|
757
|
+
<button
|
|
758
|
+
type="button"
|
|
759
|
+
class={[
|
|
760
|
+
'flex-1 rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors',
|
|
761
|
+
(m === 'detail' && activeMode === 'browse') || activeMode === m
|
|
762
|
+
? 'bg-surface-overlay text-primary'
|
|
763
|
+
: 'text-secondary hover:bg-surface-overlay/70 hover:text-primary'
|
|
764
|
+
]}
|
|
765
|
+
onclick={() => setMode(m === 'detail' ? 'browse' : m)}
|
|
766
|
+
>
|
|
767
|
+
{modeLabel(m)}
|
|
768
|
+
</button>
|
|
769
|
+
{/each}
|
|
770
|
+
{#if focusedRecord && caps.update}
|
|
771
|
+
<Button size="xs" variant="secondary" onclick={() => openEdit(focusedRecord!)}
|
|
772
|
+
>Edit</Button
|
|
773
|
+
>
|
|
774
|
+
{/if}
|
|
775
|
+
</div>
|
|
776
|
+
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
|
777
|
+
{#if activeMode === 'browse'}
|
|
778
|
+
{#key focusedRecord ? String(focusedRecord[primaryKey] ?? '') : 'none'}
|
|
779
|
+
{@render detailBody(focusedRecord)}
|
|
780
|
+
{/key}
|
|
781
|
+
{/if}
|
|
782
|
+
</div>
|
|
783
|
+
</aside>
|
|
784
|
+
{/if}
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<Drawer
|
|
790
|
+
bind:open={formOpen}
|
|
791
|
+
title={formMode === 'create' ? L.addRecord : 'Edit record'}
|
|
792
|
+
side="right"
|
|
793
|
+
size="md"
|
|
794
|
+
>
|
|
795
|
+
{#if recordForm}
|
|
796
|
+
{@render recordForm(formMode, formRecord)}
|
|
797
|
+
{:else if activeResource}
|
|
798
|
+
<SchemaForm
|
|
799
|
+
schema={formSchema}
|
|
800
|
+
bind:values={draftForm}
|
|
801
|
+
showCancel
|
|
802
|
+
submitLabel="Save"
|
|
803
|
+
cancelLabel="Cancel"
|
|
804
|
+
onsubmit={submitForm}
|
|
805
|
+
oncancel={() => (formOpen = false)}
|
|
806
|
+
/>
|
|
807
|
+
{/if}
|
|
808
|
+
</Drawer>
|
|
809
|
+
|
|
810
|
+
<Drawer bind:open={importOpen} title="Import" side="right" size="sm">
|
|
811
|
+
<DropZone
|
|
812
|
+
label="Drop a CSV or JSON file"
|
|
813
|
+
hint="Your app handles parsing via onimport"
|
|
814
|
+
multiple={false}
|
|
815
|
+
ondropfiles={(files) => {
|
|
816
|
+
const file = files?.[0];
|
|
817
|
+
if (file) {
|
|
818
|
+
onimport?.(file);
|
|
819
|
+
importOpen = false;
|
|
820
|
+
}
|
|
821
|
+
}}
|
|
822
|
+
/>
|
|
823
|
+
</Drawer>
|
|
824
|
+
|
|
825
|
+
<ConfirmDelete
|
|
826
|
+
bind:open={deleteOpen}
|
|
827
|
+
entity={L.records}
|
|
828
|
+
itemName={`${pendingDeleteIds.length} selected`}
|
|
829
|
+
onconfirm={confirmDelete}
|
|
830
|
+
/>
|