@stonecrop/nuxt 0.13.9 → 0.13.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/package.json +9 -9
- package/src/cli/index.ts +4 -3
- package/src/cli/installers/doctypes.ts +17 -162
- package/src/cli/installers/frontend.ts +79 -1
- package/src/cli/installers/grafserv.ts +28 -180
- package/src/cli/utils/config.ts +39 -0
- package/src/cli/utils/templates.ts +34 -0
- package/templates/Project.json +56 -0
- package/templates/Task.json +74 -0
- package/templates/data.ts +90 -0
- package/templates/index.vue +98 -0
- package/templates/resolvers.ts +222 -205
- package/templates/schema.graphql +29 -2
- package/templates/stonecrop.client.ts +42 -0
- package/templates/stonecrop.ts +82 -0
- package/templates/useDoctypes.ts +53 -0
- package/templates/useRouteAdapter.ts +36 -0
- package/templates/Example.json +0 -84
- package/templates/example-table.json +0 -58
package/templates/resolvers.ts
CHANGED
|
@@ -1,141 +1,173 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stonecrop GraphQL Resolvers
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Uses Grafast plan resolvers — functions that return Steps (execution plans)
|
|
5
|
+
* rather than raw data. The key step functions used here:
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* (
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* - object({ ... }) - Creates an object with planned properties
|
|
12
|
-
* - access($step, 'property') - Accesses a property from another step
|
|
13
|
-
* - lambda($step, fn) - Transforms a step SYNCHRONOUSLY (no async/await!)
|
|
14
|
-
* - loadOne($step, fn) - Batch loads data ASYNCHRONOUSLY (for DB queries)
|
|
15
|
-
* - list([$a, $b]) - Combines multiple steps into a tuple
|
|
7
|
+
* - constant(value) — return a static value
|
|
8
|
+
* - lambda($step, fn) — transform a step SYNCHRONOUSLY (no async/await)
|
|
9
|
+
* - loadOne($step, fn) — batch-load data ASYNCHRONOUSLY (for DB queries)
|
|
10
|
+
* - object({ ... }) — group multiple steps into a single step object
|
|
16
11
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
12
|
+
* Data reads go through loadOne; data writes happen in server/plugins/stonecrop.ts
|
|
13
|
+
* via registered action handlers (start_task, complete_task, archive_project).
|
|
14
|
+
*
|
|
15
|
+
* To connect a real database, replace the imports from ./data with your
|
|
16
|
+
* PostGraphile setup. See: https://stonecrop.io/docs/guides/postgraphile
|
|
21
17
|
*/
|
|
22
18
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import {
|
|
19
|
+
import { constant, lambda, loadOne, object } from 'grafast'
|
|
20
|
+
import { getMeta, getAllMeta, getHandler } from '@stonecrop/graphql-middleware'
|
|
21
|
+
import type { ActionContext } from '@stonecrop/graphql-middleware'
|
|
22
|
+
import type { DoctypeMeta } from '@stonecrop/schema'
|
|
23
|
+
import { projects, tasks, type Project, type Task } from './data'
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// Formatting helpers (shape doctype metadata for the GraphQL
|
|
27
|
+
// response type defined in server/schema.graphql)
|
|
28
|
+
// ============================================================
|
|
29
|
+
|
|
30
|
+
function formatFieldMeta(field: { kind?: string; fieldname: string; fieldtype?: string; [key: string]: unknown }) {
|
|
31
|
+
return {
|
|
32
|
+
fieldname: field.fieldname,
|
|
33
|
+
fieldtype: field.fieldtype ?? null,
|
|
34
|
+
label: field.label ?? null,
|
|
35
|
+
required: field.required ?? false,
|
|
36
|
+
readOnly: field.readOnly ?? false,
|
|
37
|
+
options: field.options ?? null,
|
|
38
|
+
default: field.default ?? null,
|
|
39
|
+
width: field.width ?? null,
|
|
40
|
+
validation: field.validation ?? null,
|
|
41
|
+
component: field.component ?? null,
|
|
42
|
+
align: field.align ?? null,
|
|
43
|
+
edit: field.edit ?? null,
|
|
44
|
+
hidden: field.hidden ?? null,
|
|
45
|
+
mask: field.mask ?? null,
|
|
46
|
+
precision: field.precision ?? null,
|
|
47
|
+
scale: field.scale ?? null,
|
|
48
|
+
mode: field.mode ?? null,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatDoctypeMeta(meta: DoctypeMeta) {
|
|
53
|
+
const actions = meta.workflow?.actions
|
|
54
|
+
const actionList = actions
|
|
55
|
+
? Object.entries(actions as Record<string, Record<string, unknown>>).map(([name, action]) => ({
|
|
56
|
+
name,
|
|
57
|
+
label: action.label ?? null,
|
|
58
|
+
handler: action.handler ?? null,
|
|
59
|
+
requiredFields: (action.requiredFields as string[]) ?? [],
|
|
60
|
+
allowedStates: (action.allowedStates as string[]) ?? [],
|
|
61
|
+
confirm: action.confirm ?? false,
|
|
62
|
+
args: action.args ?? null,
|
|
63
|
+
}))
|
|
64
|
+
: []
|
|
65
|
+
return {
|
|
66
|
+
name: meta.name,
|
|
67
|
+
slug: meta.slug ?? null,
|
|
68
|
+
fields: meta.fields.map(formatFieldMeta),
|
|
69
|
+
workflow: meta.workflow
|
|
70
|
+
? {
|
|
71
|
+
states: meta.workflow.states ?? null,
|
|
72
|
+
actions: actionList,
|
|
73
|
+
}
|
|
74
|
+
: null,
|
|
75
|
+
inherits: meta.inherits ?? null,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Record helpers — read/write the in-memory Maps by doctype
|
|
81
|
+
// ============================================================
|
|
82
|
+
|
|
83
|
+
function getRecord(doctype: string, id: string): Project | Task | null {
|
|
84
|
+
const d = doctype.toLowerCase()
|
|
85
|
+
if (d === 'project') return projects.get(id) ?? null
|
|
86
|
+
if (d === 'task') return tasks.get(id) ?? null
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getRecords(doctype: string, filters?: Record<string, unknown>): (Project | Task)[] {
|
|
91
|
+
const d = doctype.toLowerCase()
|
|
92
|
+
if (d === 'project') {
|
|
93
|
+
return Array.from(projects.values())
|
|
94
|
+
}
|
|
95
|
+
if (d === 'task') {
|
|
96
|
+
let all = Array.from(tasks.values())
|
|
97
|
+
if (filters?.projectId) {
|
|
98
|
+
all = all.filter(t => t.projectId === filters.projectId)
|
|
99
|
+
}
|
|
100
|
+
return all
|
|
101
|
+
}
|
|
102
|
+
return []
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function nextId(doctype: string): string {
|
|
106
|
+
const d = doctype.toLowerCase()
|
|
107
|
+
const ids = d === 'project' ? projects.keys() : tasks.keys()
|
|
108
|
+
const max = Math.max(0, ...Array.from(ids, id => Number(id) || 0))
|
|
109
|
+
return String(max + 1)
|
|
110
|
+
}
|
|
26
111
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
112
|
+
// ============================================================
|
|
113
|
+
// Resolvers (Grafast plan format)
|
|
114
|
+
// ============================================================
|
|
29
115
|
|
|
30
116
|
export const resolvers = {
|
|
31
117
|
Query: {
|
|
32
118
|
plans: {
|
|
33
|
-
/**
|
|
34
|
-
* Health check endpoint
|
|
35
|
-
* Returns a constant object with health status
|
|
36
|
-
*/
|
|
37
119
|
healthCheck() {
|
|
38
|
-
return constant({
|
|
39
|
-
status: 'healthy',
|
|
40
|
-
timestamp: new Date().toISOString(),
|
|
41
|
-
version: '1.0.0',
|
|
42
|
-
})
|
|
120
|
+
return constant({ status: 'healthy', timestamp: new Date().toISOString(), version: '1.0.0' })
|
|
43
121
|
},
|
|
44
122
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
// TODO: Implement doctype metadata lookup
|
|
53
|
-
// For synchronous lookups, use lambda:
|
|
54
|
-
// return lambda($doctype, doctype => {
|
|
55
|
-
// const meta = getMeta(doctype) // Must be synchronous!
|
|
56
|
-
// return meta ? formatDoctypeMeta(meta) : null
|
|
57
|
-
// })
|
|
58
|
-
//
|
|
59
|
-
// For async lookups, use loadOne:
|
|
60
|
-
// return loadOne($doctype, async doctypes => {
|
|
61
|
-
// return await Promise.all(
|
|
62
|
-
// doctypes.map(async dt => {
|
|
63
|
-
// const meta = await fetchMeta(dt)
|
|
64
|
-
// return meta ? formatDoctypeMeta(meta) : null
|
|
65
|
-
// })
|
|
66
|
-
// )
|
|
67
|
-
// })
|
|
123
|
+
getMeta(_: unknown, { $doctype }: any) {
|
|
124
|
+
return lambda($doctype, (doctype: unknown) => {
|
|
125
|
+
const meta = getMeta(doctype as string)
|
|
126
|
+
return meta ? formatDoctypeMeta(meta) : null
|
|
127
|
+
})
|
|
128
|
+
},
|
|
68
129
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
130
|
+
stonecropMeta(_: unknown, { $doctype }: any) {
|
|
131
|
+
return lambda($doctype, (doctype: unknown) => {
|
|
132
|
+
const meta = getMeta(doctype as string)
|
|
133
|
+
return meta ? formatDoctypeMeta(meta) : null
|
|
72
134
|
})
|
|
73
135
|
},
|
|
74
136
|
|
|
75
|
-
/**
|
|
76
|
-
* Get all registered doctype metadata
|
|
77
|
-
* Returns an array of doctype metadata
|
|
78
|
-
*/
|
|
79
137
|
stonecropAllMeta() {
|
|
80
|
-
|
|
81
|
-
// Example with graphql-middleware:
|
|
82
|
-
// return constant(getAllMeta().map(formatDoctypeMeta))
|
|
83
|
-
|
|
84
|
-
return constant([])
|
|
138
|
+
return constant(getAllMeta().map(formatDoctypeMeta))
|
|
85
139
|
},
|
|
86
140
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// TODO: Implement record fetching from your database
|
|
94
|
-
// Use loadOne for async database queries:
|
|
95
|
-
// return loadOne(list([$doctype, $id]), async pairs => {
|
|
96
|
-
// return await Promise.all(
|
|
97
|
-
// pairs.map(async ([doctype, id]) => {
|
|
98
|
-
// const record = await fetchRecordFromDB(doctype, id)
|
|
99
|
-
// return { data: record, doctype }
|
|
100
|
-
// })
|
|
101
|
-
// )
|
|
102
|
-
// })
|
|
103
|
-
|
|
104
|
-
return lambda(list([$doctype, $id]), ([doctype, id]) => {
|
|
105
|
-
console.log('stonecropRecord called:', { doctype, id })
|
|
106
|
-
return {
|
|
107
|
-
data: null,
|
|
108
|
-
doctype,
|
|
109
|
-
}
|
|
141
|
+
stonecropRecord(_: unknown, { $doctype, $id, $options }: any) {
|
|
142
|
+
return loadOne(object({ doctype: $doctype, id: $id, options: $options }), async (specs: readonly any[]) => {
|
|
143
|
+
return specs.map(spec => ({
|
|
144
|
+
data: getRecord(spec.doctype, spec.id),
|
|
145
|
+
doctype: spec.doctype,
|
|
146
|
+
}))
|
|
110
147
|
})
|
|
111
148
|
},
|
|
112
149
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
data: [],
|
|
136
|
-
doctype,
|
|
137
|
-
count: 0,
|
|
138
|
-
}
|
|
150
|
+
stonecropRecords(_: unknown, { $doctype, $filters, $orderBy, $limit, $offset, $options }: any) {
|
|
151
|
+
return loadOne(
|
|
152
|
+
object({
|
|
153
|
+
doctype: $doctype,
|
|
154
|
+
filters: $filters,
|
|
155
|
+
orderBy: $orderBy,
|
|
156
|
+
limit: $limit,
|
|
157
|
+
offset: $offset,
|
|
158
|
+
options: $options,
|
|
159
|
+
}),
|
|
160
|
+
async (specs: readonly any[]) => {
|
|
161
|
+
return specs.map(spec => {
|
|
162
|
+
const all = getRecords(spec.doctype, spec.filters ?? {})
|
|
163
|
+
const offset = spec.offset ?? 0
|
|
164
|
+
const limit = spec.limit ?? 100
|
|
165
|
+
return {
|
|
166
|
+
data: all.slice(offset, offset + limit),
|
|
167
|
+
doctype: spec.doctype,
|
|
168
|
+
count: all.length,
|
|
169
|
+
}
|
|
170
|
+
})
|
|
139
171
|
}
|
|
140
172
|
)
|
|
141
173
|
},
|
|
@@ -144,109 +176,94 @@ export const resolvers = {
|
|
|
144
176
|
|
|
145
177
|
Mutation: {
|
|
146
178
|
plans: {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
stonecropAction(_: unknown, { $doctype, $action, $args: $actionArgs }: any) {
|
|
180
|
+
return loadOne(
|
|
181
|
+
object({ doctype: $doctype, action: $action, actionArgs: $actionArgs }),
|
|
182
|
+
async (specs: readonly any[]) => {
|
|
183
|
+
return Promise.all(
|
|
184
|
+
specs.map(async spec => {
|
|
185
|
+
const meta = getMeta(spec.doctype)
|
|
186
|
+
if (!meta) return { success: false, data: null, error: `Unknown doctype: ${spec.doctype}` }
|
|
152
187
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// return loadOne(list([$doctype, $action, $args]), async actionParams => {
|
|
156
|
-
// return await Promise.all(
|
|
157
|
-
// actionParams.map(async ([doctype, action, actionArgs]) => {
|
|
158
|
-
// const result = await executeAction(doctype, action, actionArgs)
|
|
159
|
-
// return { success: result.success, data: result.data, error: result.error }
|
|
160
|
-
// })
|
|
161
|
-
// )
|
|
162
|
-
// })
|
|
188
|
+
const actionDef = meta.workflow?.actions?.[spec.action]
|
|
189
|
+
if (!actionDef) return { success: false, data: null, error: `Unknown action: ${spec.action}` }
|
|
163
190
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
success: true,
|
|
168
|
-
data: null,
|
|
169
|
-
error: null,
|
|
170
|
-
}
|
|
171
|
-
})
|
|
172
|
-
},
|
|
191
|
+
const handler = getHandler(actionDef.handler)
|
|
192
|
+
if (!handler)
|
|
193
|
+
return { success: false, data: null, error: `Handler not registered: ${actionDef.handler}` }
|
|
173
194
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Arguments come pre-destructured as steps with $ prefix
|
|
195
|
+
// Pass doctype metadata via ActionContext. The handler in server/plugins/stonecrop.ts
|
|
196
|
+
// imports and mutates the data Maps directly — it does not need additional context here.
|
|
197
|
+
// In a PostGraphile setup, context.pgClient would provide the database connection instead.
|
|
198
|
+
const actionContext: ActionContext = { doctype: meta }
|
|
179
199
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
// )
|
|
189
|
-
// })
|
|
190
|
-
|
|
191
|
-
return lambda(list([$doctype, $input]), ([doctype, input]) => {
|
|
192
|
-
console.log('stonecropCreate called:', { doctype, input })
|
|
193
|
-
return {
|
|
194
|
-
data: { id: 'new-id', ...input },
|
|
195
|
-
doctype,
|
|
200
|
+
try {
|
|
201
|
+
const result = await handler(spec.actionArgs ?? [], actionContext)
|
|
202
|
+
return { success: true, data: result, error: null }
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return { success: false, data: null, error: err instanceof Error ? err.message : String(err) }
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
)
|
|
196
208
|
}
|
|
197
|
-
|
|
209
|
+
)
|
|
198
210
|
},
|
|
199
211
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
console.log('stonecropUpdate called:', { doctype, id, patch })
|
|
219
|
-
return {
|
|
220
|
-
data: { id, ...patch },
|
|
221
|
-
doctype,
|
|
222
|
-
}
|
|
212
|
+
stonecropCreate(_: unknown, { $doctype, $input }: any) {
|
|
213
|
+
return loadOne(object({ doctype: $doctype, input: $input }), async (specs: readonly any[]) => {
|
|
214
|
+
return specs.map(spec => {
|
|
215
|
+
const d = spec.doctype.toLowerCase()
|
|
216
|
+
const id = nextId(d)
|
|
217
|
+
const now = new Date().toISOString()
|
|
218
|
+
if (d === 'project') {
|
|
219
|
+
const record: Project = { id, createdAt: now, status: 'Active', description: '', ...spec.input }
|
|
220
|
+
projects.set(id, record)
|
|
221
|
+
return { data: record, doctype: spec.doctype }
|
|
222
|
+
}
|
|
223
|
+
if (d === 'task') {
|
|
224
|
+
const record: Task = { id, createdAt: now, status: 'Todo', description: '', dueDate: null, ...spec.input }
|
|
225
|
+
tasks.set(id, record)
|
|
226
|
+
return { data: record, doctype: spec.doctype }
|
|
227
|
+
}
|
|
228
|
+
return { data: null, doctype: spec.doctype }
|
|
229
|
+
})
|
|
223
230
|
})
|
|
224
231
|
},
|
|
225
232
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
233
|
+
stonecropUpdate(_: unknown, { $doctype, $id, $patch }: any) {
|
|
234
|
+
return loadOne(object({ doctype: $doctype, id: $id, patch: $patch }), async (specs: readonly any[]) => {
|
|
235
|
+
return specs.map(spec => {
|
|
236
|
+
const d = spec.doctype.toLowerCase()
|
|
237
|
+
const existing = getRecord(d, spec.id)
|
|
238
|
+
if (!existing) return null
|
|
239
|
+
if (d === 'project') {
|
|
240
|
+
const updated = { ...existing, ...spec.patch } as Project
|
|
241
|
+
projects.set(spec.id, updated)
|
|
242
|
+
return { data: updated, doctype: spec.doctype }
|
|
243
|
+
}
|
|
244
|
+
if (d === 'task') {
|
|
245
|
+
const updated = { ...existing, ...spec.patch } as Task
|
|
246
|
+
tasks.set(spec.id, updated)
|
|
247
|
+
return { data: updated, doctype: spec.doctype }
|
|
248
|
+
}
|
|
249
|
+
return null
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
},
|
|
242
253
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
254
|
+
stonecropDelete(_: unknown, { $doctype, $id }: any) {
|
|
255
|
+
return loadOne(object({ doctype: $doctype, id: $id }), async (specs: readonly any[]) => {
|
|
256
|
+
return specs.map(spec => {
|
|
257
|
+
const d = spec.doctype.toLowerCase()
|
|
258
|
+
let deleted = false
|
|
259
|
+
if (d === 'project') deleted = projects.delete(spec.id)
|
|
260
|
+
else if (d === 'task') deleted = tasks.delete(spec.id)
|
|
261
|
+
return {
|
|
262
|
+
success: deleted,
|
|
263
|
+
data: deleted ? { id: spec.id } : null,
|
|
264
|
+
error: deleted ? null : 'Record not found',
|
|
265
|
+
}
|
|
266
|
+
})
|
|
250
267
|
})
|
|
251
268
|
},
|
|
252
269
|
},
|
package/templates/schema.graphql
CHANGED
|
@@ -20,6 +20,27 @@ type StonecropField {
|
|
|
20
20
|
default: JSON
|
|
21
21
|
width: String
|
|
22
22
|
validation: JSON
|
|
23
|
+
component: String
|
|
24
|
+
align: String
|
|
25
|
+
edit: Boolean
|
|
26
|
+
hidden: Boolean
|
|
27
|
+
mask: String
|
|
28
|
+
precision: Int
|
|
29
|
+
scale: Int
|
|
30
|
+
mode: String
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
A single workflow action definition
|
|
35
|
+
"""
|
|
36
|
+
type WorkflowAction {
|
|
37
|
+
name: String!
|
|
38
|
+
label: String
|
|
39
|
+
handler: String
|
|
40
|
+
requiredFields: [String!]
|
|
41
|
+
allowedStates: [String!]
|
|
42
|
+
confirm: Boolean
|
|
43
|
+
args: JSON
|
|
23
44
|
}
|
|
24
45
|
|
|
25
46
|
"""
|
|
@@ -27,7 +48,7 @@ Workflow metadata with states and actions
|
|
|
27
48
|
"""
|
|
28
49
|
type WorkflowMeta {
|
|
29
50
|
states: [String!]
|
|
30
|
-
actions:
|
|
51
|
+
actions: [WorkflowAction!]
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
"""
|
|
@@ -54,6 +75,7 @@ type HealthStatus {
|
|
|
54
75
|
type RecordResult {
|
|
55
76
|
data: JSON
|
|
56
77
|
doctype: String!
|
|
78
|
+
unknownLinks: [String!]
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
type RecordsResult {
|
|
@@ -83,6 +105,11 @@ type Query {
|
|
|
83
105
|
"""
|
|
84
106
|
getMeta(doctype: String!): DoctypeMeta
|
|
85
107
|
|
|
108
|
+
"""
|
|
109
|
+
Get metadata for a doctype (used by StonecropClient)
|
|
110
|
+
"""
|
|
111
|
+
stonecropMeta(doctype: String!): DoctypeMeta
|
|
112
|
+
|
|
86
113
|
"""
|
|
87
114
|
Get all registered doctype metadata
|
|
88
115
|
"""
|
|
@@ -91,7 +118,7 @@ type Query {
|
|
|
91
118
|
"""
|
|
92
119
|
Get a single record by doctype and ID
|
|
93
120
|
"""
|
|
94
|
-
stonecropRecord(doctype: String!, id: String
|
|
121
|
+
stonecropRecord(doctype: String!, id: String!, options: JSON): RecordResult
|
|
95
122
|
|
|
96
123
|
"""
|
|
97
124
|
Get multiple records with optional filtering
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { StonecropClient } from '@stonecrop/graphql-client'
|
|
2
|
+
import { Doctype } from '@stonecrop/stonecrop'
|
|
3
|
+
|
|
4
|
+
import { doctypeMap } from '~/composables/useDoctypes'
|
|
5
|
+
|
|
6
|
+
export default defineNuxtPlugin({
|
|
7
|
+
name: 'stonecrop-client',
|
|
8
|
+
dependsOn: ['stonecrop'],
|
|
9
|
+
setup() {
|
|
10
|
+
const { registerClient, registerMeta, registry } = useStonecropSetup()
|
|
11
|
+
|
|
12
|
+
const client = new StonecropClient({
|
|
13
|
+
endpoint: '/graphql/',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
registerClient(client)
|
|
17
|
+
|
|
18
|
+
registerMeta(async routeContext => {
|
|
19
|
+
const slug = routeContext.segments?.[0] ?? ''
|
|
20
|
+
if (!slug) throw new Error('Cannot resolve doctype from route context')
|
|
21
|
+
|
|
22
|
+
const localDoctype = doctypeMap.get(slug)
|
|
23
|
+
if (!localDoctype) throw new Error(`No doctype registered for slug: ${slug}`)
|
|
24
|
+
|
|
25
|
+
return Doctype.fromObject(localDoctype)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
for (const [slug, doctypeConfig] of doctypeMap.entries()) {
|
|
29
|
+
const doctypeInstance = Doctype.fromObject(doctypeConfig)
|
|
30
|
+
registry!.addDoctype(doctypeInstance)
|
|
31
|
+
if (slug !== doctypeInstance.slug) {
|
|
32
|
+
registry!.registry[slug] = doctypeInstance
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
provide: {
|
|
38
|
+
stonecropClient: client,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stonecrop Server Plugin
|
|
3
|
+
*
|
|
4
|
+
* Runs at server startup (Nitro plugin) to:
|
|
5
|
+
* 1. Load doctype definitions from the doctypes/ directory
|
|
6
|
+
* 2. Register built-in action handlers (validation, etc.)
|
|
7
|
+
* 3. Register custom workflow action handlers
|
|
8
|
+
*
|
|
9
|
+
* Action handlers receive (args, context) where args is the array sent by the
|
|
10
|
+
* client — the app's frontend (app/composables/useDoctypes.ts) sends a single
|
|
11
|
+
* `{ id, data? }` object per action — and context.doctype is the DoctypeMeta
|
|
12
|
+
* for the doctype being acted upon. For this in-memory setup, data is imported
|
|
13
|
+
* directly from server/data.ts. In production with PostGraphile, context.pgClient
|
|
14
|
+
* provides an active database connection instead — see:
|
|
15
|
+
* https://stonecrop.io/docs/guides/postgraphile
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { resolve } from 'node:path'
|
|
19
|
+
import { clearRegistry, loadDoctypes, registerBuiltinHandlers, registerHandler } from '@stonecrop/graphql-middleware'
|
|
20
|
+
import { projects, tasks, type Project, type Task } from '../data'
|
|
21
|
+
|
|
22
|
+
/** Action argument shape sent by the frontend (see runDoctypeAction in useDoctypes.ts) */
|
|
23
|
+
type ActionArgs = [{ id: string; data?: Record<string, unknown> }]
|
|
24
|
+
|
|
25
|
+
export default defineNitroPlugin(async () => {
|
|
26
|
+
clearRegistry()
|
|
27
|
+
|
|
28
|
+
const doctypesDir = resolve(process.cwd(), 'doctypes')
|
|
29
|
+
loadDoctypes(doctypesDir, { continueOnError: true })
|
|
30
|
+
|
|
31
|
+
registerBuiltinHandlers()
|
|
32
|
+
|
|
33
|
+
// Persist form edits to a Project
|
|
34
|
+
registerHandler('project:save', async (args: unknown[]) => {
|
|
35
|
+
const [{ id, data }] = args as ActionArgs
|
|
36
|
+
const project = projects.get(id)
|
|
37
|
+
if (!project) throw new Error(`Project not found: ${id}`)
|
|
38
|
+
const updated = { ...project, ...data, id } as Project
|
|
39
|
+
projects.set(id, updated)
|
|
40
|
+
return updated
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Persist form edits to a Task
|
|
44
|
+
registerHandler('task:save', async (args: unknown[]) => {
|
|
45
|
+
const [{ id, data }] = args as ActionArgs
|
|
46
|
+
const task = tasks.get(id)
|
|
47
|
+
if (!task) throw new Error(`Task not found: ${id}`)
|
|
48
|
+
const updated = { ...task, ...data, id } as Task
|
|
49
|
+
tasks.set(id, updated)
|
|
50
|
+
return updated
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Transition a Task from Todo → In Progress
|
|
54
|
+
registerHandler('start_task', async (args: unknown[]) => {
|
|
55
|
+
const [{ id }] = args as ActionArgs
|
|
56
|
+
const task = tasks.get(id)
|
|
57
|
+
if (!task) throw new Error(`Task not found: ${id}`)
|
|
58
|
+
const updated = { ...task, status: 'In Progress' as const }
|
|
59
|
+
tasks.set(id, updated)
|
|
60
|
+
return updated
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Transition a Task from In Progress → Done
|
|
64
|
+
registerHandler('complete_task', async (args: unknown[]) => {
|
|
65
|
+
const [{ id }] = args as ActionArgs
|
|
66
|
+
const task = tasks.get(id)
|
|
67
|
+
if (!task) throw new Error(`Task not found: ${id}`)
|
|
68
|
+
const updated = { ...task, status: 'Done' as const }
|
|
69
|
+
tasks.set(id, updated)
|
|
70
|
+
return updated
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Transition a Project from Active → Archived
|
|
74
|
+
registerHandler('archive_project', async (args: unknown[]) => {
|
|
75
|
+
const [{ id }] = args as ActionArgs
|
|
76
|
+
const project = projects.get(id)
|
|
77
|
+
if (!project) throw new Error(`Project not found: ${id}`)
|
|
78
|
+
const updated = { ...project, status: 'Archived' as const }
|
|
79
|
+
projects.set(id, updated)
|
|
80
|
+
return updated
|
|
81
|
+
})
|
|
82
|
+
})
|