@kernhq/module-quire 0.14.0 → 0.15.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/contract/models.d.ts +209 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +151 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +32 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +3 -3
- package/dist/contract/router.d.ts +509 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +211 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +573 -1269
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +180 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/markdown.d.ts.map +1 -1
- package/dist/server/export/markdown.js +40 -1
- package/dist/server/export/markdown.js.map +1 -1
- package/dist/server/render.d.ts +158 -0
- package/dist/server/render.d.ts.map +1 -1
- package/dist/server/render.js +240 -0
- package/dist/server/render.js.map +1 -1
- package/dist/server/schema.d.ts +260 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +108 -1
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/index.d.ts +6 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +27 -3
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/macros.d.ts +83 -0
- package/dist/server/services/macros.d.ts.map +1 -0
- package/dist/server/services/macros.js +488 -0
- package/dist/server/services/macros.js.map +1 -0
- package/dist/server/services/publications.d.ts +2 -1
- package/dist/server/services/publications.d.ts.map +1 -1
- package/dist/server/services/publications.js +43 -4
- package/dist/server/services/publications.js.map +1 -1
- package/dist/server/services/templates.d.ts +135 -0
- package/dist/server/services/templates.d.ts.map +1 -0
- package/dist/server/services/templates.js +897 -0
- package/dist/server/services/templates.js.map +1 -0
- package/dist/server/services/versions.d.ts +12 -0
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +3 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0011_templates.sql +157 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/client/components/NewSpaceDialog.svelte +77 -8
- package/src/client/components/PageEditor.svelte +80 -0
- package/src/client/components/PagePicker.svelte +264 -0
- package/src/client/components/SaveAsTemplateDialog.svelte +502 -0
- package/src/client/components/SidebarSpaces.svelte +33 -1
- package/src/client/components/TemplatePicker.svelte +437 -0
- package/src/client/i18n.ts +327 -0
- package/src/client/index.ts +19 -0
- package/src/client/mock.ts +274 -0
- package/src/client/pages/PageView.svelte +50 -0
- package/src/client/pages/SpacePage.svelte +20 -4
- package/src/client/query.ts +17 -0
- package/src/contract/models.ts +191 -0
- package/src/contract/permissions.ts +33 -0
- package/src/contract/router.ts +228 -0
package/src/client/mock.ts
CHANGED
|
@@ -24,6 +24,11 @@ import type {
|
|
|
24
24
|
RecentEntry,
|
|
25
25
|
RowRef,
|
|
26
26
|
Space,
|
|
27
|
+
Template,
|
|
28
|
+
TemplateChoice,
|
|
29
|
+
TemplateKind,
|
|
30
|
+
TemplateResult,
|
|
31
|
+
TemplateVariable,
|
|
27
32
|
TransferCounts,
|
|
28
33
|
View,
|
|
29
34
|
ViewConfig,
|
|
@@ -1136,6 +1141,91 @@ export function createMockQuireApi() {
|
|
|
1136
1141
|
row.finishedAt = new Date().toISOString()
|
|
1137
1142
|
}
|
|
1138
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* The five starters, **in English only**.
|
|
1146
|
+
*
|
|
1147
|
+
* The real ones are constants on the server with a five-locale table beside them, resolved against
|
|
1148
|
+
* the reader's own locale — `server/services/templates.ts` explains why they cannot live in this
|
|
1149
|
+
* package's message bundle. This mock has no server and no principal, so it carries the English
|
|
1150
|
+
* names and nothing else: it is the demo interface, and a demo of the picker is a demo of the
|
|
1151
|
+
* shape rather than of the translation. Anything that needs to see a starter in Persian needs a
|
|
1152
|
+
* server.
|
|
1153
|
+
*/
|
|
1154
|
+
const STARTER_NAMES: Array<[string, string, string, string]> = [
|
|
1155
|
+
['meeting-notes', 'users', 'Meeting notes', 'Who was there, what was decided, and who does what next.'],
|
|
1156
|
+
[
|
|
1157
|
+
'decision-record',
|
|
1158
|
+
'flag',
|
|
1159
|
+
'Decision record',
|
|
1160
|
+
'One decision, why it was taken, and what it commits you to.',
|
|
1161
|
+
],
|
|
1162
|
+
[
|
|
1163
|
+
'requirements',
|
|
1164
|
+
'target',
|
|
1165
|
+
'Requirements',
|
|
1166
|
+
'What a piece of work has to do, and what it deliberately does not.',
|
|
1167
|
+
],
|
|
1168
|
+
[
|
|
1169
|
+
'retrospective',
|
|
1170
|
+
'refresh-cw',
|
|
1171
|
+
'Retrospective',
|
|
1172
|
+
'What went well, what got in the way, and what to change.',
|
|
1173
|
+
],
|
|
1174
|
+
['how-to', 'wrench', 'How-to', 'A task somebody can follow from start to finish.'],
|
|
1175
|
+
]
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* One template somebody in this workspace already saved, so the picker is not five shipped entries
|
|
1179
|
+
* and nothing else — the interesting half of the feature is the one a colleague made.
|
|
1180
|
+
*/
|
|
1181
|
+
const templateRows: Template[] = [
|
|
1182
|
+
{
|
|
1183
|
+
id: uid(700),
|
|
1184
|
+
workspaceId: '' as Template['workspaceId'],
|
|
1185
|
+
spaceId: uid(1),
|
|
1186
|
+
kind: 'page',
|
|
1187
|
+
key: null,
|
|
1188
|
+
builtIn: false,
|
|
1189
|
+
name: 'Weekly review',
|
|
1190
|
+
description: 'What moved this week, and what is stuck.',
|
|
1191
|
+
icon: 'calendar-days',
|
|
1192
|
+
doc: {
|
|
1193
|
+
type: 'doc',
|
|
1194
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Week {{week}} · {{author}}' }] }],
|
|
1195
|
+
},
|
|
1196
|
+
variables: [
|
|
1197
|
+
{
|
|
1198
|
+
name: 'week',
|
|
1199
|
+
label: 'Which week',
|
|
1200
|
+
type: 'text',
|
|
1201
|
+
options: [],
|
|
1202
|
+
default: null,
|
|
1203
|
+
required: true,
|
|
1204
|
+
},
|
|
1205
|
+
],
|
|
1206
|
+
createdBy: COLLEAGUE as Template['createdBy'],
|
|
1207
|
+
createdAt: iso(9e6),
|
|
1208
|
+
updatedAt: iso(9e6),
|
|
1209
|
+
},
|
|
1210
|
+
]
|
|
1211
|
+
|
|
1212
|
+
const templateChoice = (row: Template): TemplateChoice => ({
|
|
1213
|
+
id: row.id,
|
|
1214
|
+
key: row.key,
|
|
1215
|
+
builtIn: row.builtIn,
|
|
1216
|
+
kind: row.kind,
|
|
1217
|
+
spaceId: row.spaceId,
|
|
1218
|
+
name: row.name,
|
|
1219
|
+
description: row.description,
|
|
1220
|
+
icon: row.icon,
|
|
1221
|
+
variables: row.variables,
|
|
1222
|
+
updatedAt: row.updatedAt,
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
/** The same single-pass, text-only substitution the server does — see `services/templates.ts`. */
|
|
1226
|
+
const fill = (text: string, values: Record<string, string>) =>
|
|
1227
|
+
text.replace(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g, (whole, name: string) => values[name] ?? whole)
|
|
1228
|
+
|
|
1139
1229
|
return {
|
|
1140
1230
|
spaces: {
|
|
1141
1231
|
list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
|
|
@@ -1698,6 +1788,190 @@ export function createMockQuireApi() {
|
|
|
1698
1788
|
},
|
|
1699
1789
|
},
|
|
1700
1790
|
|
|
1791
|
+
/**
|
|
1792
|
+
* What somebody writes with.
|
|
1793
|
+
*
|
|
1794
|
+
* The starters and the rows come back as one list with the override rule applied, exactly as the
|
|
1795
|
+
* server does it — a row carrying a starter's key stands in that starter's place rather than
|
|
1796
|
+
* appearing beside it, which is the whole shape a picker has to draw.
|
|
1797
|
+
*
|
|
1798
|
+
* `instantiate` makes a real page in the tree. It cannot write a *body*: there is no collab
|
|
1799
|
+
* service behind `dev:mock`, and a page's prose lives there rather than in this list. So the
|
|
1800
|
+
* title is substituted and the page appears where it should, and the body is the one thing a
|
|
1801
|
+
* demo of this feature cannot show.
|
|
1802
|
+
*/
|
|
1803
|
+
templates: {
|
|
1804
|
+
list: async ({
|
|
1805
|
+
kind = 'page',
|
|
1806
|
+
spaceId = null,
|
|
1807
|
+
}: {
|
|
1808
|
+
kind?: TemplateKind
|
|
1809
|
+
spaceId?: string | null
|
|
1810
|
+
} = {}) => {
|
|
1811
|
+
const rows = templateRows.filter(
|
|
1812
|
+
(row) => row.kind === kind && (row.spaceId === null || row.spaceId === spaceId),
|
|
1813
|
+
)
|
|
1814
|
+
const byKey = new Map(rows.filter((row) => row.key).map((row) => [row.key as string, row]))
|
|
1815
|
+
const out: TemplateChoice[] = []
|
|
1816
|
+
if (kind === 'page')
|
|
1817
|
+
for (const [key, icon, name, description] of STARTER_NAMES) {
|
|
1818
|
+
const override = byKey.get(key)
|
|
1819
|
+
out.push(
|
|
1820
|
+
override
|
|
1821
|
+
? templateChoice(override)
|
|
1822
|
+
: {
|
|
1823
|
+
id: null,
|
|
1824
|
+
key,
|
|
1825
|
+
builtIn: true,
|
|
1826
|
+
kind: 'page',
|
|
1827
|
+
spaceId: null,
|
|
1828
|
+
name,
|
|
1829
|
+
description,
|
|
1830
|
+
icon,
|
|
1831
|
+
variables: [],
|
|
1832
|
+
updatedAt: null,
|
|
1833
|
+
},
|
|
1834
|
+
)
|
|
1835
|
+
}
|
|
1836
|
+
for (const row of rows)
|
|
1837
|
+
if (!row.key || !STARTER_NAMES.some(([key]) => key === row.key)) out.push(templateChoice(row))
|
|
1838
|
+
return out
|
|
1839
|
+
},
|
|
1840
|
+
|
|
1841
|
+
get: async ({ templateId }: { templateId: string }) => {
|
|
1842
|
+
const row = templateRows.find((t) => t.id === templateId)
|
|
1843
|
+
if (!row) throw notFound('Template')
|
|
1844
|
+
return row
|
|
1845
|
+
},
|
|
1846
|
+
|
|
1847
|
+
createFromPage: async (input: {
|
|
1848
|
+
kind?: TemplateKind
|
|
1849
|
+
sourceId: string
|
|
1850
|
+
spaceId?: string | null
|
|
1851
|
+
name: string
|
|
1852
|
+
description?: string
|
|
1853
|
+
icon?: string | null
|
|
1854
|
+
variables?: TemplateVariable[]
|
|
1855
|
+
key?: string | null
|
|
1856
|
+
}) => {
|
|
1857
|
+
if (input.key && templateRows.some((t) => t.key === input.key))
|
|
1858
|
+
throw Object.assign(new Error('This workspace already has its own version of that template'), {
|
|
1859
|
+
code: 'CONFLICT',
|
|
1860
|
+
})
|
|
1861
|
+
const source = input.kind === 'space' ? null : found(input.sourceId)
|
|
1862
|
+
const row: Template = {
|
|
1863
|
+
id: nextId(),
|
|
1864
|
+
workspaceId: '' as Template['workspaceId'],
|
|
1865
|
+
spaceId: input.kind === 'space' ? null : (input.spaceId ?? null),
|
|
1866
|
+
kind: input.kind ?? 'page',
|
|
1867
|
+
key: input.key ?? null,
|
|
1868
|
+
builtIn: Boolean(input.key),
|
|
1869
|
+
name: input.name,
|
|
1870
|
+
description: input.description ?? '',
|
|
1871
|
+
icon: input.icon ?? null,
|
|
1872
|
+
// No collab service, so there is no body to read — the title stands in for the prose, which
|
|
1873
|
+
// is enough for the picker and honest about what a mock can know.
|
|
1874
|
+
doc:
|
|
1875
|
+
input.kind === 'space'
|
|
1876
|
+
? { pages: [] }
|
|
1877
|
+
: {
|
|
1878
|
+
type: 'doc',
|
|
1879
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: source?.title ?? '' }] }],
|
|
1880
|
+
},
|
|
1881
|
+
variables: input.variables ?? [],
|
|
1882
|
+
createdBy: ME as Template['createdBy'],
|
|
1883
|
+
createdAt: new Date().toISOString(),
|
|
1884
|
+
updatedAt: new Date().toISOString(),
|
|
1885
|
+
}
|
|
1886
|
+
templateRows.push(row)
|
|
1887
|
+
return row
|
|
1888
|
+
},
|
|
1889
|
+
|
|
1890
|
+
update: async ({ templateId, ...patch }: { templateId: string } & Partial<Template>) => {
|
|
1891
|
+
const row = templateRows.find((t) => t.id === templateId)
|
|
1892
|
+
if (!row) throw notFound('Template')
|
|
1893
|
+
Object.assign(row, patch, { updatedAt: new Date().toISOString() })
|
|
1894
|
+
return row
|
|
1895
|
+
},
|
|
1896
|
+
|
|
1897
|
+
remove: async ({ templateId }: { templateId: string }) => {
|
|
1898
|
+
const at = templateRows.findIndex((t) => t.id === templateId)
|
|
1899
|
+
if (at < 0) throw notFound('Template')
|
|
1900
|
+
templateRows.splice(at, 1)
|
|
1901
|
+
return { ok: true as const }
|
|
1902
|
+
},
|
|
1903
|
+
|
|
1904
|
+
instantiate: async (input: {
|
|
1905
|
+
templateId?: string | null
|
|
1906
|
+
starterKey?: string | null
|
|
1907
|
+
spaceId?: string | null
|
|
1908
|
+
parentId?: string | null
|
|
1909
|
+
afterId?: string | null
|
|
1910
|
+
title?: string
|
|
1911
|
+
key?: string | null
|
|
1912
|
+
name?: string
|
|
1913
|
+
values?: Record<string, string>
|
|
1914
|
+
}): Promise<TemplateResult> => {
|
|
1915
|
+
const row = input.templateId
|
|
1916
|
+
? templateRows.find((t) => t.id === input.templateId)
|
|
1917
|
+
: templateRows.find((t) => t.key === input.starterKey)
|
|
1918
|
+
const starter = STARTER_NAMES.find(([key]) => key === input.starterKey)
|
|
1919
|
+
if (!row && !starter) throw notFound('Template')
|
|
1920
|
+
|
|
1921
|
+
const values: Record<string, string> = {
|
|
1922
|
+
date: new Date().toLocaleDateString(),
|
|
1923
|
+
time: new Date().toLocaleTimeString(),
|
|
1924
|
+
author: 'You',
|
|
1925
|
+
space: spaces.find((s) => s.id === input.spaceId)?.name ?? '',
|
|
1926
|
+
...(input.values ?? {}),
|
|
1927
|
+
}
|
|
1928
|
+
for (const variable of row?.variables ?? [])
|
|
1929
|
+
if (variable.required && !values[variable.name])
|
|
1930
|
+
throw Object.assign(new Error(`"${variable.label}" is needed before this can be made`), {
|
|
1931
|
+
code: 'BAD_REQUEST',
|
|
1932
|
+
})
|
|
1933
|
+
|
|
1934
|
+
if (row?.kind === 'space') {
|
|
1935
|
+
if (!input.key || !input.name)
|
|
1936
|
+
throw Object.assign(new Error('A space template needs a name and an address'), {
|
|
1937
|
+
code: 'BAD_REQUEST',
|
|
1938
|
+
})
|
|
1939
|
+
const space: Space = {
|
|
1940
|
+
id: nextId(),
|
|
1941
|
+
workspaceId: '' as Space['workspaceId'],
|
|
1942
|
+
key: input.key,
|
|
1943
|
+
name: fill(input.name, values),
|
|
1944
|
+
description: '',
|
|
1945
|
+
icon: null,
|
|
1946
|
+
visibility: 'open',
|
|
1947
|
+
homepageId: null,
|
|
1948
|
+
createdBy: ME as Space['createdBy'],
|
|
1949
|
+
createdAt: new Date().toISOString(),
|
|
1950
|
+
updatedAt: new Date().toISOString(),
|
|
1951
|
+
archivedAt: null,
|
|
1952
|
+
}
|
|
1953
|
+
spaces.push(space)
|
|
1954
|
+
return { spaceId: space.id, pageId: null, pageCount: 0 }
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
const spaceId = input.spaceId
|
|
1958
|
+
if (!spaceId) throw Object.assign(new Error('A page template needs a space'), { code: 'BAD_REQUEST' })
|
|
1959
|
+
const siblings = pages
|
|
1960
|
+
.filter((p) => p.spaceId === spaceId && p.parentId === (input.parentId ?? null) && !p.deletedAt)
|
|
1961
|
+
.sort((a, b) => (a._order < b._order ? -1 : 1))
|
|
1962
|
+
const title = fill(input.title || row?.name || starter?.[2] || '', values)
|
|
1963
|
+
const created = page(++seq, spaceId, title, `${siblings.at(-1)?._order ?? 'a'}m`, null, {
|
|
1964
|
+
icon: row?.icon ?? starter?.[1] ?? null,
|
|
1965
|
+
})
|
|
1966
|
+
created.id = uid(seq)
|
|
1967
|
+
created.parentId = input.parentId ?? null
|
|
1968
|
+
created.createdAt = new Date().toISOString()
|
|
1969
|
+
created.updatedAt = created.createdAt
|
|
1970
|
+
pages.push(created)
|
|
1971
|
+
return { spaceId, pageId: created.id, pageCount: 1 }
|
|
1972
|
+
},
|
|
1973
|
+
},
|
|
1974
|
+
|
|
1701
1975
|
publishing: {
|
|
1702
1976
|
publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
|
|
1703
1977
|
const row = found(pageId)
|
|
@@ -27,6 +27,7 @@ import FavoriteStar from '../components/FavoriteStar.svelte'
|
|
|
27
27
|
import PageEditor from '../components/PageEditor.svelte'
|
|
28
28
|
import PageLabels from '../components/PageLabels.svelte'
|
|
29
29
|
import PublishDialog from '../components/PublishDialog.svelte'
|
|
30
|
+
import SaveAsTemplateDialog from '../components/SaveAsTemplateDialog.svelte'
|
|
30
31
|
import VersionHistory from '../components/VersionHistory.svelte'
|
|
31
32
|
import { type CoreApi, toPerson } from '../core-api.js'
|
|
32
33
|
import DatabaseView from '../database/DatabaseView.svelte'
|
|
@@ -78,6 +79,19 @@ const membersQuery = createQuery(() => ({
|
|
|
78
79
|
queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
|
|
79
80
|
}))
|
|
80
81
|
|
|
82
|
+
/*
|
|
83
|
+
* The space by name, for the one sentence that needs it: "offered only in Handbook".
|
|
84
|
+
*
|
|
85
|
+
* The same query key the sidebar already holds, so this is a cache read rather than a request —
|
|
86
|
+
* and the key is what the URL carries, which is not a thing to show somebody in a sentence.
|
|
87
|
+
*/
|
|
88
|
+
const spacesQuery = createQuery(() => ({
|
|
89
|
+
queryKey: quireKeys.spaces(workspaceId),
|
|
90
|
+
enabled: Boolean(workspaceId),
|
|
91
|
+
queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
|
|
92
|
+
}))
|
|
93
|
+
const spaceName = $derived((spacesQuery.data ?? []).find((space) => space.key === spaceKey)?.name ?? spaceKey)
|
|
94
|
+
|
|
81
95
|
const query = createQuery(() => ({
|
|
82
96
|
queryKey: quireKeys.page(workspaceId, pageId),
|
|
83
97
|
enabled: Boolean(workspaceId && pageId),
|
|
@@ -276,6 +290,14 @@ const canPublish = $derived(canQuire('pagePublish'))
|
|
|
276
290
|
*/
|
|
277
291
|
let exportOpen = $state(false)
|
|
278
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Saving this page as something to write with again.
|
|
295
|
+
*
|
|
296
|
+
* A flag and a menu entry, exactly like the export above: what a template needs to know is the
|
|
297
|
+
* dialog's business, and it reads the starters and the space list itself when it opens.
|
|
298
|
+
*/
|
|
299
|
+
let templateOpen = $state(false)
|
|
300
|
+
|
|
279
301
|
const publicationsQuery = createQuery(() => ({
|
|
280
302
|
queryKey: quireKeys.publications(workspaceId, doc?.spaceId ?? ''),
|
|
281
303
|
enabled: canPublish && Boolean(workspaceId && doc?.spaceId),
|
|
@@ -621,6 +643,23 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
621
643
|
},
|
|
622
644
|
]
|
|
623
645
|
: []),
|
|
646
|
+
/*
|
|
647
|
+
* `space.manage`, not `page.edit`, matching what `templates.createFromPage` asks — and the
|
|
648
|
+
* asymmetry is the point. A template changes what everybody in the space is offered the
|
|
649
|
+
* next time they make a page, which is the space's furniture rather than this page's
|
|
650
|
+
* content, exactly as a label is. Somebody who may write here is not thereby somebody who
|
|
651
|
+
* may add to everybody else's picker.
|
|
652
|
+
*/
|
|
653
|
+
...(canQuire('spaceManage')
|
|
654
|
+
? [
|
|
655
|
+
{
|
|
656
|
+
id: 'save-template',
|
|
657
|
+
label: t('template_menu'),
|
|
658
|
+
icon: 'clipboard-list',
|
|
659
|
+
onSelect: () => (templateOpen = true),
|
|
660
|
+
},
|
|
661
|
+
]
|
|
662
|
+
: []),
|
|
624
663
|
{
|
|
625
664
|
id: 'watch',
|
|
626
665
|
label: watching ? t('watch_stop') : t('watch'),
|
|
@@ -793,6 +832,17 @@ async function undoTrash(workspace: string, id: string, spaceId: string, title:
|
|
|
793
832
|
page={{ id: doc.id, title: doc.title }}
|
|
794
833
|
/>
|
|
795
834
|
|
|
835
|
+
{#if canQuire('spaceManage')}
|
|
836
|
+
<SaveAsTemplateDialog
|
|
837
|
+
bind:open={templateOpen}
|
|
838
|
+
{workspaceId}
|
|
839
|
+
spaceId={doc.spaceId}
|
|
840
|
+
{spaceName}
|
|
841
|
+
pageId={doc.id}
|
|
842
|
+
pageTitle={doc.title}
|
|
843
|
+
/>
|
|
844
|
+
{/if}
|
|
845
|
+
|
|
796
846
|
<!--
|
|
797
847
|
The body says nothing about numbers until it knows them. Naming a count before the tree has
|
|
798
848
|
loaded would be the same silent lie in a smaller size — "it goes to the trash" for a page that
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Button, EmptyState, navigation, Skeleton, session } from '@kernhq/ui'
|
|
3
3
|
import { createQuery } from '@tanstack/svelte-query'
|
|
4
4
|
import { getQuireApi } from '../api-instance.js'
|
|
5
|
+
import TemplatePicker from '../components/TemplatePicker.svelte'
|
|
5
6
|
import { t } from '../i18n.js'
|
|
6
7
|
import { canQuire } from '../permissions.js'
|
|
7
8
|
import { quireKeys } from '../query.js'
|
|
@@ -57,6 +58,16 @@ const firstPageId = $derived(
|
|
|
57
58
|
)
|
|
58
59
|
|
|
59
60
|
let creating = $state(false)
|
|
61
|
+
/**
|
|
62
|
+
* The first page of an empty space is the one page most worth offering a template for — a handbook
|
|
63
|
+
* that starts as a how-to is a handbook somebody kept writing. The picker still opens on **Blank
|
|
64
|
+
* page**, so nothing about starting from nothing got slower.
|
|
65
|
+
*/
|
|
66
|
+
let pickerOpen = $state(false)
|
|
67
|
+
|
|
68
|
+
const open = (pageId: string) =>
|
|
69
|
+
void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(pageId)}`)
|
|
70
|
+
|
|
60
71
|
async function createFirst() {
|
|
61
72
|
if (!space || creating) return
|
|
62
73
|
creating = true
|
|
@@ -70,9 +81,7 @@ async function createFirst() {
|
|
|
70
81
|
icon: null,
|
|
71
82
|
afterId: null,
|
|
72
83
|
})
|
|
73
|
-
|
|
74
|
-
`/${workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(created.id)}`,
|
|
75
|
-
)
|
|
84
|
+
open(created.id)
|
|
76
85
|
} finally {
|
|
77
86
|
creating = false
|
|
78
87
|
}
|
|
@@ -96,11 +105,18 @@ async function createFirst() {
|
|
|
96
105
|
<EmptyState icon="file-text" title={t('space_empty')} description={t('space_empty_desc')}>
|
|
97
106
|
{#snippet actions()}
|
|
98
107
|
{#if canQuire('pageCreate')}
|
|
99
|
-
<Button
|
|
108
|
+
<Button aria-busy={creating} onclick={() => (pickerOpen = true)}>{t('new_page')}</Button>
|
|
100
109
|
{/if}
|
|
101
110
|
{/snippet}
|
|
102
111
|
</EmptyState>
|
|
103
112
|
</div>
|
|
113
|
+
<TemplatePicker
|
|
114
|
+
bind:open={pickerOpen}
|
|
115
|
+
{workspaceId}
|
|
116
|
+
spaceId={space.id}
|
|
117
|
+
onBlank={() => void createFirst()}
|
|
118
|
+
onMade={(result) => result.pageId && open(result.pageId)}
|
|
119
|
+
/>
|
|
104
120
|
{/if}
|
|
105
121
|
|
|
106
122
|
<style>
|
package/src/client/query.ts
CHANGED
|
@@ -54,6 +54,23 @@ export const quireKeys = {
|
|
|
54
54
|
['quire', 'publication', workspaceId, spaceId] as const,
|
|
55
55
|
site: (workspaceId: string, slug: string) => ['quire', 'publication', workspaceId, 'site', slug] as const,
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* What may be made here.
|
|
59
|
+
*
|
|
60
|
+
* `template` is the entity `templates.createFromPage|update|remove` announce, so somebody saving a
|
|
61
|
+
* page as a template puts it in everybody else's picker without a reload — which matters more here
|
|
62
|
+
* than for most lists, because the whole point of a template is that a colleague finds it.
|
|
63
|
+
*
|
|
64
|
+
* Keyed by space *and* kind: a space-scoped template is offered in one space and not the next, so
|
|
65
|
+
* one key for the workspace would serve the Handbook's list to Engineering. `spaceId` is null for
|
|
66
|
+
* the workspace-wide question the "New space" picker asks, which is a different answer and
|
|
67
|
+
* therefore a different key.
|
|
68
|
+
*/
|
|
69
|
+
templates: (workspaceId: string, kind: string, spaceId: string | null) =>
|
|
70
|
+
['quire', 'template', workspaceId, kind, spaceId ?? 'workspace'] as const,
|
|
71
|
+
template: (workspaceId: string, templateId: string) =>
|
|
72
|
+
['quire', 'template', workspaceId, templateId] as const,
|
|
73
|
+
|
|
57
74
|
/** the schema — properties and views — which every open tab of a database is drawing */
|
|
58
75
|
database: (workspaceId: string, databaseId: string) =>
|
|
59
76
|
['quire', 'database', workspaceId, databaseId] as const,
|
package/src/contract/models.ts
CHANGED
|
@@ -473,4 +473,195 @@ export const ImportJob = z.object({
|
|
|
473
473
|
})
|
|
474
474
|
export type ImportJob = z.infer<typeof ImportJob>
|
|
475
475
|
|
|
476
|
+
// =====================================================================================
|
|
477
|
+
// Templates
|
|
478
|
+
// =====================================================================================
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* What a template makes.
|
|
482
|
+
*
|
|
483
|
+
* `page` — one page; the body is a page doc.
|
|
484
|
+
* `space` — a whole space; the body is a tree of pages, each with its own body.
|
|
485
|
+
*
|
|
486
|
+
* Two rather than one with a "just make the root" flag, because they are made from different places
|
|
487
|
+
* and answer different questions: a page template is offered on "New page" inside a space that
|
|
488
|
+
* already exists, and a space template is what somebody reaches for when there is no space yet.
|
|
489
|
+
*/
|
|
490
|
+
export const TemplateKind = z.enum(['page', 'space'])
|
|
491
|
+
export type TemplateKind = z.infer<typeof TemplateKind>
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The starters this module ships.
|
|
495
|
+
*
|
|
496
|
+
* **They are constants in the module, not rows in a customer's database**, and the key is what a
|
|
497
|
+
* constant has instead of an id. `migrations/0011_templates.sql` argues the choice at length; the
|
|
498
|
+
* short of it is that a migration runs once per *database* and has no workspace to seed into, that a
|
|
499
|
+
* seeded row is frozen at the release that wrote it, and that a row holds one language in a product
|
|
500
|
+
* that ships five.
|
|
501
|
+
*
|
|
502
|
+
* A workspace edits a starter by **overriding** it: a `Template` row carrying one of these keys takes
|
|
503
|
+
* that starter's place in the picker rather than sitting beside it, so the first edit writes one row,
|
|
504
|
+
* a workspace that never touches them has none, and resetting is deleting the row.
|
|
505
|
+
*
|
|
506
|
+
* `Template.key` is deliberately **not** this enum — see the comment there.
|
|
507
|
+
*/
|
|
508
|
+
export const TemplateStarterKey = z.enum([
|
|
509
|
+
'meeting-notes',
|
|
510
|
+
'decision-record',
|
|
511
|
+
'requirements',
|
|
512
|
+
'retrospective',
|
|
513
|
+
'how-to',
|
|
514
|
+
])
|
|
515
|
+
export type TemplateStarterKey = z.infer<typeof TemplateStarterKey>
|
|
516
|
+
export const TEMPLATE_STARTER_KEYS = TemplateStarterKey.options
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* What a variable's type decides, which is **the control somebody is shown and not the storage**.
|
|
520
|
+
*
|
|
521
|
+
* Every filled value is substituted into prose as text, whatever the type: `{{sprint}}` in a heading
|
|
522
|
+
* becomes characters in a heading. The type is what turns "type the date" into a date picker and
|
|
523
|
+
* "type one of these four" into a menu — the difference between a form somebody fills correctly and
|
|
524
|
+
* one they fill approximately.
|
|
525
|
+
*/
|
|
526
|
+
export const TemplateVariableType = z.enum(['text', 'number', 'date', 'select', 'user'])
|
|
527
|
+
export type TemplateVariableType = z.infer<typeof TemplateVariableType>
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The names a template author may not take, because the module already fills them.
|
|
531
|
+
*
|
|
532
|
+
* `date` and `author` are the two the plan names. The other three are reserved now rather than
|
|
533
|
+
* later, and that is the whole reason the list is longer than it needs to be today: adding
|
|
534
|
+
* `{{time}}` in a future release would silently change what an existing template renders if some
|
|
535
|
+
* author had already declared a variable of that name. Reserving a name costs an author one
|
|
536
|
+
* synonym; taking one back costs somebody a document that used to be right.
|
|
537
|
+
*/
|
|
538
|
+
export const TEMPLATE_BUILT_IN_VARIABLES = ['date', 'time', 'author', 'space', 'workspace'] as const
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* What appears between the braces.
|
|
542
|
+
*
|
|
543
|
+
* ASCII, lowercase, no spaces — the same shape as `Space.key` and for the same reason: this string
|
|
544
|
+
* is matched against the body, so `{{Sprint}}` and `{{sprint}}` being two variables is exactly the
|
|
545
|
+
* near-duplicate that `labels_ws_space_name_uq` refuses next door. An author's own language belongs
|
|
546
|
+
* in `label`, which is the half a person actually reads.
|
|
547
|
+
*/
|
|
548
|
+
export const TemplateVariableName = z
|
|
549
|
+
.string()
|
|
550
|
+
.min(1)
|
|
551
|
+
.max(40)
|
|
552
|
+
.regex(/^[a-z][a-z0-9_]*$/, 'lowercase letters, digits and underscores, starting with a letter')
|
|
553
|
+
.refine(
|
|
554
|
+
(name) => !(TEMPLATE_BUILT_IN_VARIABLES as readonly string[]).includes(name),
|
|
555
|
+
'that name is filled by the module itself',
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* One thing somebody is asked before the page is made.
|
|
560
|
+
*
|
|
561
|
+
* Every field but `name`, `label` and `type` defaults, so a variable written by an older client
|
|
562
|
+
* still parses. Note what that does to the *output* type: it makes them required, so anything
|
|
563
|
+
* constructing a variable supplies all six — which is the point, and the reason a widened contract
|
|
564
|
+
* is additive for parsing and breaking for constructing.
|
|
565
|
+
*/
|
|
566
|
+
export const TemplateVariable = z.object({
|
|
567
|
+
name: TemplateVariableName,
|
|
568
|
+
/** what the person filling it in reads; their own language goes here, not in `name` */
|
|
569
|
+
label: z.string().min(1).max(120),
|
|
570
|
+
type: TemplateVariableType,
|
|
571
|
+
/** the menu, for `select`; empty for every other type */
|
|
572
|
+
options: z.array(z.string().min(1).max(120)).max(50).default([]),
|
|
573
|
+
/** what the field starts with — text whatever the type, because substitution is textual */
|
|
574
|
+
default: z.string().max(2000).nullable().default(null),
|
|
575
|
+
/** whether the page can be made without it */
|
|
576
|
+
required: z.boolean().default(false),
|
|
577
|
+
})
|
|
578
|
+
export type TemplateVariable = z.infer<typeof TemplateVariable>
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* One page of a space template, and everything under it.
|
|
582
|
+
*
|
|
583
|
+
* Recursive because a space is a tree and flattening it into a list with parent pointers would mean
|
|
584
|
+
* inventing local ids that exist only inside the column — an identifier nothing outside this
|
|
585
|
+
* document ever resolves. The depth is bounded by the space it was made from.
|
|
586
|
+
*
|
|
587
|
+
* The type is written out and the schema annotated with it, rather than left to inference, because
|
|
588
|
+
* a self-referencing `const` has no type TypeScript can name on its own and the package emits
|
|
589
|
+
* declarations.
|
|
590
|
+
*/
|
|
591
|
+
export type TemplateSpaceNode = {
|
|
592
|
+
title: string
|
|
593
|
+
icon: string | null
|
|
594
|
+
doc: Record<string, unknown>
|
|
595
|
+
children: TemplateSpaceNode[]
|
|
596
|
+
}
|
|
597
|
+
export const TemplateSpaceNode: z.ZodType<TemplateSpaceNode, TemplateSpaceNode> = z.lazy(() =>
|
|
598
|
+
z.object({
|
|
599
|
+
title: z.string().max(300),
|
|
600
|
+
icon: z.string().max(64).nullable(),
|
|
601
|
+
/** the page's body, with the same `{{variables}}` in it as any other */
|
|
602
|
+
doc: RichDoc,
|
|
603
|
+
children: z.array(TemplateSpaceNode),
|
|
604
|
+
}),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The body of a `space` template: the tree, under one key.
|
|
609
|
+
*
|
|
610
|
+
* An object rather than a bare array so that `Template.doc` is a JSON object for both kinds — the
|
|
611
|
+
* column defaults to `'{}'`, and a default that is the wrong *type* for half the rows is a default
|
|
612
|
+
* that lies. It also means the two bodies are told apart by their keys rather than by `typeof`.
|
|
613
|
+
*/
|
|
614
|
+
export const TemplateSpaceBody = z.object({ pages: z.array(TemplateSpaceNode) })
|
|
615
|
+
export type TemplateSpaceBody = z.infer<typeof TemplateSpaceBody>
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* A page, or a whole space, saved so it can be made again.
|
|
619
|
+
*
|
|
620
|
+
* `doc` is left opaque here for the reason `RichDoc` is: this file is not what knows the shape of a
|
|
621
|
+
* document. It is a page doc when `kind` is `page` and a `TemplateSpaceBody` when `kind` is `space`,
|
|
622
|
+
* and the server parses the second with the schema above.
|
|
623
|
+
*
|
|
624
|
+
* A shipped starter is not one of these — it has no id, no workspace and no timestamps, because it
|
|
625
|
+
* is a constant. What the picker returns is the starters and these together, with a row carrying a
|
|
626
|
+
* starter's `key` standing in for that starter.
|
|
627
|
+
*/
|
|
628
|
+
export const Template = z.object({
|
|
629
|
+
id: Id,
|
|
630
|
+
workspaceId: WorkspaceId,
|
|
631
|
+
/** null is workspace-wide; a space id scopes it to one space. Always null when `kind` is `space`. */
|
|
632
|
+
spaceId: Id.nullable(),
|
|
633
|
+
kind: TemplateKind,
|
|
634
|
+
/**
|
|
635
|
+
* The starter this row replaces, or null for somebody's own template.
|
|
636
|
+
*
|
|
637
|
+
* `z.string()` and **not** `TemplateStarterKey`, deliberately. This is stored data: a release that
|
|
638
|
+
* stopped shipping a starter would turn every override of it into a parse failure, which is a
|
|
639
|
+
* picker that throws rather than a picker missing one entry. A key naming a starter that no longer
|
|
640
|
+
* exists is an ordinary custom template, and the read side treats it as one.
|
|
641
|
+
*/
|
|
642
|
+
key: z.string().min(1).max(64).nullable(),
|
|
643
|
+
/** true exactly when `key` is set — the database holds the pair to that with a check constraint */
|
|
644
|
+
builtIn: z.boolean(),
|
|
645
|
+
name: z.string().min(1).max(120),
|
|
646
|
+
description: z.string().max(2000),
|
|
647
|
+
/** a Lucide icon name, or an emoji */
|
|
648
|
+
icon: z.string().max(64).nullable(),
|
|
649
|
+
doc: RichDoc,
|
|
650
|
+
variables: z.array(TemplateVariable),
|
|
651
|
+
createdBy: UserId.nullable(),
|
|
652
|
+
createdAt: Timestamp,
|
|
653
|
+
updatedAt: Timestamp,
|
|
654
|
+
})
|
|
655
|
+
export type Template = z.infer<typeof Template>
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* A template without its body.
|
|
659
|
+
*
|
|
660
|
+
* This exists so that opening a picker does not download every template's prose to draw a list of
|
|
661
|
+
* names — thirty page docs to render thirty rows, of which one is ever used. The body is fetched
|
|
662
|
+
* when a template is chosen.
|
|
663
|
+
*/
|
|
664
|
+
export const TemplateSummary = Template.omit({ doc: true })
|
|
665
|
+
export type TemplateSummary = z.infer<typeof TemplateSummary>
|
|
666
|
+
|
|
476
667
|
export const Ok = z.object({ ok: z.literal(true) })
|