@lavalogic/scoria 0.38.7 → 0.38.8

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.
@@ -0,0 +1,377 @@
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 Icon from '../../Icon.svelte';
10
+ import MultiSelect from '../../MultiSelect.svelte';
11
+ import { cssLength } from '../../../Helpers/Helpers.svelte.js';
12
+ import { ColourSet } from '../../../scss/colours.js';
13
+ import { Size } from '../../../Types/Internal/Size.js';
14
+ import { Variant } from '../../../Types/Internal/Variant.js';
15
+ import { getContext, onMount } from 'svelte';
16
+ import { TableContext } from '../Types/Context/TableContext.svelte.js';
17
+ import type { Primitive } from '../Types/Context/TableInitOptions.js';
18
+ import type { DatatableViewUser } from '../Types/Persistence/RemoteTableLayoutAdapter.js';
19
+ import type { ShareViewModalProps } from './ShareViewModalProps.js';
20
+
21
+ const { view, onclose, onsaved }: ShareViewModalProps = $props();
22
+
23
+ const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
24
+
25
+ // All users the current user is allowed to share with. `null` until the
26
+ // boot-time `listUsers` fetch resolves; an array (possibly empty) once
27
+ // the call lands. Bound to the `MultiSelect` options below.
28
+ let allUsers = $state<ReadonlyArray<DatatableViewUser> | null>(null);
29
+
30
+ // User-visible error banner. Adapter rejections are routed here so the
31
+ // modal never throws into the host UI.
32
+ let errorMessage = $state<string | null>(null);
33
+
34
+ // In-flight flags for the two adapter call sites. `loadingUsers` blanks
35
+ // the picker while `listUsers` is in flight; `saving` disables the
36
+ // footer buttons while the subscribe / unsubscribe deltas land.
37
+ let loadingUsers = $state(false);
38
+ let saving = $state(false);
39
+
40
+ /**
41
+ * Current picker selection. Each entry is a `DatatableViewUser`. The
42
+ * initial value is seeded from `view.subscribers` (the existing
43
+ * roster) inside `onMount` so opening the modal shows "who is already
44
+ * shared with", and Save can diff against the originals.
45
+ *
46
+ * `displayName` falls back to the opaque `userId` when the subscriber
47
+ * record carries no label, matching the rest of the Configuration
48
+ * modal's fallback behaviour.
49
+ */
50
+ let selected = $state<Array<DatatableViewUser>>([]);
51
+
52
+ /**
53
+ * Snapshot of the subscriber ids the view started with. The Save diff
54
+ * reads it to compute which subscribers were added (POST subscribe)
55
+ * vs removed (POST unsubscribe). Seeded in `onMount` from
56
+ * `view.subscribers` so the snapshot stays a true *initial* roster
57
+ * regardless of any concurrent edits to `view`.
58
+ *
59
+ * Typed as a plain `Set` (not `ReadonlySet`) only because it is
60
+ * reassigned once in `onMount`; reads downstream still treat it as
61
+ * read-only. Not reactive - the modal owns this snapshot for its
62
+ * lifetime and never re-derives it.
63
+ */
64
+ let initialIds: Set<string> = new Set();
65
+
66
+ // Whether anything in the picker differs from the initial roster.
67
+ // Drives the Save button's disabled state so a no-op Save never hits
68
+ // the network.
69
+ const dirty = $derived.by(() => {
70
+ const currentIds = new Set(selected.map((s) => s.id));
71
+ if (currentIds.size !== initialIds.size) {
72
+ return true;
73
+ }
74
+ for (const id of currentIds) {
75
+ if (!initialIds.has(id)) {
76
+ return true;
77
+ }
78
+ }
79
+ return false;
80
+ });
81
+
82
+ onMount(() => {
83
+ // Snapshot the initial subscriber roster once on mount so the
84
+ // Save diff has a stable starting point. Done inside `onMount`
85
+ // (rather than at `$state` init) so Svelte's reactivity lint does
86
+ // not flag the read as "captures the initial value of `view`" -
87
+ // the modal owns this snapshot for its lifetime intentionally.
88
+ const initial = (view.subscribers ?? []).map((s) => ({
89
+ id: s.userId,
90
+ displayName: s.displayName?.trim() ? s.displayName : s.userId,
91
+ }));
92
+ selected = initial;
93
+ initialIds = new Set(initial.map((u) => u.id));
94
+
95
+ const adapter = tableContext.remoteLayouts;
96
+ if (!adapter) {
97
+ errorMessage = 'Sharing is unavailable: no remote layouts adapter is configured.';
98
+ return;
99
+ }
100
+ loadingUsers = true;
101
+ adapter
102
+ .listUsers()
103
+ .then((users) => {
104
+ // Exclude the view's owner from the picker - sharing a view
105
+ // with its own owner is nonsensical and the backend would
106
+ // reject the subscribe anyway.
107
+ allUsers = users.filter((u) => u.id !== view.ownerUserId);
108
+ })
109
+ .catch((e: unknown) => {
110
+ console.error('[ShareViewModal] listUsers failed:', e);
111
+ errorMessage =
112
+ e instanceof Error
113
+ ? `Could not load the user list. (${e.message})`
114
+ : 'Could not load the user list.';
115
+ })
116
+ .finally(() => {
117
+ loadingUsers = false;
118
+ });
119
+ });
120
+
121
+ /** Remove a user from the current selection. */
122
+ function removeFromSelection(userId: string): void {
123
+ selected = selected.filter((s) => s.id !== userId);
124
+ }
125
+
126
+ /** Apply the picker selection by issuing the required deltas. */
127
+ async function save(): Promise<void> {
128
+ const adapter = tableContext.remoteLayouts;
129
+ if (!adapter) {
130
+ errorMessage = 'Sharing is unavailable: no remote layouts adapter is configured.';
131
+ return;
132
+ }
133
+ if (!dirty) {
134
+ onclose();
135
+ return;
136
+ }
137
+
138
+ const currentIds = new Set(selected.map((s) => s.id));
139
+ const toAdd: Array<string> = [];
140
+ const toRemove: Array<string> = [];
141
+
142
+ // Added: in the new selection but not the original roster.
143
+ for (const id of currentIds) {
144
+ if (!initialIds.has(id)) {
145
+ toAdd.push(id);
146
+ }
147
+ }
148
+ // Removed: in the original roster but not the new selection.
149
+ for (const id of initialIds) {
150
+ if (!currentIds.has(id)) {
151
+ toRemove.push(id);
152
+ }
153
+ }
154
+
155
+ errorMessage = null;
156
+ saving = true;
157
+ try {
158
+ // Run sequentially so a partial failure surfaces a useful error
159
+ // and leaves the already-applied deltas committed (an aborted
160
+ // `Promise.all` is much harder to reason about).
161
+ for (const userId of toAdd) {
162
+ await adapter.addSubscriber(view.id, userId);
163
+ }
164
+ for (const userId of toRemove) {
165
+ await adapter.removeSubscriber(view.id, userId);
166
+ }
167
+ if (onsaved) {
168
+ await onsaved();
169
+ }
170
+ onclose();
171
+ } catch (e: unknown) {
172
+ console.error('[ShareViewModal] save failed:', e);
173
+ errorMessage =
174
+ e instanceof Error
175
+ ? `Could not update sharing. Some changes may have been applied. (${e.message})`
176
+ : 'Could not update sharing. Some changes may have been applied.';
177
+ } finally {
178
+ saving = false;
179
+ }
180
+ }
181
+ </script>
182
+
183
+ <DesktopModal
184
+ headerLabel={`Share "${view.name}"`}
185
+ headerSubtitle={`Choose who can access this ${view.kind === 'filter' ? 'filter' : 'layout'}.`}
186
+ onclose={() => {
187
+ if (!saving) {
188
+ onclose();
189
+ }
190
+ }}
191
+ width={cssLength('40vw')}
192
+ minWidth={cssLength('32rem')}
193
+ buttons={[
194
+ {
195
+ label: 'Cancel',
196
+ callback: () => {
197
+ if (!saving) {
198
+ onclose();
199
+ }
200
+ },
201
+ disabled: saving,
202
+ },
203
+ {
204
+ label: saving ? 'Saving…' : 'Save',
205
+ callback: () => {
206
+ void save();
207
+ },
208
+ colourSet: ColourSet.Primary,
209
+ disabled: saving || !dirty || allUsers === null,
210
+ },
211
+ ]}
212
+ >
213
+ <div class="share-body">
214
+ {#if errorMessage}
215
+ <div
216
+ class="error-banner"
217
+ role="alert"
218
+ >
219
+ <Icon
220
+ name="circle-exclamation"
221
+ size={Size.Small}
222
+ />
223
+ <span>{errorMessage}</span>
224
+ </div>
225
+ {/if}
226
+
227
+ <div class="picker">
228
+ {#if loadingUsers && allUsers === null}
229
+ <p class="muted">Loading users…</p>
230
+ {:else if allUsers !== null}
231
+ <MultiSelect
232
+ label="Users"
233
+ name="User"
234
+ options={allUsers as Array<DatatableViewUser>}
235
+ labelProp="displayName"
236
+ valueProp="id"
237
+ valueAsObject={true}
238
+ selectedValues={selected}
239
+ onchange={(values) => {
240
+ selected = values;
241
+ }}
242
+ />
243
+ {/if}
244
+ </div>
245
+
246
+ {#if selected.length === 0}
247
+ <p class="muted">
248
+ No users selected. Use the picker above to share this {view.kind === 'filter'
249
+ ? 'filter'
250
+ : 'layout'} with one or more users.
251
+ </p>
252
+ {:else}
253
+ <ul class="user-card-list">
254
+ {#each selected as user (user.id)}
255
+ <li class="user-card">
256
+ <div class="user-info">
257
+ <Icon
258
+ name="user-3"
259
+ size={Size.Medium}
260
+ />
261
+ <span class="user-name">{user.displayName}</span>
262
+ </div>
263
+ <div class="user-meta">
264
+ <!--
265
+ v1 sharing exposes a single role - subscriber - so
266
+ the "permission" surface is informational only. The
267
+ backend's `editor` / `systemOwned` flags are
268
+ reserved no-ops in v1 (see `DatatableView` DTO
269
+ JSDoc); scoria never sets them.
270
+ -->
271
+ <span class="permission-pill">Subscriber</span>
272
+ <Button
273
+ variant={Variant.Secondary}
274
+ size={Size.MediumSmall}
275
+ nowrap
276
+ iconLeft={{ name: 'circle-cross', size: Size.Small }}
277
+ disabled={saving}
278
+ onclick={() => {
279
+ removeFromSelection(user.id);
280
+ }}>Remove</Button
281
+ >
282
+ </div>
283
+ </li>
284
+ {/each}
285
+ </ul>
286
+ {/if}
287
+ </div>
288
+ </DesktopModal>
289
+
290
+ <style>.share-body {
291
+ display: flex;
292
+ flex-flow: column nowrap;
293
+ gap: 0.75rem;
294
+ padding: 1rem;
295
+ min-height: 25vh;
296
+ max-height: 60vh;
297
+ overflow-y: auto;
298
+ background-color: #ffffff;
299
+ }
300
+
301
+ .error-banner {
302
+ display: flex;
303
+ flex-flow: row nowrap;
304
+ align-items: center;
305
+ gap: 0.5rem;
306
+ padding: 0.5rem 0.75rem;
307
+ border: solid 1px #aa1414;
308
+ border-radius: 4px;
309
+ background-color: #f9e9e9;
310
+ color: #aa1414;
311
+ }
312
+
313
+ .picker {
314
+ display: flex;
315
+ flex-flow: column nowrap;
316
+ }
317
+
318
+ .muted {
319
+ color: #647d8e;
320
+ margin: 0;
321
+ }
322
+
323
+ .user-card-list {
324
+ list-style: none;
325
+ margin: 0;
326
+ padding: 0;
327
+ display: flex;
328
+ flex-flow: column nowrap;
329
+ gap: 0.5rem;
330
+ }
331
+
332
+ .user-card {
333
+ display: flex;
334
+ flex-flow: row nowrap;
335
+ align-items: center;
336
+ justify-content: space-between;
337
+ gap: 0.75rem;
338
+ padding: 0.6rem 0.75rem;
339
+ border: solid 1px #cbd4da;
340
+ border-radius: 4px;
341
+ background-color: #ffffff;
342
+ }
343
+
344
+ .user-info {
345
+ display: flex;
346
+ flex-flow: row nowrap;
347
+ align-items: center;
348
+ gap: 0.5rem;
349
+ min-width: 0;
350
+ }
351
+
352
+ .user-name {
353
+ font-weight: 600;
354
+ overflow: hidden;
355
+ text-overflow: ellipsis;
356
+ white-space: nowrap;
357
+ }
358
+
359
+ .user-meta {
360
+ display: flex;
361
+ flex-flow: row nowrap;
362
+ align-items: center;
363
+ gap: 0.5rem;
364
+ flex-shrink: 0;
365
+ }
366
+
367
+ /* v1 permissions are informational only - one pill per card showing
368
+ "Subscriber". The visual is a quiet tag so it does not compete with
369
+ the Remove action; the backend's `editor` / `systemOwned` flags are
370
+ reserved no-ops in v1. */
371
+ .permission-pill {
372
+ font-size: 0.875rem;
373
+ padding: 0.15rem 0.5rem;
374
+ border-radius: 999px;
375
+ background-color: #e9ecf1;
376
+ color: #3a4952;
377
+ }</style>
@@ -0,0 +1,26 @@
1
+ import type { Primitive } from '../Types/Context/TableInitOptions.js';
2
+ import type { ShareViewModalProps } from './ShareViewModalProps.js';
3
+ declare function $$render<T extends object, IdType extends Primitive>(): {
4
+ props: ShareViewModalProps;
5
+ exports: {};
6
+ bindings: "";
7
+ slots: {};
8
+ events: {};
9
+ };
10
+ declare class __sveltets_Render<T extends object, IdType extends Primitive> {
11
+ props(): ReturnType<typeof $$render<T, IdType>>['props'];
12
+ events(): ReturnType<typeof $$render<T, IdType>>['events'];
13
+ slots(): ReturnType<typeof $$render<T, IdType>>['slots'];
14
+ bindings(): "";
15
+ exports(): {};
16
+ }
17
+ interface $$IsomorphicComponent {
18
+ 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']>> & {
19
+ $$bindings?: ReturnType<__sveltets_Render<T, IdType>['bindings']>;
20
+ } & ReturnType<__sveltets_Render<T, IdType>['exports']>;
21
+ <T extends object, IdType extends Primitive>(internal: unknown, props: ReturnType<__sveltets_Render<T, IdType>['props']> & {}): ReturnType<__sveltets_Render<T, IdType>['exports']>;
22
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
23
+ }
24
+ declare const ShareViewModal: $$IsomorphicComponent;
25
+ type ShareViewModal<T extends object, IdType extends Primitive> = InstanceType<typeof ShareViewModal<T, IdType>>;
26
+ export default ShareViewModal;
@@ -0,0 +1,24 @@
1
+ import type { DatatableView } from '../Types/Persistence/DatatableView.js';
2
+ /**
3
+ * Props for `ShareViewModal` - the FPM 403 round-4 share dialog opened
4
+ * from a saved view card's "Share" button. The modal renders a user
5
+ * picker (driven by `RemoteTableLayoutAdapter.listUsers`) plus a
6
+ * permission card per selected user; on Save it diffs the picker
7
+ * selection against the view's current `subscribers` and issues one
8
+ * `addSubscriber` / `removeSubscriber` call per change.
9
+ */
10
+ export interface ShareViewModalProps {
11
+ /** The view being shared. The modal reads its current `subscribers`
12
+ * roster as the initial picker selection and writes deltas back via
13
+ * `addSubscriber` / `removeSubscriber`. */
14
+ view: DatatableView;
15
+ /** Called when the modal should close - both on Cancel and after a
16
+ * successful Save. The host (Table Configuration modal) is also
17
+ * expected to refresh its view list after a successful Save so the
18
+ * subscriber strip on the card reflects the change. */
19
+ onclose: () => void;
20
+ /** Called after a successful Save, before `onclose`. The host wires
21
+ * this to a saved-views refresh so the subscriber strip on the
22
+ * caller's card updates. Optional - omit to skip the refresh. */
23
+ onsaved?: () => void | Promise<void>;
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -19,6 +19,7 @@
19
19
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
20
20
  import type { DatatableView } from '../Types/Persistence/DatatableView.js';
21
21
  import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
22
+ import ShareViewModal from './ShareViewModal.svelte';
22
23
  import type { TableConfigurationModalProps } from './TableConfigurationModalProps.js';
23
24
 
24
25
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
@@ -52,6 +53,12 @@
52
53
  // My-tab shows "Loading…" rather than the "nothing saved yet" message.
53
54
  let loadingViews = $state(false);
54
55
 
56
+ // The view whose Share modal is currently open. `null` when no share
57
+ // modal is rendered. Owner cards (rendered on the My-tab) wire their
58
+ // Share button to open `ShareViewModal` against the view they belong
59
+ // to; the modal closes itself on Cancel / Save.
60
+ let sharingView = $state<DatatableView | null>(null);
61
+
55
62
  // Whether remote saved views are available at all. A table created
56
63
  // without `datatableUuid` / `remoteLayouts` opts out of remote views;
57
64
  // the modal then renders a graceful "unavailable" empty state and
@@ -356,7 +363,12 @@
356
363
  nowrap
357
364
  iconLeft={{ name: 'external-link', size: Size.Small }}
358
365
  onclick={() => {
359
- selectTab('shared');
366
+ // Open the share dialog rather than switching to
367
+ // the "Shared" tab - subscribing OTHER users to
368
+ // this view is a distinct flow from "Subscribe
369
+ // to Additional" (which is the current user
370
+ // joining a public view).
371
+ sharingView = view;
360
372
  }}>Share</Button
361
373
  >
362
374
  <Button
@@ -486,10 +498,16 @@
486
498
  }
487
499
  }}
488
500
  />
489
- <Button
490
- disabled={!newViewName.trim()}
491
- onclick={saveNewView}>Save Current</Button
492
- >
501
+ <!-- Wrap the button in a row so the flex column's default
502
+ `align-items: stretch` does not blow it up to the input's
503
+ width. The row itself can stretch; the button inside sits
504
+ at its natural content width like every other Button. -->
505
+ <div class="save-tab-actions">
506
+ <Button
507
+ disabled={!newViewName.trim()}
508
+ onclick={saveNewView}>Save Current</Button
509
+ >
510
+ </div>
493
511
  </div>
494
512
  {/if}
495
513
  </div>
@@ -524,6 +542,25 @@
524
542
  {/if}
525
543
  </DesktopModal>
526
544
 
545
+ <!--
546
+ Share dialog stacked on top of the Configuration modal. Rendered as a
547
+ sibling (not nested in the modal body) so the second modal's overlay
548
+ covers the first - matching how every other layered modal in scoria
549
+ stacks. After a successful Save we refresh the saved-views list so the
550
+ owner's card's subscriber strip updates immediately, then close.
551
+ -->
552
+ {#if sharingView}
553
+ <ShareViewModal
554
+ view={sharingView}
555
+ onclose={() => {
556
+ sharingView = null;
557
+ }}
558
+ onsaved={async () => {
559
+ await tableContext.refreshSavedViews();
560
+ }}
561
+ />
562
+ {/if}
563
+
527
564
  <style>.empty-state {
528
565
  display: flex;
529
566
  flex-flow: column nowrap;
@@ -574,7 +611,9 @@
574
611
  frame visually stable across every section/tab combination (short
575
612
  tabs no longer shrink the dialog); the `max-height` + `overflow-y`
576
613
  keep taller content scrolling within that fixed frame rather than
577
- growing the dialog. */
614
+ growing the dialog. The white background overrides the wrapping
615
+ `VerticalTabGroup .content` background (`$ui-blue-1`, a grey tint)
616
+ so the body reads as a clean content surface. */
578
617
  .tab-body {
579
618
  display: flex;
580
619
  flex-flow: column nowrap;
@@ -583,6 +622,7 @@
583
622
  min-height: 30vh;
584
623
  max-height: 60vh;
585
624
  overflow-y: auto;
625
+ background-color: #ffffff;
586
626
  }
587
627
 
588
628
  .muted {
@@ -659,4 +699,14 @@
659
699
  flex-flow: column nowrap;
660
700
  gap: 0.75rem;
661
701
  max-width: 24rem;
702
+ }
703
+
704
+ /* Row wrapping the Save Current button. The wrapper stretches with
705
+ the surrounding flex column, but the button inside sits at its
706
+ natural content width (matching every other standard Button) and
707
+ left-aligns on the row. */
708
+ .save-tab-actions {
709
+ display: flex;
710
+ flex-flow: row nowrap;
711
+ justify-content: flex-start;
662
712
  }</style>
@@ -409,6 +409,18 @@ export declare class TableContext<T extends object, RowIdType extends Primitive>
409
409
  * factory layout regardless of which saved view was active.
410
410
  */
411
411
  private _defaultLayoutSnapshot;
412
+ /**
413
+ * Coded-default column widths captured before any user interaction, by
414
+ * column id. Populated once during the boot chain (after
415
+ * `setupDefaultColumnDefs` and before the working copy / saved view
416
+ * hydration overlays anything). `_applyLayout` reads it to restore the
417
+ * factory widths of columns NOT mentioned in the applied layout's
418
+ * `sizing` map; without this, switching back to the Default view (whose
419
+ * `sizing` is empty) would leave previously-resized columns at the
420
+ * resized width because the apply loop only touches columns it has an
421
+ * explicit entry for.
422
+ */
423
+ private _defaultColumnWidths;
412
424
  /**
413
425
  * Baseline `JSONTableFilter` the *filter* dirty comparison is
414
426
  * measured against: the saved state of whatever filter view
@@ -500,6 +500,17 @@ export class TableContext {
500
500
  // `resetToDefault` restores it.
501
501
  this._defaultLayoutSnapshot = this._captureLayout();
502
502
  this._viewBaseline = this._defaultLayoutSnapshot;
503
+ // Capture each column's coded-default width here, BEFORE
504
+ // any saved snapshot or user-resize can mutate `def.width`.
505
+ // `_applyLayout` consults this map to restore widths of
506
+ // columns absent from an applied layout's `sizing` (e.g.
507
+ // the Default view, whose `sizing` is empty) - otherwise
508
+ // a previously-resized column would stay at its resized
509
+ // width forever.
510
+ this._defaultColumnWidths.clear();
511
+ for (const def of this.columnDefs) {
512
+ this._defaultColumnWidths.set(def.id, def.width);
513
+ }
503
514
  // Capture the Default-filter baseline: the empty / initial
504
515
  // filter state at boot, BEFORE the localStorage working
505
516
  // copy is overlaid. This is the "Default" the FPM 403
@@ -1015,6 +1026,19 @@ export class TableContext {
1015
1026
  * factory layout regardless of which saved view was active.
1016
1027
  */
1017
1028
  _defaultLayoutSnapshot = undefined;
1029
+ /**
1030
+ * Coded-default column widths captured before any user interaction, by
1031
+ * column id. Populated once during the boot chain (after
1032
+ * `setupDefaultColumnDefs` and before the working copy / saved view
1033
+ * hydration overlays anything). `_applyLayout` reads it to restore the
1034
+ * factory widths of columns NOT mentioned in the applied layout's
1035
+ * `sizing` map; without this, switching back to the Default view (whose
1036
+ * `sizing` is empty) would leave previously-resized columns at the
1037
+ * resized width because the apply loop only touches columns it has an
1038
+ * explicit entry for.
1039
+ */
1040
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
1041
+ _defaultColumnWidths = new Map();
1018
1042
  /**
1019
1043
  * Baseline `JSONTableFilter` the *filter* dirty comparison is
1020
1044
  * measured against: the saved state of whatever filter view
@@ -1986,8 +2010,26 @@ export class TableContext {
1986
2010
  }
1987
2011
  this.columnVisibility = visibility;
1988
2012
  // Sizing: restore user-resized widths onto the matching defs. The
1989
- // grid template reads `def.width` + `def.userResized`, so set both;
1990
- // columns absent from the snapshot keep their factory default.
2013
+ // grid template reads `def.width` + `def.userResized`, so set both.
2014
+ //
2015
+ // Columns ABSENT from `layout.sizing` must be reset to their
2016
+ // coded-default width (with `userResized = false`) - otherwise a
2017
+ // previously-resized column stays at its resized width forever when
2018
+ // the user switches to a layout that does not mention it (most
2019
+ // notably the Default view, whose `sizing` is empty). Two passes:
2020
+ // reset everything to its coded default first, then overwrite with
2021
+ // the layout's explicit entries.
2022
+ const sizedIds = new Set(Object.keys(layout.sizing));
2023
+ for (const def of this.columnDefs) {
2024
+ if (sizedIds.has(def.id)) {
2025
+ continue;
2026
+ }
2027
+ const original = this._defaultColumnWidths.get(def.id);
2028
+ if (original !== undefined) {
2029
+ def.width = original;
2030
+ }
2031
+ def.userResized = false;
2032
+ }
1991
2033
  for (const [id, width] of Object.entries(layout.sizing)) {
1992
2034
  const def = byId.get(id);
1993
2035
  if (def) {
@@ -1,4 +1,17 @@
1
1
  import type { CreateDatatableViewRequest, DatatableView, UpdateDatatableViewRequest } from './DatatableView.js';
2
+ /**
3
+ * One entry in the user-picker roster returned by
4
+ * `RemoteTableLayoutAdapter.listUsers`. The opaque `id` is the same token
5
+ * space as `DatatableView.ownerUserId` / `DatatableViewSubscriber.userId`
6
+ * (so it can be passed straight to `addSubscriber` / `removeSubscriber`);
7
+ * `displayName` is a host-provided human label scoria renders verbatim.
8
+ */
9
+ export interface DatatableViewUser {
10
+ /** Opaque user identifier (same token space as `ownerUserId`). */
11
+ id: string;
12
+ /** Human-readable label shown in the share modal's user picker. */
13
+ displayName: string;
14
+ }
2
15
  /**
3
16
  * Injected backend adapter for saved datatable layouts and filters.
4
17
  *
@@ -44,4 +57,15 @@ export interface RemoteTableLayoutAdapter {
44
57
  addSubscriber(viewId: string, userId: string): Promise<void>;
45
58
  /** Remove a subscriber. */
46
59
  removeSubscriber(viewId: string, userId: string): Promise<void>;
60
+ /**
61
+ * Roster of users the current user could share a view with. Used by
62
+ * the Table Configuration modal's Share dialog to populate the user
63
+ * picker. The implementation decides who is "available" (typically
64
+ * everyone the current user can see); scoria treats the result as an
65
+ * opaque list. A backend "not found" / empty list is returned as an
66
+ * empty array - implementations must never return `null`.
67
+ *
68
+ * Added in the FPM 403 round-4 share flow.
69
+ */
70
+ listUsers(): Promise<ReadonlyArray<DatatableViewUser>>;
47
71
  }
package/dist/index.d.ts CHANGED
@@ -113,7 +113,7 @@ export { type JSONTableFilter, TABLE_FILTER_SCHEMA_VERSION, isJSONTableFilter, }
113
113
  export { type DatatableViewKind } from './Components/Table/Types/Persistence/DatatableViewKind.js';
114
114
  export { type DatatableViewBody } from './Components/Table/Types/Persistence/DatatableViewEnvelope.js';
115
115
  export type { CreateDatatableViewRequest, DatatableView, DatatableViewSubscriber, DatatableViewSubscription, UpdateDatatableViewRequest, } from './Components/Table/Types/Persistence/DatatableView.js';
116
- export { type RemoteTableLayoutAdapter } from './Components/Table/Types/Persistence/RemoteTableLayoutAdapter.js';
116
+ export { type RemoteTableLayoutAdapter, type DatatableViewUser, } from './Components/Table/Types/Persistence/RemoteTableLayoutAdapter.js';
117
117
  export { type ActiveViewRef, type JSONActiveView, TABLE_ACTIVE_VIEW_SCHEMA_VERSION, isJSONActiveView, } from './Components/Table/Types/Persistence/JSONActiveView.js';
118
118
  export { type ValidationFn } from './Components/Table/Types/Columns/Definitions/ValidationFn.js';
119
119
  export { type TableSettings } from './Components/Table/Types/Context/TableSettings.js';
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.7",
4
+ "version": "0.38.8",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },