@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svgrid/enterprise",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Pro feature pack for sv-grid: data export (Excel, PDF, CSV, TSV, HTML), import (xlsx, CSV, TSV, JSON), pivot table + designer, AI assistant (BYO model), and printing. Requires a paid license key.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "jQWidgets <sales@jqwidgets.com>",
|
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
"svelte": "./src/pivot.ts",
|
|
38
38
|
"import": "./src/pivot.ts"
|
|
39
39
|
},
|
|
40
|
+
"./studio": {
|
|
41
|
+
"types": "./src/studio/index.ts",
|
|
42
|
+
"node": "./dist/node/studio.js",
|
|
43
|
+
"svelte": "./src/studio/index.ts",
|
|
44
|
+
"import": "./src/studio/index.ts"
|
|
45
|
+
},
|
|
40
46
|
"./cdn/svelte-external": {
|
|
41
47
|
"import": "./dist/cdn/svgrid-enterprise.svelte-external.js",
|
|
42
48
|
"default": "./dist/cdn/svgrid-enterprise.svelte-external.js"
|
|
@@ -54,9 +60,10 @@
|
|
|
54
60
|
"scripts": {
|
|
55
61
|
"test": "vitest run",
|
|
56
62
|
"test:types": "tsc -p tsconfig.json --noEmit",
|
|
57
|
-
"build": "node ./scripts/build-cdn.mjs",
|
|
63
|
+
"build": "node ./scripts/build-cdn.mjs && node ./scripts/build-node.mjs",
|
|
58
64
|
"build:cdn": "node ./scripts/build-cdn.mjs",
|
|
59
|
-
"
|
|
65
|
+
"build:node": "node ./scripts/build-node.mjs",
|
|
66
|
+
"prepublishOnly": "node ./scripts/build-cdn.mjs && node ./scripts/build-node.mjs"
|
|
60
67
|
},
|
|
61
68
|
"peerDependencies": {
|
|
62
69
|
"svelte": "^5.0.0",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvAuthGate - require a signed-in user before showing its content. Renders a
|
|
4
|
+
* login / sign-up form when signed out (Supabase Auth via `createSupabaseAuth`)
|
|
5
|
+
* and its children when signed in, with a small "signed in as ... / Sign out"
|
|
6
|
+
* bar. Pair with Row-Level Security so each user sees only their own rows.
|
|
7
|
+
*
|
|
8
|
+
* <SvAuthGate {client}>
|
|
9
|
+
* <MyStudioScreen />
|
|
10
|
+
* </SvAuthGate>
|
|
11
|
+
*/
|
|
12
|
+
import type { Snippet } from 'svelte'
|
|
13
|
+
import { createSupabaseAuth, type AuthState, type SupabaseAuthClientLike, type SupabaseAuthController } from './sources/auth-supabase'
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
client: SupabaseAuthClientLike
|
|
17
|
+
children: Snippet
|
|
18
|
+
title?: string
|
|
19
|
+
/** Show the "signed in as ... / Sign out" bar. Default true. */
|
|
20
|
+
showBar?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { client, children, title = 'Sign in', showBar = true }: Props = $props()
|
|
24
|
+
|
|
25
|
+
let auth = $state<SupabaseAuthController | undefined>()
|
|
26
|
+
let authState = $state<AuthState>({ user: null, loading: true, error: null })
|
|
27
|
+
let mode = $state<'signin' | 'signup'>('signin')
|
|
28
|
+
let email = $state('')
|
|
29
|
+
let password = $state('')
|
|
30
|
+
|
|
31
|
+
$effect(() => {
|
|
32
|
+
const controller = createSupabaseAuth({ client, onChange: (s) => (authState = s) })
|
|
33
|
+
auth = controller
|
|
34
|
+
return () => controller.dispose()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function submit(e: SubmitEvent) {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
if (mode === 'signin') auth?.signIn(email, password)
|
|
40
|
+
else auth?.signUp(email, password)
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
{#if authState.user}
|
|
45
|
+
{#if showBar}
|
|
46
|
+
<div class="sv-auth-bar">
|
|
47
|
+
<span>Signed in as <strong>{authState.user.email ?? authState.user.id}</strong></span>
|
|
48
|
+
<button type="button" onclick={() => auth?.signOut()} disabled={authState.loading}>Sign out</button>
|
|
49
|
+
</div>
|
|
50
|
+
{/if}
|
|
51
|
+
{@render children()}
|
|
52
|
+
{:else}
|
|
53
|
+
<div class="sv-auth">
|
|
54
|
+
<form class="sv-auth__form" onsubmit={submit}>
|
|
55
|
+
<h2 class="sv-auth__title">{mode === 'signin' ? title : 'Create account'}</h2>
|
|
56
|
+
|
|
57
|
+
<label class="sv-auth__field">
|
|
58
|
+
<span>Email</span>
|
|
59
|
+
<input type="email" autocomplete="email" bind:value={email} required />
|
|
60
|
+
</label>
|
|
61
|
+
<label class="sv-auth__field">
|
|
62
|
+
<span>Password</span>
|
|
63
|
+
<input type="password" autocomplete="current-password" bind:value={password} required />
|
|
64
|
+
</label>
|
|
65
|
+
|
|
66
|
+
{#if authState.error}<p class="sv-auth__error">{authState.error}</p>{/if}
|
|
67
|
+
|
|
68
|
+
<button class="sv-auth__submit" type="submit" disabled={authState.loading}>
|
|
69
|
+
{authState.loading ? 'Please wait…' : mode === 'signin' ? 'Sign in' : 'Create account'}
|
|
70
|
+
</button>
|
|
71
|
+
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
class="sv-auth__toggle"
|
|
75
|
+
onclick={() => (mode = mode === 'signin' ? 'signup' : 'signin')}
|
|
76
|
+
>
|
|
77
|
+
{mode === 'signin' ? 'Need an account? Sign up' : 'Have an account? Sign in'}
|
|
78
|
+
</button>
|
|
79
|
+
</form>
|
|
80
|
+
</div>
|
|
81
|
+
{/if}
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
.sv-auth { display: flex; justify-content: center; padding: 32px 16px; }
|
|
85
|
+
.sv-auth__form {
|
|
86
|
+
display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 320px;
|
|
87
|
+
padding: 24px; border: 1px solid var(--sg-border, #e2e2e2); border-radius: 12px;
|
|
88
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, inherit);
|
|
89
|
+
}
|
|
90
|
+
.sv-auth__title { margin: 0 0 4px; font-size: 18px; font-weight: 600; }
|
|
91
|
+
.sv-auth__field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: var(--sg-muted, #555); }
|
|
92
|
+
.sv-auth__field input {
|
|
93
|
+
padding: 8px 10px; border: 1px solid var(--sg-border, #ccc); border-radius: 6px;
|
|
94
|
+
background: var(--sg-input-bg, #fff); color: var(--sg-fg, inherit); font: inherit;
|
|
95
|
+
}
|
|
96
|
+
.sv-auth__error { margin: 0; font-size: 13px; color: #dc2626; }
|
|
97
|
+
.sv-auth__submit {
|
|
98
|
+
padding: 9px 12px; border: 1px solid var(--sg-accent, #2563eb); border-radius: 6px;
|
|
99
|
+
background: var(--sg-accent, #2563eb); color: #fff; cursor: pointer; font: inherit; font-weight: 600;
|
|
100
|
+
}
|
|
101
|
+
.sv-auth__submit:disabled { opacity: 0.6; cursor: default; }
|
|
102
|
+
.sv-auth__toggle { border: none; background: none; color: var(--sg-accent, #2563eb); cursor: pointer; font: inherit; font-size: 13px; }
|
|
103
|
+
|
|
104
|
+
.sv-auth-bar {
|
|
105
|
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
106
|
+
padding: 8px 12px; margin-bottom: 12px; font-size: 13px;
|
|
107
|
+
border: 1px solid var(--sg-border, #e2e2e2); border-radius: 8px;
|
|
108
|
+
background: var(--sg-header-bg, #f8fafc); color: var(--sg-fg, inherit);
|
|
109
|
+
}
|
|
110
|
+
.sv-auth-bar button {
|
|
111
|
+
padding: 5px 10px; border: 1px solid var(--sg-border, #ccc); border-radius: 6px;
|
|
112
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; font: inherit;
|
|
113
|
+
}
|
|
114
|
+
.sv-auth-bar button:disabled { opacity: 0.5; cursor: default; }
|
|
115
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM test: SvBoard renders an enum field's options as columns and each row as a
|
|
3
|
+
* card in its column, with counts and badges. Mounts against the real CRM deals
|
|
4
|
+
* schema so the board <-> sample path is covered.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
7
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
8
|
+
import SvBoard from './SvBoard.svelte'
|
|
9
|
+
import { getSampleApp } from './studio/samples/index'
|
|
10
|
+
import type { EntitySchema } from './schema'
|
|
11
|
+
|
|
12
|
+
const dealsSchema: EntitySchema = {
|
|
13
|
+
name: 'deals',
|
|
14
|
+
idField: 'id',
|
|
15
|
+
fields: [
|
|
16
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
17
|
+
{ field: 'title', type: 'text' },
|
|
18
|
+
{ field: 'value', type: 'number', label: 'Value ($)' },
|
|
19
|
+
{ field: 'stage', type: 'enum', options: [
|
|
20
|
+
{ value: 'lead', label: 'Lead', color: '#94a3b8' },
|
|
21
|
+
{ value: 'won', label: 'Won', color: '#10b981' },
|
|
22
|
+
{ value: 'lost', label: 'Lost', color: '#ef4444' },
|
|
23
|
+
] },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let host: HTMLElement | null = null
|
|
28
|
+
let comp: ReturnType<typeof mount> | null = null
|
|
29
|
+
afterEach(() => { if (comp) { unmount(comp); comp = null } if (host) { host.remove(); host = null } })
|
|
30
|
+
|
|
31
|
+
function render(props: Record<string, unknown>): HTMLElement {
|
|
32
|
+
host = document.createElement('div')
|
|
33
|
+
document.body.appendChild(host)
|
|
34
|
+
comp = mount(SvBoard, { target: host, props })
|
|
35
|
+
flushSync()
|
|
36
|
+
return host
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('SvBoard (DOM)', () => {
|
|
40
|
+
it('renders a column per enum option and a card per row', () => {
|
|
41
|
+
const rows = [
|
|
42
|
+
{ id: 'd1', title: 'Alpha deal', value: 1000, stage: 'lead' },
|
|
43
|
+
{ id: 'd2', title: 'Beta deal', value: 2000, stage: 'won' },
|
|
44
|
+
{ id: 'd3', title: 'Gamma deal', value: 500, stage: 'lead' },
|
|
45
|
+
]
|
|
46
|
+
const el = render({ schema: dealsSchema, rows, groupBy: 'stage', titleField: 'title', badgeField: 'value' })
|
|
47
|
+
const cols = el.querySelectorAll('.sv-board__col')
|
|
48
|
+
expect(cols.length).toBe(3) // lead / won / lost
|
|
49
|
+
// Column titles + counts.
|
|
50
|
+
const titles = [...el.querySelectorAll('.sv-board__coltitle')].map((n) => n.textContent)
|
|
51
|
+
expect(titles).toEqual(['Lead', 'Won', 'Lost'])
|
|
52
|
+
const counts = [...el.querySelectorAll('.sv-board__count')].map((n) => n.textContent)
|
|
53
|
+
expect(counts).toEqual(['2', '1', '0'])
|
|
54
|
+
// Cards + a formatted money badge ($ from the "Value ($)" label).
|
|
55
|
+
const cards = [...el.querySelectorAll('.sv-board__card-title')].map((n) => n.textContent)
|
|
56
|
+
expect(cards).toContain('Alpha deal')
|
|
57
|
+
expect(el.textContent).toContain('$1,000')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('mounts against the real CRM deals sample', () => {
|
|
61
|
+
const proj = getSampleApp('crm')!.build()
|
|
62
|
+
const rows = (proj.dataSources!.deals as { seed?: Record<string, unknown>[] }).seed!
|
|
63
|
+
const el = render({ schema: proj.entities.find((e) => e.name === 'deals'), rows, groupBy: 'stage', titleField: 'title', badgeField: 'value', onMove: vi.fn() })
|
|
64
|
+
expect(el.querySelectorAll('.sv-board__col').length).toBe(5) // 5 deal stages
|
|
65
|
+
expect(el.querySelectorAll('.sv-board__card').length).toBe(rows.length)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvBoard - a Kanban board over an entity's rows. Columns are the options of an
|
|
4
|
+
* `enum` field (`groupBy`); each row is a card grouped into its column. Drag a
|
|
5
|
+
* card to another column to change its `groupBy` value (fires `onMove`). A real
|
|
6
|
+
* pipeline / task / ticket board, not a report - the signature view that makes a
|
|
7
|
+
* Studio app read like a product.
|
|
8
|
+
*
|
|
9
|
+
* Pure presentation over plain rows + an EntitySchema (same inputs as
|
|
10
|
+
* SvSchemaChart / SvGridEditPanel), so both the generated app and the designer
|
|
11
|
+
* preview render it identically.
|
|
12
|
+
*/
|
|
13
|
+
import type { EntitySchema, EntityField } from './schema'
|
|
14
|
+
|
|
15
|
+
type Row = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
schema,
|
|
19
|
+
rows = [],
|
|
20
|
+
groupBy,
|
|
21
|
+
titleField,
|
|
22
|
+
badgeField,
|
|
23
|
+
subtitleField,
|
|
24
|
+
onMove,
|
|
25
|
+
onOpen,
|
|
26
|
+
loading = false,
|
|
27
|
+
height,
|
|
28
|
+
}: {
|
|
29
|
+
schema: EntitySchema
|
|
30
|
+
rows?: ReadonlyArray<Row>
|
|
31
|
+
/** Show shimmer placeholders (first load) instead of columns. */
|
|
32
|
+
loading?: boolean
|
|
33
|
+
/** The enum field whose options become the columns. */
|
|
34
|
+
groupBy: string
|
|
35
|
+
/** Field shown as the card title. */
|
|
36
|
+
titleField: string
|
|
37
|
+
/** Optional numeric/text field shown as a chip on the card. */
|
|
38
|
+
badgeField?: string
|
|
39
|
+
/** Optional secondary line on the card. */
|
|
40
|
+
subtitleField?: string
|
|
41
|
+
/** Called with (rowId, newColumnValue) when a card is dropped in a column. */
|
|
42
|
+
onMove?: (id: string, value: string) => void
|
|
43
|
+
/** Called with the card's row id when it is clicked (drill to a detail page). */
|
|
44
|
+
onOpen?: (id: string) => void
|
|
45
|
+
height?: number
|
|
46
|
+
} = $props()
|
|
47
|
+
|
|
48
|
+
const idField = $derived(schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id')
|
|
49
|
+
const fieldOf = (name?: string): EntityField | undefined => (name ? schema.fields.find((f) => f.field === name) : undefined)
|
|
50
|
+
const groupField = $derived(fieldOf(groupBy))
|
|
51
|
+
const badgeIsMoney = $derived(/\$/.test(fieldOf(badgeField)?.label ?? ''))
|
|
52
|
+
|
|
53
|
+
/** The columns: the groupBy field's enum options (value + label + color). */
|
|
54
|
+
const columns = $derived(
|
|
55
|
+
(groupField?.options ?? []).map((o) => ({ value: String(o.value), label: o.label ?? String(o.value), color: o.color ?? 'var(--sg-accent, #6366f1)' })),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const cardsFor = (value: string): Row[] => rows.filter((r) => String(r[groupBy] ?? '') === value)
|
|
59
|
+
|
|
60
|
+
function badgeText(r: Row): string {
|
|
61
|
+
if (!badgeField) return ''
|
|
62
|
+
const v = r[badgeField]
|
|
63
|
+
if (v == null || v === '') return ''
|
|
64
|
+
if (typeof v === 'number') return badgeIsMoney ? '$' + v.toLocaleString(undefined, { maximumFractionDigits: 0 }) : v.toLocaleString()
|
|
65
|
+
return String(v)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let dragId = $state<string | null>(null)
|
|
69
|
+
let overCol = $state<string | null>(null)
|
|
70
|
+
function onDragStart(e: DragEvent, id: string) {
|
|
71
|
+
dragId = id
|
|
72
|
+
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id) }
|
|
73
|
+
}
|
|
74
|
+
function onDrop(value: string) {
|
|
75
|
+
if (dragId != null) {
|
|
76
|
+
const from = rows.find((r) => String(r[idField]) === dragId)
|
|
77
|
+
if (from && String(from[groupBy] ?? '') !== value) onMove?.(dragId, value)
|
|
78
|
+
}
|
|
79
|
+
dragId = null
|
|
80
|
+
overCol = null
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<div class="sv-board" style:height={height != null ? `${height}px` : undefined}>
|
|
85
|
+
{#if loading}
|
|
86
|
+
{#each [0, 1, 2] as c (c)}
|
|
87
|
+
<section class="sv-board__col sv-board__col--skeleton">
|
|
88
|
+
<header class="sv-board__colhead"><span class="sv-board__sk sv-board__sk--head"></span></header>
|
|
89
|
+
<div class="sv-board__cards">
|
|
90
|
+
{#each [0, 1] as k (k)}<div class="sv-board__card sv-board__sk sv-board__sk--card"></div>{/each}
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
{/each}
|
|
94
|
+
{:else if columns.length > 0 && rows.length === 0}
|
|
95
|
+
<div class="sv-board__blank">
|
|
96
|
+
<div class="sv-board__blank-mark" aria-hidden="true"></div>
|
|
97
|
+
<p>No records yet. New items will appear as cards here.</p>
|
|
98
|
+
</div>
|
|
99
|
+
{:else}
|
|
100
|
+
{#each columns as col (col.value)}
|
|
101
|
+
{@const cards = cardsFor(col.value)}
|
|
102
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
103
|
+
<section
|
|
104
|
+
class="sv-board__col"
|
|
105
|
+
class:is-over={overCol === col.value}
|
|
106
|
+
ondragover={(e) => { e.preventDefault(); overCol = col.value }}
|
|
107
|
+
ondragleave={() => { if (overCol === col.value) overCol = null }}
|
|
108
|
+
ondrop={(e) => { e.preventDefault(); onDrop(col.value) }}
|
|
109
|
+
>
|
|
110
|
+
<header class="sv-board__colhead" style:--col={col.color}>
|
|
111
|
+
<span class="sv-board__dot"></span>
|
|
112
|
+
<span class="sv-board__coltitle">{col.label}</span>
|
|
113
|
+
<span class="sv-board__count">{cards.length}</span>
|
|
114
|
+
</header>
|
|
115
|
+
<div class="sv-board__cards">
|
|
116
|
+
{#each cards as r (String(r[idField]))}
|
|
117
|
+
<!-- svelte-ignore a11y_no_static_element_interactions a11y_no_noninteractive_tabindex -->
|
|
118
|
+
<article
|
|
119
|
+
class="sv-board__card"
|
|
120
|
+
class:is-dragging={dragId === String(r[idField])}
|
|
121
|
+
class:is-clickable={!!onOpen}
|
|
122
|
+
style:--col={col.color}
|
|
123
|
+
draggable="true"
|
|
124
|
+
ondragstart={(e) => onDragStart(e, String(r[idField]))}
|
|
125
|
+
ondragend={() => { dragId = null; overCol = null }}
|
|
126
|
+
role={onOpen ? 'button' : undefined}
|
|
127
|
+
tabindex={onOpen ? 0 : undefined}
|
|
128
|
+
onclick={onOpen ? () => onOpen(String(r[idField])) : undefined}
|
|
129
|
+
onkeydown={onOpen ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(String(r[idField])) } } : undefined}
|
|
130
|
+
>
|
|
131
|
+
<div class="sv-board__card-title">{String(r[titleField] ?? '(untitled)')}</div>
|
|
132
|
+
{#if subtitleField && r[subtitleField] != null && r[subtitleField] !== ''}
|
|
133
|
+
<div class="sv-board__card-sub">{String(r[subtitleField])}</div>
|
|
134
|
+
{/if}
|
|
135
|
+
{#if badgeText(r)}<span class="sv-board__badge">{badgeText(r)}</span>{/if}
|
|
136
|
+
</article>
|
|
137
|
+
{/each}
|
|
138
|
+
{#if cards.length === 0}<div class="sv-board__empty">Drop here</div>{/if}
|
|
139
|
+
</div>
|
|
140
|
+
</section>
|
|
141
|
+
{/each}
|
|
142
|
+
{#if columns.length === 0}
|
|
143
|
+
<p class="sv-board__hint">Set <code>groupBy</code> to an enum field to build the board columns.</p>
|
|
144
|
+
{/if}
|
|
145
|
+
{/if}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<style>
|
|
149
|
+
.sv-board {
|
|
150
|
+
display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; padding: 4px;
|
|
151
|
+
min-height: 260px; box-sizing: border-box;
|
|
152
|
+
}
|
|
153
|
+
.sv-board__col {
|
|
154
|
+
flex: 0 0 clamp(240px, 22vw, 300px); display: flex; flex-direction: column; gap: 10px; min-height: 120px;
|
|
155
|
+
background: var(--sg-header-bg, #f6f7f9); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px;
|
|
156
|
+
padding: 12px; transition: background 0.12s, box-shadow 0.12s;
|
|
157
|
+
}
|
|
158
|
+
.sv-board__col.is-over { background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, var(--sg-header-bg, #f6f7f9)); box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--sg-accent, #6366f1) 40%, transparent); }
|
|
159
|
+
.sv-board__colhead { display: flex; align-items: center; gap: 8px; padding: 0 2px 2px; }
|
|
160
|
+
.sv-board__dot { width: 9px; height: 9px; border-radius: 50%; background: var(--col, var(--sg-accent, #6366f1)); flex: none; }
|
|
161
|
+
.sv-board__coltitle { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-fg, #0f172a); }
|
|
162
|
+
.sv-board__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: var(--sg-muted, #64748b); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 999px; }
|
|
163
|
+
.sv-board__cards { display: flex; flex-direction: column; gap: 8px; min-height: 40px; }
|
|
164
|
+
.sv-board__card {
|
|
165
|
+
position: relative; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-left: 3px solid var(--col, var(--sg-accent, #6366f1));
|
|
166
|
+
border-radius: 9px; padding: 10px 12px; cursor: grab; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); user-select: none;
|
|
167
|
+
transition: box-shadow 0.12s, transform 0.06s;
|
|
168
|
+
}
|
|
169
|
+
.sv-board__card:hover { box-shadow: 0 4px 12px -4px rgba(15, 23, 42, 0.18); }
|
|
170
|
+
.sv-board__card.is-clickable:hover { border-color: color-mix(in srgb, var(--col, #6366f1) 45%, var(--sg-border, #e6e8ec)); }
|
|
171
|
+
.sv-board__card.is-clickable:focus-visible { outline: 2px solid var(--col, #6366f1); outline-offset: 2px; }
|
|
172
|
+
.sv-board__card:active { cursor: grabbing; }
|
|
173
|
+
.sv-board__card.is-dragging { opacity: 0.5; }
|
|
174
|
+
.sv-board__card-title { font-size: 13.5px; font-weight: 600; color: var(--sg-fg, #0f172a); line-height: 1.35; }
|
|
175
|
+
.sv-board__card-sub { margin-top: 3px; font-size: 12px; color: var(--sg-muted, #64748b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
176
|
+
.sv-board__badge {
|
|
177
|
+
display: inline-flex; margin-top: 8px; padding: 2px 8px; font-size: 11.5px; font-weight: 700; border-radius: 999px;
|
|
178
|
+
color: var(--col, var(--sg-accent, #6366f1)); background: color-mix(in srgb, var(--col, #6366f1) 14%, transparent);
|
|
179
|
+
}
|
|
180
|
+
.sv-board__empty { padding: 14px; text-align: center; font-size: 12px; color: var(--sg-muted, #94a3b8); border: 1px dashed var(--sg-border, #e6e8ec); border-radius: 8px; }
|
|
181
|
+
.sv-board__hint { color: var(--sg-muted, #64748b); font-size: 13px; padding: 20px; }
|
|
182
|
+
.sv-board__hint code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 5px; border-radius: 5px; }
|
|
183
|
+
/* First-load skeleton + empty state. */
|
|
184
|
+
.sv-board__col--skeleton { pointer-events: none; }
|
|
185
|
+
.sv-board__sk { position: relative; overflow: hidden; background: color-mix(in srgb, var(--sg-fg, #0f172a) 7%, transparent); border: none; }
|
|
186
|
+
.sv-board__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-board-sheen 1.2s ease-in-out infinite; }
|
|
187
|
+
.sv-board__sk--head { display: block; width: 55%; height: 12px; border-radius: 5px; }
|
|
188
|
+
.sv-board__sk--card { height: 52px; box-shadow: none; }
|
|
189
|
+
@keyframes sv-board-sheen { 100% { transform: translateX(100%); } }
|
|
190
|
+
.sv-board__blank { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 200px; color: var(--sg-muted, #94a3b8); font-size: 13.5px; text-align: center; }
|
|
191
|
+
.sv-board__blank-mark { width: 42px; height: 42px; border-radius: 11px; border: 2px dashed var(--sg-border, #cbd5e1); }
|
|
192
|
+
</style>
|