@dfosco/storyboard 0.6.7 → 0.6.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -13,6 +13,7 @@ import {
13
13
  buildTemplateRecipeIndex,
14
14
  resolveTemplateRecipeEntry,
15
15
  } from '../templateIndex.js'
16
+ import { readWorkshopPartials } from '../partialRender.js'
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Helpers
@@ -461,7 +462,10 @@ function generateFlowJson({ title, author, description, globals, sourceData, rou
461
462
  */
462
463
  export function createFlowsHandler(ctx) {
463
464
  const { root, sendJson, workshopConfig = {} } = ctx
464
- const getTemplateRecipes = () => buildTemplateRecipeIndex(root, workshopConfig.partials)
465
+ const getTemplateRecipes = () => {
466
+ const partials = readWorkshopPartials(root)
467
+ return buildTemplateRecipeIndex(root, partials.length ? partials : workshopConfig.partials)
468
+ }
465
469
 
466
470
  return async (req, res, { body, path: routePath, method }) => {
467
471
  const templateRecipes = getTemplateRecipes()
@@ -21,6 +21,7 @@ import {
21
21
  buildTemplateRecipeIndex,
22
22
  resolveTemplateRecipeEntry,
23
23
  } from '../templateIndex.js'
24
+ import { readWorkshopPartials } from '../partialRender.js'
24
25
 
25
26
  const FLOW_SKELETON = JSON.stringify({ $global: [] }, null, 2) + '\n'
26
27
 
@@ -178,7 +179,10 @@ function generatePrototypeJson({ title, author, description, partialEntry, url }
178
179
  */
179
180
  export function createPrototypesHandler(ctx) {
180
181
  const { root, sendJson, workshopConfig = {} } = ctx
181
- const getTemplateRecipes = () => buildTemplateRecipeIndex(root, workshopConfig.partials)
182
+ const getTemplateRecipes = () => {
183
+ const partials = readWorkshopPartials(root)
184
+ return buildTemplateRecipeIndex(root, partials.length ? partials : workshopConfig.partials)
185
+ }
182
186
 
183
187
  return async (req, res, { body, path: routePath, method }) => {
184
188
  const templateRecipes = getTemplateRecipes()
@@ -11,11 +11,27 @@
11
11
  * Schemas may declare `field.dynamic = 'prototypes'`. Pass
12
12
  * `dynamicOptions={{ prototypes: ['my-app', ...] }}` to populate them.
13
13
  */
14
- import { useState, useMemo, useEffect } from 'react'
14
+ import { useState, useMemo, useEffect, useRef } from 'react'
15
15
  import { FormControl, Button, TextInput, Textarea, Flash, Text, ActionMenu, ActionList } from '@primer/react'
16
16
  import { ARTIFACT_SCHEMAS, validateArtifact } from './artifactSchemas.js'
17
17
  import styles from './ArtifactForm.module.css'
18
18
 
19
+ /**
20
+ * Slugify a freeform string into the kebab-case form the `name` field expects.
21
+ * Mirrors the NAME_PATTERN in artifactSchemas.js — lowercase letters, digits,
22
+ * and hyphens. Used to auto-derive `name` from `title` while the user types,
23
+ * until they manually edit `name` themselves.
24
+ */
25
+ function slugifyName(value) {
26
+ return String(value || '')
27
+ .toLowerCase()
28
+ .normalize('NFKD')
29
+ .replace(/[\u0300-\u036f]/g, '')
30
+ .replace(/[^a-z0-9]+/g, '-')
31
+ .replace(/^-+|-+$/g, '')
32
+ .slice(0, 64)
33
+ }
34
+
19
35
  function TypeSelector({ selected, onChange }) {
20
36
  const types = Object.entries(ARTIFACT_SCHEMAS)
21
37
  const current = ARTIFACT_SCHEMAS[selected]
@@ -150,18 +166,66 @@ export default function ArtifactForm({
150
166
  const [errors, setErrors] = useState({})
151
167
  const [submitting, setSubmitting] = useState(false)
152
168
 
169
+ // Tracks whether the `name` field should keep auto-following `title`.
170
+ // Flips to false the first time the user edits `name` directly, so we don't
171
+ // clobber their input. Reset on type change or unmount.
172
+ const nameAutoFollowRef = useRef(true)
173
+
153
174
  // Reset form when fixed type changes (parent-controlled)
154
175
  useEffect(() => {
155
176
  if (!fixedType) return
156
177
  setValues({ ...initialValues(ARTIFACT_SCHEMAS[fixedType]), ...(initialOverride || {}) })
157
178
  setErrors({})
158
179
  setSubmitting(false)
180
+ nameAutoFollowRef.current = true
159
181
  }, [fixedType])
160
182
 
183
+ // Merge late-arriving initial overrides (e.g. a gh-login fetch that resolves
184
+ // after mount) into empty fields. Only fills fields the user hasn't typed
185
+ // into yet, so we never overwrite their input. Stringify the override so the
186
+ // dep check fires on content changes rather than parent re-render identity.
187
+ const overrideKey = useMemo(() => JSON.stringify(initialOverride || {}), [initialOverride])
188
+ useEffect(() => {
189
+ if (!initialOverride || !schema) return
190
+ setValues(prev => {
191
+ let next = prev
192
+ for (const [k, v] of Object.entries(initialOverride)) {
193
+ if (v == null || v === '') continue
194
+ const fieldExists = schema.fields.some(f => f.name === k)
195
+ const isEmpty = prev[k] === '' || prev[k] == null
196
+ if (fieldExists && isEmpty) {
197
+ if (next === prev) next = { ...prev }
198
+ next[k] = v
199
+ }
200
+ }
201
+ return next
202
+ })
203
+ // eslint-disable-next-line react-hooks/exhaustive-deps
204
+ }, [overrideKey, schema])
205
+
206
+ // Derive `name` (kebab-case) from `title` as the user types, until they
207
+ // manually edit `name`. Only applies to schemas that expose both fields
208
+ // (prototype, canvas, flow). Component uses PascalCase and has no title,
209
+ // so it's naturally excluded.
210
+ useEffect(() => {
211
+ if (!schema || !nameAutoFollowRef.current) return
212
+ const hasName = schema.fields.some(f => f.name === 'name' && f.type === 'text')
213
+ const hasTitle = schema.fields.some(f => f.name === 'title')
214
+ if (!hasName || !hasTitle) return
215
+ const derived = slugifyName(values.title)
216
+ if (derived === (values.name || '')) return
217
+ setValues(prev => ({ ...prev, name: derived }))
218
+ if (errors.name) {
219
+ setErrors(prev => { const next = { ...prev }; delete next.name; return next })
220
+ }
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ }, [values.title, schema])
223
+
161
224
  function handleTypeChange(newType) {
162
225
  setSelectedType(newType)
163
226
  setValues(initialValues(ARTIFACT_SCHEMAS[newType]))
164
227
  setErrors({})
228
+ nameAutoFollowRef.current = true
165
229
  }
166
230
 
167
231
  const [showAdvanced, setShowAdvanced] = useState(false)
@@ -205,6 +269,9 @@ export default function ArtifactForm({
205
269
  }
206
270
 
207
271
  function handleChange(fieldName, value) {
272
+ if (fieldName === 'name') {
273
+ nameAutoFollowRef.current = false
274
+ }
208
275
  setValues(prev => ({ ...prev, [fieldName]: value }))
209
276
  if (errors[fieldName]) {
210
277
  setErrors(prev => { const next = { ...prev }; delete next[fieldName]; return next })
@@ -27,6 +27,7 @@ export default function CreateDialog({ type, basePath, onClose }) {
27
27
  const schema = schemaKey ? ARTIFACT_SCHEMAS[schemaKey] : null
28
28
  const [prototypes, setPrototypes] = useState([])
29
29
  const [partials, setPartials] = useState([])
30
+ const [ghLogin, setGhLogin] = useState(null)
30
31
 
31
32
  const needsPrototypes = useMemo(() => {
32
33
  if (!schema) return false
@@ -38,6 +39,11 @@ export default function CreateDialog({ type, basePath, onClose }) {
38
39
  return schema.fields.some(f => f.dynamic === 'partials')
39
40
  }, [schema])
40
41
 
42
+ const needsAuthor = useMemo(() => {
43
+ if (!schema) return false
44
+ return schema.fields.some(f => f.name === 'author')
45
+ }, [schema])
46
+
41
47
  useEffect(() => {
42
48
  if (!needsPrototypes || !schemaKey) return
43
49
  const apiBase = (basePath || '/').replace(/\/+$/, '')
@@ -68,6 +74,25 @@ export default function CreateDialog({ type, basePath, onClose }) {
68
74
  .catch(() => {})
69
75
  }, [needsPartials, schemaKey, basePath])
70
76
 
77
+ // Fetch the gh CLI login once per dialog open and prefill it as the default
78
+ // author when the schema supports it (currently only `prototype`).
79
+ useEffect(() => {
80
+ if (!needsAuthor || !schemaKey) return
81
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
82
+ fetch(`${apiBase}/_storyboard/git-user`)
83
+ .then(r => (r.ok ? r.json() : null))
84
+ .then(data => { if (data?.login) setGhLogin(data.login) })
85
+ .catch(() => {})
86
+ }, [needsAuthor, schemaKey, basePath])
87
+
88
+ // Build the initial values object for the form. Late-arriving values (like
89
+ // the gh login fetch above) get merged into empty fields inside ArtifactForm
90
+ // — see the override-merge effect there.
91
+ const initialValues = useMemo(() => {
92
+ if (!needsAuthor || !ghLogin) return undefined
93
+ return { author: ghLogin }
94
+ }, [needsAuthor, ghLogin])
95
+
71
96
  if (!schemaKey || !schema) return null
72
97
 
73
98
  async function handleSubmit({ type: t, values }) {
@@ -120,6 +145,7 @@ export default function CreateDialog({ type, basePath, onClose }) {
120
145
  onSubmit={handleSubmit}
121
146
  onCancel={onClose}
122
147
  dynamicOptions={{ prototypes, partials }}
148
+ initialValues={initialValues}
123
149
  hideHeader
124
150
  />
125
151
  </Dialog>