@xeplr/ui-factory 1.8.5 → 1.8.7

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.
@@ -0,0 +1,85 @@
1
+ // A FUNCTION-FED LIST'S DATE RANGE AND KPI CARDS — the parts of a dashboard.
2
+ //
3
+ // "dateRange": { "label": "Placed", "default": "last30" },
4
+ // "kpis": [{ "key": "ordered", "label": "Not started" }, { "key": "open", "label": "Open" }, …]
5
+ //
6
+ // The range is one value, kept in the URL as it was chosen — a preset
7
+ // ("last30") or two days ("2026-10-01..2026-10-31") — and turned into the
8
+ // two days the function is asked for (`from`, `to`, both included) on the day
9
+ // it is asked: "last 30 days" opened tomorrow is tomorrow's last 30 days.
10
+ //
11
+ // A KPI card is a count the function works out for the range, and a filter:
12
+ // clicking one asks for its rows (`kpi`), clicking it again lets go. What
13
+ // each card counts is the function's to decide — the document names them.
14
+ //
15
+ // Pure: no React, no requests.
16
+
17
+ /** The ranges a person can pick without typing two dates. */
18
+ export const DATE_PRESETS = [
19
+ { value: 'today', label: 'Today' },
20
+ { value: 'yesterday', label: 'Yesterday' },
21
+ { value: 'last7', label: 'Last 7 days' },
22
+ { value: 'last30', label: 'Last 30 days' },
23
+ { value: 'thisMonth', label: 'This month' },
24
+ { value: 'lastMonth', label: 'Last month' },
25
+ { value: 'all', label: 'All time' }
26
+ ]
27
+
28
+ export const DATE_PRESET_VALUES = DATE_PRESETS.map((p) => p.value)
29
+
30
+ const DAY = /^\d{4}-\d{2}-\d{2}$/
31
+
32
+ /** Whether a string is a real day, "YYYY-MM-DD". */
33
+ export function isDay(s) {
34
+ if (typeof s !== 'string' || !DAY.test(s)) return false
35
+ const d = new Date(s + 'T00:00:00Z')
36
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s
37
+ }
38
+
39
+ /** A Date's own calendar day where it is — "YYYY-MM-DD". */
40
+ export function dayOf(date) {
41
+ const d = date instanceof Date ? date : new Date(date)
42
+ const pad = (n) => String(n).padStart(2, '0')
43
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
44
+ }
45
+
46
+ function addDays(day, n) {
47
+ const d = new Date(day + 'T00:00:00Z')
48
+ d.setUTCDate(d.getUTCDate() + n)
49
+ return d.toISOString().slice(0, 10)
50
+ }
51
+
52
+ /**
53
+ * A range as it is kept → the days it means: { from, to, preset } — from/to
54
+ * empty for "all time". `today` is "YYYY-MM-DD" (the caller's own day).
55
+ * Anything unreadable falls back to `fallback` (the list's default), so an old
56
+ * or mistyped link opens the usual range rather than an error.
57
+ */
58
+ export function resolveRange(value, today, fallback = 'all') {
59
+ const v = typeof value === 'string' ? value : ''
60
+ if (v.includes('..')) {
61
+ const [from, to] = v.split('..')
62
+ if ((from === '' || isDay(from)) && (to === '' || isDay(to)) && !(from && to && from > to)) return { from, to, preset: null }
63
+ return resolveRange(fallback, today, 'all')
64
+ }
65
+ const month = today.slice(0, 7)
66
+ switch (v) {
67
+ case 'today': return { from: today, to: today, preset: v }
68
+ case 'yesterday': { const y = addDays(today, -1); return { from: y, to: y, preset: v } }
69
+ case 'last7': return { from: addDays(today, -6), to: today, preset: v }
70
+ case 'last30': return { from: addDays(today, -29), to: today, preset: v }
71
+ case 'thisMonth': return { from: month + '-01', to: today, preset: v }
72
+ case 'lastMonth': {
73
+ const firstThis = month + '-01'
74
+ const lastPrev = addDays(firstThis, -1)
75
+ return { from: lastPrev.slice(0, 7) + '-01', to: lastPrev, preset: v }
76
+ }
77
+ case 'all': return { from: '', to: '', preset: 'all' }
78
+ default: return v === fallback ? { from: '', to: '', preset: 'all' } : resolveRange(fallback, today, 'all')
79
+ }
80
+ }
81
+
82
+ /** Two days typed → how the range is kept. */
83
+ export function customRange(from, to) {
84
+ return `${from || ''}..${to || ''}`
85
+ }
package/src/model.js CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // import { screenFromSpec, validateDocument, formSchema } from '@xeplr/ui-factory/model'
5
5
 
6
- export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, acceptList, controlGroups, withControls } from './controls.js'
6
+ export { CONTROLS, OPTION_ID, OPTION_LABEL, LABEL_VARIANTS, LABEL_PRESETS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, FONT_FAMILIES, LAYOUTS, CHOICE_TYPES, STEP_LIMIT, MULTI_SEPARATOR, FILE_KINDS, MAX_FILE_MB, IMAGE_EXTENSIONS, FILE_SOURCES, MAX_FILES, isImageFile, filePicker, acceptList, controlGroups, withControls } from './controls.js'
7
7
  export {
8
8
  DOCUMENT_KIND, DOCUMENT_VERSION, DEFAULT_ASPECT, DEFAULT_WIDTH, MARGIN, GAP,
9
9
  createScreen, renameScreen, setScreenProperty, addControl, moveNode, setNodeProperty, getNodeProperty, removeNodes,
@@ -16,12 +16,12 @@ export { validateDocument, assertValidDocument } from './validateDocument.js'
16
16
  export { SCREEN_EVENTS, SCREEN_EVENT_KEYS, DECIDE_EVENT, FUNCTION_NAME, screenFunctions, missingFunctions, functionProblems, setEvent, functionsFor } from './events.js'
17
17
  // Buttons and lists whose rows a function works out — names and values only.
18
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, buttonPressed, screenButtons, buttonById, buttonInput, nodeFunctions, paramProblems, optionValues
19
+ BUTTON_EVENT, LIST_ROWS_EVENT, ROW_DETAILS_EVENT, BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, NOTE_INPUT, FILES_INPUT, CONDITION_OPERATORS,
20
+ conditionMet, buttonShown, buttonDisabled, buttonPressed, screenButtons, buttonById, buttonInput, askFile, askFileProblem, askFields, askFieldStart, askFieldValues, ASK_FIELD_TYPES, nodeFunctions, paramProblems, optionValues
21
21
  } from './buttons.js'
22
22
  // Which pages are drawn from documents and which are web only (ui/pages.json).
23
23
  export { checkInventory, routesIn, pageWarnings, webOnlyMenus } from './pagesInventory.js'
24
- export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
24
+ export { formSchema, initialValues, parseInput, validateValues, fieldError, sameId, optionValue, chooseable, saveState, recordValues, displayValue, fileLabel, isMultiFile, toDbValue, fromDbValue, listColumns, listSource } from './values.js'
25
25
  export { screenFromSpec, screensFromSpec, entityNames, SPEC_HEIGHTS, ROW_GAP } from './generate.js'
26
26
  export { scaffoldEntity } from './scaffold.js'
27
27
  export { getAtPath, setAtPath } from './propertyPath.js'
@@ -43,3 +43,5 @@ export {
43
43
  addTransition, setTransition, removeTransition, firstStep, reachableSteps, layoutFlow, validateFlow, describeWhen,
44
44
  arrowTargets, navigationOf, setNavigation
45
45
  } from './flow.js'
