@lavalogic/scoria 0.38.0 → 0.38.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.
@@ -4,6 +4,7 @@
4
4
  import ColumnList from '../Body/Rows/ColumnList.svelte';
5
5
  import TextInput from '../../TextInput.svelte';
6
6
  import { Size } from '../../../Types/Internal/Size.js';
7
+ import TableViewDropdown from './TableViewDropdown.svelte';
7
8
 
8
9
  let searchValue = $state('');
9
10
  </script>
@@ -14,6 +15,10 @@
14
15
  </header>
15
16
 
16
17
  <div class="toolbar">
18
+ <!-- Quick-switch dropdown for saved column layouts. Sits above the
19
+ search box; saving a layout is done from the Table
20
+ Configuration modal's "Save a new Layout" tab. -->
21
+ <TableViewDropdown kind="layout" />
17
22
  <TextInput
18
23
  bind:value={searchValue}
19
24
  leftIcon={{ name: 'search', size: Size.Medium }}
@@ -83,6 +88,10 @@
83
88
  box-sizing: border-box;
84
89
  }
85
90
 
91
+ /* Top toolbar (saved-layout dropdown + Search input) sits below the
92
+ header but above the scrollable list. It is intentionally not
93
+ inside `.side-panel-body` because the list takes the remaining
94
+ vertical space and scrolls on its own. */
86
95
  .toolbar {
87
96
  flex-shrink: 0;
88
97
  padding: 1rem 1.25rem;
@@ -100,6 +109,13 @@
100
109
  display: flex;
101
110
  flex-flow: column nowrap;
102
111
  background-color: #ffffff;
112
+ /* Horizontal padding aligned with FilterPanel's `.side-panel-body`
113
+ so the column-list cards leave the same gutter before the panel's
114
+ right border as the filter chips do, and the SidePanel's
115
+ `border-right` stays visible between the content and the toolbar
116
+ icons. Vertical padding is 0 because `.column-list` provides
117
+ its own 0.5rem padding (which doubles up to a 1.5rem gap from
118
+ the toolbar above and the footer below). */
103
119
  padding: 0 1.25rem;
104
120
  box-sizing: border-box;
105
121
  }</style>
@@ -5,17 +5,16 @@
5
5
  generics="T extends object, IdType extends Primitive"
6
6
  >
7
7
  import Action from '../../Action.svelte';
8
- import Button from '../../Button.svelte';
9
8
  import TextInput from '../../TextInput.svelte';
10
9
  import { Colour, ColourSet } from '../../../scss/colours.js';
11
10
  import { Size } from '../../../Types/Internal/Size.js';
12
- import { Variant } from '../../../Types/Internal/Variant.js';
13
11
  import { SvelteSet } from 'svelte/reactivity';
14
12
  import { getContext } from 'svelte';
15
13
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
16
14
  import type { ColumnDef } from '../Types/Columns/Definitions/ColumnDef.svelte.js';
17
15
  import { TableContext } from '../Types/Context/TableContext.svelte.js';
18
16
  import FilterInput from './FilterInput.svelte';
17
+ import TableViewDropdown from './TableViewDropdown.svelte';
19
18
 
20
19
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
21
20
 
@@ -31,11 +30,6 @@
31
30
  * string is the "no filter" state. */
32
31
  let quickFindValue: string = $state('');
33
32
 
34
- /** Whether the Save Filter overflow menu is open. The chevron
35
- * toggles it; clicking anywhere else (via the window-level
36
- * listener below) closes it. */
37
- let saveMenuOpen: boolean = $state(false);
38
-
39
33
  /** Names of currently-collapsed groups. A name absent from this
40
34
  * set is rendered expanded. Reactive via `SvelteSet` so the panel
41
35
  * re-renders on toggle without manually re-assigning. */
@@ -106,39 +100,6 @@
106
100
  }
107
101
  }
108
102
 
