@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.
@@ -40,12 +40,22 @@ export function boxStyle(style) {
40
40
  })
41
41
  }
42
42
 
43
- /** The screen's defaults, on its container: what every control inherits. */
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
- * There is no submit button: a screen saves itself as it is filled in.
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
- const type = field.type || 'text'
138
- if (!controls[type]) throw new Error(`screenFromSpec: ${at}.type "${type}" is not a control — one of: ${Object.keys(controls).join(', ')}`)
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
- function propsFromField(type, field, at) {
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 on every autosave, so keep it quick and safe to repeat.
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, AUTOSAVE_DELAY } from './useFactoryScreen.js'
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={ctrl.closeEditor}
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: the edit screen saves itself,
77
- // and every save refreshes the list behind the popup.
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`, { 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
  }