46
+ // A function-fed list's date range: presets → days, and what a day is.
47
+ export { DATE_PRESETS, DATE_PRESET_VALUES, resolveRange, customRange, isDay, dayOf } from './listRange.js'
package/src/pages.jsx CHANGED
@@ -65,6 +65,8 @@ export function FactoryBuilder(props) {
65
65
  * @prop fetchRecord async ({ screen, id }) → record — Edit loads the record fresh
66
66
  * @prop onDelete async ({ id, source, record }) → void
67
67
  * @prop onChange (values) → void
68
+ * @prop uploadFile async (file, { screen, field }) → { path } — a file field's upload
69
+ * @prop readFile async (path) → Blob — an attached file's bytes, for a picture's preview
68
70
  * @prop screens { id → document } — the screens a list's Edit / New open in a popup
69
71
  * @prop loadScreen async (id) → document — for screens not in `screens`
70
72
  * @prop controls control registry (default: built-ins)
@@ -107,6 +109,7 @@ export function FactoryScreen(props) {
107
109
  onDelete={props.onDelete}
108
110
  uploadFile={props.uploadFile}
109
111
  fileUrl={props.fileUrl}
112
+ readFile={props.readFile}
110
113
  screens={props.screens}
111
114
  loadScreen={props.loadScreen}
112
115
  controls={props.controls}
@@ -138,6 +141,10 @@ export function FactoryScreen(props) {
138
141
  fetchRecord={props.fetchRecord}
139
142
  fetchRecords={props.fetchRecords}
140
143
  onDelete={props.onDelete}
144
+ // A file field in the popup uploads like one on the page.
145
+ uploadFile={props.uploadFile}
146
+ fileUrl={props.fileUrl}
147
+ readFile={props.readFile}
141
148
  screens={props.screens}
142
149
  loadScreen={props.loadScreen}
143
150
  controls={props.controls}
package/src/remote.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // in the app's database; this only sends and receives.
4
4
  //
5
5
  // import { authFetch } from '@xeplr/ui-account'
6
- // const factory = createFactoryApi({ fetch: authFetch })
6
+ // const factory = createFactoryApi({ fetch: authFetch, fetchFile: authFetchFile })
7
7
  // <FactoryScreen document={screen} {...factory.screenProps} />
8
8
  // <FactoryBuilder document={screen} {...factory.builderProps} lockedNames={…} />
9
9
  //
@@ -12,10 +12,13 @@
12
12
  /**
13
13
  * @param options.fetch a fetch that adds auth and tenant headers (e.g. @xeplr/ui-account's authFetch)
14
14
  * @param options.base prefix before /factory, if the router is mounted elsewhere (default '')
15
+ * @param options.fetchFile (url) → Blob, signed like `fetch` (@xeplr/ui-account's authFetchFile):
16
+ * how an attached picture is read back for its preview. Without it
17
+ * a file field shows names only — an <img> cannot send the token.
15
18
  */
16
19
  import { createChildTable } from './masters.js'
17
20
 
18
- export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
21
+ export function createFactoryApi({ fetch: doFetch, fetchFile, base = '' } = {}) {
19
22
  if (typeof doFetch !== 'function') throw new Error('createFactoryApi: pass { fetch } — usually authFetch from @xeplr/ui-account')
20
23
 
21
24
  // Two kinds of fetch are accepted, because both are what apps have:
@@ -129,13 +132,23 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
129
132
 
130
133
  // ROWS A FUNCTION WORKS OUT, for a list with rowsFrom: { rows, counts }.
131
134
  // Only what the person typed goes with it — the function is the screen's.
132
- fetchRows: async ({ screen, node, q, filter }) => one(await call('GET', `/rows/${enc(screen)}${query({ node, q, filter })}`)),
135
+ fetchRows: async ({ screen, node, q, filter, kpi, from, to }) => one(await call('GET', `/rows/${enc(screen)}${query({ node, q, filter, kpi, from, to })}`)),
133
136
  // An activity cell's hover box for one row.
134
137
  fetchRowDetails: ({ screen, node, column, id }) => call('GET', `/rows/${enc(screen)}/details${query({ node, column, id })}`),
135
138
  // A button's press on one record → { button, message, output }. The
136
139
  // server reads the button — its function and parameters — from the
137
140
  // published screen; the request names it and carries the note.
138
- pressButton: async ({ screen, button, id, note }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, note ? { id, note } : { id })),
141
+ pressButton: async ({ screen, button, id, note, files, values }) => one(await call('POST', `/buttons/${enc(screen)}/${enc(button)}`, {
142
+ id, ...(note ? { note } : {}), ...(Array.isArray(files) && files.length ? { files } : {}),
143
+ ...(values && Object.keys(values).length ? { values } : {})
144
+ })),
145
+ // A file a button's ask.file takes, stored before the press; the press
146
+ // then names it by the path this answers with.
147
+ uploadButtonFile: async (file, { screen, button }) => {
148
+ const form = new FormData()
149
+ form.append('file', file)
150
+ return one(await send('POST', `/buttons/${enc(screen)}/${enc(button)}/files`, form))
151
+ },
139
152
 
140
153
  // A file field's upload. The answer's `path` is what the record stores;
141
154
  // the file itself is read back through the same route, with auth.
@@ -144,10 +157,12 @@ export function createFactoryApi({ fetch: doFetch, base = '' } = {}) {
144
157
  form.append('file', file)
145
158
  return one(await send('POST', `/files/${enc(screen)}/${enc(field)}`, form))
146
159
  },
147
- fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`
160
+ fileUrl: (path) => `${base}/factory/files/${String(path).split('/').map(enc).join('/')}`,
161
+ // An attached file's bytes (a picture's preview), when the host said how.
162
+ readFile: typeof fetchFile === 'function' ? (path) => fetchFile(api.fileUrl(path)) : undefined
148
163
  }
149
- api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl,
150
- fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton }
164
+ api.screenProps = { loadScreen: api.loadScreen, onSave: api.onSave, fetchRecords: api.fetchRecords, fetchRecord: api.fetchRecord, onDelete: api.onDelete, fetchOptions: api.fetchOptions, uploadFile: api.uploadFile, fileUrl: api.fileUrl, readFile: api.readFile,
165
+ fetchRows: api.fetchRows, fetchRowDetails: api.fetchRowDetails, pressButton: api.pressButton, uploadButtonFile: api.uploadButtonFile }
151
166
  // makeChildTable: a list on a form can make the table it is about to point
152
167
  // at — rows belonging to this record — without leaving the designer. A
153
168
  // server act, which is why the builder is given it rather than doing it.
@@ -88,8 +88,10 @@ export function columnForField(node) {
88
88
  case 'datetime':
89
89
  return { ...base, type: 'timestamp' }
90
90
  case 'file':
91
- // The stored file's path, as the upload answered with it.
92
- return { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
91
+ // The stored file's path, as the upload answered with it — several of
92
+ // them, comma separated, in a text column (varchar → text is a widening,
93
+ // so turning "several files" on keeps what is saved).
94
+ return p.multiple ? { ...base, type: 'text' } : { ...base, type: 'varchar', length: FILE_PATH_LENGTH }
93
95
  case 'multiselect':
94
96
  // Every chosen id in one text column, separated by commas.
95
97
  return { ...base, type: 'text' }
package/src/urlState.js CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  /**
14
14
  * The query keys under a host's `urlKey`, or null (in memory).
15
- * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab', q: 'formQ', filter: 'formFilter' }
15
+ * factoryUrlKeys('form') → { steps: 'formSteps', selected: 'formSelected', tab: 'formTab', q: 'formQ', filter: 'formFilter', kpi: 'formKpi', dates: 'formDates' }
16
16
  */
17
17
  export function factoryUrlKeys(urlKey) {
18
18
  if (!urlKey) return null
19
- return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab`, q: `${urlKey}Q`, filter: `${urlKey}Filter` }
19
+ return { steps: `${urlKey}Steps`, selected: `${urlKey}Selected`, tab: `${urlKey}Tab`, q: `${urlKey}Q`, filter: `${urlKey}Filter`, kpi: `${urlKey}Kpi`, dates: `${urlKey}Dates` }
20
20
  }
21
21
 
22
22
  /**
@@ -6,6 +6,7 @@ import { initialValues, parseInput, saveState, fieldError, optionValue, recordVa
6
6
  import { hookMethod } from './hooks.js'
7
7
  import { useUrlSteps, useUrlListQuery } from './useUrlPlaces.js'
8
8
  import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
9
+ import { resolveRange, dayOf } from './listRange.js'
9
10
 
10
11
  // A saved screen, running. No JSX — designs/ScreenSample.jsx draws it.
11
12
  //
@@ -59,9 +60,11 @@ import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
59
60
  * @param onDirtyChange (dirty) → void — whether there are unsaved changes, as that changes
60
61
  * @param controls the control registry (default: the built-ins)
61
62
  * @param hooks a FactoryHooks (or an object with some of its methods) — get / save / delete / actions
62
- * @param fetchRows async ({ screen, node, q, filter }) → { rows, counts } — a list whose rows a function works out (rowsFrom)
63
+ * @param fetchRows async ({ screen, node, q, filter, kpi, from, to }) → { rows, counts, kpis } — a list whose rows a function works out (rowsFrom)
63
64
  * @param fetchRowDetails async ({ screen, node, column, id }) → [{ text, sub?, at?, open? }] — an activity cell's hover box
64
- * @param pressButton async ({ screen, button, id, note }) → { message } — a button's press, run on the server
65
+ * @param pressButton async ({ screen, button, id, note, files, values }) → { message } — a button's press, run on the server
66
+ * @param uploadButtonFile async (file, { screen, button }) → { path } — a file a button's `ask.file` takes, stored before the press
67
+ * @param readFile async (path) → Blob — an attached file's bytes: a picture's preview (no preview without it)
65
68
  * @param onButtonDone ({ button, result, record }) → void — after a press: the host reloads what it shows
66
69
  * @param reloadKey anything — when it changes, every list reloads (the host heard something changed)
67
70
  * @param urlKey where the open step of each stepper lives in the URL (`<urlKey>Steps`),
@@ -82,9 +85,9 @@ import { buttonShown, buttonDisabled, buttonPressed } from './buttons.js'
82
85
  const SEARCH_PAUSE_MS = 250
83
86
 
84
87
  export function useFactoryScreen({
85
- document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl,
88
+ document: doc, record, recordKey = 'id', onSave, fetchOptions, fetchRecords, fetchRecord, onDelete, onChange, screens, loadScreen, hooks, uploadFile, fileUrl, readFile,
86
89
  onOpenRecord, onDone, onDirtyChange, params, urlKey, previous,
87
- fetchRows, fetchRowDetails, pressButton, onButtonDone, reloadKey,
90
+ fetchRows, fetchRowDetails, pressButton, uploadButtonFile, onButtonDone, reloadKey,
88
91
  link, onReady,
89
92
  controls = CONTROLS
90
93
  } = {}) {
@@ -610,6 +613,15 @@ export function useFactoryScreen({
610
613
  // rows. The search waits for a pause in typing before it asks.
611
614
  const [listQ, setListQ] = useUrlListQuery(urlKey, 'q')
612
615
  const [listFilter, setListFilter] = useUrlListQuery(urlKey, 'filter')
616
+ // A dashboard's KPI card (clicked: its rows) and date range (a preset, or
617
+ // "from..to"), in the URL too. The range is turned into days when asked.
618
+ const [listKpi, setListKpi] = useUrlListQuery(urlKey, 'kpi')
619
+ const [listDates, setListDates] = useUrlListQuery(urlKey, 'dates')
620
+ const rangeFor = useCallback((node) => {
621
+ const r = node && node.props && node.props.dateRange
622
+ if (!r) return null
623
+ return resolveRange(listDates || r.default || 'all', dayOf(new Date()), r.default || 'all')
624
+ }, [listDates])
613
625
  const [askedQ, setAskedQ] = useState(listQ)
614
626
  useEffect(() => {
615
627
  if (askedQ === listQ) return undefined
@@ -633,14 +645,21 @@ export function useFactoryScreen({
633
645
  const run = Promise.resolve().then(() => hook('get')({
634
646
  many: true, id: null, screen: doc.id, source: src, node, document: doc,
635
647
  steps: live.current.steps,
636
- query: fed ? { q: askedQ, filter: listFilter } : undefined,
648
+ query: fed ? { q: askedQ, filter: listFilter, kpi: listKpi, ...(rangeFor(node) || {}) } : undefined,
637
649
  defaults: {
638
650
  get: () => {
639
651
  // ROWS A FUNCTION WORKS OUT: the server runs the list's rowsFrom
640
652
  // with the search and the chip, and answers { rows, counts }.
641
653
  if (fed) {
642
654
  if (!fetchRowsRef.current) throw new Error(`No fetchRows was provided — the list's rows come from "${node.props.rowsFrom}"`)
643
- return fetchRowsRef.current({ screen: doc.id, node: node.id, q: askedQ, filter: listFilter || undefined })
655
+ const range = rangeFor(node)
656
+ const kpis = Array.isArray(node.props.kpis) ? node.props.kpis : []
657
+ return fetchRowsRef.current({
658
+ screen: doc.id, node: node.id, q: askedQ, filter: listFilter || undefined,
659
+ kpi: kpis.some((k) => k.key === listKpi) ? listKpi : undefined,
660
+ from: range && range.from ? range.from : undefined,
661
+ to: range && range.to ? range.to : undefined
662
+ })
644
663
  }
645
664
  if (!fetchRecordsRef.current) throw new Error(`No fetchRecords was provided to list "${src}"`)
646
665
  // A SUB-TABLE IS THIS RECORD'S. `belongsTo` names the column on
@@ -661,15 +680,21 @@ export function useFactoryScreen({
661
680
  if (cancelled) return
662
681
  const rows = Array.isArray(out) ? out : out && Array.isArray(out.rows) ? out.rows : []
663
682
  const counts = out && !Array.isArray(out) && out.counts ? out.counts : {}
664
- setLists((l) => ({ ...l, [node.id]: { rows, counts, loading: false, error: null } }))
683
+ const kpis = out && !Array.isArray(out) && out.kpis ? out.kpis : {}
684
+ setLists((l) => ({ ...l, [node.id]: { rows, counts, kpis, loading: false, error: null } }))
665
685
  })
666
686
  .catch((err) => { if (!cancelled) setLists((l) => ({ ...l, [node.id]: { rows: l[node.id]?.rows || [], counts: l[node.id]?.counts || {}, loading: false, error: err.message || 'Could not load records' } })) })
667
687
  })
668
688
  return () => { cancelled = true }
669
- }, [listNodes, listVersion, doc, askedQ, listFilter, reloadKey]) // eslint-disable-line react-hooks/exhaustive-deps
689
+ }, [listNodes, listVersion, doc, askedQ, listFilter, listKpi, rangeFor, reloadKey]) // eslint-disable-line react-hooks/exhaustive-deps
670
690
 
671
691
  /** What a function-fed list is narrowed to, and how to change it. */
672
- const listQuery = useMemo(() => ({ q: listQ, filter: listFilter, setQ: setListQ, setFilter: setListFilter }), [listQ, listFilter, setListQ, setListFilter])
692
+ const listQuery = useMemo(() => ({
693
+ q: listQ, filter: listFilter, setQ: setListQ, setFilter: setListFilter,
694
+ kpi: listKpi, setKpi: setListKpi,
695
+ // The range a list shows (its own default until one is chosen), and choosing one.
696
+ rangeFor, setDates: setListDates
697
+ }), [listQ, listFilter, setListQ, setListFilter, listKpi, setListKpi, rangeFor, setListDates])
673
698
 
674
699
  /** An activity cell's hover box: the column's function, for one row. */
675
700
  const fetchRowDetailsRef = useRef(fetchRowDetails); fetchRowDetailsRef.current = fetchRowDetails
@@ -830,6 +855,7 @@ export function useFactoryScreen({
830
855
  // and is greyed out is read from it; the press runs on the server, which
831
856
  // checks the same again on the saved record.
832
857
  const pressRef = useRef(pressButton); pressRef.current = pressButton
858
+ const buttonUploadRef = useRef(uploadButtonFile); buttonUploadRef.current = uploadButtonFile
833
859
  const buttonDoneRef = useRef(onButtonDone); buttonDoneRef.current = onButtonDone
834
860
  const [pressing, setPressing] = useState(null) // button id on the wire
835
861
  const [buttonNote, setButtonNote] = useState(null) // { kind: 'done' | 'error', text, button }
@@ -842,12 +868,20 @@ export function useFactoryScreen({
842
868
  pressing,
843
869
  note: buttonNote,
844
870
  clearNote: () => setButtonNote(null),
845
- press: async (b, typed) => {
871
+ readFile: readFile || null,
872
+ // A file the button's ask.file takes: stored first, its path sent with the press.
873
+ upload: async (file, b) => {
874
+ if (!buttonUploadRef.current) throw new Error('No uploadButtonFile was provided, so this button cannot take a file')
875
+ const out = await buttonUploadRef.current(file, { screen: doc.id, button: b.id })
876
+ if (!out || !out.path) throw new Error('The upload did not answer with the stored file')
877
+ return out
878
+ },
879
+ press: async (b, typed, files, values) => {
846
880
  if (!pressRef.current) { setButtonNote({ kind: 'error', text: 'No pressButton was provided', button: b.id }); return false }
847
881
  setPressing(b.id)
848
882
  setButtonNote(null)
849
883
  try {
850
- const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed })
884
+ const result = await pressRef.current({ screen: doc.id, button: b.id, id: recordId, note: typed, files, values })
851
885
  setButtonNote({ kind: 'done', text: (result && result.message) || `${b.label} — done`, button: b.id })
852
886
  setListVersion((v) => v + 1)
853
887
  if (buttonDoneRef.current) buttonDoneRef.current({ button: b, result, record: buttonRecord })
@@ -859,7 +893,7 @@ export function useFactoryScreen({
859
893
  setPressing(null)
860
894
  }
861
895
  }
862
- }), [buttonRecord, pressing, buttonNote, doc, recordId])
896
+ }), [buttonRecord, pressing, buttonNote, doc, recordId, readFile])
863
897
 
864
898
  return {
865
899
  document: doc,
@@ -889,6 +923,7 @@ export function useFactoryScreen({
889
923
  visibleNodes,
890
924
  upload,
891
925
  fileUrl,
926
+ readFile: readFile || null,
892
927
  flush,
893
928
  save: runSave,
894
929
  saveNow: runSave,
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { isImageFile } from './controls.js'
3
+
4
+ // A STORED PICTURE, SHOWN. Attachments come back only through the factory's
5
+ // signed route, which an <img src> cannot call — so the bytes are fetched
6
+ // (`readFile`, the host's signed fetch) and shown from a local object URL,
7
+ // released when the path changes or the field goes away. No JSX.
8
+
9
+ /**
10
+ * @param path a stored path, as the upload answered with it
11
+ * @param readFile async (path) → Blob, or nothing (then: no preview)
12
+ * @returns {{ url, failed }} — url is null until loaded, for a non-picture,
13
+ * or without readFile
14
+ */
15
+ export function useFilePreview(path, readFile) {
16
+ const [state, setState] = useState({ url: null, failed: false })
17
+ const wanted = Boolean(path && readFile && isImageFile(path))
18
+
19
+ useEffect(() => {
20
+ if (!wanted) { setState({ url: null, failed: false }); return undefined }
21
+ let gone = false
22
+ let url = null
23
+ Promise.resolve()
24
+ .then(() => readFile(path))
25
+ .then((blob) => {
26
+ if (gone) return
27
+ url = URL.createObjectURL(blob)
28
+ setState({ url, failed: false })
29
+ })
30
+ .catch(() => { if (!gone) setState({ url: null, failed: true }) })
31
+ return () => {
32
+ gone = true
33
+ if (url) URL.revokeObjectURL(url)
34
+ }
35
+ // readFile is the host's function; a new one each render must not refetch.
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [path, wanted])
38
+
39
+ return state
40
+ }
@@ -11,12 +11,13 @@
11
11
  // fields writing the same key — looks like a data problem to whoever uses it.
12
12
 
13
13
  import { SCREEN_EVENT_KEYS, FUNCTION_NAME } from './events.js'
14
- import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList } from './controls.js'
14
+ import { CONTROLS, LABEL_VARIANTS, LIST_ACTIONS, STYLE_KEYS, SCREEN_STYLE_KEYS, LAYOUTS, MULTI_SEPARATOR, MAX_FILE_MB, STEP_LIMIT, acceptList, FILE_SOURCES, IMAGE_EXTENSIONS, MAX_FILES } from './controls.js'
15
15
  import { DOCUMENT_KIND, DOCUMENT_VERSION } from './document.js'
16
+ import { DATE_PRESET_VALUES } from './listRange.js'
16
17
  import { RESERVED_COLUMNS, MAX_IDENTIFIER } from './tableSchema.js'
17
18
  import { FIELD_PRESETS, INPUT_TYPES, presetFor } from './presets.js'
18
19
  import { isBinding } from './values.js'
19
- import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS } from './buttons.js'
20
+ import { BUTTON_VARIANTS, COLUMN_DISPLAYS, BADGE_TONES, CONDITION_OPERATORS, ASK_FIELD_TYPES, NOTE_INPUT, FILES_INPUT } from './buttons.js'
20
21
 
21
22
  const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
22
23
  const TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_.$-]*$/
@@ -281,19 +282,44 @@ function checkStep(node, doc, at, err) {
281
282
 
282
283
  /** A file field: which extensions it takes, and how big a file may be. */
283
284
  function checkFile(props, at, err) {
284
- if (props.accept !== undefined) {
285
- if (typeof props.accept !== 'string') err(`${at}.props.accept`, 'must be a string of extensions, e.g. ".pdf,.docx"')
285
+ checkFileRules(props, `${at}.props`, err)
286
+ }
287
+
288
+ /**
289
+ * A file's rules — a file field's props, or a button's `ask.file`: what it
290
+ * takes, how large, how many, and from where. `at` is where they sit.
291
+ */
292
+ function checkFileRules(rules, at, err) {
293
+ if (rules.accept !== undefined) {
294
+ if (typeof rules.accept !== 'string') err(`${at}.accept`, 'must be a string of extensions, e.g. ".pdf,.docx"')
286
295
  else {
287
- const list = acceptList(props.accept)
288
- if (!list.length) err(`${at}.props.accept`, 'names no extension — ".pdf,.docx", or leave it out for any file')
296
+ const list = acceptList(rules.accept)
297
+ if (!list.length) err(`${at}.accept`, 'names no extension — ".pdf,.docx", or leave it out for any file')
289
298
  list.forEach((x) => {
290
- if (!EXTENSION.test(x)) err(`${at}.props.accept`, `"${x}" is not an extension — letters and digits after a dot, e.g. ".xlsx"`)
299
+ if (!EXTENSION.test(x)) err(`${at}.accept`, `"${x}" is not an extension — letters and digits after a dot, e.g. ".xlsx"`)
291
300
  })
292
301
  }
293
302
  }
294
- if (props.maxSize !== undefined) {
295
- if (!(Number.isFinite(props.maxSize) && props.maxSize > 0 && props.maxSize <= MAX_FILE_MB)) {
296
- err(`${at}.props.maxSize`, `must be a number of megabytes from 1 to ${MAX_FILE_MB}`)
303
+ if (rules.maxSize !== undefined) {
304
+ if (!(Number.isFinite(rules.maxSize) && rules.maxSize > 0 && rules.maxSize <= MAX_FILE_MB)) {
305
+ err(`${at}.maxSize`, `must be a number of megabytes from 1 to ${MAX_FILE_MB}`)
306
+ }
307
+ }
308
+ if (rules.multiple !== undefined && typeof rules.multiple !== 'boolean') err(`${at}.multiple`, 'must be true or false')
309
+ if (rules.maxFiles !== undefined) {
310
+ if (!(Number.isInteger(rules.maxFiles) && rules.maxFiles >= 1 && rules.maxFiles <= MAX_FILES)) err(`${at}.maxFiles`, `must be a whole number of files from 1 to ${MAX_FILES}`)
311
+ else if (!rules.multiple) err(`${at}.maxFiles`, 'is for several files — turn on "multiple", or remove it')
312
+ }
313
+ if (rules.source !== undefined) {
314
+ const sources = FILE_SOURCES.map((s) => s.value)
315
+ if (!sources.includes(rules.source)) err(`${at}.source`, `must be one of: ${sources.join(', ')}`)
316
+ else if (rules.source === 'camera') {
317
+ // A camera takes pictures: a field that also takes PDFs could never be
318
+ // filled the way it says, and one that takes no images at all is wrong.
319
+ const list = typeof rules.accept === 'string' ? acceptList(rules.accept) : []
320
+ if (!list.length || list.some((x) => !IMAGE_EXTENSIONS.includes(x))) {
321
+ err(`${at}.accept`, `a camera takes photos — with "source": "camera", the allowed types must be images only (${IMAGE_EXTENSIONS.join(',')})`)
322
+ }
297
323
  }
298
324
  }
299
325
  }
@@ -483,6 +509,35 @@ function checkFedList(props, fed, at, err) {
483
509
  })
484
510
  }
485
511
  }
512
+ need('kpis', 'KPI cards')
513
+ need('dateRange', 'a date range')
514
+ if (props.kpis !== undefined) {
515
+ if (!Array.isArray(props.kpis)) err(`${at}.props.kpis`, 'must be an array of { key, label, tone? }')
516
+ else {
517
+ const keys = new Set()
518
+ props.kpis.forEach((k, j) => {
519
+ const p = `${at}.props.kpis[${j}]`
520
+ if (!k || typeof k !== 'object') { err(p, 'must be { key, label, tone? }'); return }
521
+ if (!(nonEmptyString(k.key) && /^[A-Za-z0-9_-]+$/.test(k.key))) err(`${p}.key`, 'must be letters, digits, _ or - — what the function is asked for, e.g. "open"')
522
+ else if (keys.has(k.key)) err(`${p}.key`, `"${k.key}" appears twice`)
523
+ else keys.add(k.key)
524
+ if (!nonEmptyString(k.label)) err(`${p}.label`, 'must be the card\'s text, e.g. "Open"')
525
+ if (k.tone !== undefined && !BADGE_TONES.includes(k.tone)) err(`${p}.tone`, `must be one of: ${BADGE_TONES.join(', ')}`)
526
+ const extra = Object.keys(k).filter((x) => !['key', 'label', 'tone'].includes(x))
527
+ if (extra.length) err(p, `only "key", "label" and "tone" are used — remove: ${extra.join(', ')}`)
528
+ })
529
+ }
530
+ }
531
+ if (props.dateRange !== undefined) {
532
+ const d = props.dateRange
533
+ if (!d || typeof d !== 'object' || Array.isArray(d)) err(`${at}.props.dateRange`, 'must be { label?, default? } — the range the function is asked for')
534
+ else {
535
+ const extra = Object.keys(d).filter((x) => x !== 'label' && x !== 'default')
536
+ if (extra.length) err(`${at}.props.dateRange`, `only "label" and "default" are used — remove: ${extra.join(', ')}`)
537
+ if (d.label !== undefined && typeof d.label !== 'string') err(`${at}.props.dateRange.label`, 'must be a string, e.g. "Placed"')
538
+ if (d.default !== undefined && !DATE_PRESET_VALUES.includes(d.default)) err(`${at}.props.dateRange.default`, `must be one of: ${DATE_PRESET_VALUES.join(', ')}`)
539
+ }
540
+ }
486
541
  if (props.refreshEvery !== undefined && !(Number.isInteger(props.refreshEvery) && props.refreshEvery >= 10 && props.refreshEvery <= 3600)) {
487
542
  err(`${at}.props.refreshEvery`, 'must be a whole number of seconds from 10 to 3600')
488
543
  }
@@ -524,12 +579,55 @@ function checkFedColumn(c, p, err) {
524
579
 
525
580
  const BUTTON_KEYS = ['id', 'label', 'action', 'params', 'ask', 'when', 'disabledWhen', 'pressedWhen', 'variant']
526
581
  const ASK_KEYS = ['title', 'message', 'note']
582
+ // A button's `ask.file`: the file a press takes — a file field's rules, plus its label and required.
583
+ const ASK_FILE_KEYS = ['label', 'required', 'accept', 'maxSize', 'multiple', 'maxFiles', 'source']
527
584
 
528
585
  /**
529
586
  * A panel of buttons. Each names a function and the values it is given; the
530
587
  * server checks the names against what is registered when the screen is
531
588
  * published (functionProblems). Here: the shape.
532
589
  */
590
+ const ASK_FIELD_KEYS = ['name', 'label', 'type', 'unit', 'required', 'min', 'max', 'fill']
591
+
592
+ /** A button's `ask.fields`: the values a press asks for, each reaching the function under its name. */
593
+ function checkAskFields(fields, params, at, err) {
594
+ if (!Array.isArray(fields)) { err(at, 'must be a list of { name, label, type, unit?, required?, min?, max?, fill? }'); return }
595
+ const seen = new Set()
596
+ fields.forEach((f, i) => {
597
+ const p = `${at}[${i}]`
598
+ if (!f || typeof f !== 'object' || Array.isArray(f)) { err(p, 'must be { name, label, type, … }'); return }
599
+ const bad = Object.keys(f).filter((k) => !ASK_FIELD_KEYS.includes(k))
600
+ if (bad.length) err(p, `only ${ASK_FIELD_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
601
+ if (!(nonEmptyString(f.name) && /^[A-Za-z][A-Za-z0-9_]{0,62}$/.test(f.name))) err(`${p}.name`, 'must be letters, digits or _ — what the function receives it as, e.g. "lengthIn"')
602
+ else if (seen.has(f.name)) err(`${p}.name`, `"${f.name}" is asked twice`)
603
+ else if (f.name === NOTE_INPUT || f.name === FILES_INPUT) err(`${p}.name`, `"${f.name}" is the note's or the files' own name — use ask.note or ask.file`)
604
+ else if (params && Object.prototype.hasOwnProperty.call(params, f.name)) err(`${p}.name`, `"${f.name}" is already one of the button's parameters — the screen gives it, so nobody should be asked`)
605
+ else seen.add(f.name)
606
+ if (!nonEmptyString(f.label)) err(`${p}.label`, 'must be what the person sees, e.g. "Length"')
607
+ if (!ASK_FIELD_TYPES.includes(f.type)) err(`${p}.type`, `must be one of: ${ASK_FIELD_TYPES.join(', ')}`)
608
+ if (f.unit !== undefined && !(typeof f.unit === 'string' && f.unit.length <= 12)) err(`${p}.unit`, 'must be a short text, e.g. "in" or "kg"')
609
+ if (f.required !== undefined && typeof f.required !== 'boolean') err(`${p}.required`, 'must be true or false')
610
+ ;['min', 'max'].forEach((k) => {
611
+ if (f[k] === undefined) return
612
+ if (f.type !== 'number') err(`${p}.${k}`, 'is for a number field')
613
+ else if (!Number.isFinite(f[k])) err(`${p}.${k}`, 'must be a number')
614
+ })
615
+ if (Number.isFinite(f.min) && Number.isFinite(f.max) && f.min > f.max) err(`${p}.min`, 'is more than max')
616
+ if (f.fill !== undefined && !(nonEmptyString(f.fill) && /^[A-Za-z_][A-Za-z0-9_]*$/.test(f.fill))) err(`${p}.fill`, 'must be a field of the record, e.g. "parcelLengthIn"')
617
+ })
618
+ }
619
+
620
+ function checkAskFile(f, at, err) {
621
+ if (!f || typeof f !== 'object' || Array.isArray(f)) { err(at, 'must be { label?, accept, maxSize?, multiple?, maxFiles?, source?, required? } — the file a press takes'); return }
622
+ const bad = Object.keys(f).filter((k) => !ASK_FILE_KEYS.includes(k))
623
+ if (bad.length) err(at, `only ${ASK_FILE_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
624
+ if (f.label !== undefined && typeof f.label !== 'string') err(`${at}.label`, 'must be a string')
625
+ if (f.required !== undefined && typeof f.required !== 'boolean') err(`${at}.required`, 'must be true or false')
626
+ // The server checks an upload against these, so there must be something to check.
627
+ if (f.accept === undefined) err(`${at}.accept`, 'is required — the extensions it takes, e.g. ".png,.jpg,.jpeg" (the server refuses anything else)')
628
+ checkFileRules(f, at, err)
629
+ }
630
+
533
631
  function checkButtons(props, at, err, ids) {
534
632
  if (props.title !== undefined && typeof props.title !== 'string') err(`${at}.props.title`, 'must be a string')
535
633
  if (!Array.isArray(props.buttons)) { err(`${at}.props.buttons`, 'must be an array of buttons — { id, label, action, params? }'); return }
@@ -553,9 +651,11 @@ function checkButtons(props, at, err, ids) {
553
651
  if (b.ask !== undefined) {
554
652
  if (!b.ask || typeof b.ask !== 'object' || Array.isArray(b.ask)) err(`${p}.ask`, 'must be { title?, message?, note? } — what the person is asked before it runs')
555
653
  else {
556
- const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k))
557
- if (bad.length) err(`${p}.ask`, `only ${ASK_KEYS.map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
654
+ const bad = Object.keys(b.ask).filter((k) => !ASK_KEYS.includes(k) && k !== 'file' && k !== 'fields')
655
+ if (bad.length) err(`${p}.ask`, `only ${[...ASK_KEYS, 'file', 'fields'].map((k) => `"${k}"`).join(', ')} are used — remove: ${bad.join(', ')}`)
558
656
  ASK_KEYS.forEach((k) => { if (b.ask[k] !== undefined && typeof b.ask[k] !== 'string') err(`${p}.ask.${k}`, 'must be a string') })
657
+ if (b.ask.file !== undefined) checkAskFile(b.ask.file, `${p}.ask.file`, err)
658
+ if (b.ask.fields !== undefined) checkAskFields(b.ask.fields, b.params, `${p}.ask.fields`, err)
559
659
  }
560
660
  }
561
661
  if (b.variant !== undefined && !BUTTON_VARIANTS.includes(b.variant)) err(`${p}.variant`, `must be one of: ${BUTTON_VARIANTS.join(', ')}`)