109
- function resetFilters(): void {
110
- tableContext.paginationRepo?.resetFilters();
111
- }
112
-
113
- /**
114
- * Placeholder for the future Save-Filter / preset workflow. For
115
- * now this just closes the overflow menu; consumers wire the real
116
- * persistence layer in a follow-up workorder. The chevron menu is
117
- * still useful today as a home for the Reset action and as the
118
- * insertion point for "Load Filter" / "Manage Filters" entries
119
- * once those land.
120
- */
121
- function saveFilter(): void {
122
- saveMenuOpen = false;
123
- if (typeof window !== 'undefined') {
124
- window.dispatchEvent(
125
- new CustomEvent('scoria:save-filter', {
126
- detail: { tableName: tableContext.tableName },
127
- })
128
- );
129
- }
130
- }
131
-
132
- function handleWindowClick(e: MouseEvent): void {
133
- if (!saveMenuOpen) {
134
- return;
135
- }
136
- const target = e.target as HTMLElement | null;
137
- if (target?.closest('.save-filter-cluster') == null) {
138
- saveMenuOpen = false;
139
- }
140
- }
141
-
142
103
  /**
143
104
  * No-op keydown handler used to override `TextInput`'s default
144
105
  * `passthrough` keydown (defined in `Helpers.svelte.ts`), which
@@ -155,14 +116,16 @@
155
116
  }
156
117
  </script>
157
118
 
158
- <svelte:window onclick={handleWindowClick} />
159
-
160
119
  <section class="side-panel-section">
161
120
  <header class="side-panel-header">
162
121
  <h2>Advanced Filter</h2>
163
122
  </header>
164
123
 
165
124
  <div class="toolbar">
125
+ <!-- Quick-switch dropdown for saved filter sets. Sits above the
126
+ search box; saving a filter is done from the Table
127
+ Configuration modal's "Save a new Filter" tab. -->
128
+ <TableViewDropdown kind="filter" />
166
129
  <div class="quick-find">
167
130
  <TextInput
168
131
  labelTop="Quick Find a Filter"
@@ -172,36 +135,6 @@
172
135
  onkeydown={noopKeydown}
173
136
  />
174
137
  </div>
175
-
176
- <div class="save-filter-cluster">
177
- <Button
178
- variant={Variant.Secondary}
179
- nowrap
180
- disabled
181
- size={Size.MediumSmall}
182
- onclick={saveFilter}>Save Filter</Button
183
- >
184
- <Action
185
- label="More save options"
186
- colourSet={ColourSet.Auxiliary}
187
- icon={{ name: 'chevron-down', size: Size.Medium }}
188
- onclick={() => {
189
- saveMenuOpen = !saveMenuOpen;
190
- }}
191
- />
192
- {#if saveMenuOpen}
193
- <div class="save-menu">
194
- <button
195
- type="button"
196
- class="save-menu-item"
197
- onclick={() => {
198
- saveMenuOpen = false;
199
- resetFilters();
200
- }}>Reset all filters</button
201
- >
202
- </div>
203
- {/if}
204
- </div>
205
138
  </div>
206
139
 
207
140
  <div class="side-panel-body">
@@ -306,59 +239,24 @@
306
239
  box-sizing: border-box;
307
240
  }
308
241
 
242
+ /* Top toolbar (saved-filter dropdown + Quick Find) sits below the
243
+ header but above the scrollable body. Kept outside
244
+ `.side-panel-body` so it stays pinned while the groups list
245
+ scrolls underneath. The two rows stack vertically: the
246
+ quick-switch dropdown on top, the search field below it. */
309
247
  .toolbar {
310
248
  flex-shrink: 0;
311
249
  display: flex;
312
- flex-flow: row nowrap;
313
- align-items: center;
250
+ flex-flow: column nowrap;
314
251
  gap: 0.75rem;
315
252
  padding: 1rem 1.25rem;
316
253
  background-color: #ffffff;
317
254
  border-bottom: solid 1px #cbd4da;
318
255
  }
319
256
  .toolbar .quick-find {
320
- flex: 1;
321
257
  min-width: 0;
322
258
  --input-height: 3rem;
323
259
  }
324
- .toolbar .save-filter-cluster {
325
- position: relative;
326
- display: flex;
327
- flex-flow: row nowrap;
328
- align-items: center;
329
- gap: 0.25rem;
330
- }
331
- .toolbar .save-filter-cluster .save-menu {
332
- position: absolute;
333
- top: 100%;
334
- right: 0;
335
- margin-top: 0.25rem;
336
- background-color: #ffffff;
337
- border: solid 1px #cbd4da;
338
- border-radius: 4px;
339
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
340
- min-width: 12rem;
341
- z-index: 5;
342
- display: flex;
343
- flex-flow: column nowrap;
344
- padding: 0.25rem 0;
345
- }
346
- .toolbar .save-filter-cluster .save-menu-item {
347
- background: transparent;
348
- border: none;
349
- text-align: left;
350
- padding: 0.5rem 0.75rem;
351
- cursor: pointer;
352
- font: inherit;
353
- color: #3a4952;
354
- }
355
- .toolbar .save-filter-cluster .save-menu-item:hover {
356
- background-color: #f4f7f9;
357
- }
358
- .toolbar .save-filter-cluster .save-menu-item:focus-visible {
359
- outline: 2px solid #259fc7;
360
- outline-offset: -2px;
361
- }
362
260
 
