@svgrid/enterprise 1.2.0 → 2.0.0
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/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
let lookupUid = 0
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* SvLookupInput - a searchable single-select for a foreign-key ("lookup")
|
|
8
|
+
* field. It searches the related entity through a `RelationLookup`, shows human
|
|
9
|
+
* labels, and stores the selected key. Used by SvGridEditPanel for `relation`
|
|
10
|
+
* fields, and usable on its own.
|
|
11
|
+
*/
|
|
12
|
+
import type { RelationLookup, RelationOption } from './sources/relation-lookup'
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
value: unknown
|
|
16
|
+
lookup: RelationLookup
|
|
17
|
+
onSelect: (value: string) => void
|
|
18
|
+
id?: string
|
|
19
|
+
placeholder?: string
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
required?: boolean
|
|
22
|
+
/** Debounce for the search query, ms. Default 200. */
|
|
23
|
+
debounceMs?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
value,
|
|
28
|
+
lookup,
|
|
29
|
+
onSelect,
|
|
30
|
+
id,
|
|
31
|
+
placeholder = 'Search…',
|
|
32
|
+
disabled = false,
|
|
33
|
+
required = false,
|
|
34
|
+
debounceMs = 200,
|
|
35
|
+
}: Props = $props()
|
|
36
|
+
|
|
37
|
+
// Stable listbox id so the combobox input can reference it via aria-controls.
|
|
38
|
+
const listboxId = `sv-lookup-list-${++lookupUid}`
|
|
39
|
+
let open = $state(false)
|
|
40
|
+
let query = $state('')
|
|
41
|
+
let options = $state<RelationOption[]>([])
|
|
42
|
+
let loading = $state(false)
|
|
43
|
+
let active = $state(-1)
|
|
44
|
+
let label = $state('')
|
|
45
|
+
let rootEl: HTMLDivElement | undefined = $state()
|
|
46
|
+
|
|
47
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
48
|
+
let labelToken = 0
|
|
49
|
+
let searchToken = 0
|
|
50
|
+
|
|
51
|
+
// Resolve the current value's label whenever it changes (and we're not typing).
|
|
52
|
+
$effect(() => {
|
|
53
|
+
const v = value
|
|
54
|
+
if (v == null || v === '') { label = ''; return }
|
|
55
|
+
const token = ++labelToken
|
|
56
|
+
lookup.labelFor(v).then((l) => {
|
|
57
|
+
if (token === labelToken) label = l ?? String(v)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
async function runSearch(q: string) {
|
|
62
|
+
const token = ++searchToken
|
|
63
|
+
loading = true
|
|
64
|
+
try {
|
|
65
|
+
const res = await lookup.search(q)
|
|
66
|
+
if (token === searchToken) { options = res; active = res.length ? 0 : -1 }
|
|
67
|
+
} finally {
|
|
68
|
+
if (token === searchToken) loading = false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function openList() {
|
|
73
|
+
if (disabled) return
|
|
74
|
+
open = true
|
|
75
|
+
query = ''
|
|
76
|
+
runSearch('')
|
|
77
|
+
}
|
|
78
|
+
function closeList() {
|
|
79
|
+
open = false
|
|
80
|
+
query = ''
|
|
81
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
82
|
+
}
|
|
83
|
+
function onInput(e: Event) {
|
|
84
|
+
query = (e.currentTarget as HTMLInputElement).value
|
|
85
|
+
open = true
|
|
86
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
87
|
+
searchTimer = setTimeout(() => runSearch(query), debounceMs)
|
|
88
|
+
}
|
|
89
|
+
function choose(opt: RelationOption) {
|
|
90
|
+
label = opt.label
|
|
91
|
+
onSelect(opt.value)
|
|
92
|
+
closeList()
|
|
93
|
+
}
|
|
94
|
+
function clear() {
|
|
95
|
+
onSelect('')
|
|
96
|
+
label = ''
|
|
97
|
+
closeList()
|
|
98
|
+
}
|
|
99
|
+
function onKeydown(e: KeyboardEvent) {
|
|
100
|
+
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { openList(); return }
|
|
101
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, options.length - 1) }
|
|
102
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
|
|
103
|
+
else if (e.key === 'Enter') { if (open && options[active]) { e.preventDefault(); choose(options[active]) } }
|
|
104
|
+
else if (e.key === 'Escape') { if (open) { e.preventDefault(); closeList() } }
|
|
105
|
+
}
|
|
106
|
+
function onDocClick(e: MouseEvent) {
|
|
107
|
+
if (open && rootEl && !rootEl.contains(e.target as Node)) closeList()
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<svelte:document onclick={onDocClick} />
|
|
112
|
+
|
|
113
|
+
<div class="sv-lookup" bind:this={rootEl}>
|
|
114
|
+
<div class="sv-lookup__control">
|
|
115
|
+
<input
|
|
116
|
+
{id}
|
|
117
|
+
class="sv-lookup__input"
|
|
118
|
+
type="text"
|
|
119
|
+
role="combobox"
|
|
120
|
+
aria-expanded={open}
|
|
121
|
+
aria-controls={listboxId}
|
|
122
|
+
aria-autocomplete="list"
|
|
123
|
+
autocomplete="off"
|
|
124
|
+
{placeholder}
|
|
125
|
+
{disabled}
|
|
126
|
+
{required}
|
|
127
|
+
value={open ? query : label}
|
|
128
|
+
onfocus={openList}
|
|
129
|
+
oninput={onInput}
|
|
130
|
+
onkeydown={onKeydown}
|
|
131
|
+
/>
|
|
132
|
+
{#if value != null && value !== '' && !required && !disabled}
|
|
133
|
+
<button type="button" class="sv-lookup__clear" title="Clear" aria-label="Clear" onclick={clear}>×</button>
|
|
134
|
+
{/if}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<ul class="sv-lookup__menu" role="listbox" id={listboxId} hidden={!open}>
|
|
138
|
+
{#if loading}
|
|
139
|
+
<li class="sv-lookup__msg">Searching…</li>
|
|
140
|
+
{:else if options.length === 0}
|
|
141
|
+
<li class="sv-lookup__msg">No matches</li>
|
|
142
|
+
{:else}
|
|
143
|
+
{#each options as opt, i (opt.value)}
|
|
144
|
+
<li>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
role="option"
|
|
148
|
+
aria-selected={i === active}
|
|
149
|
+
class="sv-lookup__opt"
|
|
150
|
+
class:is-active={i === active}
|
|
151
|
+
onmouseenter={() => (active = i)}
|
|
152
|
+
onclick={() => choose(opt)}
|
|
153
|
+
>{opt.label}</button>
|
|
154
|
+
</li>
|
|
155
|
+
{/each}
|
|
156
|
+
{/if}
|
|
157
|
+
</ul>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<style>
|
|
161
|
+
.sv-lookup { position: relative; }
|
|
162
|
+
.sv-lookup__control { position: relative; display: flex; align-items: center; }
|
|
163
|
+
.sv-lookup__input { width: 100%; }
|
|
164
|
+
.sv-lookup__clear {
|
|
165
|
+
position: absolute; right: 6px; border: none; background: none; cursor: pointer;
|
|
166
|
+
font-size: 16px; line-height: 1; color: var(--sg-muted, #888); padding: 2px 4px;
|
|
167
|
+
}
|
|
168
|
+
.sv-lookup__menu {
|
|
169
|
+
position: absolute; z-index: 30; top: calc(100% + 2px); left: 0; right: 0; margin: 0; padding: 4px;
|
|
170
|
+
list-style: none; max-height: 220px; overflow: auto;
|
|
171
|
+
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #ddd); border-radius: 8px;
|
|
172
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
173
|
+
}
|
|
174
|
+
.sv-lookup__opt {
|
|
175
|
+
display: block; width: 100%; text-align: left; padding: 7px 10px; border: none; border-radius: 6px;
|
|
176
|
+
background: none; cursor: pointer; font: inherit; color: var(--sg-fg, inherit);
|
|
177
|
+
}
|
|
178
|
+
.sv-lookup__opt.is-active { background: var(--sg-accent, #2563eb); color: #fff; }
|
|
179
|
+
.sv-lookup__msg { padding: 8px 10px; color: var(--sg-muted, #888); font-size: 13px; }
|
|
180
|
+
</style>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM test: SvRecordDetail renders a record header (title + status pill + metric
|
|
3
|
+
* tiles), a tabbed Overview of field groups, and a related-collection timeline
|
|
4
|
+
* filtered by the foreign key. Also mounts against the real CRM deals + activities
|
|
5
|
+
* sample so the detail <-> sample path is covered.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
8
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
9
|
+
import SvRecordDetail from './SvRecordDetail.svelte'
|
|
10
|
+
import { getSampleApp } from './studio/samples/index'
|
|
11
|
+
import type { EntitySchema } from './schema'
|
|
12
|
+
|
|
13
|
+
const dealsSchema: EntitySchema = {
|
|
14
|
+
name: 'deals',
|
|
15
|
+
idField: 'id',
|
|
16
|
+
fields: [
|
|
17
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
18
|
+
{ field: 'title', type: 'text' },
|
|
19
|
+
{ field: 'owner', type: 'text', label: 'Owner' },
|
|
20
|
+
{ field: 'value', type: 'number', label: 'Value ($)' },
|
|
21
|
+
{ field: 'probability', type: 'number', label: 'Probability (%)' },
|
|
22
|
+
{ field: 'stage', type: 'enum', options: [
|
|
23
|
+
{ value: 'lead', label: 'Lead', color: '#94a3b8' },
|
|
24
|
+
{ value: 'won', label: 'Won', color: '#10b981' },
|
|
25
|
+
] },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
const actsSchema: EntitySchema = {
|
|
29
|
+
name: 'activities',
|
|
30
|
+
idField: 'id',
|
|
31
|
+
fields: [
|
|
32
|
+
{ field: 'id', type: 'text', primaryKey: true },
|
|
33
|
+
{ field: 'subject', type: 'text' },
|
|
34
|
+
{ field: 'dealId', type: 'relation', relation: { entity: 'deals', foreignKey: 'dealId', labelField: 'title' } },
|
|
35
|
+
{ field: 'dueDate', type: 'date' },
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let host: HTMLElement | null = null
|
|
40
|
+
let comp: ReturnType<typeof mount> | null = null
|
|
41
|
+
afterEach(() => { if (comp) { unmount(comp); comp = null } if (host) { host.remove(); host = null } })
|
|
42
|
+
|
|
43
|
+
function render(props: Record<string, unknown>): HTMLElement {
|
|
44
|
+
host = document.createElement('div')
|
|
45
|
+
document.body.appendChild(host)
|
|
46
|
+
comp = mount(SvRecordDetail, { target: host, props })
|
|
47
|
+
flushSync()
|
|
48
|
+
return host
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('SvRecordDetail (DOM)', () => {
|
|
52
|
+
it('renders header title, status pill, and metric tiles', () => {
|
|
53
|
+
const rows = [{ id: 'd1', title: 'Alpha deal', owner: 'Sam', value: 1000, probability: 60, stage: 'won' }]
|
|
54
|
+
const el = render({ schema: dealsSchema, rows, titleField: 'title', subtitleField: 'owner', statusField: 'stage', metricFields: ['value', 'probability'] })
|
|
55
|
+
expect(el.querySelector('.sv-detail__title')?.textContent).toBe('Alpha deal')
|
|
56
|
+
expect(el.querySelector('.sv-detail__status')?.textContent).toBe('Won')
|
|
57
|
+
const metrics = [...el.querySelectorAll('.sv-detail__metric-value')].map((n) => n.textContent)
|
|
58
|
+
expect(metrics).toContain('$1,000') // money-formatted from the "Value ($)" label
|
|
59
|
+
expect(metrics).toContain('60')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('auto-builds an Overview of the remaining fields when no sections given', () => {
|
|
63
|
+
const rows = [{ id: 'd1', title: 'Alpha', owner: 'Sam', value: 1000, probability: 60, stage: 'won' }]
|
|
64
|
+
const el = render({ schema: dealsSchema, rows, titleField: 'title', statusField: 'stage', metricFields: ['value'] })
|
|
65
|
+
const labels = [...el.querySelectorAll('.sv-detail__field dt')].map((n) => n.textContent)
|
|
66
|
+
// title/stage/value are surfaced in the header, so the auto group shows the rest.
|
|
67
|
+
expect(labels).toContain('Owner')
|
|
68
|
+
expect(labels).toContain('Probability (%)')
|
|
69
|
+
expect(labels).not.toContain('Value ($)') // in the header metrics, not the Overview
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows a related timeline filtered by the foreign key', () => {
|
|
73
|
+
const rows = [{ id: 'd1', title: 'Alpha', stage: 'won' }, { id: 'd2', title: 'Beta', stage: 'lead' }]
|
|
74
|
+
const acts = [
|
|
75
|
+
{ id: 'a1', subject: 'Call', dealId: 'd1', dueDate: '2026-03-01' },
|
|
76
|
+
{ id: 'a2', subject: 'Email', dealId: 'd1', dueDate: '2026-03-05' },
|
|
77
|
+
{ id: 'a3', subject: 'Other', dealId: 'd2', dueDate: '2026-03-02' },
|
|
78
|
+
]
|
|
79
|
+
const el = render({
|
|
80
|
+
schema: dealsSchema, rows, titleField: 'title', statusField: 'stage',
|
|
81
|
+
related: [{ label: 'Activities', schema: actsSchema, rows: acts, foreignKey: 'dealId', titleField: 'subject', dateField: 'dueDate' }],
|
|
82
|
+
})
|
|
83
|
+
// Two tabs: Overview + Activities.
|
|
84
|
+
const tabs = [...el.querySelectorAll('.sv-detail__tab')].map((n) => n.textContent)
|
|
85
|
+
expect(tabs).toEqual(['Overview', 'Activities'])
|
|
86
|
+
// Switch to the Activities tab.
|
|
87
|
+
const actTab = [...el.querySelectorAll<HTMLButtonElement>('.sv-detail__tab')].find((b) => b.textContent === 'Activities')!
|
|
88
|
+
actTab.click()
|
|
89
|
+
flushSync()
|
|
90
|
+
const events = [...el.querySelectorAll('.sv-detail__event-title')].map((n) => n.textContent)
|
|
91
|
+
expect(events).toEqual(['Email', 'Call']) // only d1's activities, newest date first
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('mounts against the real CRM deals + activities sample', () => {
|
|
95
|
+
const proj = getSampleApp('crm')!.build()
|
|
96
|
+
const deals = proj.entities.find((e) => e.name === 'deals')
|
|
97
|
+
const activities = proj.entities.find((e) => e.name === 'activities')
|
|
98
|
+
const dealRows = (proj.dataSources!.deals as { seed?: Record<string, unknown>[] }).seed!
|
|
99
|
+
const actRows = (proj.dataSources!.activities as { seed?: Record<string, unknown>[] }).seed!
|
|
100
|
+
const el = render({
|
|
101
|
+
schema: deals, rows: dealRows, titleField: 'title', subtitleField: 'companyId', statusField: 'stage', metricFields: ['value', 'probability'],
|
|
102
|
+
related: [{ label: 'Activities', schema: activities, rows: actRows, foreignKey: 'dealId', titleField: 'subject', dateField: 'dueDate', statusField: 'priority' }],
|
|
103
|
+
})
|
|
104
|
+
expect(el.querySelector('.sv-detail__title')?.textContent).toBeTruthy()
|
|
105
|
+
expect(el.querySelectorAll('.sv-detail__metric').length).toBe(2)
|
|
106
|
+
expect([...el.querySelectorAll('.sv-detail__tab')].map((n) => n.textContent)).toEqual(['Overview', 'Activities'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('matches a related timeline by parentField when the child has no FK id (denormalized view)', () => {
|
|
110
|
+
const productsSchema: EntitySchema = {
|
|
111
|
+
name: 'products', idField: 'id',
|
|
112
|
+
fields: [{ field: 'id', type: 'number', primaryKey: true }, { field: 'name', type: 'text' }],
|
|
113
|
+
}
|
|
114
|
+
const linesSchema: EntitySchema = {
|
|
115
|
+
name: 'order_lines', idField: 'id',
|
|
116
|
+
fields: [{ field: 'id', type: 'number', primaryKey: true }, { field: 'product', type: 'text' }, { field: 'customer', type: 'text' }, { field: 'line_total', type: 'number', label: 'Total ($)' }],
|
|
117
|
+
}
|
|
118
|
+
const rows = [{ id: 1, name: 'Chai' }, { id: 2, name: 'Chang' }]
|
|
119
|
+
// The view carries the product NAME (not its id), so the timeline matches name.
|
|
120
|
+
const lines = [
|
|
121
|
+
{ id: 10, product: 'Chai', customer: 'Alfreds', line_total: 90 },
|
|
122
|
+
{ id: 11, product: 'Chang', customer: 'Berglunds', line_total: 40 },
|
|
123
|
+
{ id: 12, product: 'Chai', customer: "B's", line_total: 54 },
|
|
124
|
+
]
|
|
125
|
+
const el = render({
|
|
126
|
+
schema: productsSchema, rows, titleField: 'name',
|
|
127
|
+
related: [{ label: 'Orders', schema: linesSchema, rows: lines, foreignKey: 'product', parentField: 'name', titleField: 'customer', subtitleField: 'line_total' }],
|
|
128
|
+
})
|
|
129
|
+
const ordersTab = [...el.querySelectorAll<HTMLButtonElement>('.sv-detail__tab')].find((b) => b.textContent === 'Orders')!
|
|
130
|
+
ordersTab.click()
|
|
131
|
+
flushSync()
|
|
132
|
+
// Opens on Chai -> its two lines (matched by name, not id), $ money-formatted.
|
|
133
|
+
const events = [...el.querySelectorAll('.sv-detail__event-title')].map((n) => n.textContent)
|
|
134
|
+
expect(events.sort()).toEqual(['Alfreds', "B's"])
|
|
135
|
+
expect(el.textContent).toContain('$90')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvRecordDetail - a full record "detail page" over an entity's rows: a header
|
|
4
|
+
* (title + subtitle + colored status pill + a few metric tiles + a record
|
|
5
|
+
* switcher), then tabbed sections - an Overview of grouped label/value pairs plus
|
|
6
|
+
* one tab per related child collection (Activity / Related), each a timeline of
|
|
7
|
+
* the child rows that point back at this record. The universal signature view for
|
|
8
|
+
* relation-heavy entities (a customer / deal / patient / trip page), where a board
|
|
9
|
+
* or calendar does not fit.
|
|
10
|
+
*
|
|
11
|
+
* Pure presentation over plain rows + an EntitySchema (same inputs as SvBoard /
|
|
12
|
+
* SvSchemaChart), so the generated app and the designer preview render it
|
|
13
|
+
* identically. When `row` is omitted it self-drives off `rows` (defaults to the
|
|
14
|
+
* first, switchable via the header dropdown).
|
|
15
|
+
*/
|
|
16
|
+
import type { EntitySchema, EntityField } from './schema'
|
|
17
|
+
|
|
18
|
+
type Row = Record<string, unknown>
|
|
19
|
+
type Section = { label: string; fields: string[] }
|
|
20
|
+
type Related = {
|
|
21
|
+
label: string
|
|
22
|
+
schema: EntitySchema
|
|
23
|
+
rows?: ReadonlyArray<Row>
|
|
24
|
+
/** The child field pointing back at this record. */
|
|
25
|
+
foreignKey: string
|
|
26
|
+
/** The parent field the child's `foreignKey` matches (default: the id) - lets a
|
|
27
|
+
* denormalized view relate by name / company when it has no FK ids. */
|
|
28
|
+
parentField?: string
|
|
29
|
+
titleField: string
|
|
30
|
+
subtitleField?: string
|
|
31
|
+
dateField?: string
|
|
32
|
+
statusField?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
schema,
|
|
37
|
+
rows = [],
|
|
38
|
+
row,
|
|
39
|
+
titleField,
|
|
40
|
+
subtitleField,
|
|
41
|
+
statusField,
|
|
42
|
+
metricFields = [],
|
|
43
|
+
sections = [],
|
|
44
|
+
related = [],
|
|
45
|
+
selectedId,
|
|
46
|
+
loading = false,
|
|
47
|
+
height,
|
|
48
|
+
}: {
|
|
49
|
+
schema: EntitySchema
|
|
50
|
+
rows?: ReadonlyArray<Row>
|
|
51
|
+
/** The specific record to show. Omit to self-drive off `rows`. */
|
|
52
|
+
row?: Row
|
|
53
|
+
/** Initial record to open, by id (e.g. from a URL `?id=` param). Switchable. */
|
|
54
|
+
selectedId?: string
|
|
55
|
+
/** Show a skeleton during first load. */
|
|
56
|
+
loading?: boolean
|
|
57
|
+
titleField: string
|
|
58
|
+
subtitleField?: string
|
|
59
|
+
/** Enum field rendered as a colored status pill in the header. */
|
|
60
|
+
statusField?: string
|
|
61
|
+
/** A few (numeric) fields shown as header stat tiles. */
|
|
62
|
+
metricFields?: string[]
|
|
63
|
+
/** Overview field groups. Empty -> one auto "Details" group. */
|
|
64
|
+
sections?: Section[]
|
|
65
|
+
/** Related child collections, one tab each. */
|
|
66
|
+
related?: Related[]
|
|
67
|
+
height?: number
|
|
68
|
+
} = $props()
|
|
69
|
+
|
|
70
|
+
const idField = $derived(schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id')
|
|
71
|
+
const fieldOf = (name?: string): EntityField | undefined => (name ? schema.fields.find((f) => f.field === name) : undefined)
|
|
72
|
+
const titleCase = (s: string) => s.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ').replace(/^\w/, (c) => c.toUpperCase()).replace(/\bId\b/, '').trim()
|
|
73
|
+
const labelOf = (name: string) => fieldOf(name)?.label ?? titleCase(name)
|
|
74
|
+
const isMoneyName = (s: string) => /\$|price|amount|total|cost|value|revenue|fee|salary|balance|subtotal|budget/i.test(s)
|
|
75
|
+
|
|
76
|
+
/** Format a value against its field: enum -> option label, money numbers -> $,
|
|
77
|
+
* else localized. Works for any schema so parent + child rows format alike. */
|
|
78
|
+
function fmtField(f: EntityField | undefined, v: unknown): string {
|
|
79
|
+
if (v == null || v === '') return '-'
|
|
80
|
+
if (f?.options?.length) { const o = f.options.find((x) => String(x.value) === String(v)); if (o) return o.label ?? String(v) }
|
|
81
|
+
if (typeof v === 'number') return isMoneyName(f?.label ?? f?.field ?? '') ? '$' + v.toLocaleString(undefined, { maximumFractionDigits: 0 }) : v.toLocaleString()
|
|
82
|
+
if (typeof v === 'boolean') return v ? 'Yes' : 'No'
|
|
83
|
+
if (Array.isArray(v)) return v.join(', ')
|
|
84
|
+
return String(v)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let picked = $state<string | null>(null)
|
|
88
|
+
// A URL-driven `selectedId` seeds the selection (and follows navigation to a new
|
|
89
|
+
// id); a manual switch via the header dropdown then overrides it.
|
|
90
|
+
$effect(() => { picked = selectedId ?? null })
|
|
91
|
+
const current = $derived.by<Row | undefined>(() => {
|
|
92
|
+
if (row) return row
|
|
93
|
+
if (picked != null) { const r = rows.find((x) => String(x[idField]) === picked); if (r) return r }
|
|
94
|
+
return rows[0]
|
|
95
|
+
})
|
|
96
|
+
const currentId = $derived(current ? String(current[idField]) : '')
|
|
97
|
+
|
|
98
|
+
const fmt = (name: string, r: Row | undefined): string => fmtField(fieldOf(name), r?.[name])
|
|
99
|
+
const childField = (rel: Related, name?: string): EntityField | undefined => (name ? rel.schema.fields.find((f) => f.field === name) : undefined)
|
|
100
|
+
const fmtChild = (rel: Related, name: string, r: Row): string => fmtField(childField(rel, name), r[name])
|
|
101
|
+
|
|
102
|
+
function optOf(field: string | undefined, r: Row | undefined): { label: string; color: string } | undefined {
|
|
103
|
+
if (!field || !r) return undefined
|
|
104
|
+
const v = String(r[field] ?? '')
|
|
105
|
+
if (!v) return undefined
|
|
106
|
+
const o = fieldOf(field)?.options?.find((x) => String(x.value) === v)
|
|
107
|
+
return { label: o?.label ?? v, color: o?.color ?? 'var(--sg-muted, #64748b)' }
|
|
108
|
+
}
|
|
109
|
+
const status = $derived(optOf(statusField, current))
|
|
110
|
+
|
|
111
|
+
// Overview groups: explicit sections, else one auto "Details" of the remaining
|
|
112
|
+
// non-key fields (skipping ones already surfaced in the header).
|
|
113
|
+
const shown = $derived(new Set([titleField, subtitleField, statusField, ...metricFields].filter(Boolean) as string[]))
|
|
114
|
+
const groups = $derived.by<Section[]>(() => {
|
|
115
|
+
if (sections.length) return sections
|
|
116
|
+
const pk = idField
|
|
117
|
+
const fields = schema.fields.filter((f) => f.field !== pk && !shown.has(f.field) && !(typeof f.hidden === 'object' && f.hidden.form) && f.hidden !== true).map((f) => f.field)
|
|
118
|
+
return fields.length ? [{ label: 'Details', fields }] : []
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const tabs = $derived(['Overview', ...related.map((r) => r.label)])
|
|
122
|
+
let tab = $state(0)
|
|
123
|
+
$effect(() => { if (tab >= tabs.length) tab = 0 })
|
|
124
|
+
|
|
125
|
+
function childRows(rel: Related): Row[] {
|
|
126
|
+
const key = String(current?.[rel.parentField ?? idField] ?? '')
|
|
127
|
+
const list = (rel.rows ?? []).filter((c) => String(c[rel.foreignKey] ?? '') === key)
|
|
128
|
+
if (rel.dateField) list.sort((a, b) => String(b[rel.dateField!] ?? '').localeCompare(String(a[rel.dateField!] ?? '')))
|
|
129
|
+
return list
|
|
130
|
+
}
|
|
131
|
+
function childOpt(rel: Related, r: Row): { label: string; color: string } | undefined {
|
|
132
|
+
if (!rel.statusField) return undefined
|
|
133
|
+
const v = String(r[rel.statusField] ?? '')
|
|
134
|
+
const o = rel.schema.fields.find((f) => f.field === rel.statusField)?.options?.find((x) => String(x.value) === v)
|
|
135
|
+
return v ? { label: o?.label ?? v, color: o?.color ?? 'var(--sg-accent, #6366f1)' } : undefined
|
|
136
|
+
}
|
|
137
|
+
function fmtDate(v: unknown): string {
|
|
138
|
+
if (v == null || v === '') return ''
|
|
139
|
+
const s = String(v)
|
|
140
|
+
const d = new Date(s)
|
|
141
|
+
return isNaN(d.getTime()) ? s : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<div class="sv-detail" style:height={height != null ? `${height}px` : undefined}>
|
|
146
|
+
{#if loading && !current}
|
|
147
|
+
<div class="sv-detail__sk sv-detail__sk--title"></div>
|
|
148
|
+
<div class="sv-detail__sk sv-detail__sk--sub"></div>
|
|
149
|
+
<div class="sv-detail__metrics">
|
|
150
|
+
{#each [0, 1, 2] as m (m)}<div class="sv-detail__metric sv-detail__sk sv-detail__sk--metric"></div>{/each}
|
|
151
|
+
</div>
|
|
152
|
+
<div class="sv-detail__sk sv-detail__sk--row"></div>
|
|
153
|
+
<div class="sv-detail__sk sv-detail__sk--row"></div>
|
|
154
|
+
{:else if !current}
|
|
155
|
+
<p class="sv-detail__hint">No record to show.</p>
|
|
156
|
+
{:else}
|
|
157
|
+
<header class="sv-detail__head" style:--accent={status?.color ?? 'var(--sg-accent, #6366f1)'}>
|
|
158
|
+
<div class="sv-detail__head-main">
|
|
159
|
+
<div class="sv-detail__titlerow">
|
|
160
|
+
<h2 class="sv-detail__title">{fmt(titleField, current)}</h2>
|
|
161
|
+
{#if status}<span class="sv-detail__status" style:--pill={status.color}>{status.label}</span>{/if}
|
|
162
|
+
</div>
|
|
163
|
+
{#if subtitleField}<div class="sv-detail__subtitle">{fmt(subtitleField, current)}</div>{/if}
|
|
164
|
+
</div>
|
|
165
|
+
{#if !row && rows.length > 1}
|
|
166
|
+
<label class="sv-detail__switch">
|
|
167
|
+
<span>Record</span>
|
|
168
|
+
<select value={currentId} onchange={(e) => (picked = (e.currentTarget as HTMLSelectElement).value)}>
|
|
169
|
+
{#each rows as r (String(r[idField]))}
|
|
170
|
+
<option value={String(r[idField])}>{fmt(titleField, r)}</option>
|
|
171
|
+
{/each}
|
|
172
|
+
</select>
|
|
173
|
+
</label>
|
|
174
|
+
{/if}
|
|
175
|
+
</header>
|
|
176
|
+
|
|
177
|
+
{#if metricFields.length}
|
|
178
|
+
<div class="sv-detail__metrics">
|
|
179
|
+
{#each metricFields as m (m)}
|
|
180
|
+
<div class="sv-detail__metric">
|
|
181
|
+
<span class="sv-detail__metric-label">{labelOf(m)}</span>
|
|
182
|
+
<span class="sv-detail__metric-value">{fmt(m, current)}</span>
|
|
183
|
+
</div>
|
|
184
|
+
{/each}
|
|
185
|
+
</div>
|
|
186
|
+
{/if}
|
|
187
|
+
|
|
188
|
+
{#if tabs.length > 1}
|
|
189
|
+
<div class="sv-detail__tabs" role="tablist">
|
|
190
|
+
{#each tabs as t, i (t)}
|
|
191
|
+
<button class="sv-detail__tab" class:is-active={tab === i} role="tab" aria-selected={tab === i} onclick={() => (tab = i)}>{t}</button>
|
|
192
|
+
{/each}
|
|
193
|
+
</div>
|
|
194
|
+
{/if}
|
|
195
|
+
|
|
196
|
+
<div class="sv-detail__body">
|
|
197
|
+
{#if tab === 0}
|
|
198
|
+
{#each groups as g (g.label)}
|
|
199
|
+
<section class="sv-detail__section">
|
|
200
|
+
{#if groups.length > 1 || g.label !== 'Details'}<h3 class="sv-detail__section-head">{g.label}</h3>{/if}
|
|
201
|
+
<dl class="sv-detail__fields">
|
|
202
|
+
{#each g.fields as f (f)}
|
|
203
|
+
<div class="sv-detail__field">
|
|
204
|
+
<dt>{labelOf(f)}</dt>
|
|
205
|
+
<dd>{fmt(f, current)}</dd>
|
|
206
|
+
</div>
|
|
207
|
+
{/each}
|
|
208
|
+
</dl>
|
|
209
|
+
</section>
|
|
210
|
+
{/each}
|
|
211
|
+
{#if groups.length === 0}<p class="sv-detail__hint">No fields to show.</p>{/if}
|
|
212
|
+
{:else}
|
|
213
|
+
{@const rel = related[tab - 1]}
|
|
214
|
+
{@const list = childRows(rel)}
|
|
215
|
+
{#if list.length === 0}
|
|
216
|
+
<p class="sv-detail__hint">No {rel.label.toLowerCase()} yet.</p>
|
|
217
|
+
{:else}
|
|
218
|
+
<ul class="sv-detail__timeline">
|
|
219
|
+
{#each list as c (String(c[rel.schema.idField ?? 'id']))}
|
|
220
|
+
{@const co = childOpt(rel, c)}
|
|
221
|
+
<li class="sv-detail__event" style:--dot={co?.color ?? 'var(--sg-accent, #6366f1)'}>
|
|
222
|
+
<span class="sv-detail__event-dot"></span>
|
|
223
|
+
<div class="sv-detail__event-main">
|
|
224
|
+
<div class="sv-detail__event-title">{fmtChild(rel, rel.titleField, c) || '(untitled)'}</div>
|
|
225
|
+
{#if rel.subtitleField && c[rel.subtitleField] != null && c[rel.subtitleField] !== ''}
|
|
226
|
+
<div class="sv-detail__event-sub">{fmtChild(rel, rel.subtitleField, c)}</div>
|
|
227
|
+
{/if}
|
|
228
|
+
</div>
|
|
229
|
+
{#if co}<span class="sv-detail__event-status" style:--pill={co.color}>{co.label}</span>{/if}
|
|
230
|
+
{#if rel.dateField}<time class="sv-detail__event-date">{fmtDate(c[rel.dateField])}</time>{/if}
|
|
231
|
+
</li>
|
|
232
|
+
{/each}
|
|
233
|
+
</ul>
|
|
234
|
+
{/if}
|
|
235
|
+
{/if}
|
|
236
|
+
</div>
|
|
237
|
+
{/if}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<style>
|
|
241
|
+
.sv-detail {
|
|
242
|
+
display: flex; flex-direction: column; gap: 14px; box-sizing: border-box;
|
|
243
|
+
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px;
|
|
244
|
+
padding: 18px 20px; overflow: auto; min-height: 220px;
|
|
245
|
+
}
|
|
246
|
+
.sv-detail__head { display: flex; align-items: flex-start; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--sg-border, #e6e8ec); position: relative; }
|
|
247
|
+
.sv-detail__head::before { content: ''; position: absolute; left: -20px; top: -18px; bottom: 0; width: 4px; background: var(--accent, var(--sg-accent, #6366f1)); border-radius: 0 3px 3px 0; }
|
|
248
|
+
.sv-detail__head-main { flex: 1; min-width: 0; }
|
|
249
|
+
.sv-detail__titlerow { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
250
|
+
.sv-detail__title { margin: 0; font-size: 20px; font-weight: 700; color: var(--sg-fg, #0f172a); line-height: 1.2; }
|
|
251
|
+
.sv-detail__subtitle { margin-top: 4px; font-size: 13.5px; color: var(--sg-muted, #64748b); }
|
|
252
|
+
.sv-detail__status { padding: 3px 11px; font-size: 12px; font-weight: 700; border-radius: 999px; color: var(--pill, #64748b); background: color-mix(in srgb, var(--pill, #64748b) 14%, transparent); white-space: nowrap; }
|
|
253
|
+
.sv-detail__switch { display: flex; flex-direction: column; gap: 3px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #94a3b8); }
|
|
254
|
+
.sv-detail__switch select { font: inherit; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 8px; padding: 6px 10px; max-width: 220px; }
|
|
255
|
+
.sv-detail__metrics { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
256
|
+
.sv-detail__metric { flex: 1 1 120px; min-width: 110px; display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; background: var(--sg-header-bg, #f6f7f9); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; }
|
|
257
|
+
.sv-detail__metric-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #94a3b8); }
|
|
258
|
+
.sv-detail__metric-value { font-size: 19px; font-weight: 700; color: var(--sg-fg, #0f172a); }
|
|
259
|
+
.sv-detail__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--sg-border, #e6e8ec); }
|
|
260
|
+
.sv-detail__tab { appearance: none; border: none; background: none; font: inherit; font-size: 13px; font-weight: 600; color: var(--sg-muted, #64748b); padding: 8px 12px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
261
|
+
.sv-detail__tab:hover { color: var(--sg-fg, #0f172a); }
|
|
262
|
+
.sv-detail__tab.is-active { color: var(--sg-accent, #6366f1); border-bottom-color: var(--sg-accent, #6366f1); }
|
|
263
|
+
.sv-detail__body { min-height: 60px; }
|
|
264
|
+
.sv-detail__section + .sv-detail__section { margin-top: 18px; }
|
|
265
|
+
.sv-detail__section-head { margin: 0 0 10px; font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
|
|
266
|
+
.sv-detail__fields { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px 20px; margin: 0; }
|
|
267
|
+
.sv-detail__field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
268
|
+
.sv-detail__field dt { font-size: 11.5px; font-weight: 600; color: var(--sg-muted, #94a3b8); }
|
|
269
|
+
.sv-detail__field dd { margin: 0; font-size: 14px; color: var(--sg-fg, #0f172a); overflow: hidden; text-overflow: ellipsis; }
|
|
270
|
+
.sv-detail__timeline { list-style: none; margin: 0; padding: 4px 0 0; display: flex; flex-direction: column; }
|
|
271
|
+
.sv-detail__event { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--sg-border, #eef0f3); position: relative; }
|
|
272
|
+
.sv-detail__event:last-child { border-bottom: none; }
|
|
273
|
+
.sv-detail__event-dot { flex: none; width: 9px; height: 9px; margin-top: 5px; border-radius: 50%; background: var(--dot, var(--sg-accent, #6366f1)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot, #6366f1) 18%, transparent); }
|
|
274
|
+
.sv-detail__event-main { flex: 1; min-width: 0; }
|
|
275
|
+
.sv-detail__event-title { font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); }
|
|
276
|
+
.sv-detail__event-sub { margin-top: 2px; font-size: 12px; color: var(--sg-muted, #64748b); }
|
|
277
|
+
.sv-detail__event-status { flex: none; padding: 2px 8px; font-size: 11px; font-weight: 700; border-radius: 999px; color: var(--pill, #64748b); background: color-mix(in srgb, var(--pill, #64748b) 14%, transparent); }
|
|
278
|
+
.sv-detail__event-date { flex: none; font-size: 12px; color: var(--sg-muted, #94a3b8); white-space: nowrap; }
|
|
279
|
+
.sv-detail__hint { color: var(--sg-muted, #64748b); font-size: 13px; padding: 18px 4px; }
|
|
280
|
+
/* First-load skeleton. */
|
|
281
|
+
.sv-detail__sk { position: relative; overflow: hidden; border-radius: 8px; background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, transparent); }
|
|
282
|
+
.sv-detail__sk::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--sg-bg, #fff) 55%, transparent), transparent); animation: sv-detail-sheen 1.2s ease-in-out infinite; }
|
|
283
|
+
.sv-detail__sk--title { width: 240px; max-width: 60%; height: 22px; }
|
|
284
|
+
.sv-detail__sk--sub { width: 160px; max-width: 40%; height: 13px; margin-top: 2px; }
|
|
285
|
+
.sv-detail__sk--metric { height: 58px; }
|
|
286
|
+
.sv-detail__sk--row { height: 46px; margin-top: 4px; }
|
|
287
|
+
@keyframes sv-detail-sheen { 100% { transform: translateX(100%); } }
|
|
288
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM test: SvSchedule renders a month grid anchored to the earliest event, places
|
|
3
|
+
* each dated row as an event chip on its day, and paginates months.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
6
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
7
|
+
import SvSchedule from './SvSchedule.svelte'
|
|
8
|
+
import type { EntitySchema } from './schema'
|
|
9
|
+
|
|
10
|
+
const schema: EntitySchema = {
|
|
11
|
+
name: 'events',
|
|
12
|
+
idField: 'id',
|
|
13
|
+
fields: [
|
|
14
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
15
|
+
{ field: 'name', type: 'text' },
|
|
16
|
+
{ field: 'startAt', type: 'datetime' },
|
|
17
|
+
{ field: 'category', type: 'enum', options: [
|
|
18
|
+
{ value: 'conf', label: 'Conference', color: '#6366f1' },
|
|
19
|
+
{ value: 'workshop', label: 'Workshop', color: '#10b981' },
|
|
20
|
+
] },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let host: HTMLElement | null = null
|
|
25
|
+
let comp: ReturnType<typeof mount> | null = null
|
|
26
|
+
afterEach(() => { if (comp) { unmount(comp); comp = null } if (host) { host.remove(); host = null } })
|
|
27
|
+
function render(props: Record<string, unknown>): HTMLElement {
|
|
28
|
+
host = document.createElement('div')
|
|
29
|
+
document.body.appendChild(host)
|
|
30
|
+
comp = mount(SvSchedule, { target: host, props })
|
|
31
|
+
flushSync()
|
|
32
|
+
return host
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('SvSchedule (DOM)', () => {
|
|
36
|
+
const rows = [
|
|
37
|
+
{ id: 'e1', name: 'Svelte Summit', startAt: '2026-09-12T09:00:00Z', category: 'conf' },
|
|
38
|
+
{ id: 'e2', name: 'A11y Workshop', startAt: '2026-09-20T10:00:00Z', category: 'workshop' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
it('anchors to the earliest event month and renders its events', () => {
|
|
42
|
+
const el = render({ schema, rows, dateField: 'startAt', titleField: 'name', colorField: 'category' })
|
|
43
|
+
expect(el.querySelector('.sv-sched__month')?.textContent).toContain('September 2026')
|
|
44
|
+
expect(el.querySelectorAll('.sv-sched__cell').length).toBe(42) // 6 weeks
|
|
45
|
+
const evs = [...el.querySelectorAll('.sv-sched__ev')].map((n) => n.textContent?.trim())
|
|
46
|
+
expect(evs).toContain('Svelte Summit')
|
|
47
|
+
expect(evs).toContain('A11y Workshop')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('paginates to the next month (events drop off)', () => {
|
|
51
|
+
const el = render({ schema, rows, dateField: 'startAt', titleField: 'name' })
|
|
52
|
+
el.querySelector<HTMLButtonElement>('[aria-label="Next month"]')!.click()
|
|
53
|
+
flushSync()
|
|
54
|
+
expect(el.querySelector('.sv-sched__month')?.textContent).toContain('October 2026')
|
|
55
|
+
expect(el.querySelectorAll('.sv-sched__ev').length).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|