@xeplr/ui-factory 1.0.14 → 1.2.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 +167 -20
- package/package.json +7 -2
- 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 +18 -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/urlState.js +71 -0
- package/src/useFactoryBuilder.js +97 -25
- package/src/useFactoryScreen.js +85 -47
- package/src/useFlowBuilder.js +7 -4
- package/src/useScreenSource.js +81 -0
- package/src/useUrlPlaces.js +48 -0
- package/src/validateDocument.js +5 -1
- package/src/values.js +18 -5
package/src/designs/styles.js
CHANGED
|
@@ -40,12 +40,22 @@ export function boxStyle(style) {
|
|
|
40
40
|
})
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The screen's defaults, on its container: what every control inherits.
|
|
45
|
+
*
|
|
46
|
+
* A screen with no colours of its own follows the app's theme (factory.css).
|
|
47
|
+
* One that sets them keeps them EVERYWHERE — its ink and paper become the
|
|
48
|
+
* tokens the controls read, input boxes included — so a screen designed dark
|
|
49
|
+
* ink on white is never shown as that ink on a dark theme's input box.
|
|
50
|
+
*/
|
|
44
51
|
export function screenStyle(doc) {
|
|
45
52
|
const s = doc.style || {}
|
|
46
53
|
return clean({
|
|
47
54
|
'--xf-width': doc.width,
|
|
48
55
|
'--xf-font': s.fontSize,
|
|
56
|
+
'--xf-ink': s.color,
|
|
57
|
+
'--xf-surface': s.background,
|
|
58
|
+
'--xf-field-bg': s.background,
|
|
49
59
|
fontFamily: s.fontFamily,
|
|
50
60
|
color: s.color,
|
|
51
61
|
background: s.background
|
package/src/generate.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { CONTROLS, CHOICE_TYPES } from './controls.js'
|
|
27
27
|
import { createScreen, addControl, MARGIN, GAP, slugify, camelName, stepsOf } from './document.js'
|
|
28
28
|
import { assertValidDocument } from './validateDocument.js'
|
|
29
|
+
import { presetFor, presetProps, suggestPreset } from './presets.js'
|
|
29
30
|
|
|
30
31
|
/** Heights by control, as page fractions (a page is as tall as it is wide). */
|
|
31
32
|
export const SPEC_HEIGHTS = {
|
|
@@ -45,7 +46,7 @@ export const SPEC_HEIGHTS = {
|
|
|
45
46
|
*/
|
|
46
47
|
export const ROW_GAP = 0.04
|
|
47
48
|
|
|
48
|
-
const FIELD_KEYS = ['type', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'layout', 'accept', 'maxSize', 'step', 'steps', 'showNumbers', 'width', 'text', 'variant', 'style']
|
|
49
|
+
const FIELD_KEYS = ['type', 'preset', 'label', 'name', 'required', 'placeholder', 'default', 'validation', 'data', 'options', 'table', 'layout', 'accept', 'maxSize', 'step', 'steps', 'showNumbers', 'width', 'text', 'variant', 'style']
|
|
49
50
|
const SPEC_KEYS = ['name', 'id', 'source', 'columns', 'heading', 'fields', 'list', 'aspect', 'width', 'style']
|
|
50
51
|
const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
|
|
51
52
|
|
|
@@ -58,6 +59,12 @@ const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
|
|
|
58
59
|
* fields in reading order. Each: { label, type?='text', name?, required?, placeholder?,
|
|
59
60
|
* default?, validation?, width?: 'half'|'full' }
|
|
60
61
|
* dropdown: options: ['Full time', …] or [{ id, name }] —or— table: 'departments'
|
|
62
|
+
* READY-MADE: `type` may be a preset key — 'email', 'phone', 'url', 'linkedin',
|
|
63
|
+
* 'age', 'dateOfBirth', 'gender', 'country', 'amount', 'percentage', 'yesNo',
|
|
64
|
+
* 'postalCode' (presets.js) — or `preset: '<key>'`. A field with NO type and
|
|
65
|
+
* no options is matched by its label: { label: 'Age' } is a whole number 0–130,
|
|
66
|
+
* { label: 'LinkedIn URL' } a LinkedIn address. `type: 'text'` opts out.
|
|
67
|
+
* Anything the field also gives (label, required, validation…) wins.
|
|
61
68
|
* section heading: { type: 'label', text, variant?: 'subheading' }
|
|
62
69
|
* source? the table records are saved to, e.g. 'employees'
|
|
63
70
|
* width? design width in px (default 800) — style sizes are px at this width
|
|
@@ -65,7 +72,8 @@ const LIST_KEYS = ['title', 'source', 'columns', 'pageSize', 'actions', 'style']
|
|
|
65
72
|
* list? true, or { title, source, columns: [{ field, label }], pageSize, actions } —
|
|
66
73
|
* a list of the saved records below the fields, with New / Edit / Delete
|
|
67
74
|
*
|
|
68
|
-
*
|
|
75
|
+
* Do not add a button control for saving: every screen already has Save (and
|
|
76
|
+
* Cancel where it can go back) in its footer — an AJAX call, never a form submit.
|
|
69
77
|
*/
|
|
70
78
|
export function screenFromSpec(spec, controls = CONTROLS) {
|
|
71
79
|
if (!spec || typeof spec !== 'object') throw new Error('screenFromSpec: spec must be an object')
|
|
@@ -134,14 +142,19 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
134
142
|
const at = `spec.fields[${i}]`
|
|
135
143
|
if (!field || typeof field !== 'object') throw new Error(`screenFromSpec: ${at} must be an object`)
|
|
136
144
|
unknownKeys(field, FIELD_KEYS, at)
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
// A ready-made field: named (type or preset), or recognised from a label
|
|
146
|
+
// that says nothing else about itself.
|
|
147
|
+
const bare = !field.type && !field.preset && !field.options && !field.table && !field.data
|
|
148
|
+
const preset = presetFor(field.preset) || presetFor(field.type) || (bare ? suggestPreset(field.label) : null)
|
|
149
|
+
if (field.preset && !presetFor(field.preset)) throw new Error(`screenFromSpec: ${at}.preset "${field.preset}" is not a ready-made field`)
|
|
150
|
+
const type = preset ? preset.control : (field.type || 'text')
|
|
151
|
+
if (!controls[type]) throw new Error(`screenFromSpec: ${at}.type "${type}" is not a control or a ready-made field — controls: ${Object.keys(controls).join(', ')}`)
|
|
139
152
|
if (field.width !== undefined && field.width !== 'half' && field.width !== 'full') throw new Error(`screenFromSpec: ${at}.width must be "half" or "full"`)
|
|
140
153
|
if (field.step !== undefined) {
|
|
141
154
|
if (!stepper) throw new Error(`screenFromSpec: ${at}.step needs a stepper before it — { "type": "stepper", "steps": [...] }`)
|
|
142
155
|
openStep(field.step)
|
|
143
156
|
}
|
|
144
|
-
place(type, propsFromField(type, field, at), field.width)
|
|
157
|
+
place(type, preset ? withPreset(preset, propsFromField(type, { ...field, type }, at, true), field) : propsFromField(type, field, at), field.width)
|
|
145
158
|
})
|
|
146
159
|
|
|
147
160
|
if (col > 0) { y += rowH + ROW_GAP; col = 0; rowH = 0 }
|
|
@@ -157,7 +170,17 @@ export function screenFromSpec(spec, controls = CONTROLS) {
|
|
|
157
170
|
return assertValidDocument(doc, controls)
|
|
158
171
|
}
|
|
159
172
|
|
|
160
|
-
|
|
173
|
+
/** A preset's props under what the field itself gave. */
|
|
174
|
+
function withPreset(preset, own, field) {
|
|
175
|
+
const base = presetProps(preset)
|
|
176
|
+
const out = { ...base, ...own }
|
|
177
|
+
// Rules merge key by key: { label: 'Age', validation: { max: 120 } } keeps min and integer.
|
|
178
|
+
if (base.validation || own.validation) out.validation = { ...(base.validation || {}), ...(own.validation || {}) }
|
|
179
|
+
if (!field.label) out.label = base.label
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function propsFromField(type, field, at, fromPreset) {
|
|
161
184
|
if (type === 'stepper') {
|
|
162
185
|
const steps = field.steps
|
|
163
186
|
if (!Array.isArray(steps) || steps.length < 2) throw new Error(`screenFromSpec: ${at} is a stepper — give it steps: ["Connect", "Transform", …]`)
|
|
@@ -175,13 +198,15 @@ function propsFromField(type, field, at) {
|
|
|
175
198
|
if (field.style) props.style = field.style
|
|
176
199
|
return props
|
|
177
200
|
}
|
|
178
|
-
if (!field.label) throw new Error(`screenFromSpec: ${at}.label is required`)
|
|
179
|
-
const props = { label: field.label }
|
|
201
|
+
if (!field.label && !fromPreset) throw new Error(`screenFromSpec: ${at}.label is required`)
|
|
202
|
+
const props = field.label ? { label: field.label } : {}
|
|
180
203
|
;['name', 'required', 'placeholder', 'default', 'validation', 'style'].forEach((k) => {
|
|
181
204
|
if (field[k] !== undefined) props[k] = field[k]
|
|
182
205
|
})
|
|
183
|
-
if (type === 'dropdown' && props.placeholder === undefined) props.placeholder = 'Select…'
|
|
184
|
-
if (CHOICE_TYPES.includes(type)) {
|
|
206
|
+
if (type === 'dropdown' && props.placeholder === undefined && !fromPreset) props.placeholder = 'Select…'
|
|
207
|
+
if (CHOICE_TYPES.includes(type) && fromPreset && !field.options && !field.table && !field.data) {
|
|
208
|
+
if (field.layout !== undefined && type !== 'dropdown') props.layout = field.layout
|
|
209
|
+
} else if (CHOICE_TYPES.includes(type)) {
|
|
185
210
|
props.data = dataFromField(field, at, type)
|
|
186
211
|
if (field.layout !== undefined && type !== 'dropdown') props.layout = field.layout
|
|
187
212
|
// A field reading another table stores that row's id: name the column
|
package/src/hooks.js
CHANGED
|
@@ -40,7 +40,7 @@ export class FactoryHooks {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Save — called
|
|
43
|
+
* Save — called when the person presses Save (or a flow's Next with unsaved changes).
|
|
44
44
|
* @param values what the form holds
|
|
45
45
|
* @param ctx.steps switch steps off or on as the record decides them —
|
|
46
46
|
* ctx.steps.disable('approval'), ctx.steps.enable()
|
package/src/index.js
CHANGED
|
@@ -10,12 +10,16 @@ export * from './model.js'
|
|
|
10
10
|
|
|
11
11
|
// Controllers
|
|
12
12
|
export { useFactoryBuilder } from './useFactoryBuilder.js'
|
|
13
|
-
export { useFactoryScreen, normaliseOptions
|
|
13
|
+
export { useFactoryScreen, normaliseOptions } from './useFactoryScreen.js'
|
|
14
|
+
// This app's screens, shaped for another package's designer — see
|
|
15
|
+
// useScreenSource.js. Two props instead of a page of glue per application.
|
|
16
|
+
export { useScreenSource } from './useScreenSource.js'
|
|
17
|
+
export { describeScreen, screenStub, loadScreens, keyFromName, editScreenOf } from './screenSource.js'
|
|
14
18
|
export { useFlowRun } from './useFlowRun.js'
|
|
15
19
|
export { useFlowBuilder, FLOW_AUTOSAVE_DELAY } from './useFlowBuilder.js'
|
|
16
20
|
|
|
17
21
|
// Designs
|
|
18
|
-
export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
|
22
|
+
export { BuilderSample, ScreenSample, ListView, ScreenModal, FlowRunnerSample, FlowBuilderSample, ScreenEditorSample, ControlView, VIEWS, Palette, DRAG_TYPE, PropertyPanel, EDITORS } from './designs/index.js'
|
|
19
23
|
|
|
20
24
|
// Front-end hooks — extend FactoryHooks, override what you need, call super
|
|
21
25
|
export { FactoryHooks } from './hooks.js'
|
package/src/model.js
CHANGED
|
@@ -19,8 +19,9 @@ export { createFactoryApi } from './remote.js'
|
|
|
19
19
|
export {
|
|
20
20
|
STANDARD_COLUMNS, RESERVED_COLUMNS, MAX_IDENTIFIER,
|
|
21
21
|
columnForField, tableForScreen, columnSql, widening, diffTables, migrationFor, nextMigrationName,
|
|
22
|
-
createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange
|
|
22
|
+
createStatements, addStatements, alterStatement, dropStatement, columnFromDatabase, planTableChange, conversionFor
|
|
23
23
|
} from './tableSchema.js'
|
|
24
|
+
export { FIELD_PRESETS, PATTERNS, INPUT_TYPES, presetFor, presetProps, suggestPreset, presetGroup, presetHints, convertField } from './presets.js'
|
|
24
25
|
export { createFlowsApi } from './flows.js'
|
|
25
26
|
export {
|
|
26
27
|
FLOW_KIND, FLOW_VERSION, FLOW_END, FLOW_OPERATORS, VALUELESS_OPERATORS,
|
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'
|
|
@@ -18,6 +19,7 @@ import { BuilderSample, ScreenSample, ScreenModal, FlowRunnerSample, FlowBuilder
|
|
|
18
19
|
* @prop lockedNames field names that are already columns of the table — shown read-only
|
|
19
20
|
* @prop screens [{ id, name, document? }] — other screens: "Edit in" choices, and Preview's popups
|
|
20
21
|
* @prop controls control registry (default: built-ins)
|
|
22
|
+
* @prop urlKey keep the selected control and open steps in the URL (<urlKey>Selected, <urlKey>Steps)
|
|
21
23
|
*/
|
|
22
24
|
export function FactoryBuilder(props) {
|
|
23
25
|
const ctrl = useFactoryBuilder(props)
|
|
@@ -56,9 +58,19 @@ export function FactoryBuilder(props) {
|
|
|
56
58
|
* @prop loadScreen async (id) → document — for screens not in `screens`
|
|
57
59
|
* @prop controls control registry (default: built-ins)
|
|
58
60
|
* @prop hooks your FactoryHooks subclass (get / save / delete / actions) — also used by the popup
|
|
61
|
+
* @prop urlKey keep the open step of each stepper in the URL (<urlKey>Steps); never passed to the popup
|
|
59
62
|
*/
|
|
60
63
|
export function FactoryScreen(props) {
|
|
61
64
|
const ctrl = useFactoryScreen(props)
|
|
65
|
+
// Whether the popup's form holds unsaved changes — its × asks before
|
|
66
|
+
// dropping them, as its Cancel does.
|
|
67
|
+
const popupDirty = useRef(false)
|
|
68
|
+
const closePopup = () => {
|
|
69
|
+
// eslint-disable-next-line no-alert
|
|
70
|
+
if (popupDirty.current && typeof window !== 'undefined' && !window.confirm('Discard your unsaved changes?')) return
|
|
71
|
+
popupDirty.current = false
|
|
72
|
+
ctrl.closeEditor()
|
|
73
|
+
}
|
|
62
74
|
return (
|
|
63
75
|
<ScreenSample
|
|
64
76
|
ctrl={ctrl}
|
|
@@ -68,13 +80,13 @@ export function FactoryScreen(props) {
|
|
|
68
80
|
<ScreenModal
|
|
69
81
|
title={popup.document ? popup.document.name : 'Loading…'}
|
|
70
82
|
width={popup.document ? popup.document.width : undefined}
|
|
71
|
-
onClose={
|
|
83
|
+
onClose={closePopup}
|
|
72
84
|
>
|
|
73
85
|
{popup.error && <div className="xeplr-factory-invalid" role="alert">{popup.error}</div>}
|
|
74
86
|
{popup.loading && <div className="xeplr-factory-hint">Loading…</div>}
|
|
75
87
|
{popup.document && (
|
|
76
|
-
// The same component, one level down
|
|
77
|
-
//
|
|
88
|
+
// The same component, one level down. Its Save refreshes the list
|
|
89
|
+
// behind the popup and closes it; Cancel closes it unsaved.
|
|
78
90
|
<FactoryScreen
|
|
79
91
|
key={`${popup.screenId}:${popup.record ? popup.record[ctrl.recordKey] : 'new'}`}
|
|
80
92
|
document={popup.document}
|
|
@@ -89,6 +101,8 @@ export function FactoryScreen(props) {
|
|
|
89
101
|
loadScreen={props.loadScreen}
|
|
90
102
|
controls={props.controls}
|
|
91
103
|
hooks={props.hooks}
|
|
104
|
+
onDone={() => { popupDirty.current = false; ctrl.closeEditor() }}
|
|
105
|
+
onDirtyChange={(d) => { popupDirty.current = d }}
|
|
92
106
|
/>
|
|
93
107
|
)}
|
|
94
108
|
</ScreenModal>
|
|
@@ -154,6 +168,7 @@ const EMPTY_SCREEN = {
|
|
|
154
168
|
* @prop onSave async (flow) → void
|
|
155
169
|
* @prop onPublish async (key) → void
|
|
156
170
|
* @prop onChange (flow) → void
|
|
171
|
+
* @prop urlKey keep the selected step in the URL (<urlKey>Selected)
|
|
157
172
|
*/
|
|
158
173
|
export function FlowBuilder(props) {
|
|
159
174
|
const ctrl = useFlowBuilder(props)
|
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
|
}
|