363
261
  p.empty {
364
262
  color: #647d8e;
@@ -372,6 +270,11 @@ p.empty {
372
270
  gap: 1rem;
373
271
  }
374
272
 
273
+ /* `overflow: visible` (no longer `overflow: hidden`) so a filter
274
+ row's select dropdown can extend below the group's bottom edge
275
+ when there are not enough rows in the group to push the dropdown
276
+ past it. The header / body each round their own outer corners
277
+ instead so the rounded-card look survives without clipping. */
375
278
  .group {
376
279
  border: solid 1px #cbd4da;
377
280
  border-radius: 4px;
@@ -387,6 +290,11 @@ p.empty {
387
290
  padding: 0.5rem 0.75rem;
388
291
  background-color: #f4f7f9;
389
292
  border-bottom: solid 1px #cbd4da;
293
+ /* Round the header's top corners directly so the tinted strip
294
+ follows the parent's rounded outline. Without this rule
295
+ (combined with the `.group` `overflow: visible` change) the
296
+ header's blue band would leak past the parent's rounded
297
+ corners. */
390
298
  border-top-left-radius: 4px;
391
299
  border-top-right-radius: 4px;
392
300
  }
@@ -399,10 +307,14 @@ p.empty {
399
307
 
400
308
  .group.collapsed > .group-header {
401
309
  border-bottom: none;
310
+ /* When collapsed the header is the only visible row, so it
311
+ has to round all four corners to follow the parent outline. */
402
312
  border-bottom-left-radius: 4px;
403
313
  border-bottom-right-radius: 4px;
404
314
  }
405
315
 
316
+ /* Round the bottom of the body so its visual edge follows the
317
+ parent's rounded outline once `overflow: hidden` is gone. */
406
318
  .group:not(.collapsed) > .group-body {
407
319
  border-bottom-left-radius: 4px;
408
320
  border-bottom-right-radius: 4px;
@@ -6,9 +6,13 @@
6
6
  >
7
7
  import Button from '../../Button.svelte';
8
8
  import DesktopModal from '../../DesktopModal.svelte';
9
+ import HorizontalTabGroup from '../../HorizontalTabGroup.svelte';
9
10
  import Icon from '../../Icon.svelte';
10
11
  import TextInput from '../../TextInput.svelte';
12
+ import VerticalTabGroup from '../../VerticalTabGroup.svelte';
13
+ import { cssLength } from '../../../Helpers/Helpers.svelte.js';
11
14
  import { Size } from '../../../Types/Internal/Size.js';
15
+ import type { TabOption } from '../../../Types/Internal/TabOption.js';
12
16
  import { Variant } from '../../../Types/Internal/Variant.js';
13
17
  import { getContext } from 'svelte';
14
18
  import { TableContext } from '../Types/Context/TableContext.svelte.js';
@@ -24,7 +28,8 @@
24
28
  // ── Left-nav section + right-pane tab selection ──────────────────────
25
29
  // `section` picks the kind (layout vs filter); `tab` picks which of the
26
30
  // three horizontal panes is visible for that section. Both are plain
27
- // `$state` since they are pure local UI selection.
31
+ // `$state` since they are pure local UI selection. They drive the
32
+ // `selected` prop of the two scoria tab groups below.
28
33
  let section = $state<DatatableViewKind>('layout');
29
34
  type ConfigTab = 'mine' | 'shared' | 'save';
30
35
  let tab = $state<ConfigTab>('mine');
@@ -106,7 +111,14 @@
106
111
  }, `Could not delete "${view.name}". Please try again.`);
107
112
  }
108
113
 
109
- /** Save the live state of the active section as a brand-new view. */
114
+ /**
115
+ * Save the live state of the active section as a brand-new view.
116
+ * `saveCurrentAsNewView` re-fetches the saved views and makes the new
117
+ * one active; the `My …` list and the panel dropdowns are all driven
118
+ * off the reactive `_viewState.savedViews(section)` so they update
119
+ * without a reopen. On success we also switch to the `My …` tab so
120
+ * the user immediately sees the view they just saved.
121
+ */
110
122
  function saveNewView(): void {
111
123
  const trimmed = newViewName.trim();
112
124
  if (!trimmed) {
@@ -115,6 +127,7 @@
115
127
  void guard(async () => {
116
128
  await tableContext.saveCurrentAsNewView(trimmed, section);
117
129
  newViewName = '';
130
+ selectTab('mine');
118
131
  }, `Could not save the new ${sectionNoun.toLowerCase()}. Please try again.`);
119
132
  }
120
133
 
@@ -191,8 +204,272 @@
191
204
  publicViews = null;
192
205
  }
193
206
  }
207
+
208
+ // ── scoria tab-group wiring ──────────────────────────────────────────
209
+ // The left-hand vertical nav is a `HorizontalTabGroup` (tabs strip on
210
+ // the left, content on the right); the top horizontal tabs are a
211
+ // `VerticalTabGroup` (tabs strip on top, content below). Same two
212
+ // scoria primitives the flowms order-allocation modal uses, so the
213
+ // look is consistent across the app.
214
+
215
+ /** The three top tabs for the active section. Rebuilt when the section
216
+ * flips so the labels track "My Layouts" / "My Filters" etc. */
217
+ const innerTabs: Array<TabOption<{ tab: ConfigTab }>> = $derived([
218
+ {
219
+ label: `My ${sectionNounPlural}`,
220
+ content: tabBody,
221
+ args: { tab: 'mine' },
222
+ onclick: () => {
223
+ selectTab('mine');
224
+ },
225
+ },
226
+ {
227
+ // DESIGN-REVIEW: the mockups were internally inconsistent
228
+ // ("Subscribed to Filters" on one, "Shared Filters" on
229
+ // another). Resolved to "Shared" consistently for both the
230
+ // layout and filter sections.
231
+ label: `Shared ${sectionNounPlural}`,
232
+ content: tabBody,
233
+ args: { tab: 'shared' },
234
+ onclick: () => {
235
+ selectTab('shared');
236
+ },
237
+ },
238
+ {
239
+ label: `Save a new ${sectionNoun}`,
240
+ content: tabBody,
241
+ args: { tab: 'save' },
242
+ onclick: () => {
243
+ selectTab('save');
244
+ },
245
+ },
246
+ ]);
247
+
248
+ const selectedInnerTab = $derived(innerTabs.find((t) => t.args.tab === tab) ?? innerTabs[0]);
249
+
250
+ /** The two left-nav sections. Each renders the inner `VerticalTabGroup`
251
+ * via the shared `sectionPane` snippet. */
252
+ const sectionTabs: Array<TabOption<{ kind: DatatableViewKind }>> = [
253
+ {
254
+ label: 'Table Layouts',
255
+ content: sectionPane,
256
+ args: { kind: 'layout' },
257
+ onclick: () => {
258
+ selectSection('layout');
259
+ },
260
+ },
261
+ {
262
+ label: 'Table Filters',
263
+ content: sectionPane,
264
+ args: { kind: 'filter' },
265
+ onclick: () => {
266
+ selectSection('filter');
267
+ },
268
+ },
269
+ ];
270
+
271
+ const selectedSectionTab = $derived(
272
+ sectionTabs.find((t) => t.args.kind === section) ?? sectionTabs[0]
273
+ );
194
274
  </script>
