@xeplr/ui-factory 1.0.13 → 1.1.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/src/pages.jsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { useRef } from 'react'
1
2
  import { useFactoryBuilder } from './useFactoryBuilder.js'
2
3
  import { useFactoryScreen } from './useFactoryScreen.js'
3
4
  import { useFlowRun } from './useFlowRun.js'
@@ -59,6 +60,15 @@ export function FactoryBuilder(props) {
59
60
  */
60
61
  export function FactoryScreen(props) {
61
62
  const ctrl = useFactoryScreen(props)
63
+ // Whether the popup's form holds unsaved changes — its × asks before
64
+ // dropping them, as its Cancel does.
65
+ const popupDirty = useRef(false)
66
+ const closePopup = () => {
67
+ // eslint-disable-next-line no-alert
68
+ if (popupDirty.current && typeof window !== 'undefined' && !window.confirm('Discard your unsaved changes?')) return
69
+ popupDirty.current = false
70
+ ctrl.closeEditor()
71
+ }
62
72
  return (
63
73
  <ScreenSample
64
74
  ctrl={ctrl}
@@ -68,13 +78,13 @@ export function FactoryScreen(props) {
68
78
  <ScreenModal
69
79
  title={popup.document ? popup.document.name : 'Loading…'}
70
80
  width={popup.document ? popup.document.width : undefined}
71
- onClose={ctrl.closeEditor}
81
+ onClose={closePopup}
72
82
  >
73
83
  {popup.error && <div className="xeplr-factory-invalid" role="alert">{popup.error}</div>}
74
84
  {popup.loading && <div className="xeplr-factory-hint">Loading…</div>}
75
85
  {popup.document && (
76
- // The same component, one level down: the edit screen saves itself,
77
- // and every save refreshes the list behind the popup.
86
+ // The same component, one level down. Its Save refreshes the list
87
+ // behind the popup and closes it; Cancel closes it unsaved.
78
88
  <FactoryScreen
79
89
  key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
80
90
  document={popup.document}
@@ -89,6 +99,8 @@ export function FactoryScreen(props) {
89
99
  loadScreen={props.loadScreen}
90
100
  controls={props.controls}
91
101
  hooks={props.hooks}
102
+ onDone={() => { popupDirty.current = false; ctrl.closeEditor() }}
103
+ onDirtyChange={(d) => { popupDirty.current = d }}
92
104
  />
93
105
  )}
94
106
  </ScreenModal>
package/src/presets.js ADDED
@@ -0,0 +1,324 @@
1
+ // READY-MADE FIELDS — an email, a phone number, an age, a gender… — so nobody
2
+ // has to remember that a LinkedIn profile is a URL with a particular shape, or
3
+ // that an age is a whole number between 0 and 130.
4
+ //
5
+ // A preset is NOT a new kind of control. It is a named set of props for one of
6
+ // the controls that already exist (a text box with an email keyboard and an
7
+ // email pattern; a radio group with three options), COPIED into the field when
8
+ // it is added or chosen. So:
9
+ //
10
+ // · the document stays self-describing — the server, the CLI and Claude
11
+ // read the rules off the field, not out of this file;
12
+ // · every setting stays editable afterwards, like any other field's;
13
+ // · changing a preset here never changes a form already made with it.
14
+ //
15
+ // The field remembers where it came from in `props.preset` (the key), which is
16
+ // what the builder's "Field type" shows. `props.preset: false` means "plain on
17
+ // purpose" — the builder stops suggesting one.
18
+ //
19
+ // Pure data and functions: no React. Runs in node, like controls.js.
20
+
21
+ import { CONTROLS, CHOICE_TYPES } from './controls.js'
22
+ import { camelName, uniqueFieldName } from './document.js'
23
+
24
+ // ── patterns ─────────────────────────────────────────────────────────────
25
+ // Strings, not RegExps: they are stored in the document and checked again on
26
+ // the server (schema-handler's `pattern`), with no flags — so any case that
27
+ // matters is spelled out.
28
+ export const PATTERNS = {
29
+ email: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$',
30
+ // A number people can type the way they write it: +44 20 7946 0958, (555) 010-9999.
31
+ phone: '^\\+?[0-9][0-9 ().\\-]{5,18}[0-9]$',
32
+ url: '^[Hh][Tt][Tt][Pp][Ss]?://[^\\s/]+\\.[^\\s/]+(/\\S*)?$',
33
+ linkedin: '^[Hh][Tt][Tt][Pp][Ss]?://([a-zA-Z]{2,3}\\.)?[Ll][Ii][Nn][Kk][Ee][Dd][Ii][Nn]\\.[Cc][Oo][Mm]/(in|company|pub)/[^\\s/?#]+/?([?#]\\S*)?$',
34
+ postalCode: '^[A-Za-z0-9][A-Za-z0-9 \\-]{1,8}[A-Za-z0-9]$'
35
+ }
36
+
37
+ /** How a text field's box behaves on a phone keyboard, and in autofill. */
38
+ export const INPUT_TYPES = ['text', 'email', 'tel', 'url']
39
+
40
+ // ── countries ────────────────────────────────────────────────────────────
41
+ // Stored as the ISO 3166 code (a stable id), shown by name. The names come
42
+ // from the runtime's own Intl data, in English, when the preset is applied —
43
+ // and are then part of the field like any options typed by hand.
44
+ const COUNTRY_CODES = (
45
+ 'AF AL DZ AD AO AG AR AM AU AT AZ BS BH BD BB BY BE BZ BJ BT BO BA BW BR BN BG BF BI CV KH CM CA CF TD CL CN CO KM CG CD CR CI HR CU CY CZ ' +
46
+ 'DK DJ DM DO EC EG SV GQ ER EE SZ ET FJ FI FR GA GM GE DE GH GR GD GT GN GW GY HT HN HK HU IS IN ID IR IQ IE IL IT JM JP JO KZ KE KI KW KG ' +
47
+ 'LA LV LB LS LR LY LI LT LU MO MG MW MY MV ML MT MH MR MU MX FM MD MC MN ME MA MZ MM NA NR NP NL NZ NI NE NG KP MK NO OM PK PW PS PA PG PY ' +
48
+ 'PE PH PL PT PR QA RO RU RW KN LC VC WS SM ST SA SN RS SC SL SG SK SI SB SO ZA KR SS ES LK SD SR SE CH SY TW TJ TZ TH TL TG TO TT TN TR TM ' +
49
+ 'TV UG UA AE GB US UY UZ VU VA VE VN YE ZM ZW'
50
+ ).split(' ')
51
+
52
+ function countryOptions() {
53
+ let names = null
54
+ try { names = new Intl.DisplayNames(['en'], { type: 'region' }) } catch (_) { /* old runtime: codes only */ }
55
+ return COUNTRY_CODES
56
+ .map((code) => ({ id: code, name: (names && names.of(code)) || code }))
57
+ .sort((a, b) => a.name.localeCompare(b.name))
58
+ }
59
+
60
+ const options = (...names) => names.map((name) => ({ id: camelName(name), name }))
61
+
62
+ // ── the catalogue ────────────────────────────────────────────────────────
63
+ // Palette order. `match` is how a LABEL is recognised ("LinkedIn URL" → the
64
+ // LinkedIn preset) — first match wins, so the specific ones come before the
65
+ // general ones they would also match (LinkedIn before Website).
66
+ //
67
+ // key stored in props.preset — never renamed
68
+ // label the palette's name for it, and the field's starting label
69
+ // control the control it is made of
70
+ // props what it sets on that control (copied)
71
+ // size? a starting height other than the control's own
72
+
73
+ export const FIELD_PRESETS = [
74
+ {
75
+ key: 'email', label: 'Email', control: 'text',
76
+ match: /\be-?mail\b/i,
77
+ props: { inputType: 'email', placeholder: 'name@example.com', validation: { maxLength: 254, pattern: PATTERNS.email, patternMessage: 'Enter an email address, like name@example.com' } }
78
+ },
79
+ {
80
+ key: 'phone', label: 'Phone', control: 'text',
81
+ match: /\b(phone|mobile|cell|telephone|whats ?app)\b|contact (no|number)/i,
82
+ props: { inputType: 'tel', placeholder: '+1 555 010 9999', validation: { maxLength: 20, pattern: PATTERNS.phone, patternMessage: 'Enter a phone number — digits, spaces, + ( ) and - only' } }
83
+ },
84
+ {
85
+ key: 'linkedin', label: 'LinkedIn profile', control: 'text',
86
+ match: /linked\s*-?\s*in/i,
87
+ props: { inputType: 'url', placeholder: 'https://www.linkedin.com/in/…', validation: { maxLength: 300, pattern: PATTERNS.linkedin, patternMessage: 'Enter a LinkedIn profile address, like https://www.linkedin.com/in/your-name' } }
88
+ },
89
+ {
90
+ key: 'url', label: 'Website', control: 'text',
91
+ match: /\b(website|web ?site|web address|url|homepage|home page|link)\b/i,
92
+ props: { inputType: 'url', placeholder: 'https://example.com', validation: { maxLength: 2000, pattern: PATTERNS.url, patternMessage: 'Enter a web address starting with http:// or https://' } }
93
+ },
94
+ {
95
+ key: 'age', label: 'Age', control: 'number',
96
+ match: /^\s*age\b/i,
97
+ props: { validation: { min: 0, max: 130, integer: true } }
98
+ },
99
+ {
100
+ key: 'dateOfBirth', label: 'Date of birth', control: 'date',
101
+ match: /\b(date of birth|birth ?date|birthday|d\.?o\.?b)\b/i,
102
+ props: { validation: { notFuture: true } }
103
+ },
104
+ {
105
+ key: 'gender', label: 'Gender', control: 'radio',
106
+ match: /\b(gender|sex)\b/i,
107
+ props: { layout: 'horizontal', data: { source: 'static', options: options('Male', 'Female', 'Others') } },
108
+ size: { h: 0.08 }
109
+ },
110
+ {
111
+ key: 'country', label: 'Country', control: 'dropdown',
112
+ match: /\b(country|nationality)\b/i,
113
+ props: () => ({ placeholder: 'Select a country…', data: { source: 'static', options: countryOptions() } })
114
+ },
115
+ {
116
+ key: 'amount', label: 'Amount', control: 'number',
117
+ match: /\b(amount|price|salary|cost|fee|budget|revenue|income|wage)\b/i,
118
+ props: { placeholder: '0.00', validation: { min: 0 } }
119
+ },
120
+ {
121
+ key: 'percentage', label: 'Percentage', control: 'number',
122
+ match: /percent|%/i,
123
+ props: { placeholder: '0–100', validation: { min: 0, max: 100 } }
124
+ },
125
+ {
126
+ key: 'yesNo', label: 'Yes / No', control: 'radio',
127
+ // Nothing to recognise it by — "Blocked?" could be a tick box as well.
128
+ match: null,
129
+ props: { layout: 'horizontal', data: { source: 'static', options: options('Yes', 'No') } },
130
+ size: { h: 0.08 }
131
+ },
132
+ {
133
+ key: 'postalCode', label: 'Postal code', control: 'text',
134
+ match: /\b(postal ?code|post ?code|zip( ?code)?|pin ?code)\b/i,
135
+ props: { validation: { maxLength: 10, pattern: PATTERNS.postalCode, patternMessage: 'Enter a postal code — letters, digits, spaces and - only' } }
136
+ }
137
+ ]
138
+
139
+ const BY_KEY = Object.fromEntries(FIELD_PRESETS.map((p) => [p.key, p]))
140
+
141
+ /** The preset with this key, or null. Accepts "preset:<key>" too — the palette's spelling. */
142
+ export function presetFor(key) {
143
+ if (typeof key !== 'string') return null
144
+ return BY_KEY[key.startsWith('preset:') ? key.slice(7) : key] || null
145
+ }
146
+
147
+ /** A fresh copy of what a preset puts on its control (never the shared object). */
148
+ export function presetProps(preset) {
149
+ const p = typeof preset === 'string' ? presetFor(preset) : preset
150
+ if (!p) return {}
151
+ const props = typeof p.props === 'function' ? p.props() : p.props
152
+ return { label: p.label, ...JSON.parse(JSON.stringify(props)), preset: p.key }
153
+ }
154
+
155
+ /** The preset a label reads like, or null — "LinkedIn URL" → linkedin. */
156
+ export function suggestPreset(label) {
157
+ const text = String(label || '')
158
+ if (!text.trim()) return null
159
+ return FIELD_PRESETS.find((p) => p.match && p.match.test(text)) || null
160
+ }
161
+
162
+ /** The palette's "Common fields" group — shaped like controlGroups()' groups. */
163
+ export function presetGroup() {
164
+ return {
165
+ title: 'Common fields',
166
+ controls: FIELD_PRESETS.map((p) => ({ type: 'preset:' + p.key, label: p.label, icon: p.control, preset: p.key }))
167
+ }
168
+ }
169
+
170
+ // ── changing a field's type ──────────────────────────────────────────────
171
+
172
+ /** What a dropped setting is called, for the note the builder shows. */
173
+ const SETTING_WORDS = {
174
+ minLength: 'min length', maxLength: 'max length', pattern: 'the pattern', patternMessage: 'the pattern message',
175
+ min: 'the minimum', max: 'the maximum', integer: '"whole numbers only"', minItems: 'min choices', maxItems: 'max choices',
176
+ notFuture: '"not in the future"', data: 'the options', default: 'the default value', placeholder: 'the placeholder',
177
+ layout: 'the arrangement', accept: 'the file types', maxSize: 'the size limit', inputType: 'the keyboard type'
178
+ }
179
+
180
+ /**
181
+ * A field becomes another kind — another control, or a preset — keeping what
182
+ * still makes sense: where it is, its step, its label (unless it was only the
183
+ * old type's own starting label), its field name (always, once it is a column;
184
+ * otherwise it follows the label as a new field's does), required, and every
185
+ * style the new control has.
186
+ *
187
+ * @param target a control type ('number'), or a preset key ('email' / 'preset:email')
188
+ * @param options.lockedNames field names that are columns already — never renamed
189
+ * @returns {{ document, node, dropped: string[] }} — dropped: what did not carry
190
+ * over, in words ("the pattern", "the options") for the builder to say
191
+ */
192
+ export function convertField(doc, id, target, controls = CONTROLS, options = {}) {
193
+ const node = doc.nodes.find((n) => n.id === id)
194
+ if (!node) throw new Error(`No control "${id}"`)
195
+ const from = controls[node.type]
196
+ if (!from || !from.input) throw new Error(`"${id}" is not a field`)
197
+ const preset = presetFor(target)
198
+ const type = preset ? preset.control : target
199
+ const def = controls[type]
200
+ if (!def || !def.input) throw new Error(`"${target}" is not a field type or a ready-made field`)
201
+
202
+ const old = node.props || {}
203
+ const oldPreset = presetFor(old.preset)
204
+ const fresh = preset ? presetProps(preset) : {}
205
+ const allowed = new Set(def.props)
206
+ const dropped = []
207
+ const next = {}
208
+
209
+ // The label is the person's — unless it is only what the old type started as
210
+ // ("Text"), or, going from one ready-made field to another, the old one's.
211
+ const startingLabels = [from.defaults && from.defaults.label, preset && oldPreset && oldPreset.label].filter(Boolean)
212
+ const keepLabel = old.label && !startingLabels.includes(old.label)
213
+ next.label = keepLabel ? old.label : (fresh.label || (def.defaults && def.defaults.label) || old.label)
214
+
215
+ if (old.required !== undefined) next.required = old.required
216
+
217
+ // Validation: a preset's rules replace the old ones; a plain type keeps the
218
+ // rules it also has.
219
+ const oldRules = old.validation || {}
220
+ const rules = {}
221
+ if (preset) Object.assign(rules, fresh.validation || {})
222
+ else Object.keys(oldRules).forEach((k) => { if (def.validation.includes(k)) rules[k] = oldRules[k] })
223
+ // Said out loud: every rule that did not carry over — except, going to
224
+ // another ready-made field, the old one's own (replaced, as expected).
225
+ Object.keys(oldRules).forEach((k) => {
226
+ if (sameValue(rules[k], oldRules[k])) return
227
+ if (preset && oldPreset && sameValue(oldRules[k], presetRule(oldPreset, k))) return
228
+ if (k === 'patternMessage' && rules.pattern === undefined && oldRules.pattern !== undefined) return // said with the pattern
229
+ dropped.push(SETTING_WORDS[k] || k)
230
+ })
231
+ if (Object.keys(rules).length) next.validation = rules
232
+
233
+ // Placeholder: the preset's, unless the person wrote their own.
234
+ const startingPlaceholders = [from.defaults && from.defaults.placeholder, oldPreset && presetProps(oldPreset).placeholder].filter(Boolean)
235
+ const theirPlaceholder = old.placeholder && !startingPlaceholders.includes(old.placeholder) ? old.placeholder : null
236
+ if (allowed.has('placeholder')) {
237
+ const value = theirPlaceholder || fresh.placeholder || (def.defaults && def.defaults.placeholder)
238
+ if (value) next.placeholder = value
239
+ } else if (theirPlaceholder) dropped.push(SETTING_WORDS.placeholder)
240
+
241
+ // Options: a preset's, else the old ones if both are choice fields, else the
242
+ // new type's empty start.
243
+ if (CHOICE_TYPES.includes(type)) {
244
+ next.data = fresh.data || (CHOICE_TYPES.includes(node.type) && old.data) || JSON.parse(JSON.stringify(def.defaults.data))
245
+ if (allowed.has('layout')) next.layout = fresh.layout || old.layout || def.defaults.layout
246
+ } else if (old.data && ((old.data.options || []).length || old.data.source === 'table')) dropped.push(SETTING_WORDS.data)
247
+
248
+ if (fresh.inputType && allowed.has('inputType')) next.inputType = fresh.inputType
249
+ ;['accept', 'maxSize'].forEach((k) => {
250
+ if (old[k] === undefined) return
251
+ if (allowed.has(k)) next[k] = old[k]; else dropped.push(SETTING_WORDS[k])
252
+ })
253
+ // A default is a value of the OLD kind; it carries only to the same control.
254
+ if (old.default !== undefined) {
255
+ if (type === node.type) next.default = old.default; else dropped.push(SETTING_WORDS.default)
256
+ }
257
+ // Anything else the new control starts with (a checkbox's defaults, say).
258
+ Object.keys(def.defaults || {}).forEach((k) => {
259
+ if (next[k] === undefined && k !== 'label' && allowed.has(k)) next[k] = JSON.parse(JSON.stringify(def.defaults[k]))
260
+ })
261
+
262
+ if (old.style) {
263
+ const style = {}
264
+ Object.keys(old.style).forEach((k) => { if ((def.styles || []).includes(k)) style[k] = old.style[k] })
265
+ if (Object.keys(style).length) next.style = style
266
+ }
267
+
268
+ if (preset) next.preset = preset.key
269
+ else if (old.preset === false) next.preset = false
270
+
271
+ // The name: fixed once it is a column; otherwise it follows the label, as a
272
+ // new field's does, when it was only ever the generated one.
273
+ const locked = new Set(options.lockedNames || [])
274
+ const others = { ...doc, nodes: doc.nodes.filter((n) => n.id !== id) }
275
+ const generated = !old.name || locked.has(old.name) ? false : isGeneratedName(old, from, oldPreset)
276
+ next.name = !old.name ? uniqueFieldName(others, camelName(next.label) || type)
277
+ : generated ? uniqueFieldName(others, camelName(next.label) || type)
278
+ : old.name
279
+
280
+ const converted = { ...node, type, props: next }
281
+ return {
282
+ document: { ...doc, nodes: doc.nodes.map((n) => (n.id === id ? converted : n)) },
283
+ node: converted,
284
+ dropped: [...new Set(dropped)]
285
+ }
286
+ }
287
+
288
+ function presetRule(preset, key) {
289
+ const v = presetProps(preset).validation
290
+ return v ? v[key] : undefined
291
+ }
292
+
293
+ function sameValue(a, b) {
294
+ return JSON.stringify(a) === JSON.stringify(b)
295
+ }
296
+
297
+ function isGeneratedName(props, def, preset) {
298
+ const stem = String(props.name).replace(/\d+$/, '')
299
+ return stem === camelName(props.label) || stem === camelName(def.defaults && def.defaults.label) || Boolean(preset && stem === camelName(preset.label))
300
+ }
301
+
302
+ // ── suggestions ──────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Fields whose label reads like a ready-made field they are not — "LinkedIn
306
+ * URL" as a plain text box. Suggestions, never errors: they do not stop a save
307
+ * or a publish. A field set to `preset: false` is plain on purpose and skipped.
308
+ *
309
+ * @returns {[{ nodeId, preset, message }]}
310
+ */
311
+ export function presetHints(doc, controls = CONTROLS) {
312
+ const out = []
313
+ ;(doc.nodes || []).forEach((node) => {
314
+ const def = controls[node.type]
315
+ if (!def || !def.input) return
316
+ const p = node.props || {}
317
+ if (p.preset === false || p.preset) return
318
+ const s = suggestPreset(p.label)
319
+ if (!s) return
320
+ out.push({ nodeId: node.id, preset: s.key, message: `"${p.label}" could be the ready-made ${s.label} field, which checks the format for you` })
321
+ })
322
+ return out
323
+ }
324
+
package/src/remote.js CHANGED
@@ -71,6 +71,9 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
71
71
  const extra = (json && json.dataArray && json.dataArray[0]) || {}
72
72
  // Publish: columns it would drop, waiting for a yes — and what it keeps.
73
73
  err.confirm = extra.confirm
74
+ // …columns changing kind, waiting for a yes; and ones whose values will not fit.
75
+ err.convert = extra.convert
76
+ err.wontFit = extra.wontFit
74
77
  err.keep = extra.keep
75
78
  err.refused = extra.refused
76
79
  if (extra.statements) err.detail = extra.statements.join('\n')
@@ -87,7 +90,10 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
87
90
  saveDraft: (doc) => call('PUT', `/screens/${enc(doc.id)}/draft`, { document: doc }),
88
91
  // Changes the screen's table directly. Rejects with err.confirm = [{ column, records }]
89
92
  // when a removed field would drop a column; call again with { confirmDrop: [names] }.
90
- publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, { confirmDrop: (options && options.confirmDrop) || [] })),
93
+ publish: async (doc, options) => one(await call('POST', `/screens/${enc(doc.id)}/publish`, {
94
+ confirmDrop: (options && options.confirmDrop) || [],
95
+ confirmConvert: (options && options.confirmConvert) || []
96
+ })),
91
97
  listTables: () => call('GET', '/tables'),
92
98
  // A new form from its name: { entity, plural? } → { entity, name, source, edit, list } — both screens as drafts.
93
99
  createEntity: async (spec) => one(await call('POST', '/entities', spec)),
package/src/scaffold.js CHANGED
@@ -144,7 +144,7 @@ function editPage(n, editJson, edit) {
144
144
  return `import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'
145
145
  import editScreen from './${editJson}'
146
146
 
147
- // ${edit.name} — saves itself as it is filled in; there is no submit.
147
+ // ${edit.name} — Save sends it over AJAX (one call; never a form submit).
148
148
  // Opened in a popup from ${n.listComponent}, or on its own page:
149
149
  // <${n.editComponent} api={api} /> a new ${n.singular}
150
150
  // <${n.editComponent} api={api} record={row} /> an existing one
@@ -166,7 +166,7 @@ export class ${n.hooksClass} extends FactoryHooks {
166
166
  return super.get(ctx)
167
167
  }
168
168
 
169
- /** Every autosave — keep it quick. Returns the saved record. */
169
+ /** The Save button. Returns the saved record. */
170
170
  save(values, ctx) {
171
171
  return super.save(values, ctx)
172
172
  }
@@ -0,0 +1,91 @@
1
+ // WHAT A FLOW NEEDS TO KNOW ABOUT THIS APP'S SCREENS.
2
+ //
3
+ // @xeplr/ui-workflow's designer offers a Screen step, and asks its host two
4
+ // things: which screens exist, and what each one hands back when somebody
5
+ // submits it. It cannot answer either itself — it carries a screen key and
6
+ // knows nothing else about forms, on purpose, so that an app with a different
7
+ // screen system is not locked out.
8
+ //
9
+ // This is @xeplr/factory's answer, ONCE. It used to be written out in the app:
10
+ // list the screens, load each document, filter the nodes that hold a value,
11
+ // map them to names and types. That is the same twenty lines in every app the
12
+ // CLI scaffolds, hand-copied, drifting apart the first time a control type is
13
+ // added here — which is exactly the kind of code that should not be in an
14
+ // application at all.
15
+ //
16
+ // Pure: no React. The hook around it is useScreenSource.js.
17
+
18
+ import { inputNodes } from './document.js'
19
+
20
+ /**
21
+ * A screen document → what the designer needs.
22
+ *
23
+ * `inputNodes` is this package's own answer to "which nodes hold a value", so
24
+ * a label, a stepper or a divider is never offered as a field — and a control
25
+ * type added later is included here without anybody editing an app.
26
+ */
27
+ export function describeScreen(doc) {
28
+ if (!doc || !doc.id) return null
29
+ return {
30
+ key: doc.id,
31
+ name: doc.name || doc.id,
32
+ fields: inputNodes(doc).map(function (n) {
33
+ return { name: n.id, type: n.type }
34
+ })
35
+ }
36
+ }
37
+
38
+ /** A listing row → the same shape, before its document has been read. */
39
+ export function screenStub(row) {
40
+ if (!row) return null
41
+ var key = row.screenKey || row.id || row.key
42
+ if (!key) return null
43
+ return { key: key, name: row.name || key }
44
+ }
45
+
46
+ /**
47
+ * Every screen, with its fields.
48
+ *
49
+ * TWO PASSES, because the second is slow and the first is what makes the menu
50
+ * usable: `onList` is called with the names as soon as they arrive, and the
51
+ * result resolves once each document has been read. A document that will not
52
+ * load leaves its screen in the list WITHOUT fields — choosable, with its
53
+ * output typed by hand, rather than missing.
54
+ *
55
+ * @param api a createFactoryApi() instance
56
+ * @param onList optional, called with the names-only list first
57
+ */
58
+ export async function loadScreens(api, onList) {
59
+ var rows = await api.listScreens()
60
+ var stubs = (rows || []).map(screenStub).filter(Boolean)
61
+ if (onList) onList(stubs)
62
+
63
+ return Promise.all(stubs.map(async function (stub) {
64
+ try {
65
+ return describeScreen(await api.loadScreen(stub.key)) || stub
66
+ } catch (err) {
67
+ return stub
68
+ }
69
+ }))
70
+ }
71
+
72
+ /** "Leave request" → "leave_request": what a new form is keyed by. */
73
+ export function keyFromName(label) {
74
+ return String(label == null ? '' : label)
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, '_')
77
+ .replace(/^_+|_+$/g, '')
78
+ }
79
+
80
+ /**
81
+ * The screen a new entity's FORM half is, from what createEntity answered.
82
+ *
83
+ * Read off the response rather than spelled out here: the naming is this
84
+ * package's and may not stay "<key>_edit", and an app guessing it would break
85
+ * quietly the day it changes.
86
+ */
87
+ export function editScreenOf(made, fallbackKey) {
88
+ var edit = made && made.edit
89
+ if (edit && typeof edit === 'object') return edit.id || edit.screenKey || fallbackKey
90
+ return edit || fallbackKey
91
+ }