@xeplr/ui-factory 1.8.3 → 1.8.4

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.
@@ -4,6 +4,7 @@ import { FIELD_PRESETS, presetFor } from '../presets.js'
4
4
  import { slugify, inputNodes, paramsText, screenParams } from '../document.js'
5
5
  import { isBinding, previousBinding } from '../values.js'
6
6
  import { FONT_FAMILIES, STYLE_KEYS, SCREEN_STYLE_KEYS, LIST_ACTIONS, FILE_KINDS } from '../controls.js'
7
+ import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, NOTE_INPUT, optionValues } from '../buttons.js'
7
8
 
8
9
  // The selected control's properties, GENERATED from its entry in controls.js:
9
10
  // each field there names a path and an editor type, and this file owns only
@@ -59,7 +60,7 @@ function FieldType({ node, controls, hint, onConvert, onKeepPlain }) {
59
60
  )
60
61
  }
61
62
 
62
- export default function PropertyPanel({ doc, node, control, controls, errors, tables, screens, forms, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
63
+ export default function PropertyPanel({ doc, node, control, controls, errors, tables, functions, screens, forms, lockedNames, onChange, onConvert, hint, onScreenChange, onRemove, onMakeChildTable, selectionCount }) {
63
64
  if (selectionCount > 1) {
64
65
  return (
65
66
  <aside className="xeplr-factory-panel">
@@ -103,13 +104,14 @@ export default function PropertyPanel({ doc, node, control, controls, errors, ta
103
104
  const value = field.path === 'props.default' && field.type !== 'fillFrom' && isBinding(raw) ? undefined : raw
104
105
  return (
105
106
  <div key={field.path + ':' + field.type} className={`xeplr-factory-prop${errs.length ? ' has-error' : ''}`}>
106
- {field.type !== 'toggle' && field.type !== 'toggleValue' && field.type !== 'columns' && field.type !== 'actions' && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
107
+ {!SELF_LABELLED.includes(field.type) && <label className="xeplr-factory-prop-label" htmlFor={id}>{field.label}</label>}
107
108
  <Editor
108
109
  id={id}
109
110
  field={field}
110
111
  value={value}
111
112
  onChange={(v) => onChange(field.path, v)}
112
113
  tables={tables}
114
+ functions={functions}
113
115
  screens={screens}
114
116
  forms={forms}
115
117
  node={node}
@@ -494,7 +496,10 @@ function TableEditor({ id, value, onChange, tables }) {
494
496
  * are this screen's own — or, for a list screen, those of the screen it edits
495
497
  * in. Nothing ticked means all of them.
496
498
  */
497
- function ColumnsEditor({ value, onChange, doc, node, screens }) {
499
+ function ColumnsEditor({ value, onChange, doc, node, screens, functions }) {
500
+ // A list fed by a function has no form describing its rows: its columns
501
+ // are written out, each saying what it shows and how.
502
+ if (node && node.props && node.props.rowsFrom) return <FedColumnsEditor value={value} onChange={onChange} functions={functions} />
498
503
  const editDoc = node && node.props.editScreen ? (screens || []).find((s) => s.id === node.props.editScreen)?.document : null
499
504
 
500
505
  // A LIST OF ANOTHER TABLE IS NOT DESCRIBED BY THIS SCREEN. Pointing a list
@@ -549,6 +554,9 @@ function ScreenEditor({ id, value, onChange, screens, doc }) {
549
554
  return (
550
555
  <select id={id} className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value || undefined)}>
551
556
  <option value="">— open rows in this screen's fields</option>
557
+ {/* A screen not in the list (not loaded, another app's) is still the
558
+ setting — shown by its id, not as if it had been cleared. */}
559
+ {value && !others.some((s) => s.id === value) && <option value={value}>{value}</option>}
552
560
  {others.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
553
561
  </select>
554
562
  )
@@ -778,6 +786,327 @@ function uniqueStepKey(steps, base) {
778
786
  return `${base}_${n}`
779
787
  }
780
788
 
789
+ /** Editors that draw their own label (a toggle's is beside it; a list of rows has a heading per row). */
790
+ const SELF_LABELLED = ['toggle', 'toggleValue', 'columns', 'actions', 'filters', 'buttons']
791
+
792
+ /**
793
+ * One of the app's registered functions, for one purpose (`field.event`):
794
+ * picked from those registered for it. A name that is no longer registered
795
+ * stays shown, marked — not silently blanked.
796
+ */
797
+ function FunctionEditor({ id, field, value, onChange, functions }) {
798
+ const pick = functions && functions.forEvent ? functions.forEvent(field.event) : []
799
+ if (!functions || (!pick.length && !functions.loading)) {
800
+ return (
801
+ <>
802
+ <input id={id} className="xeplr-factory-prop-input" value={value || ''} placeholder="functionName" onChange={(e) => onChange(e.target.value.trim() || undefined)} />
803
+ {functions && !functions.loading && <div className="xeplr-factory-prop-help">No function is registered for “{field.event}” — the app registers one with @xeplr/actions.</div>}
804
+ </>
805
+ )
806
+ }
807
+ const known = !value || pick.some((f) => f.name === value)
808
+ const chosen = pick.find((f) => f.name === value)
809
+ return (
810
+ <>
811
+ <select id={id} className="xeplr-factory-prop-input" value={value || ''} onChange={(e) => onChange(e.target.value || undefined)}>
812
+ <option value="">— none —</option>
813
+ {!known && <option value={value}>{value} (not registered)</option>}
814
+ {pick.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
815
+ </select>
816
+ {chosen && chosen.description && <div className="xeplr-factory-prop-help">{chosen.description}</div>}
817
+ </>
818
+ )
819
+ }
820
+
821
+ /** A list's filter chips: key (what the function is asked for) and the chip's text. */
822
+ function FiltersEditor({ value, onChange }) {
823
+ const chips = Array.isArray(value) ? value : []
824
+ const set = (next) => onChange(next.length ? next : undefined)
825
+ const edit = (i, patch) => set(chips.map((c, j) => (j === i ? { ...c, ...patch } : c)))
826
+ return (
827
+ <div className="xeplr-factory-rows-editor">
828
+ <div className="xeplr-factory-prop-label">Filter chips</div>
829
+ {chips.map((c, i) => (
830
+ <div key={i} className="xeplr-factory-rows-editor-row">
831
+ <input className="xeplr-factory-prop-input" aria-label={`Chip ${i + 1} text`} placeholder="Packed" value={c.label || ''}
832
+ onChange={(e) => edit(i, { label: e.target.value, ...(c.key && c.key !== slugify(c.label || '', '-') ? {} : { key: slugify(e.target.value, '-') }) })} />
833
+ <input className="xeplr-factory-prop-input xeplr-factory-prop-input--key" aria-label={`Chip ${i + 1} key`} placeholder="packed" value={c.key || ''}
834
+ onChange={(e) => edit(i, { key: e.target.value.trim() })} />
835
+ <RowTools i={i} count={chips.length} onMove={(to) => set(move(chips, i, to))} onRemove={() => set(chips.filter((_, j) => j !== i))} />
836
+ </div>
837
+ ))}
838
+ <button type="button" className="xeplr-factory-secondary" onClick={() => set([...chips, { key: chips.length ? `chip-${chips.length + 1}` : 'all', label: chips.length ? '' : 'All' }])}>+ Chip</button>
839
+ </div>
840
+ )
841
+ }
842
+
843
+ /** Each column of a list fed by a function: what it sorts by, what it shows, how. */
844
+ function FedColumnsEditor({ value, onChange, functions }) {
845
+ const cols = Array.isArray(value) ? value : []
846
+ const set = (next) => onChange(next.length ? next : undefined)
847
+ const edit = (i, patch) => set(cols.map((c, j) => {
848
+ if (j !== i) return c
849
+ const next = { ...c, ...patch }
850
+ Object.keys(next).forEach((k) => { if (next[k] === undefined || next[k] === '') delete next[k] })
851
+ return next
852
+ }))
853
+ const details = functions && functions.forEvent ? functions.forEvent('rowDetails') : []
854
+ return (
855
+ <div className="xeplr-factory-rows-editor">
856
+ <div className="xeplr-factory-prop-label">Columns</div>
857
+ {cols.map((c, i) => (
858
+ <fieldset key={i} className="xeplr-factory-rows-editor-card">
859
+ <legend>{c.label || `Column ${i + 1}`}</legend>
860
+ <RowTools i={i} count={cols.length} onMove={(to) => set(move(cols, i, to))} onRemove={() => set(cols.filter((_, j) => j !== i))} />
861
+ <MiniField label="Heading"><input className="xeplr-factory-prop-input" value={c.label || ''} onChange={(e) => edit(i, { label: e.target.value })} /></MiniField>
862
+ <MiniField label="Sorts by (field)"><input className="xeplr-factory-prop-input" value={c.field || ''} placeholder="placedAt" onChange={(e) => edit(i, { field: e.target.value.trim() })} /></MiniField>
863
+ <MiniField label="Shows (field)" help="Blank → the value itself"><input className="xeplr-factory-prop-input" value={c.text || ''} placeholder="placedText" onChange={(e) => edit(i, { text: e.target.value.trim() })} /></MiniField>
864
+ <MiniField label="Second line (field)"><input className="xeplr-factory-prop-input" value={c.sub || ''} onChange={(e) => edit(i, { sub: e.target.value.trim() })} /></MiniField>
865
+ <MiniField label="Drawn as">
866
+ <select className="xeplr-factory-prop-input" value={c.display || 'text'} onChange={(e) => edit(i, { display: e.target.value === 'text' ? undefined : e.target.value, ...(e.target.value !== 'images' ? { more: undefined } : {}), ...(e.target.value !== 'activity' ? { details: undefined } : {}) })}>
867
+ {COLUMN_DISPLAYS.map((d) => <option key={d} value={d}>{DISPLAY_LABELS[d]}</option>)}
868
+ </select>
869
+ </MiniField>
870
+ {c.display === 'images' && <MiniField label="How many more (field)"><input className="xeplr-factory-prop-input" value={c.more || ''} onChange={(e) => edit(i, { more: e.target.value.trim() })} /></MiniField>}
871
+ {c.display === 'activity' && (
872
+ <MiniField label="Hover box from">
873
+ <select className="xeplr-factory-prop-input" value={c.details || ''} onChange={(e) => edit(i, { details: e.target.value || undefined })}>
874
+ <option value="">— none —</option>
875
+ {c.details && !details.some((f) => f.name === c.details) && <option value={c.details}>{c.details} (not registered)</option>}
876
+ {details.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
877
+ </select>
878
+ </MiniField>
879
+ )}
880
+ <MiniField label="Badges" help="field:Text:tone, … — shown where the field is true. Tones: accent, success, warning, danger, muted">
881
+ <input className="xeplr-factory-prop-input" value={badgesText(c.badges)} placeholder="isNew:New, cancelled:Cancelled:danger"
882
+ onChange={(e) => edit(i, { badges: parseBadges(e.target.value) })} />
883
+ </MiniField>
884
+ <label className="xeplr-factory-toggle">
885
+ <input type="checkbox" checked={c.sortable !== false} onChange={(e) => edit(i, { sortable: e.target.checked ? undefined : false })} />
886
+ <span>Sortable</span>
887
+ </label>
888
+ </fieldset>
889
+ ))}
890
+ <button type="button" className="xeplr-factory-secondary" onClick={() => set([...cols, { field: `column${cols.length + 1}`, label: `Column ${cols.length + 1}` }])}>+ Column</button>
891
+ </div>
892
+ )
893
+ }
894
+
895
+ const DISPLAY_LABELS = { text: 'Text', images: 'Pictures', activity: 'Latest activity (with hover box)' }
896
+
897
+ function badgesText(badges) {
898
+ return (Array.isArray(badges) ? badges : []).map((b) => [b.field, b.label, b.tone].filter(Boolean).join(':')).join(', ')
899
+ }
900
+
901
+ function parseBadges(text) {
902
+ const list = String(text || '').split(',').map((part) => part.trim()).filter(Boolean).map((part) => {
903
+ const [field, label, tone] = part.split(':').map((x) => x.trim())
904
+ const b = { field, label: label || field }
905
+ if (tone && BADGE_TONES.includes(tone)) b.tone = tone
906
+ return b
907
+ })
908
+ return list.length ? list : undefined
909
+ }
910
+
911
+ /**
912
+ * A PANEL'S BUTTONS. Each is a function of the app and the values it is given:
913
+ * picking the function lists the parameters it takes — a dropdown where it
914
+ * names the values allowed. Then what to ask first, and when it shows or is
915
+ * greyed out.
916
+ */
917
+ function ButtonsEditor({ value, onChange, functions }) {
918
+ const buttons = Array.isArray(value) ? value : []
919
+ const set = (next) => onChange(next)
920
+ const edit = (i, patch) => set(buttons.map((b, j) => {
921
+ if (j !== i) return b
922
+ const next = { ...b, ...patch }
923
+ Object.keys(next).forEach((k) => { if (next[k] === undefined) delete next[k] })
924
+ return next
925
+ }))
926
+ const pick = functions && functions.forEvent ? functions.forEvent('button') : []
927
+ const takenIds = (except) => new Set(buttons.filter((_, j) => j !== except).map((b) => b.id))
928
+ const add = () => {
929
+ const ids = takenIds(-1)
930
+ let n = buttons.length + 1
931
+ while (ids.has(`button-${n}`)) n += 1
932
+ set([...buttons, { id: `button-${n}`, label: `Button ${n}`, action: pick[0] ? pick[0].name : '' }])
933
+ }
934
+ return (
935
+ <div className="xeplr-factory-rows-editor">
936
+ <div className="xeplr-factory-prop-label">Buttons</div>
937
+ {!pick.length && functions && !functions.loading && (
938
+ <div className="xeplr-factory-prop-help">No function is registered for buttons — the app registers one with @xeplr/actions and event “button”.</div>
939
+ )}
940
+ {buttons.map((b, i) => {
941
+ const fn = pick.find((f) => f.name === b.action) || null
942
+ const schema = (fn && fn.inputSchema) || []
943
+ const takesNote = schema.some((f) => f.name === NOTE_INPUT)
944
+ return (
945
+ <fieldset key={i} className="xeplr-factory-rows-editor-card">
946
+ <legend>{b.label || `Button ${i + 1}`}</legend>
947
+ <RowTools i={i} count={buttons.length} onMove={(to) => set(move(buttons, i, to))} onRemove={() => set(buttons.filter((_, j) => j !== i))} />
948
+ <MiniField label="Label">
949
+ <input className="xeplr-factory-prop-input" value={b.label || ''} onChange={(e) => {
950
+ const label = e.target.value
951
+ const auto = !b.id || /^button-\d+$/.test(b.id) || b.id === slugify(b.label || '', '-')
952
+ const id = auto ? uniqueId(slugify(label, '-') || `button-${i + 1}`, takenIds(i)) : b.id
953
+ edit(i, { label, id })
954
+ }} />
955
+ </MiniField>
956
+ <MiniField label="Runs">
957
+ <select className="xeplr-factory-prop-input" value={b.action || ''} onChange={(e) => edit(i, { action: e.target.value, params: undefined })}>
958
+ <option value="">— choose a function —</option>
959
+ {b.action && !fn && <option value={b.action}>{b.action} (not registered)</option>}
960
+ {pick.map((f) => <option key={f.name} value={f.name}>{f.name}</option>)}
961
+ </select>
962
+ </MiniField>
963
+ {fn && fn.description && <div className="xeplr-factory-prop-help">{fn.description}</div>}
964
+ {schema.filter((f) => f.name !== NOTE_INPUT).map((f) => (
965
+ <MiniField key={f.name} label={(f.label || f.name) + (f.required && f.default === undefined ? ' *' : '')} help={f.description}>
966
+ <ParamInput field={f} value={b.params ? b.params[f.name] : undefined} onChange={(v) => {
967
+ const params = { ...(b.params || {}) }
968
+ if (v === undefined || v === '') delete params[f.name]; else params[f.name] = v
969
+ edit(i, { params: Object.keys(params).length ? params : undefined })
970
+ }} />
971
+ </MiniField>
972
+ ))}
973
+ <label className="xeplr-factory-toggle">
974
+ <input type="checkbox" checked={Boolean(b.ask)} onChange={(e) => edit(i, { ask: e.target.checked ? { title: `${b.label || 'Continue'}?` } : undefined })} />
975
+ <span>Ask first</span>
976
+ </label>
977
+ {b.ask && (
978
+ <>
979
+ <MiniField label="Question"><input className="xeplr-factory-prop-input" value={b.ask.title || ''} onChange={(e) => edit(i, { ask: clean({ ...b.ask, title: e.target.value }) })} /></MiniField>
980
+ <MiniField label="Explanation"><input className="xeplr-factory-prop-input" value={b.ask.message || ''} onChange={(e) => edit(i, { ask: clean({ ...b.ask, message: e.target.value }) })} /></MiniField>
981
+ {takesNote
982
+ ? <MiniField label="Note box label" help="Blank → no note box"><input className="xeplr-factory-prop-input" value={b.ask.note || ''} placeholder="Reason (optional)" onChange={(e) => edit(i, { ask: clean({ ...b.ask, note: e.target.value }) })} /></MiniField>
983
+ : fn && <div className="xeplr-factory-prop-help">{fn.name} takes no note.</div>}
984
+ </>
985
+ )}
986
+ <ConditionsEditor label="Shows when" value={b.when} onChange={(v) => edit(i, { when: v })} />
987
+ <ConditionsEditor label="Greyed out when" value={b.disabledWhen} onChange={(v) => edit(i, { disabledWhen: v })} />
988
+ <MiniField label="Look">
989
+ <select className="xeplr-factory-prop-input" value={b.variant || 'default'} onChange={(e) => edit(i, { variant: e.target.value === 'default' ? undefined : e.target.value })}>
990
+ {BUTTON_VARIANTS.map((v) => <option key={v} value={v}>{VARIANT_LABELS[v]}</option>)}
991
+ </select>
992
+ </MiniField>
993
+ </fieldset>
994
+ )
995
+ })}
996
+ <button type="button" className="xeplr-factory-secondary" onClick={add}>+ Button</button>
997
+ </div>
998
+ )
999
+ }
1000
+
1001
+ const VARIANT_LABELS = { default: 'Ordinary', primary: 'Main', danger: 'Undo / remove' }
1002
+
1003
+ /** One parameter's value: a dropdown when the function names the values allowed. */
1004
+ function ParamInput({ field, value, onChange }) {
1005
+ const allowed = optionValues(field.options)
1006
+ if (allowed.length) {
1007
+ const labelOf = (v) => {
1008
+ const o = (field.options || []).find((x) => x && typeof x === 'object' && x.value === v)
1009
+ return o && o.label ? o.label : String(v)
1010
+ }
1011
+ return (
1012
+ <select className="xeplr-factory-prop-input" value={value === undefined ? '' : String(value)} onChange={(e) => {
1013
+ const hit = allowed.find((v) => String(v) === e.target.value)
1014
+ onChange(hit === undefined ? undefined : hit)
1015
+ }}>
1016
+ <option value="">—</option>
1017
+ {allowed.map((v) => <option key={String(v)} value={String(v)}>{labelOf(v)}</option>)}
1018
+ </select>
1019
+ )
1020
+ }
1021
+ if (field.type === 'boolean') {
1022
+ return (
1023
+ <select className="xeplr-factory-prop-input" value={value === undefined ? '' : String(value)} onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value === 'true')}>
1024
+ <option value="">{field.default === undefined ? '—' : `Default (${field.default ? 'yes' : 'no'})`}</option>
1025
+ <option value="true">Yes</option>
1026
+ <option value="false">No</option>
1027
+ </select>
1028
+ )
1029
+ }
1030
+ if (field.type === 'number') {
1031
+ return <input type="number" className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
1032
+ }
1033
+ return <input className="xeplr-factory-prop-input" value={value ?? ''} onChange={(e) => onChange(e.target.value)} />
1034
+ }
1035
+
1036
+ /** Conditions on the record: every one must match. */
1037
+ function ConditionsEditor({ label, value, onChange }) {
1038
+ const rules = Array.isArray(value) ? value : []
1039
+ const set = (next) => onChange(next.length ? next : undefined)
1040
+ const edit = (i, patch) => set(rules.map((r, j) => {
1041
+ if (j !== i) return r
1042
+ const next = { ...r, ...patch }
1043
+ const op = CONDITION_OPERATORS.find((o) => o.key === next.op)
1044
+ if (op && op.noValue) { delete next.value; delete next.value2 }
1045
+ if (op && !op.second) delete next.value2
1046
+ return next
1047
+ }))
1048
+ return (
1049
+ <div className="xeplr-factory-conditions">
1050
+ <div className="xeplr-factory-prop-label">{label}{rules.length > 1 ? ' — all of these' : ''}</div>
1051
+ {rules.map((r, i) => {
1052
+ const op = CONDITION_OPERATORS.find((o) => o.key === r.op)
1053
+ return (
1054
+ <div key={i} className="xeplr-factory-rows-editor-row">
1055
+ <input className="xeplr-factory-prop-input" aria-label="Field" placeholder="customStatus" value={r.field || ''} onChange={(e) => edit(i, { field: e.target.value.trim() })} />
1056
+ <select className="xeplr-factory-prop-input" aria-label="Comparison" value={r.op || 'eq'} onChange={(e) => edit(i, { op: e.target.value })}>
1057
+ {CONDITION_OPERATORS.map((o) => <option key={o.key} value={o.key}>{o.label}</option>)}
1058
+ </select>
1059
+ {!(op && op.noValue) && <input className="xeplr-factory-prop-input" aria-label="Value" value={r.value ?? ''} onChange={(e) => edit(i, { value: e.target.value })} />}
1060
+ {op && op.second && <input className="xeplr-factory-prop-input" aria-label="And" value={r.value2 ?? ''} onChange={(e) => edit(i, { value2: e.target.value })} />}
1061
+ <button type="button" className="xeplr-factory-link-danger" aria-label="Remove condition" onClick={() => set(rules.filter((_, j) => j !== i))}>×</button>
1062
+ </div>
1063
+ )
1064
+ })}
1065
+ <button type="button" className="xeplr-factory-link" onClick={() => set([...rules, { field: '', op: 'eq', value: '' }])}>+ Condition</button>
1066
+ </div>
1067
+ )
1068
+ }
1069
+
1070
+ function MiniField({ label, help, children }) {
1071
+ return (
1072
+ <label className="xeplr-factory-mini">
1073
+ <span className="xeplr-factory-mini-label">{label}</span>
1074
+ {children}
1075
+ {help && <span className="xeplr-factory-prop-help">{help}</span>}
1076
+ </label>
1077
+ )
1078
+ }
1079
+
1080
+ function RowTools({ i, count, onMove, onRemove }) {
1081
+ return (
1082
+ <span className="xeplr-factory-rows-editor-tools">
1083
+ <button type="button" className="xeplr-factory-link" aria-label="Move up" disabled={i === 0} onClick={() => onMove(i - 1)}>↑</button>
1084
+ <button type="button" className="xeplr-factory-link" aria-label="Move down" disabled={i === count - 1} onClick={() => onMove(i + 1)}>↓</button>
1085
+ <button type="button" className="xeplr-factory-link-danger" aria-label="Remove" onClick={onRemove}>×</button>
1086
+ </span>
1087
+ )
1088
+ }
1089
+
1090
+ function move(list, from, to) {
1091
+ const next = list.slice()
1092
+ const [item] = next.splice(from, 1)
1093
+ next.splice(to, 0, item)
1094
+ return next
1095
+ }
1096
+
1097
+ function uniqueId(base, taken) {
1098
+ if (!taken.has(base)) return base
1099
+ let n = 2
1100
+ while (taken.has(`${base}-${n}`)) n += 1
1101
+ return `${base}-${n}`
1102
+ }
1103
+
1104
+ function clean(obj) {
1105
+ const out = {}
1106
+ Object.keys(obj).forEach((k) => { if (obj[k] !== undefined && obj[k] !== '') out[k] = obj[k] })
1107
+ return out
1108
+ }
1109
+
781
1110
  export const EDITORS = {
782
1111
  text: TextEditor,
783
1112
  textarea: TextareaEditor,
@@ -798,5 +1127,8 @@ export const EDITORS = {
798
1127
  columns: ColumnsEditor,
799
1128
  listTable: ListTableEditor,
800
1129
  actions: ActionsEditor,
801
- dataSource: DataSourceEditor
1130
+ dataSource: DataSourceEditor,
1131
+ function: FunctionEditor,
1132
+ filters: FiltersEditor,
1133
+ buttons: ButtonsEditor
802
1134
  }
@@ -183,6 +183,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
183
183
  // Nor a <form> of its own — one form inside another is not valid HTML.
184
184
  const Page = ctrl.embedded ? 'div' : 'form'
185
185
  const steppers = ctrl.steps.nodes
186
+ // A list that grows with its rows, or a panel of buttons (wrapping on a
187
+ // narrow screen) — its screen's one control: the page is as tall as it is,
188
+ // rather than it scrolling or spilling out of a fixed box.
189
+ const only = shown.length === 1 ? shown[0] : null
190
+ const grows = Boolean(only && ((only.type === 'list' && only.props && only.props.grow) || only.type === 'buttons'))
186
191
 
187
192
  return (
188
193
  <div
@@ -190,7 +195,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
190
195
  style={{ maxWidth: doc.width, '--xf-margin': `${MARGIN * 100}%`, ...screenStyle(doc), ...style }}
191
196
  >
192
197
  <Page
193
- className="xeplr-factory-page"
198
+ className={'xeplr-factory-page' + (grows ? ' xeplr-factory-page--grows' : '')}
194
199
  style={{ '--xf-page-h': `${pageHeight}cqw` }}
195
200
  // Never a submit: Enter posts nothing and saves nothing. Save is a button.
196
201
  {...(ctrl.embedded ? { role: 'group' } : { onSubmit: (e) => e.preventDefault(), noValidate: true })}
@@ -232,6 +237,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
232
237
  recordKey: ctrl.recordKey,
233
238
  canDelete: ctrl.canDelete,
234
239
  extraActions: ctrl.actionsFor ? ctrl.actionsFor(node) : [],
240
+ // Rows a function works out: what they are narrowed to, a
241
+ // cell's hover box, and a row opened at one part of it.
242
+ query: ctrl.listQuery,
243
+ rowDetails: ctrl.rowDetails,
244
+ onOpen: (rec, where) => ctrl.openEditor(node, rec, where),
235
245
  // A list with an edit screen opens it in a popup; one on a form
236
246
  // opens the row in the form's own fields.
237
247
  // A list on the form itself swaps the record in the fields — so
@@ -243,6 +253,7 @@ export default function ScreenSample({ ctrl, className, style, renderPopup, rend
243
253
  } : undefined}
244
254
  // A form inside this one: the page draws it (renderSubform).
245
255
  subform={node.type === 'subform' ? { sub: ctrl.subformFor(node), render: renderSubform } : undefined}
256
+ buttons={node.type === 'buttons' && ctrl.buttonsFor ? ctrl.buttonsFor(node) : undefined}
246
257
  />
247
258
  </div>
248
259
  ))}
@@ -70,6 +70,12 @@
70
70
  position: absolute; box-sizing: border-box;
71
71
  left: var(--xf-x); top: var(--xf-y); width: var(--xf-w); height: var(--xf-h);
72
72
  }
73
+ /* A list that grows with its rows (props.grow), or a panel of buttons — its
74
+ screen's one control: in the flow, where it was placed across, as tall as
75
+ it needs. */
76
+ .xeplr-factory-page--grows { height: auto; }
77
+ .xeplr-factory-page--grows > .xeplr-factory-node { position: static; margin: var(--xf-y) 0 0 var(--xf-x); height: auto; }
78
+ .xeplr-factory-page--grows .xeplr-factory-list { max-height: none; }
73
79
 
74
80
  .xeplr-factory-invalid {
75
81
  border: 1px solid var(--xeplr-danger, #c2372e); border-radius: 8px; padding: 12px 16px;
@@ -288,6 +294,104 @@
288
294
  .xeplr-factory-list .xeplr-table-row.xeplr-factory-row-open td,
289
295
  .xeplr-factory-list tr.xeplr-factory-row-open td { background: color-mix(in srgb, var(--xf-accent) 10%, transparent); }
290
296
 
297
+ /* ── a list whose rows a function works out ────────────────────────────────
298
+ Search and chips over the rows; every cell is text the server wrote. */
299
+ .xeplr-factory-list--fed { overflow: hidden; }
300
+ .xeplr-factory-list--fed .xeplr-table-container { border: none; border-radius: 0; background: transparent; }
301
+ .xeplr-factory-list-tools {
302
+ display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px;
303
+ border-bottom: 1px solid var(--xf-border);
304
+ }
305
+ .xeplr-factory-list-tools .xeplr-factory-list-title { flex: 0 1 auto; }
306
+ .xeplr-factory-list-search { flex: 1 1 220px; max-width: 360px; min-width: 0; }
307
+ .xeplr-factory-chips { display: flex; flex-wrap: wrap; gap: 6px; }
308
+ .xeplr-factory-chip {
309
+ display: inline-flex; align-items: center; gap: 6px;
310
+ font: inherit; font-size: .9em; padding: 4px 10px; border-radius: 999px; cursor: pointer; white-space: nowrap;
311
+ color: var(--xf-ink); background: transparent; border: 1px solid var(--xf-border);
312
+ }
313
+ .xeplr-factory-chip:hover { background: var(--xf-hover); }
314
+ .xeplr-factory-chip.is-on { color: var(--xf-accent-ink); background: var(--xf-accent); border-color: var(--xf-accent); }
315
+ .xeplr-factory-chip:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); }
316
+ .xeplr-factory-chip-count { font-size: .85em; opacity: .75; font-variant-numeric: tabular-nums; }
317
+ .xeplr-factory-fed-table.is-openable tbody tr { cursor: pointer; }
318
+ .xeplr-factory-list tr.xeplr-factory-row-marked td { background: color-mix(in srgb, var(--xf-accent) 6%, transparent); }
319
+ .xeplr-factory-list tr.xeplr-factory-row-marked td:first-child { box-shadow: inset 3px 0 0 var(--xf-accent); }
320
+ .xeplr-factory-cell { display: inline-flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; row-gap: 2px; min-width: 0; }
321
+ .xeplr-factory-cell-open { font: inherit; color: inherit; background: none; border: none; padding: 0; text-align: left; cursor: pointer; }
322
+ .xeplr-factory-cell-open .xeplr-factory-cell-main { font-weight: 600; color: var(--xf-accent); }
323
+ .xeplr-factory-cell-open:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); border-radius: 4px; }
324
+ .xeplr-factory-cell-sub { flex-basis: 100%; font-size: .88em; color: var(--xf-muted); }
325
+ .xeplr-factory-badge {
326
+ font-size: .75em; font-weight: 600; padding: 1px 7px; border-radius: 999px; white-space: nowrap;
327
+ color: var(--xf-accent); background: color-mix(in srgb, var(--xf-accent) 14%, transparent);
328
+ }
329
+ .xeplr-factory-badge--success { color: var(--xf-good); background: color-mix(in srgb, var(--xf-good) 14%, transparent); }
330
+ .xeplr-factory-badge--warning { color: var(--xf-warn); background: color-mix(in srgb, var(--xf-warn) 16%, transparent); }
331
+ .xeplr-factory-badge--danger { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 14%, transparent); }
332
+ .xeplr-factory-badge--muted { color: var(--xf-muted); background: var(--xf-hover); }
333
+ .xeplr-factory-thumbs { display: inline-flex; align-items: center; gap: 4px; }
334
+ .xeplr-factory-thumbs img { width: 36px; height: 36px; object-fit: cover; border-radius: 6px; border: 1px solid var(--xf-border); background: var(--xf-plane); }
335
+ .xeplr-factory-thumbs small { color: var(--xf-muted); }
336
+ .xeplr-factory-activity { max-width: 280px; white-space: normal; }
337
+ .xeplr-factory-activity[tabindex]:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); border-radius: 4px; }
338
+ /* At the page's root (a portal), so it reads the app's theme directly. */
339
+ .xeplr-factory-activity-box {
340
+ position: fixed; z-index: 1200; box-sizing: border-box; overflow-y: auto; padding: 6px;
341
+ max-width: calc(100vw - 16px);
342
+ font-size: 13px; color: var(--xeplr-text-primary, #1a1a19);
343
+ background: var(--xeplr-bg-tertiary, #ffffff); border: 1px solid var(--xeplr-border-primary, #d9d8d0);
344
+ border-radius: 10px; box-shadow: 0 12px 32px rgba(0, 0, 0, .22);
345
+ }
346
+ .xeplr-factory-activity-box .xeplr-factory-hint { padding: 8px; }
347
+ .xeplr-factory-activity-item {
348
+ display: flex; flex-direction: column; gap: 2px; width: 100%; box-sizing: border-box;
349
+ padding: 7px 8px; border-radius: 6px; font: inherit; color: inherit; text-align: left; background: none; border: none;
350
+ }
351
+ button.xeplr-factory-activity-item { cursor: pointer; }
352
+ button.xeplr-factory-activity-item:hover, button.xeplr-factory-activity-item:focus-visible { outline: none; background: var(--xeplr-bg-hover, #f0f0ee); }
353
+ .xeplr-factory-activity-sub { font-size: .9em; color: var(--xeplr-text-secondary, #6b6a63); }
354
+
355
+ /* ── a panel of buttons ─────────────────────────────────────────────────── */
356
+ .xeplr-factory-buttons {
357
+ display: flex; flex-wrap: wrap; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;
358
+ font-size: max(10px, calc(var(--xf-font, 14) * var(--xf-u)));
359
+ }
360
+ .xeplr-factory-buttons-title { font-weight: 600; color: var(--xf-muted); margin-right: 4px; }
361
+ .xeplr-factory-button {
362
+ font: inherit; font-weight: 600; font-size: .92em; padding: 6px 13px; border-radius: 6px; cursor: pointer; white-space: nowrap;
363
+ color: var(--xf-ink); background: var(--xf-surface); border: 1px solid var(--xf-border);
364
+ transition: background .12s, transform .08s, box-shadow .12s;
365
+ }
366
+ .xeplr-factory-button:hover:not(:disabled) { background: var(--xf-hover); }
367
+ .xeplr-factory-button:active:not(:disabled) { transform: scale(.96); }
368
+ .xeplr-factory-button:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 35%, transparent); }
369
+ .xeplr-factory-button:disabled { opacity: .5; cursor: default; }
370
+ .xeplr-factory-button--primary { color: var(--xf-accent-ink); background: var(--xf-accent); border-color: var(--xf-accent); }
371
+ .xeplr-factory-button--primary:hover:not(:disabled) { background: var(--xf-accent); filter: brightness(1.08); }
372
+ .xeplr-factory-button--danger { color: var(--xf-danger); border-color: color-mix(in srgb, var(--xf-danger) 55%, var(--xf-border)); }
373
+ .xeplr-factory-button--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
374
+ .xeplr-factory-buttons--design .xeplr-factory-button { cursor: inherit; }
375
+ .xeplr-factory-buttons-note {
376
+ display: inline-flex; align-items: center; gap: 6px; font-size: .9em; padding: 4px 6px 4px 10px; border-radius: 6px;
377
+ color: var(--xf-good); background: color-mix(in srgb, var(--xf-good) 10%, transparent);
378
+ }
379
+ .xeplr-factory-buttons-note--error { color: var(--xf-danger); background: color-mix(in srgb, var(--xf-danger) 10%, transparent); }
380
+ .xeplr-factory-buttons-note-close { font: inherit; font-size: 1.1em; line-height: 1; color: inherit; background: none; border: none; cursor: pointer; padding: 2px 4px; }
381
+ .xeplr-factory-ask { --xf-modal-w: min(440px, 96vw); }
382
+ .xeplr-factory-ask .xeplr-factory-modal-body { padding: 4px 18px 8px; display: flex; flex-direction: column; gap: 10px; }
383
+ .xeplr-factory-ask-message { margin: 0; color: var(--xf-muted); }
384
+ .xeplr-factory-ask-field { display: flex; flex-direction: column; gap: 4px; }
385
+ .xeplr-factory-ask-label { font-weight: 600; font-size: .92em; }
386
+ .xeplr-factory-ask-note, .xeplr-factory-list-search {
387
+ box-sizing: border-box; width: 100%; font: inherit; padding: 7px 10px; border-radius: 6px;
388
+ color: var(--xf-ink); background: var(--xf-field-bg); border: 1px solid var(--xf-field-border);
389
+ }
390
+ .xeplr-factory-ask-note { resize: vertical; min-height: 80px; }
391
+ .xeplr-factory-ask-note:focus, .xeplr-factory-list-search:focus { outline: none; border-color: var(--xf-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--xf-accent) 25%, transparent); }
392
+ .xeplr-factory-ask-error { margin: 0; color: var(--xf-danger); }
393
+ .xeplr-factory-ask-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px; }
394
+
291
395
  /* ── the builder ────────────────────────────────────────────────────────── */
292
396
  .xeplr-factory-builder {
293
397
  display: flex; flex-direction: column; height: 100%; min-height: 560px;
@@ -397,6 +501,27 @@
397
501
  .xeplr-factory-panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
398
502
  .xeplr-factory-panel-type { font-weight: 700; font-size: 14px; }
399
503
  .xeplr-factory-link-danger { background: none; border: none; color: var(--xf-danger); cursor: pointer; font: inherit; padding: 2px 4px; }
504
+ .xeplr-factory-link { background: none; border: none; color: var(--xf-accent); cursor: pointer; font: inherit; padding: 2px 4px; }
505
+ .xeplr-factory-link:disabled { color: var(--xf-muted); opacity: .5; cursor: default; }
506
+ /* Lists of things edited in place: chips, columns, buttons, conditions. */
507
+ .xeplr-factory-rows-editor { display: flex; flex-direction: column; gap: 8px; }
508
+ .xeplr-factory-rows-editor > .xeplr-factory-secondary { align-self: flex-start; padding: 4px 10px; }
509
+ .xeplr-factory-rows-editor-row { display: flex; align-items: center; gap: 4px; }
510
+ .xeplr-factory-rows-editor-row .xeplr-factory-prop-input { flex: 1 1 0; min-width: 0; }
511
+ .xeplr-factory-prop-input--key { flex: 0 1 38% !important; font-family: ui-monospace, monospace; font-size: 11px; }
512
+ .xeplr-factory-rows-editor-card {
513
+ position: relative; display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 8px 10px 10px;
514
+ border: 1px solid var(--xf-border); border-radius: 8px;
515
+ }
516
+ .xeplr-factory-rows-editor-card > legend { font-weight: 600; font-size: 12px; padding: 0 4px; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
517
+ .xeplr-factory-rows-editor-card > .xeplr-factory-rows-editor-tools { position: absolute; top: -9px; right: 6px; background: var(--xf-surface); padding: 0 2px; }
518
+ .xeplr-factory-mini { display: flex; flex-direction: column; gap: 2px; }
519
+ .xeplr-factory-mini-label { font-size: 11px; font-weight: 600; color: var(--xf-muted); }
520
+ .xeplr-factory-conditions { display: flex; flex-direction: column; gap: 4px; }
521
+ .xeplr-factory-conditions > .xeplr-factory-link { align-self: flex-start; padding-left: 0; }
522
+ /* The field on a line of its own (names are long); the comparison and value under it. */
523
+ .xeplr-factory-conditions .xeplr-factory-rows-editor-row { flex-wrap: wrap; padding-bottom: 6px; border-bottom: 1px dashed var(--xf-border); }
524
+ .xeplr-factory-conditions .xeplr-factory-rows-editor-row > input:first-child { flex: 1 1 100%; }
400
525
  .xeplr-factory-group { border: none; border-top: 1px solid var(--xf-border); margin: 0; padding: 10px 0 4px; }
401
526
  .xeplr-factory-group legend { padding: 0 4px 0 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--xf-muted); }
402
527
  .xeplr-factory-prop { margin-bottom: 10px; }
@@ -703,3 +828,17 @@
703
828
  .xeplr-factory-modal-body { padding: 0; }
704
829
  .xeplr-factory-modal-close { min-width: 44px; min-height: 44px; }
705
830
  }
831
+
832
+ /* Function-fed lists and buttons on a phone: the search takes the width, chips
833
+ scroll in a row, and every button is a thumb-sized target. */
834
+ @media (max-width: 640px) {
835
+ .xeplr-factory-list-search, .xeplr-factory-ask-note { font-size: max(16px, 1em); min-height: 44px; }
836
+ .xeplr-factory-list-search { flex-basis: 100%; max-width: none; }
837
+ .xeplr-factory-ask-foot button { flex: 1; min-height: 44px; }
838
+ .xeplr-factory-chips { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; padding-bottom: 2px; }
839
+ .xeplr-factory-chip { min-height: 36px; }
840
+ .xeplr-factory-button { min-height: 44px; flex: 1 1 auto; }
841
+ .xeplr-factory-buttons-note { flex-basis: 100%; }
842
+ .xeplr-factory-ask .xeplr-factory-modal-body { padding: 4px 16px 8px; }
843
+ .xeplr-factory-ask { min-height: 0; align-self: flex-end; }
844
+ }
package/src/events.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // What each is called with, and what it may return, is the contract a
14
14
  // developer writes to. `@xeplr/factory` runs them; the designer lists them.
15
15
 
16
+ import { nodeFunctions, paramProblems } from './buttons.js'
17
+
16
18
  export const SCREEN_EVENTS = [
17
19
  {
18
20
  key: 'beforeLoad',
@@ -83,6 +85,25 @@ export function missingFunctions(doc, registered) {
83
85
  })
84
86
  }
85
87
 
88
+ /**
89
+ * EVERYTHING THAT STOPS A SCREEN RUNNING ITS FUNCTIONS — its events, and the
90
+ * functions its controls name (a list's rows, a column's hover box, each
91
+ * button), plus each button's parameters against what its function takes.
92
+ * `registered` is the registry's list ([{ name, event, inputSchema }]).
93
+ * What publishing refuses on. Empty when the screen can run.
94
+ */
95
+ export function functionProblems(doc, registered) {
96
+ const byName = new Map((registered || []).map((f) => [f.name, f]))
97
+ const fromNodes = nodeFunctions(doc).flatMap(({ event, name, at, button }) => {
98
+ const f = byName.get(name)
99
+ if (!f) return [{ event, name, at, problem: `"${name}" is not a registered function` }]
100
+ if (f.event !== event) return [{ event, name, at, problem: `"${name}" is written for ${f.event ? `"${f.event}"` : 'a workflow step'}, not "${event}"` }]
101
+ if (!button) return []
102
+ return paramProblems(button, f.inputSchema).map((p) => ({ event, name, at, problem: `button "${button.label || button.id}": "${name}" ${p}` }))
103
+ })
104
+ return [...missingFunctions(doc, registered), ...fromNodes]
105
+ }
106
+
86
107
  /** The screen with one event set to a function — or cleared, given nothing. */
87
108
  export function setEvent(doc, key, name) {
88
109
  if (!SCREEN_EVENT_KEYS.includes(key)) throw new Error(`"${key}" is not an event — one of: ${SCREEN_EVENT_KEYS.join(', ')}`)
package/src/model.js CHANGED
@@ -13,7 +13,14 @@ export {
13
13
  } from './document.js'
14
14
  export { validateDocument, assertValidDocument } from './validateDocument.js'
15
15
  // A screen's events: functions picked by name, run on the server.
16
- export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, setEvent, functionsFor } from './events.js'
16
+ export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, functionProblems, setEvent, functionsFor } from './events.js'
17
+ // Buttons and lists whose rows a function works out — names and values only.
18
+ export {
19
+ BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, CONDITION_OPERATORS,
20
+ conditionMet, buttonShown, buttonDisabled, screenButtons, buttonById, buttonInput, nodeFunctions, paramProblems, optionValues
21
+ } from './buttons.js'
22
+ // Which pages are drawn from documents and which are web only (ui/pages.json).
23
+ export { checkInventory, routesIn, pageWarnings, webOnlyMenus } from './pagesInventory.js'
17
24
  export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
18
25
  export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
19
26
  export { scaffoldEntity } from './scaffold.js'