195
275
 
276
+ <!-- Left-nav section pane: the top horizontal tabs for the active kind. -->
277
+ {#snippet sectionPane(_args: { kind: DatatableViewKind })}
278
+ <div class="pane">
279
+ <VerticalTabGroup
280
+ tabs={innerTabs}
281
+ selected={selectedInnerTab}
282
+ nohistory
283
+ />
284
+ </div>
285
+ {/snippet}
286
+
287
+ <!-- Body of a single top tab (My / Shared / Save). -->
288
+ {#snippet tabBody(args: { tab: ConfigTab })}
289
+ <div class="tab-body">
290
+ {#if errorMessage}
291
+ <div
292
+ class="error-banner"
293
+ role="alert"
294
+ >
295
+ <Icon
296
+ name="circle-exclamation"
297
+ size={Size.Small}
298
+ />
299
+ <span>{errorMessage}</span>
300
+ </div>
301
+ {/if}
302
+
303
+ {#if args.tab === 'mine'}
304
+ {#if myViews.length === 0}
305
+ <p class="muted">
306
+ You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
307
+ tab to create one.
308
+ </p>
309
+ {:else}
310
+ <ul class="card-list">
311
+ {#each myViews as view (view.id)}
312
+ <li class="view-card">
313
+ <div class="view-card-head">
314
+ <span class="view-name">{view.name}</span>
315
+ <div class="view-actions">
316
+ <Button
317
+ variant={Variant.Secondary}
318
+ size={Size.MediumSmall}
319
+ nowrap
320
+ onclick={() => {
321
+ applyView(view);
322
+ }}>Apply</Button
323
+ >
324
+ <Button
325
+ variant={Variant.Secondary}
326
+ size={Size.MediumSmall}
327
+ nowrap
328
+ iconLeft={{ name: 'external-link', size: Size.Small }}
329
+ onclick={() => {
330
+ selectTab('shared');
331
+ }}>Share</Button
332
+ >
333
+ <Button
334
+ variant={Variant.Secondary}
335
+ size={Size.MediumSmall}
336
+ nowrap
337
+ iconLeft={{ name: 'circle-cross', size: Size.Small }}
338
+ onclick={() => {
339
+ deleteView(view);
340
+ }}>Delete</Button
341
+ >
342
+ </div>
343
+ </div>
344
+ {#if view.subscribers && view.subscribers.length > 0}
345
+ <ul class="subscriber-list">
346
+ {#each view.subscribers as subscriber (subscriber.userId)}
347
+ <li class="subscriber">
348
+ <Icon
349
+ name="user-3"
350
+ size={Size.Small}
351
+ />
352
+ <span>{subscriber.displayName ?? subscriber.userId}</span>
353
+ <Button
354
+ variant={Variant.Secondary}
355
+ size={Size.Tiny}
356
+ nowrap
357
+ onclick={() => {
358
+ removeSubscriber(view, subscriber.userId);
359
+ }}>Remove</Button
360
+ >
361
+ </li>
362
+ {/each}
363
+ </ul>
364
+ {/if}
365
+ </li>
366
+ {/each}
367
+ </ul>
368
+ {/if}
369
+ {:else if args.tab === 'shared'}
370
+ <div class="shared-head">
371
+ <Button
372
+ variant={Variant.Secondary}
373
+ size={Size.MediumSmall}
374
+ nowrap
375
+ disabled={loadingPublicViews}
376
+ iconLeft={{ name: 'circle-plus', size: Size.Small }}
377
+ onclick={loadPublicViews}>Subscribe to Additional...</Button
378
+ >
379
+ </div>
380
+
381
+ {#if publicViews !== null}
382
+ {#if publicViews.length === 0}
383
+ <p class="muted">
384
+ There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
385
+ </p>
386
+ {:else}
387
+ <ul class="card-list">
388
+ {#each publicViews as view (view.id)}
389
+ <li class="view-card">
390
+ <div class="view-card-head">
391
+ <span class="view-name">{view.name}</span>
392
+ <div class="view-actions">
393
+ <Button
394
+ variant={Variant.Secondary}
395
+ size={Size.MediumSmall}
396
+ nowrap
397
+ disabled={currentUserId === undefined}
398
+ onclick={() => {
399
+ subscribe(view);
400
+ }}>Subscribe</Button
401
+ >
402
+ </div>
403
+ </div>
404
+ </li>
405
+ {/each}
406
+ </ul>
407
+ {/if}
408
+ {/if}
409
+
410
+ {#if sharedViews.length === 0}
411
+ <p class="muted">
412
+ No {sectionNounPlural.toLowerCase()} are currently shared with you.
413
+ </p>
414
+ {:else}
415
+ <ul class="card-list">
416
+ {#each sharedViews as view (view.id)}
417
+ <li class="view-card">
418
+ <div class="view-card-head">
419
+ <span class="view-name">{view.name}</span>
420
+ <div class="view-actions">
421
+ <Button
422
+ variant={Variant.Secondary}
423
+ size={Size.MediumSmall}
424
+ nowrap
425
+ onclick={() => {
426
+ applyView(view);
427
+ }}>Apply</Button
428
+ >
429
+ <Button
430
+ variant={Variant.Secondary}
431
+ size={Size.MediumSmall}
432
+ nowrap
433
+ disabled={currentUserId === undefined}
434
+ onclick={() => {
435
+ unsubscribe(view);
436
+ }}>Unsubscribe</Button
437
+ >
438
+ </div>
439
+ </div>
440
+ </li>
441
+ {/each}
442
+ </ul>
443
+ {/if}
444
+ {:else}
445
+ <div class="save-tab">
446
+ <p class="muted">
447
+ Save the table's current {sectionNoun.toLowerCase()} as a reusable named
448
+ {sectionNoun.toLowerCase()}.
449
+ </p>
450
+ <TextInput
451
+ placeholder={`${sectionNoun} name`}
452
+ bind:value={newViewName}
453
+ onkeydown={(e: KeyboardEvent) => {
454
+ if (e.key === 'Enter') {
455
+ e.preventDefault();
456
+ saveNewView();
457
+ }
458
+ }}
459
+ />
460
+ <Button
461
+ type="submit"
462
+ variant={Variant.Secondary}
463
+ size={Size.MediumSmall}
464
+ nowrap
465
+ disabled={!newViewName.trim()}
466
+ onclick={saveNewView}>Save Current</Button
467
+ >
468
+ </div>
469
+ {/if}
470
+ </div>
471
+ {/snippet}
472
+
196
473
  <DesktopModal
197
474
  headerLabel="Table Configuration"
198
475
  headerSubtitle={tableContext.datatableUuid
@@ -200,6 +477,8 @@
200
477
  : undefined}
201
478
  onclose={() => onclose?.()}
202
479
  buttons={[{ label: 'Close', callback: () => onclose?.() }]}
480
+ width={cssLength('50vw')}
481
+ minWidth={cssLength('40rem')}
203
482
  >
204
483
  {#if !remoteAvailable}
205
484
  <div class="empty-state">
@@ -211,261 +490,11 @@
211
490
  </div>
212
491
  {:else}
213
492
  <div class="config">
214
- <!-- Left vertical nav: pick the kind being configured. -->
215
- <nav class="side-nav">
216
- <button
217
- type="button"
218
- class="nav-item"
219
- class:active={section === 'layout'}
220
- onclick={() => {
221
- selectSection('layout');
222
- }}
223
- >
224
- Table Layouts
225
- </button>
226
- <button
227
- type="button"
228
- class="nav-item"
229
- class:active={section === 'filter'}
230
- onclick={() => {
231
- selectSection('filter');
232
- }}
233
- >
234
- Table Filters
235
- </button>
236
- </nav>
237
-
238
- <div class="pane">
239
- <!-- Three horizontal tabs for the selected section. -->
240
- <div
241
- class="tabs"
242
- role="tablist"
243
- >
244
- <button
245
- type="button"
246
- role="tab"
247
- aria-selected={tab === 'mine'}
248
- class="tab"
249
- class:active={tab === 'mine'}
250
- onclick={() => {
251
- selectTab('mine');
252
- }}
253
- >
254
- My {sectionNounPlural}
255
- </button>
256
- <button
257
- type="button"
258
- role="tab"
259
- aria-selected={tab === 'shared'}
260
- class="tab"
261
- class:active={tab === 'shared'}
262
- onclick={() => {
263
- selectTab('shared');
264
- }}
265
- >
266
- <!-- DESIGN-REVIEW: the mockups were internally inconsistent
267
- ("Subscribed to Filters" on one, "Shared Filters" on
268
- another). Resolved to "Shared" consistently for both
269
- the layout and filter sections. -->
270
- Shared {sectionNounPlural}
271
- </button>
272
- <button
273
- type="button"
274
- role="tab"
275
- aria-selected={tab === 'save'}
276
- class="tab"
277
- class:active={tab === 'save'}
278
- onclick={() => {
279
- selectTab('save');
280
- }}
281
- >
282
- Save a new {sectionNoun}
283
- </button>
284
- </div>
285
-
286
- {#if errorMessage}
287
- <div
288
- class="error-banner"
289
- role="alert"
290
- >
291
- <Icon
292
- name="circle-exclamation"
293
- size={Size.Small}
294
- />
295
- <span>{errorMessage}</span>
296
- </div>
297
- {/if}
298
-
299
- <div class="tab-body">
300
- {#if tab === 'mine'}
301
- {#if myViews.length === 0}
302
- <p class="muted">
303
- You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
304
- tab to create one.
305
- </p>
306
- {:else}
307
- <ul class="card-list">
308
- {#each myViews as view (view.id)}
309
- <li class="view-card">
310
- <div class="view-card-head">
311
- <span class="view-name">{view.name}</span>
312
- <div class="view-actions">
313
- <Button
314
- variant={Variant.Secondary}
315
- size={Size.MediumSmall}
316
- nowrap
317
- onclick={() => {
318
- applyView(view);
319
- }}>Apply</Button
320
- >
321
- <Button
322
- variant={Variant.Secondary}
323
- size={Size.MediumSmall}
324
- nowrap
325
- iconLeft={{ name: 'external-link', size: Size.Small }}
326
- onclick={() => {
327
- selectTab('shared');
328
- }}>Share</Button
329
- >
330
- <Button
331
- variant={Variant.Secondary}
332
- size={Size.MediumSmall}
333
- nowrap
334
- iconLeft={{ name: 'circle-cross', size: Size.Small }}
335
- onclick={() => {
336
- deleteView(view);
337
- }}>Delete</Button
338
- >
339
- </div>
340
- </div>
341
- {#if view.subscribers && view.subscribers.length > 0}
342
- <ul class="subscriber-list">
343
- {#each view.subscribers as subscriber (subscriber.userId)}
344
- <li class="subscriber">
345
- <Icon
346
- name="user-3"
347
- size={Size.Small}
348
- />
349
- <span>{subscriber.displayName ?? subscriber.userId}</span>
350
- <Button
351
- variant={Variant.Secondary}
352
- size={Size.Tiny}
353
- nowrap
354
- onclick={() => {
355
- removeSubscriber(view, subscriber.userId);
356
- }}>Remove</Button
357
- >
358
- </li>
359
- {/each}
360
- </ul>
361
- {/if}
362
- </li>
363
- {/each}
364
- </ul>
365
- {/if}
366
- {:else if tab === 'shared'}
367
- <div class="shared-head">
368
- <Button
369
- variant={Variant.Secondary}
370
- size={Size.MediumSmall}
371
- nowrap
372
- disabled={loadingPublicViews}
373
- iconLeft={{ name: 'circle-plus', size: Size.Small }}
374
- onclick={loadPublicViews}>Subscribe to Additional...</Button
375
- >
376
- </div>
377
-
378
- {#if publicViews !== null}
379
- {#if publicViews.length === 0}
380
- <p class="muted">
381
- There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
382
- </p>
383
- {:else}
384
- <ul class="card-list">
385
- {#each publicViews as view (view.id)}
386
- <li class="view-card">
387
- <div class="view-card-head">
388
- <span class="view-name">{view.name}</span>
389
- <div class="view-actions">
390
- <Button
391
- variant={Variant.Secondary}
392
- size={Size.MediumSmall}
393
- nowrap
394
- disabled={currentUserId === undefined}
395
- onclick={() => {
396
- subscribe(view);
397
- }}>Subscribe</Button
398
- >
399
- </div>
400
- </div>
401
- </li>
402
- {/each}
403
- </ul>
404
- {/if}
405
- {/if}
406
-
407
- {#if sharedViews.length === 0}
408
- <p class="muted">
409
- No {sectionNounPlural.toLowerCase()} are currently shared with you.
410
- </p>
411
- {:else}
412
- <ul class="card-list">
413
- {#each sharedViews as view (view.id)}
414
- <li class="view-card">
415
- <div class="view-card-head">
416
- <span class="view-name">{view.name}</span>
417
- <div class="view-actions">
418
- <Button
419
- variant={Variant.Secondary}
420
- size={Size.MediumSmall}
421
- nowrap
422
- onclick={() => {
423
- applyView(view);
424
- }}>Apply</Button
425
- >
426
- <Button
427
- variant={Variant.Secondary}
428
- size={Size.MediumSmall}
429
- nowrap
430
- disabled={currentUserId === undefined}
431
- onclick={() => {
432
- unsubscribe(view);
433
- }}>Unsubscribe</Button
434
- >
435
- </div>
436
- </div>
437
- </li>
438
- {/each}
439
- </ul>
440
- {/if}
441
- {:else}
442
- <div class="save-tab">
443
- <p class="muted">
444
- Save the table's current {sectionNoun.toLowerCase()} as a reusable named
445
- {sectionNoun.toLowerCase()}.
446
- </p>
447
- <TextInput
448
- placeholder={`${sectionNoun} name`}
449
- bind:value={newViewName}
450
- onkeydown={(e: KeyboardEvent) => {
451
- if (e.key === 'Enter') {
452
- e.preventDefault();
453
- saveNewView();
454
- }
455
- }}
456
- />
457
- <Button
458
- type="submit"
459
- variant={Variant.Primary}
460
- size={Size.MediumSmall}
461
- nowrap
462
- disabled={!newViewName.trim()}
463
- onclick={saveNewView}>Save Current</Button
464
- >
465
- </div>
466
- {/if}
467
- </div>
468
- </div>
493
+ <HorizontalTabGroup
494
+ tabs={sectionTabs}
495
+ selected={selectedSectionTab}
496
+ grow
497
+ />
469
498
  </div>
470
499
  {/if}
471
500
  </DesktopModal>
@@ -481,72 +510,27 @@
481
510
  color: #3a4952;
482
511
  }
483
512
 
513
+ /* Fixed-frame wrapper. The modal is a fixed 50vw (see the `width`
514
+ prop on `DesktopModal`); this `.config` lets the tab group fill
515
+ that frame and the tab body scroll inside it, so switching nav
516
+ section or top tab never reflows the dialog. */
484
517
  .config {
485
518
  display: flex;
486
- flex-flow: row nowrap;
487
- gap: 1rem;
519
+ flex-flow: column nowrap;
488
520
  flex: 1;
489
521
  min-height: 0;
490
522
  width: 100%;
491
523
  }
492
524
 
493
- .side-nav {
494
- display: flex;
495
- flex-flow: column nowrap;
496
- gap: 0.25rem;
497
- flex-shrink: 0;
498
- border-right: solid 2px #cbd4da;
499
- padding-right: 1rem;
500
- }
501
- .side-nav .nav-item {
502
- text-align: left;
503
- white-space: nowrap;
504
- padding: 0.6rem 0.9rem;
505
- border: solid 1px #cbd4da;
506
- border-radius: 4px;
507
- background-color: #ffffff;
508
- color: #3a4952;
509
- font-weight: 600;
510
- cursor: pointer;
511
- }
512
- .side-nav .nav-item:hover {
513
- background-color: #e9ecf1;
514
- }
515
- .side-nav .nav-item.active {
516
- background-color: #cbd4da;
517
- color: #455661;
518
- }
519
-
525
+ /* The right-hand content pane that each left-nav section renders:
526
+ holds the top horizontal tabs and their scrolling body. */
520
527
  .pane {
521
528
  display: flex;
522
529
  flex-flow: column nowrap;
523
- gap: 0.75rem;
524
530
  flex: 1;
525
531
  min-width: 0;
526
- }
527
-
528
- .tabs {
529
- display: flex;
530
- flex-flow: row nowrap;
531
- gap: 0.25rem;
532
- border-bottom: solid 2px #cbd4da;
533
- }
534
- .tabs .tab {
535
- padding: 0.5rem 0.9rem;
536
- border: none;
537
- border-bottom: solid 3px transparent;
538
- background-color: transparent;
539
- color: #3a4952;
540
- font-weight: 600;
541
- white-space: nowrap;
542
- cursor: pointer;
543
- }
544
- .tabs .tab:hover {
545
- color: #259fc7;
546
- }
547
- .tabs .tab.active {
548
- border-bottom-color: #259fc7;
549
- color: #259fc7;
532
+ min-height: 0;
533
+ width: 100%;
550
534
  }
551
535
 
552
536
  .error-banner {
@@ -561,9 +545,18 @@
561
545
  color: #aa1414;
562
546
  }
563
547
 
548
+ /* The body of a single top tab. A `min-height` floor keeps the modal
549
+ frame visually stable across every section/tab combination (short
550
+ tabs no longer shrink the dialog); the `max-height` + `overflow-y`
551
+ keep taller content scrolling within that fixed frame rather than
552
+ growing the dialog. */
564
553
  .tab-body {
565
- flex: 1;
566
- min-height: 0;
554
+ display: flex;
555
+ flex-flow: column nowrap;
556
+ gap: 0.75rem;
557
+ padding: 1rem;
558
+ min-height: 30vh;
559
+ max-height: 60vh;
567
560
  overflow-y: auto;
568
561
  }
569
562
 
@@ -16,7 +16,6 @@
16
16
  import ColumnPanelModal from './ColumnPanelModal.svelte';
17
17
  import FilterPanelModal from './FilterPanelModal.svelte';
18
18
  import TableConfigurationModal from './TableConfigurationModal.svelte';
19
- import TableViewDropdown from './TableViewDropdown.svelte';
20
19
  import ToolboxPanelModal from './ToolboxPanelModal.svelte';
21
20
 
22
21
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
@@ -42,9 +41,6 @@
42
41
  />
43
42
  {/if}
44
43
  <div class="sidebar">
45
- <div class="view-dropdown-slot">
46
- <TableViewDropdown />
47
- </div>
48
44
  <Button
49
45
  side={Side.Bottom}
50
46
  onclick={() => {
@@ -159,12 +155,4 @@
159
155
  .sidebar .filler {
160
156
  flex-grow: 2;
161
157
  background-color: #ffffff;
162
- }
163
- .sidebar .view-dropdown-slot {
164
- display: flex;
165
- flex-flow: row nowrap;
166
- align-items: center;
167
- background-color: #ffffff;
168
- padding: 0 0.5rem;
169
- flex-shrink: 0;
170
158
  }</style>
@@ -1,6 +1,16 @@
1
1
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
2
+ import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
3
+ interface Props {
4
+ /**
5
+ * Which view-state slice this dropdown switches between. `'layout'`
6
+ * lists saved column layouts; `'filter'` lists saved filter sets.
7
+ * Drives every `_viewState` read (label / active view / saved
8
+ * views) and which kind `resetToDefault` is called with.
9
+ */
10
+ kind: DatatableViewKind;
11
+ }
2
12
  declare function $$render<T extends object, IdType extends Primitive>(): {
3
- props: Record<string, never>;
13
+ props: Props;
4
14
  exports: {};
5
15
  bindings: "";
6
16
  slots: {};
@@ -17,7 +27,7 @@ interface $$IsomorphicComponent {
17
27
  new <T extends object, IdType extends Primitive>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, IdType>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, IdType>['props']>, ReturnType<__sveltets_Render<T, IdType>['events']>, ReturnType<__sveltets_Render<T, IdType>['slots']>> & {
18
28
  $$bindings?: ReturnType<__sveltets_Render<T, IdType>['bindings']>;
19
29
  } & ReturnType<__sveltets_Render<T, IdType>['exports']>;
20
- <T extends object, IdType extends Primitive>(internal: unknown, props: {}): ReturnType<__sveltets_Render<T, IdType>['exports']>;
30
+ <T extends object, IdType extends Primitive>(internal: unknown, props: ReturnType<__sveltets_Render<T, IdType>['props']> & {}): ReturnType<__sveltets_Render<T, IdType>['exports']>;
21
31
  z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
22
32
  }
23
33
  declare const TableViewDropdown: $$IsomorphicComponent;
@@ -892,10 +892,18 @@ export declare class TableContext<T extends object, RowIdType extends Primitive>
892
892
  * `{ kind:'filter', filter: _captureFilter() }`, plus the
893
893
  * host-supplied `datatableUuid` and the owning user;
894
894
  * 2. `await remoteLayouts.createView(...)`;
895
- * 3. push the returned `DatatableView` into the matching kind's
896
- * saved-views list;
897
- * 4. apply it via `applyDatatableView`, which re-points that kind's
898
- * baseline and clears that kind's dirty flag.
895
+ * 3. `await refreshSavedViews()` so the matching kind's saved-views
896
+ * list is re-fetched from the backend - the new view then appears
897
+ * in the Table Configuration modal's My-Layouts / My-Filters list
898
+ * (and the panel quick-switch dropdowns) immediately, without a
899
+ * reopen, because both read reactively off `_viewState`;
900
+ * 4. set the newly-saved view as that kind's active view via
901
+ * `applyDatatableView`, which re-points that kind's baseline and
902
+ * clears that kind's dirty flag - so the quick-switch dropdown
903
+ * shows the new view's name straight away rather than
904
+ * `'Custom (unsaved)'`. The authoritative refreshed copy (matched
905
+ * by id) is preferred over the raw `createView` return so the
906
+ * baseline reflects exactly what the backend persisted.
899
907
  *
900
908
  * Only the named kind's state is touched. No-op (dev-logged via
901
909
  * `devCatch`) when remote saved views are disabled (`datatableUuid` /
@@ -2309,10 +2309,18 @@ export class TableContext {
2309
2309
  * `{ kind:'filter', filter: _captureFilter() }`, plus the
2310
2310
  * host-supplied `datatableUuid` and the owning user;
2311
2311
  * 2. `await remoteLayouts.createView(...)`;
2312
- * 3. push the returned `DatatableView` into the matching kind's
2313
- * saved-views list;
2314
- * 4. apply it via `applyDatatableView`, which re-points that kind's
2315
- * baseline and clears that kind's dirty flag.
2312
+ * 3. `await refreshSavedViews()` so the matching kind's saved-views
2313
+ * list is re-fetched from the backend - the new view then appears
2314
+ * in the Table Configuration modal's My-Layouts / My-Filters list
2315
+ * (and the panel quick-switch dropdowns) immediately, without a
2316
+ * reopen, because both read reactively off `_viewState`;
2317
+ * 4. set the newly-saved view as that kind's active view via
2318
+ * `applyDatatableView`, which re-points that kind's baseline and
2319
+ * clears that kind's dirty flag - so the quick-switch dropdown
2320
+ * shows the new view's name straight away rather than
2321
+ * `'Custom (unsaved)'`. The authoritative refreshed copy (matched
2322
+ * by id) is preferred over the raw `createView` return so the
2323
+ * baseline reflects exactly what the backend persisted.
2316
2324
  *
2317
2325
  * Only the named kind's state is touched. No-op (dev-logged via
2318
2326
  * `devCatch`) when remote saved views are disabled (`datatableUuid` /
@@ -2342,8 +2350,15 @@ export class TableContext {
2342
2350
  ownerUserId,
2343
2351
  };
2344
2352
  const created = await adapter.createView(request);
2345
- this._viewState.setSavedViews(kind, [...this._viewState.savedViews(kind), created]);
2346
- this.applyDatatableView(created);
2353
+ // Re-fetch the authoritative saved-views lists so the new view
2354
+ // shows up in the modal and the panel dropdowns immediately.
2355
+ await this.refreshSavedViews();
2356
+ // Prefer the refreshed copy (matched by id); fall back to the
2357
+ // raw create return if the refresh did not surface it.
2358
+ const authoritative = this._viewState.savedViews(kind).find((view) => view.id === created.id) ?? created;
2359
+ // Make the just-saved view active so the quick-switch dropdown
2360
+ // shows its name (not "Custom (unsaved)") and dirty is cleared.
2361
+ this.applyDatatableView(authoritative);
2347
2362
  }
2348
2363
  catch (e) {
2349
2364
  devCatch(e);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.38.0",
4
+ "version": "0.38.2",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },