@lavalogic/scoria 0.39.2 → 0.40.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 (141) hide show
  1. package/README.md +58 -58
  2. package/dist/Components/AccordionGroup.svelte +46 -46
  3. package/dist/Components/AccordionGroupButton.svelte +76 -76
  4. package/dist/Components/Action.svelte +136 -136
  5. package/dist/Components/AlertModal.svelte +195 -195
  6. package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +729 -729
  7. package/dist/Components/Base/BaseModal.svelte +125 -125
  8. package/dist/Components/Base/ContextWrapper.svelte +72 -72
  9. package/dist/Components/Base/Pagination.svelte +198 -198
  10. package/dist/Components/Base/Snippets.svelte +36 -36
  11. package/dist/Components/BigRadioSet.svelte +38 -38
  12. package/dist/Components/Bubble.svelte +101 -101
  13. package/dist/Components/Button.svelte +209 -209
  14. package/dist/Components/Checkbox.svelte +86 -86
  15. package/dist/Components/CollapsibleCard.svelte +78 -78
  16. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte +212 -0
  17. package/dist/Components/ConditionBuilder/ConditionBuilder.svelte.d.ts +25 -0
  18. package/dist/Components/ConditionBuilder/ConditionBuilderProps.d.ts +45 -0
  19. package/dist/Components/ConditionBuilder/ConditionBuilderProps.js +1 -0
  20. package/dist/Components/ConditionBuilder/ConditionTree.d.ts +27 -0
  21. package/dist/Components/ConditionBuilder/ConditionTree.js +9 -0
  22. package/dist/Components/Contexts/ContextMenu.svelte +164 -164
  23. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  24. package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
  25. package/dist/Components/Contexts/Toast.svelte +86 -86
  26. package/dist/Components/Contexts/ToastContainer.svelte +65 -65
  27. package/dist/Components/Contexts/Tooltip.svelte +37 -37
  28. package/dist/Components/DataMatrixIcon.svelte +112 -112
  29. package/dist/Components/DateInput.svelte +148 -148
  30. package/dist/Components/DatePicker.svelte +300 -300
  31. package/dist/Components/DesktopModal.svelte +90 -90
  32. package/dist/Components/Dial.svelte +204 -204
  33. package/dist/Components/DimensionInput.svelte +170 -170
  34. package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
  35. package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
  36. package/dist/Components/FileCard.svelte +24 -24
  37. package/dist/Components/FileUpload.svelte +236 -236
  38. package/dist/Components/GridInput.svelte +196 -196
  39. package/dist/Components/GridInputSet.svelte +29 -29
  40. package/dist/Components/HorizontalTabGroup.svelte +82 -82
  41. package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
  42. package/dist/Components/Icon.svelte +128 -128
  43. package/dist/Components/Icons.js +357 -357
  44. package/dist/Components/InfoAlert.svelte +35 -35
  45. package/dist/Components/LoadingAnimation.svelte +29 -29
  46. package/dist/Components/LoadingModal.svelte +45 -45
  47. package/dist/Components/LoadingOverlay.svelte +25 -25
  48. package/dist/Components/Modal.svelte +258 -258
  49. package/dist/Components/MultiSelect.svelte +220 -220
  50. package/dist/Components/Nav/Nav.svelte +110 -110
  51. package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
  52. package/dist/Components/Nav/NavTab.svelte +62 -62
  53. package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
  54. package/dist/Components/Nav/ServicesNav.svelte +80 -80
  55. package/dist/Components/NumberInput.svelte +157 -157
  56. package/dist/Components/PageHeading.svelte +25 -25
  57. package/dist/Components/PasswordInput.svelte +88 -88
  58. package/dist/Components/Portal.svelte +36 -36
  59. package/dist/Components/ProgressBubble.svelte +107 -107
  60. package/dist/Components/QuerySelect.svelte +366 -366
  61. package/dist/Components/SingleSelect.svelte +250 -250
  62. package/dist/Components/StepButton.svelte +70 -70
  63. package/dist/Components/StepForm.svelte +140 -140
  64. package/dist/Components/Switch.svelte +69 -69
  65. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  66. package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
  67. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
  68. package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
  69. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
  70. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
  71. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
  72. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
  73. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +120 -120
  74. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
  75. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
  76. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
  77. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
  78. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
  79. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
  80. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
  81. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
  82. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
  83. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
  84. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
  85. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
  86. package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
  87. package/dist/Components/Table/Body/TableBody.svelte +155 -155
  88. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
  89. package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
  90. package/dist/Components/Table/Headers/TableHead.svelte +415 -415
  91. package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
  92. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
  93. package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
  94. package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
  95. package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
  96. package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
  97. package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
  98. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
  99. package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
  100. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
  101. package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
  102. package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
  103. package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
  104. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
  105. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
  106. package/dist/Components/Table/NestedTable.svelte +130 -130
  107. package/dist/Components/Table/Table.svelte +692 -692
  108. package/dist/Components/Test/TestContextConsumer.svelte +13 -13
  109. package/dist/Components/TextArea.svelte +130 -130
  110. package/dist/Components/TextInput.svelte +176 -176
  111. package/dist/Components/ThreeStateRadio.svelte +131 -131
  112. package/dist/Components/Touch/BoolCard.svelte +45 -45
  113. package/dist/Components/Touch/DateModal.svelte +209 -209
  114. package/dist/Components/Touch/EditCard.svelte +59 -59
  115. package/dist/Components/Touch/InfoCard.svelte +31 -31
  116. package/dist/Components/Touch/InfoTextCard.svelte +28 -28
  117. package/dist/Components/Touch/ModalExplanation.svelte +17 -17
  118. package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
  119. package/dist/Components/Touch/NumberModal.svelte +79 -79
  120. package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
  121. package/dist/Components/Touch/SelectModal.svelte +127 -127
  122. package/dist/Components/Touch/SummaryCard.svelte +93 -93
  123. package/dist/Components/Touch/TextAreaModal.svelte +95 -95
  124. package/dist/Components/Touch/TextModal.svelte +106 -106
  125. package/dist/Components/Touch/TouchCard.svelte +33 -33
  126. package/dist/Components/Touch/TouchModal.svelte +274 -274
  127. package/dist/Components/Touch/UtilityButton.svelte +109 -109
  128. package/dist/Components/VerticalTabGroup.svelte +79 -79
  129. package/dist/Components/VerticalTabGroupButton.svelte +58 -58
  130. package/dist/Helpers/Datamatrix.d.ts +19 -19
  131. package/dist/Helpers/Datamatrix.js +291 -291
  132. package/dist/Types/Examples/ExampleModal.svelte +35 -35
  133. package/dist/index.d.ts +3 -0
  134. package/dist/index.js +2 -0
  135. package/dist/scss/_basics.scss +12 -12
  136. package/dist/scss/_colours.scss +134 -134
  137. package/dist/scss/_mixins.scss +3392 -3392
  138. package/dist/scss/_motion.scss +57 -57
  139. package/dist/scss/_sizing.scss +85 -85
  140. package/dist/scss/_transitions.scss +8 -8
  141. package/package.json +1 -1
