@lavalogic/scoria 0.40.0 → 0.40.2

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 (137) 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/Contexts/ContextMenu.svelte +164 -164
  17. package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
  18. package/dist/Components/Contexts/ContextMenuOption.svelte +60 -60
  19. package/dist/Components/Contexts/Toast.svelte +86 -86
  20. package/dist/Components/Contexts/ToastContainer.svelte +65 -65
  21. package/dist/Components/Contexts/Tooltip.svelte +37 -37
  22. package/dist/Components/DataMatrixIcon.svelte +112 -112
  23. package/dist/Components/DateInput.svelte +148 -148
  24. package/dist/Components/DatePicker.svelte +300 -300
  25. package/dist/Components/DesktopModal.svelte +90 -90
  26. package/dist/Components/Dial.svelte +204 -204
  27. package/dist/Components/DimensionInput.svelte +170 -170
  28. package/dist/Components/DragMenu/DragMenuHolder.svelte +119 -119
  29. package/dist/Components/DragMenu/DraggableCard.svelte +77 -77
  30. package/dist/Components/FileCard.svelte +24 -24
  31. package/dist/Components/FileUpload.svelte +236 -236
  32. package/dist/Components/GridInput.svelte +196 -196
  33. package/dist/Components/GridInputSet.svelte +29 -29
  34. package/dist/Components/HorizontalTabGroup.svelte +82 -82
  35. package/dist/Components/HorizontalTabGroupButton.svelte +44 -44
  36. package/dist/Components/Icon.svelte +128 -128
  37. package/dist/Components/Icons.js +357 -357
  38. package/dist/Components/InfoAlert.svelte +35 -35
  39. package/dist/Components/LoadingAnimation.svelte +29 -29
  40. package/dist/Components/LoadingModal.svelte +45 -45
  41. package/dist/Components/LoadingOverlay.svelte +25 -25
  42. package/dist/Components/Modal.svelte +258 -258
  43. package/dist/Components/MultiSelect.svelte +220 -220
  44. package/dist/Components/Nav/Nav.svelte +110 -110
  45. package/dist/Components/Nav/NavServiceMenuList.svelte +43 -43
  46. package/dist/Components/Nav/NavTab.svelte +62 -62
  47. package/dist/Components/Nav/ServiceMenuItem.svelte +112 -112
  48. package/dist/Components/Nav/ServicesNav.svelte +80 -80
  49. package/dist/Components/NumberInput.svelte +157 -157
  50. package/dist/Components/PageHeading.svelte +25 -25
  51. package/dist/Components/PasswordInput.svelte +88 -88
  52. package/dist/Components/Portal.svelte +36 -36
  53. package/dist/Components/ProgressBubble.svelte +107 -107
  54. package/dist/Components/QuerySelect.svelte +366 -366
  55. package/dist/Components/SingleSelect.svelte +250 -250
  56. package/dist/Components/StepButton.svelte +70 -70
  57. package/dist/Components/StepForm.svelte +140 -140
  58. package/dist/Components/Switch.svelte +69 -69
  59. package/dist/Components/Table/ARCHITECTURE.md +298 -298
  60. package/dist/Components/Table/Body/QuickSearchCell.svelte +679 -679
  61. package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +51 -51
  62. package/dist/Components/Table/Body/Rows/ColumnList.svelte +59 -59
  63. package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +454 -454
  64. package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +79 -79
  65. package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +185 -185
  66. package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +124 -124
  67. package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +131 -120
  68. package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +234 -234
  69. package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +105 -105
  70. package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +87 -87
  71. package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +126 -126
  72. package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +159 -159
  73. package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +246 -246
  74. package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +216 -216
  75. package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +252 -252
  76. package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +179 -179
  77. package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +221 -221
  78. package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +197 -197
  79. package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +190 -190
  80. package/dist/Components/Table/Body/Rows/TableRow.svelte +290 -290
  81. package/dist/Components/Table/Body/TableBody.svelte +155 -155
  82. package/dist/Components/Table/ColumnFactory.svelte.js +1 -0
  83. package/dist/Components/Table/Headers/HighlightAllHeader.svelte +41 -41
  84. package/dist/Components/Table/Headers/SelectAllHeader.svelte +30 -30
  85. package/dist/Components/Table/Headers/TableHead.svelte +415 -415
  86. package/dist/Components/Table/Misc/ColumnPanel.svelte +39 -39
  87. package/dist/Components/Table/Misc/ColumnPanelModal.svelte +39 -39
  88. package/dist/Components/Table/Misc/FilterInput.svelte +641 -641
  89. package/dist/Components/Table/Misc/FilterPanel.svelte +190 -190
  90. package/dist/Components/Table/Misc/FilterPanelModal.svelte +31 -31
  91. package/dist/Components/Table/Misc/ShareViewModal.svelte +291 -291
  92. package/dist/Components/Table/Misc/SidePanel.svelte +35 -35
  93. package/dist/Components/Table/Misc/TableConfigurationModal.svelte +644 -644
  94. package/dist/Components/Table/Misc/TableFooter.svelte +340 -340
  95. package/dist/Components/Table/Misc/TableHorizontalBar.svelte +140 -140
  96. package/dist/Components/Table/Misc/TableSidebar.svelte +200 -200
  97. package/dist/Components/Table/Misc/TableViewDropdown.svelte +105 -105
  98. package/dist/Components/Table/Misc/ToolboxPanel.svelte +100 -100
  99. package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +107 -107
  100. package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +192 -192
  101. package/dist/Components/Table/NestedTable.svelte +130 -130
  102. package/dist/Components/Table/Table.svelte +692 -692
  103. package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.d.ts +5 -0
  104. package/dist/Components/Table/Types/Columns/Definitions/Expand/ExpandDef.svelte.js +3 -0
  105. package/dist/Components/Table/Types/Public/ColumnOptions.d.ts +10 -0
  106. package/dist/Components/Test/TestContextConsumer.svelte +13 -13
  107. package/dist/Components/TextArea.svelte +130 -130
  108. package/dist/Components/TextInput.svelte +176 -176
  109. package/dist/Components/ThreeStateRadio.svelte +131 -131
  110. package/dist/Components/Touch/BoolCard.svelte +45 -45
  111. package/dist/Components/Touch/DateModal.svelte +209 -209
  112. package/dist/Components/Touch/EditCard.svelte +59 -59
  113. package/dist/Components/Touch/InfoCard.svelte +31 -31
  114. package/dist/Components/Touch/InfoTextCard.svelte +28 -28
  115. package/dist/Components/Touch/ModalExplanation.svelte +17 -17
  116. package/dist/Components/Touch/NumberKeyboard.svelte +225 -225
  117. package/dist/Components/Touch/NumberModal.svelte +79 -79
  118. package/dist/Components/Touch/PlusMinusCard.svelte +46 -46
  119. package/dist/Components/Touch/SelectModal.svelte +127 -127
  120. package/dist/Components/Touch/SummaryCard.svelte +93 -93
  121. package/dist/Components/Touch/TextAreaModal.svelte +95 -95
  122. package/dist/Components/Touch/TextModal.svelte +106 -106
  123. package/dist/Components/Touch/TouchCard.svelte +33 -33
  124. package/dist/Components/Touch/TouchModal.svelte +274 -274
  125. package/dist/Components/Touch/UtilityButton.svelte +109 -109
  126. package/dist/Components/VerticalTabGroup.svelte +79 -79
  127. package/dist/Components/VerticalTabGroupButton.svelte +58 -58
  128. package/dist/Helpers/Datamatrix.d.ts +19 -19
  129. package/dist/Helpers/Datamatrix.js +291 -291
  130. package/dist/Types/Examples/ExampleModal.svelte +35 -35
  131. package/dist/scss/_basics.scss +12 -12
  132. package/dist/scss/_colours.scss +134 -134
  133. package/dist/scss/_mixins.scss +3392 -3392
  134. package/dist/scss/_motion.scss +57 -57
  135. package/dist/scss/_sizing.scss +85 -85
  136. package/dist/scss/_transitions.scss +8 -8
  137. 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>