@xeplr/ui-factory 1.0.14 → 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/AUTHORING.md +29 -5
- package/README.md +140 -18
- package/package.json +1 -1
- package/src/controls.js +19 -13
- package/src/designs/BuilderSample.jsx +77 -19
- package/src/designs/ControlView.jsx +10 -1
- package/src/designs/FlowRunnerSample.jsx +6 -4
- package/src/designs/ListView.jsx +40 -21
- package/src/designs/Palette.jsx +2 -1
- package/src/designs/PropertyPanel.jsx +57 -1
- package/src/designs/ScreenEditorSample.jsx +97 -0
- package/src/designs/ScreenModal.jsx +2 -2
- package/src/designs/ScreenSample.jsx +92 -41
- package/src/designs/factory.css +170 -76
- package/src/designs/index.js +4 -0
- package/src/designs/styles.js +11 -1
- package/src/generate.js +35 -10
- package/src/hooks.js +1 -1
- package/src/index.js +6 -2
- package/src/model.js +2 -1
- package/src/pages.jsx +15 -3
- package/src/presets.js +324 -0
- package/src/remote.js +7 -1
- package/src/scaffold.js +2 -2
- package/src/screenSource.js +91 -0
- package/src/tableSchema.js +109 -6
- package/src/useFactoryBuilder.js +65 -16
- package/src/useFactoryScreen.js +74 -39
- package/src/useScreenSource.js +81 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
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={
|
|
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
|
|
77
|
-
//
|
|
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`, {
|
|
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} —
|
|
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
|
-
/**
|
|
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
|
+
}
|