@@ -1,646 +1,646 @@
1
- <svelte:options runes />
2
-
3
- <script
4
- lang="ts"
5
- generics="T extends object, IdType extends Primitive"
6
- >
7
- import Button from '../../Button.svelte';
8
- import DesktopModal from '../../DesktopModal.svelte';
9
- import HorizontalTabGroup from '../../HorizontalTabGroup.svelte';
10
- import Icon from '../../Icon.svelte';
11
- import InfoAlert from '../../InfoAlert.svelte';
12
- import TextInput from '../../TextInput.svelte';
13
- import VerticalTabGroup from '../../VerticalTabGroup.svelte';
14
- import { cssLength } from '../../../Helpers/Helpers.svelte.js';
15
- import { Size } from '../../../Types/Internal/Size.js';
16
- import type { TabOption } from '../../../Types/Internal/TabOption.js';
17
- import { Variant } from '../../../Types/Internal/Variant.js';
18
- import { getContext, onMount } from 'svelte';
19
- import { TableContext } from '../Types/Context/TableContext.svelte.js';
20
- import type { Primitive } from '../Types/Context/TableInitOptions.js';
21
- import type { DatatableView } from '../Types/Persistence/DatatableView.js';
22
- import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
23
- import ShareViewModal from './ShareViewModal.svelte';
24
- import type { TableConfigurationModalProps } from './TableConfigurationModalProps.js';
25
- import UpdateViewOptionsModal from './UpdateViewOptionsModal.svelte';
26
-
27
- const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
28
-
29
- const { onclose }: TableConfigurationModalProps = $props();
30
-
31
- // ── Left-nav section + right-pane tab selection ──────────────────────
32
- // `section` picks the kind (layout vs filter); `tab` picks which of the
33
- // three horizontal panes is visible for that section. Both are plain
34
- // `$state` since they are pure local UI selection. They drive the
35
- // `selected` prop of the two scoria tab groups below.
36
- let section = $state<DatatableViewKind>('layout');
37
- type ConfigTab = 'mine' | 'shared' | 'save';
38
- let tab = $state<ConfigTab>('mine');
39
-
40
- // Name field for the "Save a new ..." tab.
41
- let newViewName = $state('');
42
-
43
- // Public-views discovery state for the "Subscribe to Additional" flow.
44
- // `null` = not yet loaded; an array = the last successful fetch.
45
- let publicViews = $state<ReadonlyArray<DatatableView> | null>(null);
46
- let loadingPublicViews = $state(false);
47
-
48
- // User-visible error banner. Every adapter call is wrapped so a
49
- // rejection lands here instead of throwing into the UI. scoria cannot
50
- // assume a host-provided toast context exists, so the modal surfaces
51
- // errors inline in its own body.
52
- let errorMessage = $state<string | null>(null);
53
-
54
- // True while the open-time fetch of saved views is in flight, so the
55
- // My-tab shows "Loading…" rather than the "nothing saved yet" message.
56
- let loadingViews = $state(false);
57
-
58
- // The view whose Share modal is currently open. `null` when no share
59
- // modal is rendered. Owner cards (rendered on the My-tab) wire their
60
- // Share button to open `ShareViewModal` against the view they belong
61
- // to; the modal closes itself on Cancel / Save.
62
- let sharingView = $state<DatatableView | null>(null);
63
-
64
- // The view whose Update Options modal is currently open. `null` when
65
- // no options modal is rendered. Same pattern as `sharingView`.
66
- let updatingOptionsView = $state<DatatableView | null>(null);
67
-
68
- /**
69
- * Overwrite a saved view's stored body with the table's current
70
- * live state. Triggered by the "Save Layout" / "Save Filter"
71
- * button on an owner card - the typical flow is "apply view A,
72
- * tweak the columns / filters, then click Save on A". Refreshes
73
- * the saved-views list afterwards so the card label and dropdown
74
- * pick up the new body, and surfaces any failure in the inline
75
- * error banner.
76
- */
77
- function saveLiveStateInto(view: DatatableView): void {
78
- void guard(async () => {
79
- await tableContext.saveLiveStateIntoView(view);
80
- await tableContext.refreshSavedViews();
81
- }, `Could not save changes to "${view.name}". Please try again.`);
82
- }
83
-
84
- // Whether remote saved views are available at all. A table created
85
- // without `datatableUuid` / `remoteLayouts` opts out of remote views;
86
- // the modal then renders a graceful "unavailable" empty state and
87
- // makes zero adapter calls.
88
- const remoteAvailable = $derived(
89
- tableContext.datatableUuid !== undefined && tableContext.remoteLayouts !== undefined
90
- );
91
-
92
- // The opaque current-user id, used to scope "mine" vs "shared" and to
93
- // drive subscribe / unsubscribe. `undefined` when the host supplied
94
- // `() => undefined` - sharing controls then degrade gracefully.
95
- const currentUserId = $derived(tableContext.getUserId());
96
-
97
- // All saved views of the active section, split by ownership. A view the
98
- // current user owns is "mine"; anything else they can see is "shared"
99
- // (a subscription). When no user id is available every view falls into
100
- // "mine" so the user can still manage what they have.
101
- const sectionViews = $derived(tableContext._viewState.savedViews(section));
102
- const myViews = $derived(
103
- sectionViews.filter((view) => currentUserId === undefined || view.ownerUserId === currentUserId)
104
- );
105
- const sharedViews = $derived(
106
- sectionViews.filter((view) => currentUserId !== undefined && view.ownerUserId !== currentUserId)
107
- );
108
-
109
- // Human labels for the active section, so the three tabs and headings
110
- // read "My Layouts" / "My Filters" etc. without branching everywhere.
111
- const sectionNoun = $derived(section === 'filter' ? 'Filter' : 'Layout');
112
- const sectionNounPlural = $derived(section === 'filter' ? 'Filters' : 'Layouts');
113
-
114
- /**
115
- * Run an adapter-backed action, clearing any prior error first and
116
- * routing a rejection to the inline error banner. Keeps every async
117
- * branch below a one-liner and guarantees no rejection escapes to the
118
- * UI (the modal never throws).
119
- */
120
- async function guard(action: () => Promise<void>, failureMessage: string): Promise<void> {
121
- errorMessage = null;
122
- try {
123
- await action();
124
- } catch (e) {
125
- // Surface the underlying error rather than swallowing it: the
126
- // generic message alone has made failures hard to diagnose. The
127
- // full error (with stack) goes to the console; its message is
128
- // appended to the banner so it is visible without devtools.
129
- console.error('[TableConfigurationModal] action failed:', e);
130
- errorMessage = e instanceof Error ? `${failureMessage} (${e.message})` : failureMessage;
131
- }
132
- }
133
-
134
- // Fetch the saved views fresh every time the modal opens. The boot-time
135
- // fetch can miss (it races table construction / auth readiness), so the
136
- // modal cannot rely on `_viewState.savedViews` already being populated -
137
- // it pulls `GET /datatable-state` itself on open. A failure surfaces in
138
- // the inline error banner via `guard` rather than being swallowed.
139
- onMount(() => {
140
- if (!remoteAvailable) {
141
- return;
142
- }
143
- loadingViews = true;
144
- void guard(
145
- () => tableContext.refreshSavedViews(),
146
- 'Could not load saved layouts and filters. Please try again.'
147
- ).finally(() => {
148
- loadingViews = false;
149
- });
150
- });
151
-
152
- /** Apply a saved view to the live table. Pure local call - no await. */
153
- function applyView(view: DatatableView): void {
154
- errorMessage = null;
155
- tableContext.applyDatatableView(view);
156
- }
157
-
158
- /** Delete a view the current user owns, then refresh the lists. */
159
- function deleteView(view: DatatableView): void {
160
- const adapter = tableContext.remoteLayouts;
161
- if (!adapter) {
162
- return;
163
- }
164
- void guard(async () => {
165
- await adapter.deleteView(view.id);
166
- await tableContext.refreshSavedViews();
167
- }, `Could not delete "${view.name}". Please try again.`);
168
- }
169
-
170
- /**
171
- * Save the live state of the active section as a brand-new view.
172
- * `saveCurrentAsNewView` re-fetches the saved views and makes the new
173
- * one active; the `My …` list and the panel dropdowns are all driven
174
- * off the reactive `_viewState.savedViews(section)` so they update
175
- * without a reopen. On success we also switch to the `My …` tab so
176
- * the user immediately sees the view they just saved.
177
- */
178
- function saveNewView(): void {
179
- const trimmed = newViewName.trim();
180
- if (!trimmed) {
181
- return;
182
- }
183
- void guard(async () => {
184
- await tableContext.saveCurrentAsNewView(trimmed, section);
185
- newViewName = '';
186
- selectTab('mine');
187
- }, `Could not save the new ${sectionNoun.toLowerCase()}. Please try again.`);
188
- }
189
-
190
- /** Remove the current user as a subscriber of a shared view. */
191
- function unsubscribe(view: DatatableView): void {
192
- const adapter = tableContext.remoteLayouts;
193
- if (!adapter || currentUserId === undefined) {
194
- return;
195
- }
196
- void guard(async () => {
197
- await adapter.removeSubscriber(view.id, currentUserId);
198
- await tableContext.refreshSavedViews();
199
- }, `Could not unsubscribe from "${view.name}". Please try again.`);
200
- }
201
-
202
- /** Remove a named subscriber from a view the current user owns. */
203
- function removeSubscriber(view: DatatableView, userId: string): void {
204
- const adapter = tableContext.remoteLayouts;
205
- if (!adapter) {
206
- return;
207
- }
208
- void guard(async () => {
209
- await adapter.removeSubscriber(view.id, userId);
210
- await tableContext.refreshSavedViews();
211
- }, 'Could not remove the subscriber. Please try again.');
212
- }
213
-
214
- /** Fetch the public views the user could subscribe to. */
215
- function loadPublicViews(): void {
216
- const adapter = tableContext.remoteLayouts;
217
- const datatableUuid = tableContext.datatableUuid;
218
- if (!adapter || datatableUuid === undefined) {
219
- return;
220
- }
221
- loadingPublicViews = true;
222
- void guard(async () => {
223
- const all = await adapter.listPublicViews(datatableUuid);
224
- publicViews = all.filter((view) => view.kind === section);
225
- }, 'Could not load views available to subscribe to. Please try again.').finally(() => {
226
- loadingPublicViews = false;
227
- });
228
- }
229
-
230
- /** Subscribe the current user to a public view, then refresh. */
231
- function subscribe(view: DatatableView): void {
232
- const adapter = tableContext.remoteLayouts;
233
- if (!adapter || currentUserId === undefined) {
234
- return;
235
- }
236
- void guard(async () => {
237
- await adapter.addSubscriber(view.id, currentUserId);
238
- await tableContext.refreshSavedViews();
239
- loadPublicViews();
240
- }, `Could not subscribe to "${view.name}". Please try again.`);
241
- }
242
-
243
- /** Switch the active left-nav section, resetting the tab + scratch UI. */
244
- function selectSection(next: DatatableViewKind): void {
245
- section = next;
246
- tab = 'mine';
247
- newViewName = '';
248
- publicViews = null;
249
- errorMessage = null;
250
- }
251
-
252
- /** Switch the active right-pane tab, resetting tab-local scratch UI. */
253
- function selectTab(next: ConfigTab): void {
254
- tab = next;
255
- errorMessage = null;
256
- if (next !== 'save') {
257
- newViewName = '';
258
- }
259
- if (next !== 'shared') {
260
- publicViews = null;
261
- }
262
- }
263
-
264
- // ── scoria tab-group wiring ──────────────────────────────────────────
265
- // The left-hand vertical nav is a `HorizontalTabGroup` (tabs strip on
266
- // the left, content on the right); the top horizontal tabs are a
267
- // `VerticalTabGroup` (tabs strip on top, content below). Same two
268
- // scoria primitives the flowms order-allocation modal uses, so the
269
- // look is consistent across the app.
270
-
271
- /** The three top tabs for the active section. Rebuilt when the section
272
- * flips so the labels track "My Layouts" / "My Filters" etc. */
273
- const innerTabs: Array<TabOption<{ tab: ConfigTab }>> = $derived([
274
- {
275
- label: `My ${sectionNounPlural}`,
276
- content: tabBody,
277
- args: { tab: 'mine' },
278
- onclick: () => {
279
- selectTab('mine');
280
- },
281
- },
282
- {
283
- // DESIGN-REVIEW: the mockups were internally inconsistent
284
- // ("Subscribed to Filters" on one, "Shared Filters" on
285
- // another). Resolved to "Shared" consistently for both the
286
- // layout and filter sections.
287
- label: `Shared ${sectionNounPlural}`,
288
- content: tabBody,
289
- args: { tab: 'shared' },
290
- onclick: () => {
291
- selectTab('shared');
292
- },
293
- },
294
- {
295
- label: `Save a new ${sectionNoun}`,
296
- content: tabBody,
297
- args: { tab: 'save' },
298
- onclick: () => {
299
- selectTab('save');
300
- },
301
- },
302
- ]);
303
-
304
- const selectedInnerTab = $derived(innerTabs.find((t) => t.args.tab === tab) ?? innerTabs[0]);
305
-
306
- /** The two left-nav sections. Each renders the inner `VerticalTabGroup`
307
- * via the shared `sectionPane` snippet. */
308
- const sectionTabs: Array<TabOption<{ kind: DatatableViewKind }>> = [
309
- {
310
- label: 'Table Layouts',
311
- content: sectionPane,
312
- args: { kind: 'layout' },
313
- onclick: () => {
314
- selectSection('layout');
315
- },
316
- },
317
- {
318
- label: 'Table Filters',
319
- content: sectionPane,
320
- args: { kind: 'filter' },
321
- onclick: () => {
322
- selectSection('filter');
323
- },
324
- },
325
- ];
326
-
327
- const selectedSectionTab = $derived(
328
- sectionTabs.find((t) => t.args.kind === section) ?? sectionTabs[0]
329
- );
330
- </script>
331
-
332
- <!-- Left-nav section pane: the top horizontal tabs for the active kind. -->
333
- {#snippet sectionPane(_args: { kind: DatatableViewKind })}
334
- <div class="pane">
335
- <VerticalTabGroup
336
- tabs={innerTabs}
337
- selected={selectedInnerTab}
338
- nohistory
339
- />
340
- </div>
341
- {/snippet}
342
-
343
- <!-- Body of a single top tab (My / Shared / Save). -->
344
- {#snippet tabBody(args: { tab: ConfigTab })}
345
- <div class="tab-body">
346
- {#if errorMessage}
347
- <div
348
- class="error-banner"
349
- role="alert"
350
- >
351
- <Icon
352
- name="circle-exclamation"
353
- size={Size.Small}
354
- />
355
- <span>{errorMessage}</span>
356
- </div>
357
- {/if}
358
-
359
- {#if args.tab === 'mine'}
360
- {#if loadingViews}
361
- <InfoAlert>Loading saved {sectionNounPlural.toLowerCase()}…</InfoAlert>
362
- {:else if myViews.length === 0}
363
- <InfoAlert>
364
- You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
365
- tab to create one.
366
- </InfoAlert>
367
- {:else}
368
- <ul class="card-list">
369
- {#each myViews as view (view.id)}
370
- <li class="view-card">
371
- <div class="view-card-head">
372
- <span class="view-name">{view.name}</span>
373
- <div class="view-actions">
374
- <Button
375
- variant={Variant.Secondary}
376
- size={Size.MediumSmall}
377
- nowrap
378
- onclick={() => {
379
- applyView(view);
380
- }}>Apply</Button
381
- >
382
- <!--
383
- "Save Layout" / "Save Filter" overwrites THIS saved
384
- view's stored body with the table's current live state.
385
- Typical flow: user applies the view, tweaks the
386
- columns or filter inputs, then clicks Save to make
387
- the tweaks part of the saved view. Distinct from
388
- "Save a new …" (which always creates a fresh record).
389
- -->
390
- <Button
391
- variant={Variant.Secondary}
392
- size={Size.MediumSmall}
393
- nowrap
394
- iconLeft={{ name: 'circle-tick', size: Size.Small }}
395
- onclick={() => {
396
- saveLiveStateInto(view);
397
- }}>Save {sectionNoun}</Button
398
- >
399
- <!--
400
- "Update Options" opens a dedicated modal that edits
401
- the view's name and sharing visibility (private /
402
- public). Kept separate from the inline body-save
403
- above so a quick name fix never accidentally
404
- overwrites the saved layout / filter.
405
- -->
406
- <Button
407
- variant={Variant.Secondary}
408
- size={Size.MediumSmall}
409
- nowrap
410
- iconLeft={{ name: 'edit', size: Size.Small }}
411
- onclick={() => {
412
- updatingOptionsView = view;
413
- }}>Update Options</Button
414
- >
415
- <Button
416
- variant={Variant.Secondary}
417
- size={Size.MediumSmall}
418
- nowrap
419
- iconLeft={{ name: 'external-link', size: Size.Small }}
420
- onclick={() => {
421
- // Open the share dialog rather than switching to
422
- // the "Shared" tab - subscribing OTHER users to
423
- // this view is a distinct flow from "Subscribe
424
- // to Additional" (which is the current user
425
- // joining a public view).
426
- sharingView = view;
427
- }}>Share</Button
428
- >
429
- <Button
430
- variant={Variant.Secondary}
431
- size={Size.MediumSmall}
432
- nowrap
433
- iconLeft={{ name: 'circle-cross', size: Size.Small }}
434
- onclick={() => {
435
- deleteView(view);
436
- }}>Delete</Button
437
- >
438
- </div>
439
- </div>
440
- {#if view.subscribers && view.subscribers.length > 0}
441
- <ul class="subscriber-list">
442
- {#each view.subscribers as subscriber (subscriber.userId)}
443
- <li class="subscriber">
444
- <Icon
445
- name="user-3"
446
- size={Size.Small}
447
- />
448
- <span>{subscriber.displayName ?? subscriber.userId}</span>
449
- <Button
450
- variant={Variant.Secondary}
451
- size={Size.Tiny}
452
- nowrap
453
- onclick={() => {
454
- removeSubscriber(view, subscriber.userId);
455
- }}>Remove</Button
456
- >
457
- </li>
458
- {/each}
459
- </ul>
460
- {/if}
461
- </li>
462
- {/each}
463
- </ul>
464
- {/if}
465
- {:else if args.tab === 'shared'}
466
- <div class="shared-head">
467
- <Button
468
- variant={Variant.Secondary}
469
- size={Size.MediumSmall}
470
- nowrap
471
- disabled={loadingPublicViews}
472
- iconLeft={{ name: 'circle-plus', size: Size.Small }}
473
- onclick={loadPublicViews}>Subscribe to Additional...</Button
474
- >
475
- </div>
476
-
477
- {#if publicViews !== null}
478
- {#if publicViews.length === 0}
479
- <InfoAlert>
480
- There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
481
- </InfoAlert>
482
- {:else}
483
- <ul class="card-list">
484
- {#each publicViews as view (view.id)}
485
- <li class="view-card">
486
- <div class="view-card-head">
487
- <span class="view-name">{view.name}</span>
488
- <div class="view-actions">
489
- <Button
490
- variant={Variant.Secondary}
491
- size={Size.MediumSmall}
492
- nowrap
493
- disabled={currentUserId === undefined}
494
- onclick={() => {
495
- subscribe(view);
496
- }}>Subscribe</Button
497
- >
498
- </div>
499
- </div>
500
- </li>
501
- {/each}
502
- </ul>
503
- {/if}
504
- {/if}
505
-
506
- {#if sharedViews.length === 0}
507
- <InfoAlert>
508
- No {sectionNounPlural.toLowerCase()} are currently shared with you.
509
- </InfoAlert>
510
- {:else}
511
- <ul class="card-list">
512
- {#each sharedViews as view (view.id)}
513
- <li class="view-card">
514
- <div class="view-card-head">
515
- <span class="view-name">{view.name}</span>
516
- <div class="view-actions">
517
- <Button
518
- variant={Variant.Secondary}
519
- size={Size.MediumSmall}
520
- nowrap
521
- onclick={() => {
522
- applyView(view);
523
- }}>Apply</Button
524
- >
525
- <Button
526
- variant={Variant.Secondary}
527
- size={Size.MediumSmall}
528
- nowrap
529
- disabled={currentUserId === undefined}
530
- onclick={() => {
531
- unsubscribe(view);
532
- }}>Unsubscribe</Button
533
- >
534
- </div>
535
- </div>
536
- </li>
537
- {/each}
538
- </ul>
539
- {/if}
540
- {:else}
541
- <div class="save-tab">
542
- <InfoAlert>
543
- Save the table's current {sectionNoun.toLowerCase()} as a reusable named
544
- {sectionNoun.toLowerCase()}.
545
- </InfoAlert>
546
- <!-- The InfoAlert above spans the full tab body so the
547
- empty-state visuals are consistent across all three tabs.
548
- The name input + Save button below stay grouped in a
549
- narrower form column (`.save-tab-form`) so they keep
550
- comfortable form proportions instead of stretching across
551
- the whole 50vw modal. -->
552
- <div class="save-tab-form">
553
- <TextInput
554
- placeholder={`${sectionNoun} name`}
555
- bind:value={newViewName}
556
- onkeydown={(e: KeyboardEvent) => {
557
- if (e.key === 'Enter') {
558
- e.preventDefault();
559
- saveNewView();
560
- }
561
- }}
562
- />
563
- <!-- Wrap the button in a row so the flex column's default
564
- `align-items: stretch` does not blow it up to the input's
565
- width. The row itself can stretch; the button inside sits
566
- at its natural content width like every other Button. -->
567
- <div class="save-tab-actions">
568
- <Button
569
- disabled={!newViewName.trim()}
570
- onclick={saveNewView}>Save Current</Button
571
- >
572
- </div>
573
- </div>
574
- </div>
575
- {/if}
576
- </div>
577
- {/snippet}
578
-
579
- <DesktopModal
580
- headerLabel="Table Configuration"
581
- headerSubtitle={tableContext.datatableUuid
582
- ? `Table Identifier: ${tableContext.datatableUuid}`
583
- : undefined}
584
- onclose={() => onclose?.()}
585
- buttons={[{ label: 'Close', callback: () => onclose?.() }]}
586
- width={cssLength('50vw')}
587
- minWidth={cssLength('40rem')}
588
- >
589
- {#if !remoteAvailable}
590
- <div class="empty-state">
591
- <Icon
592
- name="circle-information"
593
- size={Size.Large}
594
- />
595
- <p>Saved layouts and filters are unavailable for this table.</p>
596
- </div>
597
- {:else}
598
- <div class="config">
599
- <HorizontalTabGroup
600
- tabs={sectionTabs}
601
- selected={selectedSectionTab}
602
- grow
603
- />
604
- </div>
605
- {/if}
606
- </DesktopModal>
607
-
608
- <!--
609
- Share dialog stacked on top of the Configuration modal. Rendered as a
610
- sibling (not nested in the modal body) so the second modal's overlay
611
- covers the first - matching how every other layered modal in scoria
612
- stacks. After a successful Save we refresh the saved-views list so the
613
- owner's card's subscriber strip updates immediately, then close.
614
- -->
615
- {#if sharingView}
616
- <ShareViewModal
617
- view={sharingView}
618
- onclose={() => {
619
- sharingView = null;
620
- }}
621
- onsaved={async () => {
622
- await tableContext.refreshSavedViews();
623
- }}
624
- />
625
- {/if}
626
-
627
- <!--
628
- Update-Options dialog stacked the same way as the Share dialog.
629
- After a successful Save we refresh the saved views so the My-* card
630
- picks up the new name / visibility immediately.
631
- -->
632
- {#if updatingOptionsView}
633
- <UpdateViewOptionsModal
634
- view={updatingOptionsView}
635
- onclose={() => {
636
- updatingOptionsView = null;
637
- }}
638
- onsaved={async () => {
639
- await tableContext.refreshSavedViews();
640
- }}
641
- />
642
- {/if}
643
-
1
+ <svelte:options runes />
2
+
3
+ <script
4
+ lang="ts"
5
+ generics="T extends object, IdType extends Primitive"
6
+ >
7
+ import Button from '../../Button.svelte';
8
+ import DesktopModal from '../../DesktopModal.svelte';
9
+ import HorizontalTabGroup from '../../HorizontalTabGroup.svelte';
10
+ import Icon from '../../Icon.svelte';
11
+ import InfoAlert from '../../InfoAlert.svelte';
12
+ import TextInput from '../../TextInput.svelte';
13
+ import VerticalTabGroup from '../../VerticalTabGroup.svelte';
14
+ import { cssLength } from '../../../Helpers/Helpers.svelte.js';
15
+ import { Size } from '../../../Types/Internal/Size.js';
16
+ import type { TabOption } from '../../../Types/Internal/TabOption.js';
17
+ import { Variant } from '../../../Types/Internal/Variant.js';
18
+ import { getContext, onMount } from 'svelte';
19
+ import { TableContext } from '../Types/Context/TableContext.svelte.js';
20
+ import type { Primitive } from '../Types/Context/TableInitOptions.js';
21
+ import type { DatatableView } from '../Types/Persistence/DatatableView.js';
22
+ import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
23
+ import ShareViewModal from './ShareViewModal.svelte';
24
+ import type { TableConfigurationModalProps } from './TableConfigurationModalProps.js';
25
+ import UpdateViewOptionsModal from './UpdateViewOptionsModal.svelte';
26
+
27
+ const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
28
+
29
+ const { onclose }: TableConfigurationModalProps = $props();
30
+
31
+ // ── Left-nav section + right-pane tab selection ──────────────────────
32
+ // `section` picks the kind (layout vs filter); `tab` picks which of the
33
+ // three horizontal panes is visible for that section. Both are plain
34
+ // `$state` since they are pure local UI selection. They drive the
35
+ // `selected` prop of the two scoria tab groups below.
36
+ let section = $state<DatatableViewKind>('layout');
37
+ type ConfigTab = 'mine' | 'shared' | 'save';
38
+ let tab = $state<ConfigTab>('mine');
39
+
40
+ // Name field for the "Save a new ..." tab.
41
+ let newViewName = $state('');
42
+
43
+ // Public-views discovery state for the "Subscribe to Additional" flow.
44
+ // `null` = not yet loaded; an array = the last successful fetch.
45
+ let publicViews = $state<ReadonlyArray<DatatableView> | null>(null);
46
+ let loadingPublicViews = $state(false);
47
+
48
+ // User-visible error banner. Every adapter call is wrapped so a
49
+ // rejection lands here instead of throwing into the UI. scoria cannot
50
+ // assume a host-provided toast context exists, so the modal surfaces
51
+ // errors inline in its own body.
52
+ let errorMessage = $state<string | null>(null);
53
+
54
+ // True while the open-time fetch of saved views is in flight, so the
55
+ // My-tab shows "Loading…" rather than the "nothing saved yet" message.
56
+ let loadingViews = $state(false);
57
+
58
+ // The view whose Share modal is currently open. `null` when no share
59
+ // modal is rendered. Owner cards (rendered on the My-tab) wire their
60
+ // Share button to open `ShareViewModal` against the view they belong
61
+ // to; the modal closes itself on Cancel / Save.
62
+ let sharingView = $state<DatatableView | null>(null);
63
+
64
+ // The view whose Update Options modal is currently open. `null` when
65
+ // no options modal is rendered. Same pattern as `sharingView`.
66
+ let updatingOptionsView = $state<DatatableView | null>(null);
67
+
68
+ /**
69
+ * Overwrite a saved view's stored body with the table's current
70
+ * live state. Triggered by the "Save Layout" / "Save Filter"
71
+ * button on an owner card - the typical flow is "apply view A,
72
+ * tweak the columns / filters, then click Save on A". Refreshes
73
+ * the saved-views list afterwards so the card label and dropdown
74
+ * pick up the new body, and surfaces any failure in the inline
75
+ * error banner.
76
+ */
77
+ function saveLiveStateInto(view: DatatableView): void {
78
+ void guard(async () => {
79
+ await tableContext.saveLiveStateIntoView(view);
80
+ await tableContext.refreshSavedViews();
81
+ }, `Could not save changes to "${view.name}". Please try again.`);
82
+ }
83
+
84
+ // Whether remote saved views are available at all. A table created
85
+ // without `datatableUuid` / `remoteLayouts` opts out of remote views;
86
+ // the modal then renders a graceful "unavailable" empty state and
87
+ // makes zero adapter calls.
88
+ const remoteAvailable = $derived(
89
+ tableContext.datatableUuid !== undefined && tableContext.remoteLayouts !== undefined
90
+ );
91
+
92
+ // The opaque current-user id, used to scope "mine" vs "shared" and to
93
+ // drive subscribe / unsubscribe. `undefined` when the host supplied
94
+ // `() => undefined` - sharing controls then degrade gracefully.
95
+ const currentUserId = $derived(tableContext.getUserId());
96
+
97
+ // All saved views of the active section, split by ownership. A view the
98
+ // current user owns is "mine"; anything else they can see is "shared"
99
+ // (a subscription). When no user id is available every view falls into
100
+ // "mine" so the user can still manage what they have.
101
+ const sectionViews = $derived(tableContext._viewState.savedViews(section));
102
+ const myViews = $derived(
103
+ sectionViews.filter((view) => currentUserId === undefined || view.ownerUserId === currentUserId)
104
+ );
105
+ const sharedViews = $derived(
106
+ sectionViews.filter((view) => currentUserId !== undefined && view.ownerUserId !== currentUserId)
107
+ );
108
+
109
+ // Human labels for the active section, so the three tabs and headings
110
+ // read "My Layouts" / "My Filters" etc. without branching everywhere.
111
+ const sectionNoun = $derived(section === 'filter' ? 'Filter' : 'Layout');
112
+ const sectionNounPlural = $derived(section === 'filter' ? 'Filters' : 'Layouts');
113
+
114
+ /**
115
+ * Run an adapter-backed action, clearing any prior error first and
116
+ * routing a rejection to the inline error banner. Keeps every async
117
+ * branch below a one-liner and guarantees no rejection escapes to the
118
+ * UI (the modal never throws).
119
+ */
120
+ async function guard(action: () => Promise<void>, failureMessage: string): Promise<void> {
121
+ errorMessage = null;
122
+ try {
123
+ await action();
124
+ } catch (e) {
125
+ // Surface the underlying error rather than swallowing it: the
126
+ // generic message alone has made failures hard to diagnose. The
127
+ // full error (with stack) goes to the console; its message is
128
+ // appended to the banner so it is visible without devtools.
129
+ console.error('[TableConfigurationModal] action failed:', e);
130
+ errorMessage = e instanceof Error ? `${failureMessage} (${e.message})` : failureMessage;
131
+ }
132
+ }
133
+
134
+ // Fetch the saved views fresh every time the modal opens. The boot-time
135
+ // fetch can miss (it races table construction / auth readiness), so the
136
+ // modal cannot rely on `_viewState.savedViews` already being populated -
137
+ // it pulls `GET /datatable-state` itself on open. A failure surfaces in
138
+ // the inline error banner via `guard` rather than being swallowed.
139
+ onMount(() => {
140
+ if (!remoteAvailable) {
141
+ return;
142
+ }
143
+ loadingViews = true;
144
+ void guard(
145
+ () => tableContext.refreshSavedViews(),
146
+ 'Could not load saved layouts and filters. Please try again.'
147
+ ).finally(() => {
148
+ loadingViews = false;
149
+ });
150
+ });
151
+
152
+ /** Apply a saved view to the live table. Pure local call - no await. */
153
+ function applyView(view: DatatableView): void {
154
+ errorMessage = null;
155
+ tableContext.applyDatatableView(view);
156
+ }
157
+
158
+ /** Delete a view the current user owns, then refresh the lists. */
159
+ function deleteView(view: DatatableView): void {
160
+ const adapter = tableContext.remoteLayouts;
161
+ if (!adapter) {
162
+ return;
163
+ }
164
+ void guard(async () => {
165
+ await adapter.deleteView(view.id);
166
+ await tableContext.refreshSavedViews();
167
+ }, `Could not delete "${view.name}". Please try again.`);
168
+ }
169
+
170
+ /**
171
+ * Save the live state of the active section as a brand-new view.
172
+ * `saveCurrentAsNewView` re-fetches the saved views and makes the new
173
+ * one active; the `My …` list and the panel dropdowns are all driven
174
+ * off the reactive `_viewState.savedViews(section)` so they update
175
+ * without a reopen. On success we also switch to the `My …` tab so
176
+ * the user immediately sees the view they just saved.
177
+ */
178
+ function saveNewView(): void {
179
+ const trimmed = newViewName.trim();
180
+ if (!trimmed) {
181
+ return;
182
+ }
183
+ void guard(async () => {
184
+ await tableContext.saveCurrentAsNewView(trimmed, section);
185
+ newViewName = '';
186
+ selectTab('mine');
187
+ }, `Could not save the new ${sectionNoun.toLowerCase()}. Please try again.`);
188
+ }
189
+
190
+ /** Remove the current user as a subscriber of a shared view. */
191
+ function unsubscribe(view: DatatableView): void {
192
+ const adapter = tableContext.remoteLayouts;
193
+ if (!adapter || currentUserId === undefined) {
194
+ return;
195
+ }
196
+ void guard(async () => {
197
+ await adapter.removeSubscriber(view.id, currentUserId);
198
+ await tableContext.refreshSavedViews();
199
+ }, `Could not unsubscribe from "${view.name}". Please try again.`);
200
+ }
201
+
202
+ /** Remove a named subscriber from a view the current user owns. */
203
+ function removeSubscriber(view: DatatableView, userId: string): void {
204
+ const adapter = tableContext.remoteLayouts;
205
+ if (!adapter) {
206
+ return;
207
+ }
208
+ void guard(async () => {
209
+ await adapter.removeSubscriber(view.id, userId);
210
+ await tableContext.refreshSavedViews();
211
+ }, 'Could not remove the subscriber. Please try again.');
212
+ }
213
+
214
+ /** Fetch the public views the user could subscribe to. */
215
+ function loadPublicViews(): void {
216
+ const adapter = tableContext.remoteLayouts;
217
+ const datatableUuid = tableContext.datatableUuid;
218
+ if (!adapter || datatableUuid === undefined) {
219
+ return;
220
+ }
221
+ loadingPublicViews = true;
222
+ void guard(async () => {
223
+ const all = await adapter.listPublicViews(datatableUuid);
224
+ publicViews = all.filter((view) => view.kind === section);
225
+ }, 'Could not load views available to subscribe to. Please try again.').finally(() => {
226
+ loadingPublicViews = false;
227
+ });
228
+ }
229
+
230
+ /** Subscribe the current user to a public view, then refresh. */
231
+ function subscribe(view: DatatableView): void {
232
+ const adapter = tableContext.remoteLayouts;
233
+ if (!adapter || currentUserId === undefined) {
234
+ return;
235
+ }
236
+ void guard(async () => {
237
+ await adapter.addSubscriber(view.id, currentUserId);
238
+ await tableContext.refreshSavedViews();
239
+ loadPublicViews();
240
+ }, `Could not subscribe to "${view.name}". Please try again.`);
241
+ }
242
+
243
+ /** Switch the active left-nav section, resetting the tab + scratch UI. */
244
+ function selectSection(next: DatatableViewKind): void {
245
+ section = next;
246
+ tab = 'mine';
247
+ newViewName = '';
248
+ publicViews = null;
249
+ errorMessage = null;
250
+ }
251
+
252
+ /** Switch the active right-pane tab, resetting tab-local scratch UI. */
253
+ function selectTab(next: ConfigTab): void {
254
+ tab = next;
255
+ errorMessage = null;
256
+ if (next !== 'save') {
257
+ newViewName = '';
258
+ }
259
+ if (next !== 'shared') {
260
+ publicViews = null;
261
+ }
262
+ }
263
+
264
+ // ── scoria tab-group wiring ──────────────────────────────────────────
265
+ // The left-hand vertical nav is a `HorizontalTabGroup` (tabs strip on
266
+ // the left, content on the right); the top horizontal tabs are a
267
+ // `VerticalTabGroup` (tabs strip on top, content below). Same two
268
+ // scoria primitives the flowms order-allocation modal uses, so the
269
+ // look is consistent across the app.
270
+
271
+ /** The three top tabs for the active section. Rebuilt when the section
272
+ * flips so the labels track "My Layouts" / "My Filters" etc. */
273
+ const innerTabs: Array<TabOption<{ tab: ConfigTab }>> = $derived([
274
+ {
275
+ label: `My ${sectionNounPlural}`,
276
+ content: tabBody,
277
+ args: { tab: 'mine' },
278
+ onclick: () => {
279
+ selectTab('mine');
280
+ },
281
+ },
282
+ {
283
+ // DESIGN-REVIEW: the mockups were internally inconsistent
284
+ // ("Subscribed to Filters" on one, "Shared Filters" on
285
+ // another). Resolved to "Shared" consistently for both the
286
+ // layout and filter sections.
287
+ label: `Shared ${sectionNounPlural}`,
288
+ content: tabBody,
289
+ args: { tab: 'shared' },
290
+ onclick: () => {
291
+ selectTab('shared');
292
+ },
293
+ },
294
+ {
295
+ label: `Save a new ${sectionNoun}`,
296
+ content: tabBody,
297
+ args: { tab: 'save' },
298
+ onclick: () => {
299
+ selectTab('save');
300
+ },
301
+ },
302
+ ]);
303
+
304
+ const selectedInnerTab = $derived(innerTabs.find((t) => t.args.tab === tab) ?? innerTabs[0]);
305
+
306
+ /** The two left-nav sections. Each renders the inner `VerticalTabGroup`
307
+ * via the shared `sectionPane` snippet. */
308
+ const sectionTabs: Array<TabOption<{ kind: DatatableViewKind }>> = [
309
+ {
310
+ label: 'Table Layouts',
311
+ content: sectionPane,
312
+ args: { kind: 'layout' },
313
+ onclick: () => {
314
+ selectSection('layout');
315
+ },
316
+ },
317
+ {
318
+ label: 'Table Filters',
319
+ content: sectionPane,
320
+ args: { kind: 'filter' },
321
+ onclick: () => {
322
+ selectSection('filter');
323
+ },
324
+ },
325
+ ];
326
+
327
+ const selectedSectionTab = $derived(
328
+ sectionTabs.find((t) => t.args.kind === section) ?? sectionTabs[0]
329
+ );
330
+ </script>
331
+
332
+ <!-- Left-nav section pane: the top horizontal tabs for the active kind. -->
333
+ {#snippet sectionPane(_args: { kind: DatatableViewKind })}
334
+ <div class="pane">
335
+ <VerticalTabGroup
336
+ tabs={innerTabs}
337
+ selected={selectedInnerTab}
338
+ nohistory
339
+ />
340
+ </div>
341
+ {/snippet}
342
+
343
+ <!-- Body of a single top tab (My / Shared / Save). -->
344
+ {#snippet tabBody(args: { tab: ConfigTab })}
345
+ <div class="tab-body">
346
+ {#if errorMessage}
347
+ <div
348
+ class="error-banner"
349
+ role="alert"
350
+ >
351
+ <Icon
352
+ name="circle-exclamation"
353
+ size={Size.Small}
354
+ />
355
+ <span>{errorMessage}</span>
356
+ </div>
357
+ {/if}
358
+
359
+ {#if args.tab === 'mine'}
360
+ {#if loadingViews}
361
+ <InfoAlert>Loading saved {sectionNounPlural.toLowerCase()}…</InfoAlert>
362
+ {:else if myViews.length === 0}
363
+ <InfoAlert>
364
+ You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
365
+ tab to create one.
366
+ </InfoAlert>
367
+ {:else}
368
+ <ul class="card-list">
369
+ {#each myViews as view (view.id)}
370
+ <li class="view-card">
371
+ <div class="view-card-head">
372
+ <span class="view-name">{view.name}</span>
373
+ <div class="view-actions">
374
+ <Button
375
+ variant={Variant.Secondary}
376
+ size={Size.MediumSmall}
377
+ nowrap
378
+ onclick={() => {
379
+ applyView(view);
380
+ }}>Apply</Button
381
+ >
382
+ <!--
383
+ "Save Layout" / "Save Filter" overwrites THIS saved
384
+ view's stored body with the table's current live state.
385
+ Typical flow: user applies the view, tweaks the
386
+ columns or filter inputs, then clicks Save to make
387
+ the tweaks part of the saved view. Distinct from
388
+ "Save a new …" (which always creates a fresh record).
389
+ -->
390
+ <Button
391
+ variant={Variant.Secondary}
392
+ size={Size.MediumSmall}
393
+ nowrap
394
+ iconLeft={{ name: 'circle-tick', size: Size.Small }}
395
+ onclick={() => {
396
+ saveLiveStateInto(view);
397
+ }}>Save {sectionNoun}</Button
398
+ >
399
+ <!--
400
+ "Update Options" opens a dedicated modal that edits
401
+ the view's name and sharing visibility (private /
402
+ public). Kept separate from the inline body-save
403
+ above so a quick name fix never accidentally
404
+ overwrites the saved layout / filter.
405
+ -->
406
+ <Button
407
+ variant={Variant.Secondary}
408
+ size={Size.MediumSmall}
409
+ nowrap
410
+ iconLeft={{ name: 'edit', size: Size.Small }}
411
+ onclick={() => {
412
+ updatingOptionsView = view;
413
+ }}>Update Options</Button
414
+ >
415
+ <Button
416
+ variant={Variant.Secondary}
417
+ size={Size.MediumSmall}
418
+ nowrap
419
+ iconLeft={{ name: 'external-link', size: Size.Small }}
420
+ onclick={() => {
421
+ // Open the share dialog rather than switching to
422
+ // the "Shared" tab - subscribing OTHER users to
423
+ // this view is a distinct flow from "Subscribe
424
+ // to Additional" (which is the current user
425
+ // joining a public view).
426
+ sharingView = view;
427
+ }}>Share</Button
428
+ >
429
+ <Button
430
+ variant={Variant.Secondary}
431
+ size={Size.MediumSmall}
432
+ nowrap
433
+ iconLeft={{ name: 'circle-cross', size: Size.Small }}
434
+ onclick={() => {
435
+ deleteView(view);
436
+ }}>Delete</Button
437
+ >
438
+ </div>
439
+ </div>
440
+ {#if view.subscribers && view.subscribers.length > 0}
441
+ <ul class="subscriber-list">
442
+ {#each view.subscribers as subscriber (subscriber.userId)}
443
+ <li class="subscriber">
444
+ <Icon
445
+ name="user-3"
446
+ size={Size.Small}
447
+ />
448
+ <span>{subscriber.displayName ?? subscriber.userId}</span>
449
+ <Button
450
+ variant={Variant.Secondary}
451
+ size={Size.Tiny}
452
+ nowrap
453
+ onclick={() => {
454
+ removeSubscriber(view, subscriber.userId);
455
+ }}>Remove</Button
456
+ >
457
+ </li>
458
+ {/each}
459
+ </ul>
460
+ {/if}
461
+ </li>
462
+ {/each}
463
+ </ul>
464
+ {/if}
465
+ {:else if args.tab === 'shared'}
466
+ <div class="shared-head">
467
+ <Button
468
+ variant={Variant.Secondary}
469
+ size={Size.MediumSmall}
470
+ nowrap
471
+ disabled={loadingPublicViews}
472
+ iconLeft={{ name: 'circle-plus', size: Size.Small }}
473
+ onclick={loadPublicViews}>Subscribe to Additional...</Button
474
+ >
475
+ </div>
476
+
477
+ {#if publicViews !== null}
478
+ {#if publicViews.length === 0}
479
+ <InfoAlert>
480
+ There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
481
+ </InfoAlert>
482
+ {:else}
483
+ <ul class="card-list">
484
+ {#each publicViews as view (view.id)}
485
+ <li class="view-card">
486
+ <div class="view-card-head">
487
+ <span class="view-name">{view.name}</span>
488
+ <div class="view-actions">
489
+ <Button
490
+ variant={Variant.Secondary}
491
+ size={Size.MediumSmall}
492
+ nowrap
493
+ disabled={currentUserId === undefined}
494
+ onclick={() => {
495
+ subscribe(view);
496
+ }}>Subscribe</Button
497
+ >
498
+ </div>
499
+ </div>
500
+ </li>
501
+ {/each}
502
+ </ul>
503
+ {/if}
504
+ {/if}
505
+
506
+ {#if sharedViews.length === 0}
507
+ <InfoAlert>
508
+ No {sectionNounPlural.toLowerCase()} are currently shared with you.
509
+ </InfoAlert>
510
+ {:else}
511
+ <ul class="card-list">
512
+ {#each sharedViews as view (view.id)}
513
+ <li class="view-card">
514
+ <div class="view-card-head">
515
+ <span class="view-name">{view.name}</span>
516
+ <div class="view-actions">
517
+ <Button
518
+ variant={Variant.Secondary}
519
+ size={Size.MediumSmall}
520
+ nowrap
521
+ onclick={() => {
522
+ applyView(view);
523
+ }}>Apply</Button
524
+ >
525
+ <Button
526
+ variant={Variant.Secondary}
527
+ size={Size.MediumSmall}
528
+ nowrap
529
+ disabled={currentUserId === undefined}
530
+ onclick={() => {
531
+ unsubscribe(view);
532
+ }}>Unsubscribe</Button
533
+ >
534
+ </div>
535
+ </div>
536
+ </li>
537
+ {/each}
538
+ </ul>
539
+ {/if}
540
+ {:else}
541
+ <div class="save-tab">
542
+ <InfoAlert>
543
+ Save the table's current {sectionNoun.toLowerCase()} as a reusable named
544
+ {sectionNoun.toLowerCase()}.
545
+ </InfoAlert>
546
+ <!-- The InfoAlert above spans the full tab body so the
547
+ empty-state visuals are consistent across all three tabs.
548
+ The name input + Save button below stay grouped in a
549
+ narrower form column (`.save-tab-form`) so they keep
550
+ comfortable form proportions instead of stretching across
551
+ the whole 50vw modal. -->
552
+ <div class="save-tab-form">
553
+ <TextInput
554
+ placeholder={`${sectionNoun} name`}
555
+ bind:value={newViewName}
556
+ onkeydown={(e: KeyboardEvent) => {
557
+ if (e.key === 'Enter') {
558
+ e.preventDefault();
559
+ saveNewView();
560
+ }
561
+ }}
562
+ />
563
+ <!-- Wrap the button in a row so the flex column's default
564
+ `align-items: stretch` does not blow it up to the input's
565
+ width. The row itself can stretch; the button inside sits
566
+ at its natural content width like every other Button. -->
567
+ <div class="save-tab-actions">
568
+ <Button
569
+ disabled={!newViewName.trim()}
570
+ onclick={saveNewView}>Save Current</Button
571
+ >
572
+ </div>
573
+ </div>
574
+ </div>
575
+ {/if}
576
+ </div>
577
+ {/snippet}
578
+
579
+ <DesktopModal
580
+ headerLabel="Table Configuration"
581
+ headerSubtitle={tableContext.datatableUuid
582
+ ? `Table Identifier: ${tableContext.datatableUuid}`
583
+ : undefined}
584
+ onclose={() => onclose?.()}
585
+ buttons={[{ label: 'Close', callback: () => onclose?.() }]}
586
+ width={cssLength('50vw')}
587
+ minWidth={cssLength('40rem')}
588
+ >
589
+ {#if !remoteAvailable}
590
+ <div class="empty-state">
591
+ <Icon
592
+ name="circle-information"
593
+ size={Size.Large}
594
+ />
595
+ <p>Saved layouts and filters are unavailable for this table.</p>
596
+ </div>
597
+ {:else}
598
+ <div class="config">
599
+ <HorizontalTabGroup
600
+ tabs={sectionTabs}
601
+ selected={selectedSectionTab}
602
+ grow
603
+ />
604
+ </div>
605
+ {/if}
606
+ </DesktopModal>
607
+
608
+ <!--
609
+ Share dialog stacked on top of the Configuration modal. Rendered as a
610
+ sibling (not nested in the modal body) so the second modal's overlay
611
+ covers the first - matching how every other layered modal in scoria
612
+ stacks. After a successful Save we refresh the saved-views list so the
613
+ owner's card's subscriber strip updates immediately, then close.
614
+ -->
615
+ {#if sharingView}
616
+ <ShareViewModal
617
+ view={sharingView}
618
+ onclose={() => {
619
+ sharingView = null;
620
+ }}
621
+ onsaved={async () => {
622
+ await tableContext.refreshSavedViews();
623
+ }}
624
+ />
625
+ {/if}
626
+
627
+ <!--
628
+ Update-Options dialog stacked the same way as the Share dialog.
629
+ After a successful Save we refresh the saved views so the My-* card
630
+ picks up the new name / visibility immediately.
631
+ -->
632
+ {#if updatingOptionsView}
633
+ <UpdateViewOptionsModal
634
+ view={updatingOptionsView}
635
+ onclose={() => {
636
+ updatingOptionsView = null;
637
+ }}
638
+ onsaved={async () => {
639
+ await tableContext.refreshSavedViews();
640
+ }}
641
+ />
642
+ {/if}
643
+
644
644
  <style>.empty-state {
645
645
  display: flex;
646
646
  flex-flow: column nowrap;
@@ -799,4 +799,4 @@
799
799
  display: flex;
800
800
  flex-flow: row nowrap;
801
801
  justify-content: flex-start;
802
- }</style>
802
+ }</style>