@svgrid/enterprise 3.1.9 → 3.1.11
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/LICENSE +17 -13
- package/dist/cdn/svgrid-enterprise.svelte-external.js +2 -1
- package/dist/designer/assets/{GridMenus-BUjkkAU2.js → GridMenus-CWAfhEeT.js} +2 -2
- package/dist/designer/assets/SvDateRangeInput-DbfmXqJj.js +1 -0
- package/dist/designer/assets/SvDateTimePicker-r4FqmR6R.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-DTYOEa8t.js +4 -0
- package/dist/designer/assets/{index-BPU2KNc5.js → index-BnjAONWv.js} +311 -311
- package/dist/designer/assets/index-CLQXR_oM.css +1 -0
- package/dist/designer/index.html +4 -4
- package/dist/fields/BaseTableView.svelte +43 -1
- package/dist/fields/LongTextEditor.svelte +6 -1
- package/dist/fields/RecordView.svelte +36 -9
- package/dist/fields/RowHeader.svelte +22 -3
- package/dist/fields/SelectEditor.svelte +8 -1
- package/dist/fields/SvBase.svelte +42 -2
- package/dist/fields/SvBase.svelte.d.ts +3 -0
- package/dist/fields/SvBaseViewBar.svelte +10 -0
- package/dist/fields/SvBaseViewBar.svelte.d.ts +5 -0
- package/dist/fields/activity.svelte.d.ts +42 -0
- package/dist/fields/activity.svelte.js +57 -0
- package/dist/fields/base-schema.d.ts +1 -1
- package/dist/fields/base-schema.js +7 -0
- package/dist/fields/base-sync.d.ts +21 -0
- package/dist/fields/base-sync.js +9 -7
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.js +9 -2
- package/dist/node/studio.js +3 -2
- package/dist/studio/ui-components.generated.js +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/wc/{GridMenus-FtSN_8t9.js → GridMenus-wG7PFpPh.js} +1 -1
- package/dist/wc/{SvDateRangeInput-DR6m7bSM.js → SvDateRangeInput-BYNOfYxD.js} +68 -65
- package/dist/wc/{SvDateTimePicker-D-vRnaMo.js → SvDateTimePicker-Bq9N0Oiy.js} +57 -53
- package/dist/wc/{SvGridCellEditor-B_cLJrsp.js → SvGridCellEditor-C8iNUJrd.js} +158 -147
- package/dist/wc/sv-sheet-element.js +4 -4
- package/package.json +3 -3
- package/dist/designer/assets/SvDateRangeInput-BBRzrQeg.js +0 -1
- package/dist/designer/assets/SvDateTimePicker-DCPg7WGN.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-CJxThKL_.js +0 -4
- package/dist/designer/assets/index-BmvG99kR.css +0 -1
package/dist/designer/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>SvGrid Studio - Designer</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-BnjAONWv.js"></script>
|
|
8
8
|
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-Dd_uD5pT.js">
|
|
9
9
|
<link rel="modulepreload" crossorigin href="./assets/disclose-version-CIm4S9xS.js">
|
|
10
10
|
<link rel="modulepreload" crossorigin href="./assets/cell-formatting-BWrW_O0n.js">
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
<link rel="modulepreload" crossorigin href="./assets/popover-CUkLsvRX.js">
|
|
21
21
|
<link rel="modulepreload" crossorigin href="./assets/SvGridDropdown-Cjz0H-fI.js">
|
|
22
22
|
<link rel="modulepreload" crossorigin href="./assets/dismissable-ChWRPkoa.js">
|
|
23
|
-
<link rel="modulepreload" crossorigin href="./assets/SvDateTimePicker-
|
|
24
|
-
<link rel="modulepreload" crossorigin href="./assets/SvDateRangeInput-
|
|
23
|
+
<link rel="modulepreload" crossorigin href="./assets/SvDateTimePicker-r4FqmR6R.js">
|
|
24
|
+
<link rel="modulepreload" crossorigin href="./assets/SvDateRangeInput-DbfmXqJj.js">
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/focus-trap-V2-6Fy9I.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/SvTabs-2mHC6oaz.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/SvMenuList-Dgq2D5VN.js">
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<link rel="stylesheet" crossorigin href="./assets/SvMenuList-DJ47ba1h.css">
|
|
46
46
|
<link rel="stylesheet" crossorigin href="./assets/SvGridChart-BgLuPO7I.css">
|
|
47
47
|
<link rel="stylesheet" crossorigin href="./assets/SvGridChartPanel-4Pc8y5yO.css">
|
|
48
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
48
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CLQXR_oM.css">
|
|
49
49
|
</head>
|
|
50
50
|
<body>
|
|
51
51
|
<div id="app"></div>
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* With a `runner`, the view bar has the table's Automations; an
|
|
10
10
|
* automation's "Color the record" action paints the grid while a record
|
|
11
11
|
* matches its condition.
|
|
12
|
+
*
|
|
13
|
+
* A record someone else changed (read from the database, `base-sync.ts`)
|
|
14
|
+
* has the cells that moved tinted, and a record added elsewhere the whole
|
|
15
|
+
* row, until the record is opened.
|
|
12
16
|
*/
|
|
13
17
|
import { SvGrid, hasGanttView } from '@svgrid/grid'
|
|
14
18
|
import SvBaseViewBar from './SvBaseViewBar.svelte'
|
|
@@ -16,6 +20,8 @@
|
|
|
16
20
|
import BaseChartView from './BaseChartView.svelte'
|
|
17
21
|
import BaseAutomations from './BaseAutomations.svelte'
|
|
18
22
|
import { gridOf } from './grid-access'
|
|
23
|
+
import { markAllSeen, unseenIn } from './activity.svelte'
|
|
24
|
+
import { findFieldUser } from './sources.svelte'
|
|
19
25
|
import { fieldCopyText } from './copy-text'
|
|
20
26
|
import { recordMenu } from './record-menu'
|
|
21
27
|
import { matches, type AutomationRun, type AutomationRunner, type BaseAutomation } from './automations.svelte'
|
|
@@ -106,7 +112,42 @@
|
|
|
106
112
|
}
|
|
107
113
|
return out
|
|
108
114
|
})
|
|
109
|
-
|
|
115
|
+
// ---- Changes made elsewhere ------------------------------------------------
|
|
116
|
+
const UNSEEN = 'color-mix(in srgb, var(--sg-accent, #2563eb) 20%, transparent)'
|
|
117
|
+
// A format per changed column, drawn on the records that changed it and are not seen yet.
|
|
118
|
+
const unseenFormats = $derived.by(() => {
|
|
119
|
+
const tableId = grid.baseTableId
|
|
120
|
+
const getRowId = grid.getRowId
|
|
121
|
+
if (typeof tableId !== 'string' || !tableId || typeof getRowId !== 'function') return []
|
|
122
|
+
const marks = unseenIn({ base: grid.baseScope, tableId })
|
|
123
|
+
if (!marks.size) return []
|
|
124
|
+
const idOf = (row: Record<string, unknown>) => String(getRowId(row, 0))
|
|
125
|
+
const columns = new Set<string>()
|
|
126
|
+
let added = false
|
|
127
|
+
for (const m of marks.values()) {
|
|
128
|
+
if (m.added) added = true
|
|
129
|
+
for (const c of m.columns) columns.add(c)
|
|
130
|
+
}
|
|
131
|
+
const out: any[] = []
|
|
132
|
+
if (added) out.push({ type: 'rule', when: ({ row }: { row: Record<string, unknown> }) => !!marks.get(idOf(row))?.added, background: UNSEEN })
|
|
133
|
+
for (const c of columns) out.push({ type: 'rule', columns: [c], when: ({ row }: { row: Record<string, unknown> }) => !!marks.get(idOf(row))?.columns.has(c), background: UNSEEN })
|
|
134
|
+
return out
|
|
135
|
+
})
|
|
136
|
+
// The view bar's "3 changed by Ana" chip: one name when one person made them all.
|
|
137
|
+
const changed = $derived.by(() => {
|
|
138
|
+
const tableId = grid.baseTableId
|
|
139
|
+
if (typeof tableId !== 'string' || !tableId) return null
|
|
140
|
+
const scope = { base: grid.baseScope, tableId }
|
|
141
|
+
const marks = unseenIn(scope)
|
|
142
|
+
if (!marks.size) return null
|
|
143
|
+
const authors = new Set([...marks.values()].map((m) => m.by ?? ''))
|
|
144
|
+
const only = authors.size === 1 ? [...authors][0] : ''
|
|
145
|
+
const who = only ? (findFieldUser(only)?.name ?? 'someone else') : authors.size === 1 ? 'someone else' : 'others'
|
|
146
|
+
return { label: `${marks.size} changed by ${who}`, onSeen: () => markAllSeen(scope) }
|
|
147
|
+
})
|
|
148
|
+
const conditionalFormats = $derived(
|
|
149
|
+
highlightFormats.length || unseenFormats.length ? [...((grid.conditionalFormats as any[]) ?? []), ...highlightFormats, ...unseenFormats] : grid.conditionalFormats,
|
|
150
|
+
)
|
|
110
151
|
</script>
|
|
111
152
|
|
|
112
153
|
<div class="sv-fbase" class:is-bare={bare} style:height>
|
|
@@ -119,6 +160,7 @@
|
|
|
119
160
|
viewTypes={types}
|
|
120
161
|
{rowHeights}
|
|
121
162
|
automations={runner && table ? { count: automationList.length, onManage: () => (automationsOpen = true) } : null}
|
|
163
|
+
{changed}
|
|
122
164
|
/>
|
|
123
165
|
{/if}
|
|
124
166
|
<div class="sv-fbase-body">
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* outside saves, and Escape leaves the cell as it was. With rich text on,
|
|
7
7
|
* the footer names the markup the cell draws.
|
|
8
8
|
*/
|
|
9
|
-
import { portalToBody } from '@svgrid/grid'
|
|
9
|
+
import { createDismissableLayer, portalToBody } from '@svgrid/grid'
|
|
10
10
|
import { onMount } from 'svelte'
|
|
11
11
|
import { fieldPropsOf } from './registry.svelte'
|
|
12
12
|
|
|
@@ -74,7 +74,12 @@
|
|
|
74
74
|
window.addEventListener('resize', place)
|
|
75
75
|
window.addEventListener('scroll', onScroll, true)
|
|
76
76
|
document.addEventListener('pointerdown', onDown, true)
|
|
77
|
+
// Drawn at the end of the page, outside the record view's dialog: as the
|
|
78
|
+
// top layer it keeps a click in the panel from closing that dialog.
|
|
79
|
+
const layer = createDismissableLayer({ element: () => [panel ?? null, anchor ?? null], onDismiss: () => {}, closeOnEscape: false, closeOnOutside: false })
|
|
80
|
+
layer.activate()
|
|
77
81
|
return () => {
|
|
82
|
+
layer.release()
|
|
78
83
|
cancelAnimationFrame(frame)
|
|
79
84
|
window.removeEventListener('resize', place)
|
|
80
85
|
window.removeEventListener('scroll', onScroll, true)
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* is written through the grid's own edit path, so undo, validation,
|
|
8
8
|
* onCellValueChange and "last modified" fields behave as in the cell.
|
|
9
9
|
* Fields hidden in the grid are listed under the others, folded. Beside
|
|
10
|
-
* the fields, the record's activity: its changes and its comments.
|
|
10
|
+
* the fields, the record's activity: its changes and its comments. A
|
|
11
|
+
* change made elsewhere (another user, an agent) shows who made it and what
|
|
12
|
+
* the field held before, and opening the record marks it seen.
|
|
11
13
|
* Previous and next step through the rows in the grid's order.
|
|
12
14
|
*/
|
|
13
15
|
import { onMount, untrack } from 'svelte'
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
import FieldInput from './FieldInput.svelte'
|
|
17
19
|
import { getFieldType } from './registry.svelte'
|
|
18
20
|
import { cellText, fieldColumns, primaryIdOf, type FieldColumn } from './grid-access'
|
|
19
|
-
import { addComment, changesOf, commentsOf, timeAgo } from './activity.svelte'
|
|
21
|
+
import { addComment, changesOf, commentsOf, markSeen, timeAgo, unseenOf } from './activity.svelte'
|
|
20
22
|
import { findFieldUser } from './sources.svelte'
|
|
21
23
|
|
|
22
24
|
let {
|
|
@@ -141,17 +143,36 @@
|
|
|
141
143
|
|
|
142
144
|
// ---- Activity ---------------------------------------------------------------
|
|
143
145
|
const allCols = $derived([...columns, ...hidden])
|
|
146
|
+
type FeedItem = { key: string; at: number; by?: string; automation?: string; remote?: boolean; kind: 'edit' | 'added' | 'comment'; field: string; text: string; from?: string }
|
|
144
147
|
const feed = $derived.by(() => {
|
|
145
148
|
if (!row) return []
|
|
146
|
-
const edits = changesOf(ctrl, row.id).map((ch) => {
|
|
149
|
+
const edits = changesOf(ctrl, row.id).map((ch): FeedItem => {
|
|
150
|
+
if (ch.added) return { key: `a${ch.at}`, at: ch.at, by: ch.by, remote: true, kind: 'added', field: '', text: '' }
|
|
147
151
|
const c = allCols.find((x) => x.id === ch.columnId)
|
|
148
|
-
|
|
152
|
+
const show = (v: unknown) => (c ? cellText(asField(c), v, row) : String(v ?? ''))
|
|
153
|
+
return {
|
|
154
|
+
key: `e${ch.at}${ch.columnId}`,
|
|
155
|
+
at: ch.at,
|
|
156
|
+
by: ch.by,
|
|
157
|
+
automation: ch.automation,
|
|
158
|
+
remote: ch.remote,
|
|
159
|
+
kind: 'edit',
|
|
160
|
+
field: c ? label(c) : ch.columnId,
|
|
161
|
+
text: show(ch.value),
|
|
162
|
+
// What it held before, for a change made elsewhere (nothing to show when it was empty).
|
|
163
|
+
...('from' in ch && show(ch.from) ? { from: show(ch.from) } : {}),
|
|
164
|
+
}
|
|
149
165
|
})
|
|
150
|
-
const notes = commentsOf(ctrl, row.id).map((n) => ({ key: n.id, at: n.at, by: n.by,
|
|
166
|
+
const notes = commentsOf(ctrl, row.id).map((n): FeedItem => ({ key: n.id, at: n.at, by: n.by, kind: 'comment', field: '', text: n.text }))
|
|
151
167
|
return [...edits, ...notes].sort((a, b) => a.at - b.at)
|
|
152
168
|
})
|
|
153
|
-
const who = (item:
|
|
154
|
-
item.automation ? `Automation: ${item.automation}` : item.by ? (findFieldUser(item.by)?.name ?? 'Someone') : 'You'
|
|
169
|
+
const who = (item: FeedItem) =>
|
|
170
|
+
item.automation ? `Automation: ${item.automation}` : item.by ? (findFieldUser(item.by)?.name ?? 'Someone') : item.remote ? 'Someone else' : 'You'
|
|
171
|
+
// Shown here: a change made elsewhere is seen, so the grid stops marking it.
|
|
172
|
+
$effect(() => {
|
|
173
|
+
const id = row?.id
|
|
174
|
+
if (id != null && unseenOf(ctrl, id)) untrack(() => markSeen(ctrl, id))
|
|
175
|
+
})
|
|
155
176
|
function send() {
|
|
156
177
|
if (!row || !comment.trim()) return
|
|
157
178
|
addComment(ctrl, row.id, comment)
|
|
@@ -208,8 +229,12 @@
|
|
|
208
229
|
<ol class="sv-frv-feed">
|
|
209
230
|
{#each feed as item (item.key)}
|
|
210
231
|
<li class="sv-frv-item" class:is-comment={item.kind === 'comment'}>
|
|
211
|
-
<div class="sv-frv-item-head"><strong>{who(item)}</strong>{item.kind === 'edit' ? ` changed ${item.field}` : ''}<span class="sv-frv-when">{timeAgo(item.at)}</span></div>
|
|
212
|
-
|
|
232
|
+
<div class="sv-frv-item-head"><strong>{who(item)}</strong>{item.kind === 'edit' ? ` changed ${item.field}` : item.kind === 'added' ? ' added this record' : ''}<span class="sv-frv-when">{timeAgo(item.at)}</span></div>
|
|
233
|
+
{#if item.kind !== 'added'}
|
|
234
|
+
<div class="sv-frv-item-body">
|
|
235
|
+
{#if item.from !== undefined}<span class="sv-frv-from">{item.from}</span><span class="sv-frv-arrow" aria-label="to">→</span>{/if}{item.text || (item.kind === 'edit' ? '(cleared)' : '')}
|
|
236
|
+
</div>
|
|
237
|
+
{/if}
|
|
213
238
|
</li>
|
|
214
239
|
{:else}
|
|
215
240
|
<li class="sv-frv-none">No changes or comments yet.</li>
|
|
@@ -281,6 +306,8 @@
|
|
|
281
306
|
.sv-frv-item { font-size: 12px; }
|
|
282
307
|
.sv-frv-item-head { color: var(--sg-muted, #64748b); }
|
|
283
308
|
.sv-frv-item-head strong { color: var(--sg-fg, #0f172a); font-weight: 600; }
|
|
309
|
+
.sv-frv-from { color: var(--sg-muted, #64748b); text-decoration: line-through; }
|
|
310
|
+
.sv-frv-arrow { margin: 0 5px; color: var(--sg-muted, #64748b); }
|
|
284
311
|
.sv-frv-when { margin-inline-start: 6px; opacity: 0.8; }
|
|
285
312
|
.sv-frv-item-body { margin-top: 2px; white-space: pre-wrap; word-break: break-word; }
|
|
286
313
|
.sv-frv-item.is-comment .sv-frv-item-body {
|
|
@@ -3,18 +3,27 @@
|
|
|
3
3
|
* The row-number cell with `expandRows`: the number, which on hover (or
|
|
4
4
|
* while the row is selected) turns into a checkbox that selects the row,
|
|
5
5
|
* and an expand button that opens the row as a record. A record with
|
|
6
|
-
* comments shows how many; a click opens it at its discussion.
|
|
6
|
+
* comments shows how many; a click opens it at its discussion. A record
|
|
7
|
+
* someone else changed, not opened here since, has a dot in the corner.
|
|
7
8
|
*/
|
|
8
9
|
import { openRecord } from './editor-request.svelte'
|
|
9
|
-
import { commentsOf } from './activity.svelte'
|
|
10
|
+
import { commentsOf, timeAgo, unseenOf } from './activity.svelte'
|
|
11
|
+
import { findFieldUser } from './sources.svelte'
|
|
10
12
|
|
|
11
13
|
let { ctrl, row, rowIndex }: { ctrl: any; row: { id: string; original: unknown }; rowIndex: number } = $props()
|
|
12
14
|
|
|
13
15
|
const selected = $derived(!!ctrl.isRowSelected?.(row.id, row.original))
|
|
14
16
|
const comments = $derived(commentsOf(ctrl, row.id).length)
|
|
17
|
+
const unseen = $derived(unseenOf(ctrl, row.id))
|
|
18
|
+
const unseenText = $derived.by(() => {
|
|
19
|
+
if (!unseen) return ''
|
|
20
|
+
const who = unseen.by ? (findFieldUser(unseen.by)?.name ?? 'Someone else') : 'Someone else'
|
|
21
|
+
return `${who} ${unseen.added ? 'added' : 'changed'} this record ${timeAgo(unseen.at)}`
|
|
22
|
+
})
|
|
15
23
|
</script>
|
|
16
24
|
|
|
17
25
|
<span class="sv-frh" class:is-selected={selected} class:has-comments={comments > 0}>
|
|
26
|
+
{#if unseen}<span class="sv-frh-unseen" role="img" aria-label={unseenText} title={unseenText}></span>{/if}
|
|
18
27
|
<span class="sv-frh-num" aria-hidden="true">{rowIndex + 1}</span>
|
|
19
28
|
<button
|
|
20
29
|
type="button"
|
|
@@ -61,7 +70,17 @@
|
|
|
61
70
|
</span>
|
|
62
71
|
|
|
63
72
|
<style>
|
|
64
|
-
.sv-frh { display: flex; align-items: center; gap: 4px; height: 100%; padding: 0 4px 0 8px; box-sizing: border-box; }
|
|
73
|
+
.sv-frh { position: relative; display: flex; align-items: center; gap: 4px; height: 100%; padding: 0 4px 0 8px; box-sizing: border-box; }
|
|
74
|
+
.sv-frh-unseen {
|
|
75
|
+
position: absolute; top: 4px; inset-inline-end: 3px; width: 7px; height: 7px; border-radius: 50%;
|
|
76
|
+
background: var(--sg-accent, #2563eb); box-shadow: 0 0 0 2px var(--sg-bg, #fff);
|
|
77
|
+
animation: sv-frh-unseen-in 1.2s ease-out 2;
|
|
78
|
+
}
|
|
79
|
+
@keyframes sv-frh-unseen-in {
|
|
80
|
+
0% { box-shadow: 0 0 0 2px var(--sg-bg, #fff), 0 0 0 2px color-mix(in srgb, var(--sg-accent, #2563eb) 60%, transparent); }
|
|
81
|
+
100% { box-shadow: 0 0 0 2px var(--sg-bg, #fff), 0 0 0 9px transparent; }
|
|
82
|
+
}
|
|
83
|
+
@media (prefers-reduced-motion: reduce) { .sv-frh-unseen { animation: none; } }
|
|
65
84
|
.sv-frh-num { flex: 1; min-width: 0; text-align: center; color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }
|
|
66
85
|
.sv-frh-check {
|
|
67
86
|
display: none; flex: none; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* outside keep what is chosen. Escape on a single select leaves the cell as
|
|
16
16
|
* it was.
|
|
17
17
|
*/
|
|
18
|
-
import { portalToBody } from '@svgrid/grid'
|
|
18
|
+
import { createDismissableLayer, portalToBody } from '@svgrid/grid'
|
|
19
19
|
import { onMount } from 'svelte'
|
|
20
20
|
import { fieldPropsOf } from './registry.svelte'
|
|
21
21
|
import { addSelectOptions } from './field-ops'
|
|
@@ -133,7 +133,14 @@
|
|
|
133
133
|
window.addEventListener('resize', place)
|
|
134
134
|
window.addEventListener('scroll', onScroll, true)
|
|
135
135
|
document.addEventListener('pointerdown', onDown, true)
|
|
136
|
+
// The panel is drawn at the end of the page, outside any dialog the editor
|
|
137
|
+
// sits in (the record view). As the top layer it keeps that dialog from
|
|
138
|
+
// reading a click on an option as a click outside and closing: the editor
|
|
139
|
+
// handles outside clicks and Escape itself.
|
|
140
|
+
const layer = createDismissableLayer({ element: () => [panel ?? null, anchor ?? null], onDismiss: () => {}, closeOnEscape: false, closeOnOutside: false })
|
|
141
|
+
layer.activate()
|
|
136
142
|
return () => {
|
|
143
|
+
layer.release()
|
|
137
144
|
cancelAnimationFrame(frame)
|
|
138
145
|
window.removeEventListener('resize', place)
|
|
139
146
|
window.removeEventListener('scroll', onScroll, true)
|
|
@@ -17,11 +17,16 @@
|
|
|
17
17
|
*
|
|
18
18
|
* A table with a `source` (`baseSource`) loads its records from a database
|
|
19
19
|
* or an API and writes each change back (`base-sync.ts`); the end of the
|
|
20
|
-
* tab bar says when it is loading, saving, or a save failed.
|
|
20
|
+
* tab bar says when it is loading, saving, or a save failed. A change made
|
|
21
|
+
* in the database by someone else (another user, a script, an AI agent)
|
|
22
|
+
* shows up in the page: its cells are tinted and its row marked until the
|
|
23
|
+
* record is opened, and the record's activity says who changed what, from
|
|
24
|
+
* the row's "last modified by" field. `onRemoteChange` hears of each one.
|
|
21
25
|
*/
|
|
22
26
|
import { onMount, untrack } from 'svelte'
|
|
23
27
|
import { toast } from '@svgrid/grid'
|
|
24
|
-
import { createBaseSync, type BaseSyncStatus } from './base-sync'
|
|
28
|
+
import { createBaseSync, type BaseRemoteChange, type BaseSyncStatus } from './base-sync'
|
|
29
|
+
import { noteRemoteChange } from './activity.svelte'
|
|
25
30
|
import BaseTableView from './BaseTableView.svelte'
|
|
26
31
|
import BaseTabs from './BaseTabs.svelte'
|
|
27
32
|
import { registerLinkedTable, unregisterLinkedTable } from './sources.svelte'
|
|
@@ -66,6 +71,7 @@
|
|
|
66
71
|
onApiReady,
|
|
67
72
|
serverRuns,
|
|
68
73
|
automations = true,
|
|
74
|
+
onRemoteChange,
|
|
69
75
|
...grid
|
|
70
76
|
}: {
|
|
71
77
|
/** Several tables, shown as tabs. Without it, `data` and `columns` are the one table. */
|
|
@@ -86,6 +92,8 @@
|
|
|
86
92
|
serverRuns?: () => Promise<ReadonlyArray<AutomationRun>>
|
|
87
93
|
/** The view bar's Automations; false hides it (automations already set still run). */
|
|
88
94
|
automations?: boolean
|
|
95
|
+
/** Changes made in the database by someone else, once they are in the page (tables with a `source` that has `realtime`). */
|
|
96
|
+
onRemoteChange?: (changes: BaseRemoteChange[]) => void
|
|
89
97
|
[prop: string]: any
|
|
90
98
|
} = $props()
|
|
91
99
|
|
|
@@ -179,7 +187,39 @@
|
|
|
179
187
|
syncState = s
|
|
180
188
|
},
|
|
181
189
|
onError: (e) => toast.error(`Could not ${e.action === 'load' ? 'load' : 'save'} ${e.table}: ${e.message}`),
|
|
190
|
+
onRemoteChange: (list) => {
|
|
191
|
+
for (const c of list) noteRemote(c)
|
|
192
|
+
onRemoteChange?.(list)
|
|
193
|
+
},
|
|
182
194
|
})
|
|
195
|
+
|
|
196
|
+
// Fields the database or the page stamps: a change to one says nothing new.
|
|
197
|
+
const STAMPS = new Set(['createdTime', 'createdBy', 'lastModifiedTime', 'lastModifiedBy', 'autoNumber'])
|
|
198
|
+
|
|
199
|
+
/** Who made a change elsewhere: the record's "last modified by" (or, when added, "created by"). */
|
|
200
|
+
function authorOf(t: BaseTable, record: Row, added: boolean): string | undefined {
|
|
201
|
+
const order = added ? ['createdBy', 'lastModifiedBy'] : ['lastModifiedBy']
|
|
202
|
+
for (const type of order) {
|
|
203
|
+
const d = t.columns.find((x) => x.fieldType === type && x.field != null)
|
|
204
|
+
const v = d ? record[String(d.field)] : undefined
|
|
205
|
+
if (v != null && v !== '') return String(v)
|
|
206
|
+
}
|
|
207
|
+
return undefined
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A change made elsewhere goes into the record's activity, and marks it unseen. */
|
|
211
|
+
function noteRemote(c: BaseRemoteChange) {
|
|
212
|
+
const t = tableOf(c.table)
|
|
213
|
+
if (!t || c.type === 'delete') return
|
|
214
|
+
const scope = { base: baseScope, tableId: t.id }
|
|
215
|
+
const by = authorOf(t, c.record, c.type === 'insert')
|
|
216
|
+
if (c.type === 'insert') return noteRemoteChange(scope, c.id, { by, added: true })
|
|
217
|
+
const fields = (c.fields ?? []).flatMap((f) => {
|
|
218
|
+
const d = t.columns.find((x) => x.field != null && String(x.field) === f.field)
|
|
219
|
+
return d && !STAMPS.has(d.fieldType) ? [{ columnId: colId(d), from: f.from, to: f.to }] : []
|
|
220
|
+
})
|
|
221
|
+
noteRemoteChange(scope, c.id, { by, fields })
|
|
222
|
+
}
|
|
183
223
|
onMount(() => {
|
|
184
224
|
void sync.load()
|
|
185
225
|
return () => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type BaseRemoteChange } from './base-sync.js';
|
|
1
2
|
import { type BaseTable } from './base-tables.js';
|
|
2
3
|
import type { FieldView, FieldViewType } from './view-model.js';
|
|
3
4
|
import { type AutomationRun } from './automations.svelte.js';
|
|
@@ -23,6 +24,8 @@ type $$ComponentProps = {
|
|
|
23
24
|
serverRuns?: () => Promise<ReadonlyArray<AutomationRun>>;
|
|
24
25
|
/** The view bar's Automations; false hides it (automations already set still run). */
|
|
25
26
|
automations?: boolean;
|
|
27
|
+
/** Changes made in the database by someone else, once they are in the page (tables with a `source` that has `realtime`). */
|
|
28
|
+
onRemoteChange?: (changes: BaseRemoteChange[]) => void;
|
|
26
29
|
[prop: string]: any;
|
|
27
30
|
};
|
|
28
31
|
declare const SvBase: import("svelte").Component<$$ComponentProps, {}, "tables" | "views" | "activeView" | "activeTable">;
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
rowHeights?: ReadonlyArray<{ label: string; height: number }>
|
|
50
50
|
/** The Automations button: how many rules, and what its buttons open. */
|
|
51
51
|
automations?: { count: number; onManage: () => void } | null
|
|
52
|
+
/** Records someone else changed that nobody here opened yet: what to say, and how to mark them all seen. */
|
|
53
|
+
changed?: { label: string; onSeen: () => void } | null
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
let {
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
onViewsChange,
|
|
59
61
|
viewTypes,
|
|
60
62
|
automations = null,
|
|
63
|
+
changed = null,
|
|
61
64
|
rowHeights = [
|
|
62
65
|
{ label: 'Short', height: 34 },
|
|
63
66
|
{ label: 'Medium', height: 56 },
|
|
@@ -509,6 +512,11 @@
|
|
|
509
512
|
{/if}
|
|
510
513
|
|
|
511
514
|
<span class="sv-fvb-spacer"></span>
|
|
515
|
+
{#if changed}
|
|
516
|
+
<button type="button" class="sv-fvb-btn sv-fvb-changed" title="Mark all as seen" onclick={changed.onSeen}>
|
|
517
|
+
<span class="sv-fvb-changed-dot" aria-hidden="true"></span><span>{changed.label}</span>
|
|
518
|
+
</button>
|
|
519
|
+
{/if}
|
|
512
520
|
{#if automations}
|
|
513
521
|
<button type="button" class="sv-fvb-btn" class:is-set={automations.count > 0} onclick={automations.onManage}>
|
|
514
522
|
{@render glyph(ICON.bolt)}<span>{automations.count ? plural(automations.count, 'automation', 'automations') : 'Automations'}</span>
|
|
@@ -554,6 +562,8 @@
|
|
|
554
562
|
{/if}
|
|
555
563
|
|
|
556
564
|
<style>
|
|
565
|
+
.sv-fvb-changed { color: var(--sg-fg, #0f172a); }
|
|
566
|
+
.sv-fvb-changed-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--sg-accent, #2563eb); flex: none; }
|
|
557
567
|
.sv-fvb {
|
|
558
568
|
container-type: inline-size;
|
|
559
569
|
display: flex; align-items: center; gap: 2px; flex-wrap: wrap; min-height: 40px; padding: 4px 6px;
|
|
@@ -20,6 +20,11 @@ type Props = {
|
|
|
20
20
|
count: number;
|
|
21
21
|
onManage: () => void;
|
|
22
22
|
} | null;
|
|
23
|
+
/** Records someone else changed that nobody here opened yet: what to say, and how to mark them all seen. */
|
|
24
|
+
changed?: {
|
|
25
|
+
label: string;
|
|
26
|
+
onSeen: () => void;
|
|
27
|
+
} | null;
|
|
23
28
|
};
|
|
24
29
|
declare const SvBaseViewBar: import("svelte").Component<Props, {}, "views" | "activeView">;
|
|
25
30
|
type SvBaseViewBar = ReturnType<typeof SvBaseViewBar>;
|
|
@@ -25,8 +25,28 @@ export type RecordChange = {
|
|
|
25
25
|
by?: string;
|
|
26
26
|
/** The automation that made it, by name. */
|
|
27
27
|
automation?: string;
|
|
28
|
+
/** Made elsewhere (another user, a script, an agent) and read from the database. */
|
|
29
|
+
remote?: boolean;
|
|
30
|
+
/** The record was added elsewhere: one entry, with no field. */
|
|
31
|
+
added?: boolean;
|
|
28
32
|
columnId: string;
|
|
29
33
|
value: unknown;
|
|
34
|
+
/** What the field held before, when known (a change made elsewhere). */
|
|
35
|
+
from?: unknown;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* A record someone else changed that nobody here has looked at yet: the
|
|
39
|
+
* cells that moved are tinted and the row header shows a dot until the record
|
|
40
|
+
* is opened, or `markAllSeen` runs.
|
|
41
|
+
*/
|
|
42
|
+
export type UnseenChange = {
|
|
43
|
+
at: number;
|
|
44
|
+
/** Who changed it, when the record says (its "last modified by" field). */
|
|
45
|
+
by?: string;
|
|
46
|
+
/** The column ids that changed. */
|
|
47
|
+
columns: ReadonlySet<string>;
|
|
48
|
+
/** Added elsewhere: the whole record is new. */
|
|
49
|
+
added?: boolean;
|
|
30
50
|
};
|
|
31
51
|
/** A grid, or a table of a base named by id (an automation's change). */
|
|
32
52
|
export type ActivityScope = object | string | {
|
|
@@ -44,6 +64,28 @@ export declare function setRecordComments(next: RecordCommentStore | null): void
|
|
|
44
64
|
*/
|
|
45
65
|
export declare function noteChange(scope: ActivityScope, rowId: string, columnId: string, value: unknown, automation?: string): void;
|
|
46
66
|
export declare function changesOf(scope: ActivityScope, rowId: string): ReadonlyArray<RecordChange>;
|
|
67
|
+
/**
|
|
68
|
+
* Note a change made elsewhere and read from the database: an activity entry
|
|
69
|
+
* per field (with what it held before), credited to `by`, and the record
|
|
70
|
+
* marked unseen. `added` notes a record added elsewhere.
|
|
71
|
+
*/
|
|
72
|
+
export declare function noteRemoteChange(scope: ActivityScope, rowId: string, change: {
|
|
73
|
+
by?: string;
|
|
74
|
+
added?: boolean;
|
|
75
|
+
fields?: ReadonlyArray<{
|
|
76
|
+
columnId: string;
|
|
77
|
+
from: unknown;
|
|
78
|
+
to: unknown;
|
|
79
|
+
}>;
|
|
80
|
+
}): void;
|
|
81
|
+
/** The record's change made elsewhere that nobody here has seen, if any. */
|
|
82
|
+
export declare function unseenOf(scope: ActivityScope, rowId: string): UnseenChange | undefined;
|
|
83
|
+
/** Every unseen record of a grid or table, by row id. */
|
|
84
|
+
export declare function unseenIn(scope: ActivityScope): ReadonlyMap<string, UnseenChange>;
|
|
85
|
+
/** The record was looked at: its cells and row header go back to normal. */
|
|
86
|
+
export declare function markSeen(scope: ActivityScope, rowId: string): void;
|
|
87
|
+
/** Every record of a grid or table was looked at. */
|
|
88
|
+
export declare function markAllSeen(scope: ActivityScope): void;
|
|
47
89
|
export declare function commentsOf(ctrl: any, rowId: string): ReadonlyArray<RecordComment>;
|
|
48
90
|
export declare function addComment(ctrl: any, rowId: string, text: string): void;
|
|
49
91
|
/** "just now", "5 minutes ago", "3 hours ago", else the date. */
|
|
@@ -13,6 +13,8 @@ import { getCurrentUser } from './sources.svelte.js';
|
|
|
13
13
|
let version = $state(0);
|
|
14
14
|
const changes = { bases: new WeakMap(), grids: new WeakMap() };
|
|
15
15
|
const memory = { bases: new WeakMap(), grids: new WeakMap() };
|
|
16
|
+
// One entry per record at most: its unseen change, merged as more arrive.
|
|
17
|
+
const unseen = { bases: new WeakMap(), grids: new WeakMap() };
|
|
16
18
|
// The base of a table named by id alone.
|
|
17
19
|
const ANY_BASE = {};
|
|
18
20
|
let store = null;
|
|
@@ -64,6 +66,61 @@ export function changesOf(scope, rowId) {
|
|
|
64
66
|
void version;
|
|
65
67
|
return rowsOf(changes, scope, false)?.get(rowId) ?? [];
|
|
66
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Note a change made elsewhere and read from the database: an activity entry
|
|
71
|
+
* per field (with what it held before), credited to `by`, and the record
|
|
72
|
+
* marked unseen. `added` notes a record added elsewhere.
|
|
73
|
+
*/
|
|
74
|
+
export function noteRemoteChange(scope, rowId, change) {
|
|
75
|
+
const at = Date.now();
|
|
76
|
+
const fields = change.fields ?? [];
|
|
77
|
+
if (!change.added && !fields.length)
|
|
78
|
+
return;
|
|
79
|
+
const byRow = rowsOf(changes, scope, true);
|
|
80
|
+
const list = byRow.get(rowId) ?? [];
|
|
81
|
+
const by = change.by ? { by: change.by } : {};
|
|
82
|
+
if (change.added)
|
|
83
|
+
list.push({ at, ...by, remote: true, added: true, columnId: '', value: undefined });
|
|
84
|
+
for (const f of fields)
|
|
85
|
+
list.push({ at, ...by, remote: true, columnId: f.columnId, value: copy(f.to), from: copy(f.from) });
|
|
86
|
+
if (list.length > 50)
|
|
87
|
+
list.splice(0, list.length - 50);
|
|
88
|
+
byRow.set(rowId, list);
|
|
89
|
+
const marks = rowsOf(unseen, scope, true);
|
|
90
|
+
const was = marks.get(rowId)?.[0];
|
|
91
|
+
const columns = new Set([...(was?.columns ?? []), ...fields.map((f) => f.columnId)]);
|
|
92
|
+
marks.set(rowId, [{ at, ...by, columns, ...(change.added || was?.added ? { added: true } : {}) }]);
|
|
93
|
+
version += 1;
|
|
94
|
+
}
|
|
95
|
+
const copy = (v) => (Array.isArray(v) ? [...v] : v);
|
|
96
|
+
/** The record's change made elsewhere that nobody here has seen, if any. */
|
|
97
|
+
export function unseenOf(scope, rowId) {
|
|
98
|
+
void version;
|
|
99
|
+
return rowsOf(unseen, scope, false)?.get(rowId)?.[0];
|
|
100
|
+
}
|
|
101
|
+
/** Every unseen record of a grid or table, by row id. */
|
|
102
|
+
export function unseenIn(scope) {
|
|
103
|
+
void version;
|
|
104
|
+
const out = new Map();
|
|
105
|
+
for (const [id, list] of rowsOf(unseen, scope, false) ?? [])
|
|
106
|
+
if (list[0])
|
|
107
|
+
out.set(id, list[0]);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** The record was looked at: its cells and row header go back to normal. */
|
|
111
|
+
export function markSeen(scope, rowId) {
|
|
112
|
+
const marks = rowsOf(unseen, scope, false);
|
|
113
|
+
if (marks?.delete(rowId))
|
|
114
|
+
version += 1;
|
|
115
|
+
}
|
|
116
|
+
/** Every record of a grid or table was looked at. */
|
|
117
|
+
export function markAllSeen(scope) {
|
|
118
|
+
const marks = rowsOf(unseen, scope, false);
|
|
119
|
+
if (marks?.size) {
|
|
120
|
+
marks.clear();
|
|
121
|
+
version += 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
67
124
|
const contextOf = (ctrl) => ({ tableId: tableIdOf(ctrl), api: ctrl?.buildApi?.() });
|
|
68
125
|
export function commentsOf(ctrl, rowId) {
|
|
69
126
|
void version;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* read.
|
|
5
5
|
*
|
|
6
6
|
* Column types become field types, helped by the column's name (`email`,
|
|
7
|
-
* `phone`, `price`, `notes`, `rating`); an enum, or a text column that keeps
|
|
7
|
+
* `phone`, `price`, `notes`, `rating`, `updated_by`); an enum, or a text column that keeps
|
|
8
8
|
* repeating a handful of values, becomes a single select with colored
|
|
9
9
|
* options; a foreign key becomes a link to the table it names, with a field
|
|
10
10
|
* in that table that links back; a table that only pairs two others
|
|
@@ -6,6 +6,9 @@ const title = (s) => s.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2'
|
|
|
6
6
|
const linkName = (column) => column.replace(/_?id$/i, '') || column;
|
|
7
7
|
const MONEY = /(^|_)(price|amount|cost|budget|revenue|salary|total|fee|value|spend|mrr|arr)($|_)/i;
|
|
8
8
|
const LONG = /(^|_)(notes?|description|body|comment|comments|summary|details|bio|content|message)($|_)/i;
|
|
9
|
+
// Who changed or added a row, as the app (or an agent) writes it: a user id the base names from its people list.
|
|
10
|
+
const MODIFIED_BY = /^(updated|modified|last_modified|edited)_?by$/i;
|
|
11
|
+
const CREATED_BY = /^created_?by$/i;
|
|
9
12
|
function fieldOf(f) {
|
|
10
13
|
const header = f.label ?? title(f.field);
|
|
11
14
|
const base = { id: f.field, field: f.field, header };
|
|
@@ -35,6 +38,10 @@ function fieldOf(f) {
|
|
|
35
38
|
return { ...base, fieldType: 'url' };
|
|
36
39
|
if (editor === 'phone')
|
|
37
40
|
return { ...base, fieldType: 'phone' };
|
|
41
|
+
if (MODIFIED_BY.test(f.field))
|
|
42
|
+
return { ...base, fieldType: 'lastModifiedBy' };
|
|
43
|
+
if (CREATED_BY.test(f.field))
|
|
44
|
+
return { ...base, fieldType: 'createdBy' };
|
|
38
45
|
if (LONG.test(f.field))
|
|
39
46
|
return { ...base, fieldType: 'longText' };
|
|
40
47
|
return { ...base, fieldType: 'text' };
|
|
@@ -119,6 +119,25 @@ export type BaseSyncStatus = {
|
|
|
119
119
|
table: string;
|
|
120
120
|
limit: number;
|
|
121
121
|
};
|
|
122
|
+
/**
|
|
123
|
+
* A change made in the database by someone else (another user, another tab,
|
|
124
|
+
* a script, an AI agent), as the base found it when it read the table again.
|
|
125
|
+
*/
|
|
126
|
+
export type BaseRemoteChange = {
|
|
127
|
+
/** The base table's id. */
|
|
128
|
+
table: string;
|
|
129
|
+
type: 'insert' | 'update' | 'delete';
|
|
130
|
+
/** The record's page id. */
|
|
131
|
+
id: string;
|
|
132
|
+
/** The record as it is now; for a delete, as it was. */
|
|
133
|
+
record: Row;
|
|
134
|
+
/** For an update: each stored field that moved, from what to what. */
|
|
135
|
+
fields?: Array<{
|
|
136
|
+
field: string;
|
|
137
|
+
from: unknown;
|
|
138
|
+
to: unknown;
|
|
139
|
+
}>;
|
|
140
|
+
};
|
|
122
141
|
export type BaseSyncOptions = {
|
|
123
142
|
getTables: () => BaseTable[] | undefined;
|
|
124
143
|
/** Put tables in the page without writing them (loaded, undone, changed elsewhere). */
|
|
@@ -129,6 +148,8 @@ export type BaseSyncOptions = {
|
|
|
129
148
|
action: 'insert' | 'update' | 'delete' | 'load';
|
|
130
149
|
message: string;
|
|
131
150
|
}) => void;
|
|
151
|
+
/** Changes made elsewhere, after they are in the page. Never called for the page's own writes. */
|
|
152
|
+
onRemoteChange?: (changes: BaseRemoteChange[]) => void;
|
|
132
153
|
};
|
|
133
154
|
export declare function createBaseSync(options: BaseSyncOptions): {
|
|
134
155
|
load: () => Promise<void>;
|