@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.
- package/dist/Components/Table/Misc/ShareViewModal.svelte +377 -0
- package/dist/Components/Table/Misc/ShareViewModal.svelte.d.ts +26 -0
- package/dist/Components/Table/Misc/ShareViewModalProps.d.ts +24 -0
- package/dist/Components/Table/Misc/ShareViewModalProps.js +1 -0
- package/dist/Components/Table/Misc/TableConfigurationModal.svelte +56 -6
- package/dist/Components/Table/Types/Context/TableContext.svelte.d.ts +12 -0
- package/dist/Components/Table/Types/Context/TableContext.svelte.js +44 -2
- package/dist/Components/Table/Types/Persistence/RemoteTableLayoutAdapter.d.ts +24 -0
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
//
|
|
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';
|