@lavalogic/scoria 0.38.7 → 0.38.9

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,65 @@
1
+ <svelte:options runes />
2
+
3
+ <script lang="ts">
4
+ import { Size } from '../Types/Internal/Size.js';
5
+ import Icon from './Icon.svelte';
6
+ import type { InfoAlertProps } from './InfoAlertProps.js';
7
+
8
+ const { children, iconName = 'circle-information' }: InfoAlertProps = $props();
9
+ </script>
10
+
11
+ <!--
12
+ Small, inline informational banner. Mirrors the structure of the
13
+ `.error-banner` block already used inside the Table Configuration
14
+ modal - leading icon + body text on a tinted background - but on a
15
+ neutral blue palette so it reads as "helper text" rather than "error".
16
+
17
+ The body is a snippet so callers can inline links or emphasis without
18
+ the component prescribing a content shape.
19
+ -->
20
+ <div
21
+ class="info-alert"
22
+ role="status"
23
+ >
24
+ <div class="icon">
25
+ <Icon
26
+ name={iconName}
27
+ size={Size.Small}
28
+ />
29
+ </div>
30
+ <div class="body">
31
+ {@render children()}
32
+ </div>
33
+ </div>
34
+
35
+ <style>.info-alert {
36
+ display: flex;
37
+ flex-flow: row nowrap;
38
+ align-items: flex-start;
39
+ gap: 0.5rem;
40
+ padding: 0.625rem 0.875rem;
41
+ border: solid 1px #cbd4da;
42
+ border-radius: 4px;
43
+ background-color: #f4f7f9;
44
+ color: #3a4952;
45
+ font-size: 1.125rem;
46
+ line-height: 1.4;
47
+ }
48
+
49
+ /* Nudge the icon down a hair so it sits visually centred against
50
+ the first line of body text rather than aligned to the very top
51
+ of the first character ascent. */
52
+ .icon {
53
+ display: flex;
54
+ align-items: center;
55
+ flex-shrink: 0;
56
+ padding-top: 0.0625rem;
57
+ color: #647d8e;
58
+ --colour: #647d8e;
59
+ }
60
+
61
+ .body {
62
+ flex: 1 1 auto;
63
+ min-width: 0;
64
+ color: #647d8e;
65
+ }</style>
@@ -0,0 +1,4 @@
1
+ import type { InfoAlertProps } from './InfoAlertProps.js';
2
+ declare const InfoAlert: import("svelte").Component<InfoAlertProps, {}, "">;
3
+ type InfoAlert = ReturnType<typeof InfoAlert>;
4
+ export default InfoAlert;
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { icons } from './Icons.js';
3
+ /**
4
+ * Props for `InfoAlert` - the small, inline informational banner used
5
+ * for non-blocking helper text and empty-state messages (e.g. "you
6
+ * have not saved any layouts yet…", "no filters match the search…").
7
+ *
8
+ * Visually it mirrors the existing `.error-banner` pattern used inside
9
+ * scoria's modals - an icon plus a single line / paragraph of body
10
+ * text on a tinted background - but with neutral / informational
11
+ * colours rather than error-red. It is deliberately a thin shell: any
12
+ * inline links or `<strong>` accents go inside the default snippet.
13
+ */
14
+ export interface InfoAlertProps {
15
+ /** Body of the alert, rendered to the right of the leading icon. */
16
+ children: Snippet;
17
+ /**
18
+ * Optional icon name override. Defaults to `circle-information`,
19
+ * the standard neutral-info glyph; pass another name from the
20
+ * frozen `icons` registry to fit a more specific empty-state visual
21
+ * (e.g. `circle-exclamation` for a gentle warning).
22
+ */
23
+ iconName?: keyof typeof icons;
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -17,7 +17,6 @@
17
17
 
18
18
  const { columnDef }: ColumnListRowProps<T> = $props();
19
19
 
20
-
21
20
  /**
22
21
  * Position of this column inside the sorted column list. Drives the
23
22
  * disabled state of the "move up" / "move down" buttons and supplies
@@ -507,6 +506,14 @@ li.single-column:not(.anchor):last-of-type {
507
506
  display: flex;
508
507
  flex-shrink: 0;
509
508
  }
509
+ .visibility-wrapper :global(div.inner-checkbox-container) {
510
+ border-width: 1px;
511
+ border-color: #cbd4da;
512
+ }
513
+ .visibility-wrapper :global(.outer-checkbox-container:hover div.inner-checkbox-container:not(.disabled)) {
514
+ border-width: 1px;
515
+ border-color: #adbbc5;
516
+ }
510
517
 
511
518
  .pin-selector {
512
519
  --input-height: 2.25rem;
@@ -519,6 +526,12 @@ li.single-column:not(.anchor):last-of-type {
519
526
  .pin-selector.disabled {
520
527
  opacity: 0.5;
521
528
  }
529
+ .pin-selector :global(.radios > label) {
530
+ padding: 0;
531
+ }
532
+ .pin-selector :global(.radios > label:first-child) {
533
+ border-left: none;
534
+ }
522
535
 
523
536
  .column-label {
524
537
  width: 100%;
@@ -0,0 +1,378 @@
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 InfoAlert from '../../InfoAlert.svelte';
11
+ import MultiSelect from '../../MultiSelect.svelte';
12
+ import { cssLength } from '../../../Helpers/Helpers.svelte.js';
13
+ import { ColourSet } from '../../../scss/colours.js';
14
+ import { Size } from '../../../Types/Internal/Size.js';
15
+ import { Variant } from '../../../Types/Internal/Variant.js';
16
+ import { getContext, onMount } from 'svelte';
17
+ import { TableContext } from '../Types/Context/TableContext.svelte.js';
18
+ import type { Primitive } from '../Types/Context/TableInitOptions.js';
19
+ import type { DatatableViewUser } from '../Types/Persistence/RemoteTableLayoutAdapter.js';
20
+ import type { ShareViewModalProps } from './ShareViewModalProps.js';
21
+
22
+ const { view, onclose, onsaved }: ShareViewModalProps = $props();
23
+
24
+ const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
25
+
26
+ // All users the current user is allowed to share with. `null` until the
27
+ // boot-time `listUsers` fetch resolves; an array (possibly empty) once
28
+ // the call lands. Bound to the `MultiSelect` options below.
29
+ let allUsers = $state<ReadonlyArray<DatatableViewUser> | null>(null);
30
+
31
+ // User-visible error banner. Adapter rejections are routed here so the
32
+ // modal never throws into the host UI.
33
+ let errorMessage = $state<string | null>(null);
34
+
35
+ // In-flight flags for the two adapter call sites. `loadingUsers` blanks
36
+ // the picker while `listUsers` is in flight; `saving` disables the
37
+ // footer buttons while the subscribe / unsubscribe deltas land.
38
+ let loadingUsers = $state(false);
39
+ let saving = $state(false);
40
+
41
+ /**
42
+ * Current picker selection. Each entry is a `DatatableViewUser`. The
43
+ * initial value is seeded from `view.subscribers` (the existing
44
+ * roster) inside `onMount` so opening the modal shows "who is already
45
+ * shared with", and Save can diff against the originals.
46
+ *
47
+ * `displayName` falls back to the opaque `userId` when the subscriber
48
+ * record carries no label, matching the rest of the Configuration
49
+ * modal's fallback behaviour.
50
+ */
51
+ let selected = $state<Array<DatatableViewUser>>([]);
52
+
53
+ /**
54
+ * Snapshot of the subscriber ids the view started with. The Save diff
55
+ * reads it to compute which subscribers were added (POST subscribe)
56
+ * vs removed (POST unsubscribe). Seeded in `onMount` from
57
+ * `view.subscribers` so the snapshot stays a true *initial* roster
58
+ * regardless of any concurrent edits to `view`.
59
+ *
60
+ * Typed as a plain `Set` (not `ReadonlySet`) only because it is
61
+ * reassigned once in `onMount`; reads downstream still treat it as
62
+ * read-only. Not reactive - the modal owns this snapshot for its
63
+ * lifetime and never re-derives it.
64
+ */
65
+ let initialIds: Set<string> = new Set();
66
+
67
+ // Whether anything in the picker differs from the initial roster.
68
+ // Drives the Save button's disabled state so a no-op Save never hits
69
+ // the network.
70
+ const dirty = $derived.by(() => {
71
+ const currentIds = new Set(selected.map((s) => s.id));
72
+ if (currentIds.size !== initialIds.size) {
73
+ return true;
74
+ }
75
+ for (const id of currentIds) {
76
+ if (!initialIds.has(id)) {
77
+ return true;
78
+ }
79
+ }
80
+ return false;
81
+ });
82
+
83
+ onMount(() => {
84
+ // Snapshot the initial subscriber roster once on mount so the
85
+ // Save diff has a stable starting point. Done inside `onMount`
86
+ // (rather than at `$state` init) so Svelte's reactivity lint does
87
+ // not flag the read as "captures the initial value of `view`" -
88
+ // the modal owns this snapshot for its lifetime intentionally.
89
+ const initial = (view.subscribers ?? []).map((s) => ({
90
+ id: s.userId,
91
+ displayName: s.displayName?.trim() ? s.displayName : s.userId,
92
+ }));
93
+ selected = initial;
94
+ initialIds = new Set(initial.map((u) => u.id));
95
+
96
+ const adapter = tableContext.remoteLayouts;
97
+ if (!adapter) {
98
+ errorMessage = 'Sharing is unavailable: no remote layouts adapter is configured.';
99
+ return;
100
+ }
101
+ loadingUsers = true;
102
+ adapter
103
+ .listUsers()
104
+ .then((users) => {
105
+ // Exclude the view's owner from the picker - sharing a view
106
+ // with its own owner is nonsensical and the backend would
107
+ // reject the subscribe anyway.
108
+ allUsers = users.filter((u) => u.id !== view.ownerUserId);
109
+ })
110
+ .catch((e: unknown) => {
111
+ console.error('[ShareViewModal] listUsers failed:', e);
112
+ errorMessage =
113
+ e instanceof Error
114
+ ? `Could not load the user list. (${e.message})`
115
+ : 'Could not load the user list.';
116
+ })
117
+ .finally(() => {
118
+ loadingUsers = false;
119
+ });
120
+ });
121
+
122
+ /** Remove a user from the current selection. */
123
+ function removeFromSelection(userId: string): void {
124
+ selected = selected.filter((s) => s.id !== userId);
125
+ }
126
+
127
+ /** Apply the picker selection by issuing the required deltas. */
128
+ async function save(): Promise<void> {
129
+ const adapter = tableContext.remoteLayouts;
130
+ if (!adapter) {
131
+ errorMessage = 'Sharing is unavailable: no remote layouts adapter is configured.';
132
+ return;
133
+ }
134
+ if (!dirty) {
135
+ onclose();
136
+ return;
137
+ }
138
+
139
+ const currentIds = new Set(selected.map((s) => s.id));
140
+ const toAdd: Array<string> = [];
141
+ const toRemove: Array<string> = [];
142
+
143
+ // Added: in the new selection but not the original roster.
144
+ for (const id of currentIds) {
145
+ if (!initialIds.has(id)) {
146
+ toAdd.push(id);
147
+ }
148
+ }
149
+ // Removed: in the original roster but not the new selection.
150
+ for (const id of initialIds) {
151
+ if (!currentIds.has(id)) {
152
+ toRemove.push(id);
153
+ }
154
+ }
155
+
156
+ errorMessage = null;
157
+ saving = true;
158
+ try {
159
+ // Run sequentially so a partial failure surfaces a useful error
160
+ // and leaves the already-applied deltas committed (an aborted
161
+ // `Promise.all` is much harder to reason about).
162
+ for (const userId of toAdd) {
163
+ await adapter.addSubscriber(view.id, userId);
164
+ }
165
+ for (const userId of toRemove) {
166
+ await adapter.removeSubscriber(view.id, userId);
167
+ }
168
+ if (onsaved) {
169
+ await onsaved();
170
+ }
171
+ onclose();
172
+ } catch (e: unknown) {
173
+ console.error('[ShareViewModal] save failed:', e);
174
+ errorMessage =
175
+ e instanceof Error
176
+ ? `Could not update sharing. Some changes may have been applied. (${e.message})`
177
+ : 'Could not update sharing. Some changes may have been applied.';
178
+ } finally {
179
+ saving = false;
180
+ }
181
+ }
182
+ </script>
183
+
184
+ <DesktopModal
185
+ headerLabel={`Share "${view.name}"`}
186
+ headerSubtitle={`Choose who can access this ${view.kind === 'filter' ? 'filter' : 'layout'}.`}
187
+ onclose={() => {
188
+ if (!saving) {
189
+ onclose();
190
+ }
191
+ }}
192
+ width={cssLength('40vw')}
193
+ minWidth={cssLength('32rem')}
194
+ buttons={[
195
+ {
196
+ label: 'Cancel',
197
+ callback: () => {
198
+ if (!saving) {
199
+ onclose();
200
+ }
201
+ },
202
+ disabled: saving,
203
+ },
204
+ {
205
+ label: saving ? 'Saving…' : 'Save',
206
+ callback: () => {
207
+ void save();
208
+ },
209
+ colourSet: ColourSet.Primary,
210
+ disabled: saving || !dirty || allUsers === null,
211
+ },
212
+ ]}
213
+ >
214
+ <div class="share-body">
215
+ {#if errorMessage}
216
+ <div
217
+ class="error-banner"
218
+ role="alert"
219
+ >
220
+ <Icon
221
+ name="circle-exclamation"
222
+ size={Size.Small}
223
+ />
224
+ <span>{errorMessage}</span>
225
+ </div>
226
+ {/if}
227
+
228
+ <div class="picker">
229
+ {#if loadingUsers && allUsers === null}
230
+ <InfoAlert>Loading users…</InfoAlert>
231
+ {:else if allUsers !== null}
232
+ <MultiSelect
233
+ label="Users"
234
+ name="User"
235
+ options={allUsers as Array<DatatableViewUser>}
236
+ labelProp="displayName"
237
+ valueProp="id"
238
+ valueAsObject={true}
239
+ selectedValues={selected}
240
+ onchange={(values) => {
241
+ selected = values;
242
+ }}
243
+ />
244
+ {/if}
245
+ </div>
246
+
247
+ {#if selected.length === 0}
248
+ <InfoAlert>
249
+ No users selected. Use the picker above to share this {view.kind === 'filter'
250
+ ? 'filter'
251
+ : 'layout'} with one or more users.
252
+ </InfoAlert>
253
+ {:else}
254
+ <ul class="user-card-list">
255
+ {#each selected as user (user.id)}
256
+ <li class="user-card">
257
+ <div class="user-info">
258
+ <Icon
259
+ name="user-3"
260
+ size={Size.Medium}
261
+ />
262
+ <span class="user-name">{user.displayName}</span>
263
+ </div>
264
+ <div class="user-meta">
265
+ <!--
266
+ v1 sharing exposes a single role - subscriber - so
267
+ the "permission" surface is informational only. The
268
+ backend's `editor` / `systemOwned` flags are
269
+ reserved no-ops in v1 (see `DatatableView` DTO
270
+ JSDoc); scoria never sets them.
271
+ -->
272
+ <span class="permission-pill">Subscriber</span>
273
+ <Button
274
+ variant={Variant.Secondary}
275
+ size={Size.MediumSmall}
276
+ nowrap
277
+ iconLeft={{ name: 'circle-cross', size: Size.Small }}
278
+ disabled={saving}
279
+ onclick={() => {
280
+ removeFromSelection(user.id);
281
+ }}>Remove</Button
282
+ >
283
+ </div>
284
+ </li>
285
+ {/each}
286
+ </ul>
287
+ {/if}
288
+ </div>
289
+ </DesktopModal>
290
+
291
+ <style>.share-body {
292
+ display: flex;
293
+ flex-flow: column nowrap;
294
+ gap: 0.75rem;
295
+ padding: 1rem;
296
+ min-height: 25vh;
297
+ max-height: 60vh;
298
+ overflow-y: auto;
299
+ background-color: #ffffff;
300
+ }
301
+
302
+ .error-banner {
303
+ display: flex;
304
+ flex-flow: row nowrap;
305
+ align-items: center;
306
+ gap: 0.5rem;
307
+ padding: 0.5rem 0.75rem;
308
+ border: solid 1px #aa1414;
309
+ border-radius: 4px;
310
+ background-color: #f9e9e9;
311
+ color: #aa1414;
312
+ }
313
+
314
+ .picker {
315
+ display: flex;
316
+ flex-flow: column nowrap;
317
+ }
318
+
319
+ .muted {
320
+ color: #647d8e;
321
+ margin: 0;
322
+ }
323
+
324
+ .user-card-list {
325
+ list-style: none;
326
+ margin: 0;
327
+ padding: 0;
328
+ display: flex;
329
+ flex-flow: column nowrap;
330
+ gap: 0.5rem;
331
+ }
332
+
333
+ .user-card {
334
+ display: flex;
335
+ flex-flow: row nowrap;
336
+ align-items: center;
337
+ justify-content: space-between;
338
+ gap: 0.75rem;
339
+ padding: 0.6rem 0.75rem;
340
+ border: solid 1px #cbd4da;
341
+ border-radius: 4px;
342
+ background-color: #ffffff;
343
+ }
344
+
345
+ .user-info {
346
+ display: flex;
347
+ flex-flow: row nowrap;
348
+ align-items: center;
349
+ gap: 0.5rem;
350
+ min-width: 0;
351
+ }
352
+
353
+ .user-name {
354
+ font-weight: 600;
355
+ overflow: hidden;
356
+ text-overflow: ellipsis;
357
+ white-space: nowrap;
358
+ }
359
+
360
+ .user-meta {
361
+ display: flex;
362
+ flex-flow: row nowrap;
363
+ align-items: center;
364
+ gap: 0.5rem;
365
+ flex-shrink: 0;
366
+ }
367
+
368
+ /* v1 permissions are informational only - one pill per card showing
369
+ "Subscriber". The visual is a quiet tag so it does not compete with
370
+ the Remove action; the backend's `editor` / `systemOwned` flags are
371
+ reserved no-ops in v1. */
372
+ .permission-pill {
373
+ font-size: 0.875rem;
374
+ padding: 0.15rem 0.5rem;
375
+ border-radius: 999px;
376
+ background-color: #e9ecf1;
377
+ color: #3a4952;
378
+ }</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 {};
@@ -8,6 +8,7 @@
8
8
  import DesktopModal from '../../DesktopModal.svelte';
9
9
  import HorizontalTabGroup from '../../HorizontalTabGroup.svelte';
10
10
  import Icon from '../../Icon.svelte';
11
+ import InfoAlert from '../../InfoAlert.svelte';
11
12
  import TextInput from '../../TextInput.svelte';
12
13
  import VerticalTabGroup from '../../VerticalTabGroup.svelte';
13
14
  import { cssLength } from '../../../Helpers/Helpers.svelte.js';
@@ -19,6 +20,7 @@
19
20
  import type { Primitive } from '../Types/Context/TableInitOptions.js';
20
21
  import type { DatatableView } from '../Types/Persistence/DatatableView.js';
21
22
  import type { DatatableViewKind } from '../Types/Persistence/DatatableViewKind.js';
23
+ import ShareViewModal from './ShareViewModal.svelte';
22
24
  import type { TableConfigurationModalProps } from './TableConfigurationModalProps.js';
23
25
 
24
26
  const tableContext = getContext<TableContext<T, IdType>>(TableContext.identifier);
@@ -52,6 +54,12 @@
52
54
  // My-tab shows "Loading…" rather than the "nothing saved yet" message.
53
55
  let loadingViews = $state(false);
54
56
 
57
+ // The view whose Share modal is currently open. `null` when no share
58
+ // modal is rendered. Owner cards (rendered on the My-tab) wire their
59
+ // Share button to open `ShareViewModal` against the view they belong
60
+ // to; the modal closes itself on Cancel / Save.
61
+ let sharingView = $state<DatatableView | null>(null);
62
+
55
63
  // Whether remote saved views are available at all. A table created
56
64
  // without `datatableUuid` / `remoteLayouts` opts out of remote views;
57
65
  // the modal then renders a graceful "unavailable" empty state and
@@ -329,12 +337,12 @@
329
337
 
330
338
  {#if args.tab === 'mine'}
331
339
  {#if loadingViews}
332
- <p class="muted">Loading saved {sectionNounPlural.toLowerCase()}…</p>
340
+ <InfoAlert>Loading saved {sectionNounPlural.toLowerCase()}…</InfoAlert>
333
341
  {:else if myViews.length === 0}
334
- <p class="muted">
342
+ <InfoAlert>
335
343
  You have not saved any {sectionNounPlural.toLowerCase()} yet. Use the "Save a new {sectionNoun}"
336
344
  tab to create one.
337
- </p>
345
+ </InfoAlert>
338
346
  {:else}
339
347
  <ul class="card-list">
340
348
  {#each myViews as view (view.id)}
@@ -356,7 +364,12 @@
356
364
  nowrap
357
365
  iconLeft={{ name: 'external-link', size: Size.Small }}
358
366
  onclick={() => {
359
- selectTab('shared');
367
+ // Open the share dialog rather than switching to
368
+ // the "Shared" tab - subscribing OTHER users to
369
+ // this view is a distinct flow from "Subscribe
370
+ // to Additional" (which is the current user
371
+ // joining a public view).
372
+ sharingView = view;
360
373
  }}>Share</Button
361
374
  >
362
375
  <Button
@@ -409,9 +422,9 @@
409
422
 
410
423
  {#if publicViews !== null}
411
424
  {#if publicViews.length === 0}
412
- <p class="muted">
425
+ <InfoAlert>
413
426
  There are no further {sectionNounPlural.toLowerCase()} available to subscribe to.
414
- </p>
427
+ </InfoAlert>
415
428
  {:else}
416
429
  <ul class="card-list">
417
430
  {#each publicViews as view (view.id)}
@@ -437,9 +450,9 @@
437
450
  {/if}
438
451
 
439
452
  {#if sharedViews.length === 0}
440
- <p class="muted">
453
+ <InfoAlert>
441
454
  No {sectionNounPlural.toLowerCase()} are currently shared with you.
442
- </p>
455
+ </InfoAlert>
443
456
  {:else}
444
457
  <ul class="card-list">
445
458
  {#each sharedViews as view (view.id)}
@@ -472,10 +485,10 @@
472
485
  {/if}
473
486
  {:else}
474
487
  <div class="save-tab">
475
- <p class="muted">
488
+ <InfoAlert>
476
489
  Save the table's current {sectionNoun.toLowerCase()} as a reusable named
477
490
  {sectionNoun.toLowerCase()}.
478
- </p>
491
+ </InfoAlert>
479
492
  <TextInput
480
493
  placeholder={`${sectionNoun} name`}
481
494
  bind:value={newViewName}
@@ -486,10 +499,16 @@
486
499
  }
487
500
  }}
488
501
  />
489
- <Button
490
- disabled={!newViewName.trim()}
491
- onclick={saveNewView}>Save Current</Button
492
- >
502
+ <!-- Wrap the button in a row so the flex column's default
503
+ `align-items: stretch` does not blow it up to the input's
504
+ width. The row itself can stretch; the button inside sits
505
+ at its natural content width like every other Button. -->
506
+ <div class="save-tab-actions">
507
+ <Button
508
+ disabled={!newViewName.trim()}
509
+ onclick={saveNewView}>Save Current</Button
510
+ >
511
+ </div>
493
512
  </div>
494
513
  {/if}
495
514
  </div>
@@ -524,6 +543,25 @@
524
543
  {/if}
525
544
  </DesktopModal>
526
545
 
546
+ <!--
547
+ Share dialog stacked on top of the Configuration modal. Rendered as a
548
+ sibling (not nested in the modal body) so the second modal's overlay
549
+ covers the first - matching how every other layered modal in scoria
550
+ stacks. After a successful Save we refresh the saved-views list so the
551
+ owner's card's subscriber strip updates immediately, then close.
552
+ -->
553
+ {#if sharingView}
554
+ <ShareViewModal
555
+ view={sharingView}
556
+ onclose={() => {
557
+ sharingView = null;
558
+ }}
559
+ onsaved={async () => {
560
+ await tableContext.refreshSavedViews();
561
+ }}
562
+ />
563
+ {/if}
564
+
527
565
  <style>.empty-state {
528
566
  display: flex;
529
567
  flex-flow: column nowrap;
@@ -574,7 +612,9 @@
574
612
  frame visually stable across every section/tab combination (short
575
613
  tabs no longer shrink the dialog); the `max-height` + `overflow-y`
576
614
  keep taller content scrolling within that fixed frame rather than
577
- growing the dialog. */
615
+ growing the dialog. The white background overrides the wrapping
616
+ `VerticalTabGroup .content` background (`$ui-blue-1`, a grey tint)
617
+ so the body reads as a clean content surface. */
578
618
  .tab-body {
579
619
  display: flex;
580
620
  flex-flow: column nowrap;
@@ -583,6 +623,7 @@
583
623
  min-height: 30vh;
584
624
  max-height: 60vh;
585
625
  overflow-y: auto;
626
+ background-color: #ffffff;
586
627
  }
587
628
 
588
629
  .muted {
@@ -659,4 +700,14 @@
659
700
  flex-flow: column nowrap;
660
701
  gap: 0.75rem;
661
702
  max-width: 24rem;
703
+ }
704
+
705
+ /* Row wrapping the Save Current button. The wrapper stretches with
706
+ the surrounding flex column, but the button inside sits at its
707
+ natural content width (matching every other standard Button) and
708
+ left-aligns on the row. */
709
+ .save-tab-actions {
710
+ display: flex;
711
+ flex-flow: row nowrap;
712
+ justify-content: flex-start;
662
713
  }</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
@@ -54,6 +54,8 @@ export type { HorizontalTabGroupProps } from './Components/HorizontalTabGroupPro
54
54
  export { default as Icon } from './Components/Icon.svelte';
55
55
  export { type IconProps } from './Components/IconProps.js';
56
56
  export { icons } from './Components/Icons.js';
57
+ export { default as InfoAlert } from './Components/InfoAlert.svelte';
58
+ export type { InfoAlertProps } from './Components/InfoAlertProps.js';
57
59
  export type { InputProps } from './Components/InputProps.js';
58
60
  export { default as LoadingAnimation } from './Components/LoadingAnimation.svelte';
59
61
  export type { LoadingAnimationProps } from './Components/LoadingAnimationProps.js';
@@ -113,7 +115,7 @@ export { type JSONTableFilter, TABLE_FILTER_SCHEMA_VERSION, isJSONTableFilter, }
113
115
  export { type DatatableViewKind } from './Components/Table/Types/Persistence/DatatableViewKind.js';
114
116
  export { type DatatableViewBody } from './Components/Table/Types/Persistence/DatatableViewEnvelope.js';
115
117
  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';
118
+ export { type RemoteTableLayoutAdapter, type DatatableViewUser, } from './Components/Table/Types/Persistence/RemoteTableLayoutAdapter.js';
117
119
  export { type ActiveViewRef, type JSONActiveView, TABLE_ACTIVE_VIEW_SCHEMA_VERSION, isJSONActiveView, } from './Components/Table/Types/Persistence/JSONActiveView.js';
118
120
  export { type ValidationFn } from './Components/Table/Types/Columns/Definitions/ValidationFn.js';
119
121
  export { type TableSettings } from './Components/Table/Types/Context/TableSettings.js';
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ export { default as HorizontalTabGroup } from './Components/HorizontalTabGroup.s
35
35
  export { default as Icon } from './Components/Icon.svelte';
36
36
  export {} from './Components/IconProps.js';
37
37
  export { icons } from './Components/Icons.js';
38
+ export { default as InfoAlert } from './Components/InfoAlert.svelte';
38
39
  export { default as LoadingAnimation } from './Components/LoadingAnimation.svelte';
39
40
  export { default as LoadingModal } from './Components/LoadingModal.svelte';
40
41
  export { default as LoadingOverlay } from './Components/LoadingOverlay.svelte';
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.9",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },