@fieldia/viewer 0.6.0 → 0.8.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.
@@ -1,102 +1,8 @@
1
- import { createForm, validatePage, wideColumns, MESSAGES, } from '@fieldia/core';
1
+ import { createForm, checkPage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
2
2
  import { browserPreferences, createWidget, drawIcon, installStyles, WIDGET_LABELS } from '@fieldia/widgets';
3
- import { openFormDialog, openSearchDialog } from './dialog.mjs';
4
- export const VIEWER_LABELS = {
5
- en: {
6
- save: 'Save',
7
- discard: 'Discard',
8
- saving: 'Saving…',
9
- saved: 'Saved',
10
- submit: 'Submit',
11
- next: 'Next',
12
- back: 'Back',
13
- skip: 'Skip',
14
- steps: 'Steps',
15
- stepOf: 'Step {n} of {total}',
16
- submitted: 'Thank you. Your answers were sent.',
17
- submitAnother: 'Submit another response',
18
- draftFound: 'You have unsaved answers from {time}.',
19
- restore: 'Restore',
20
- discardDraft: 'Discard',
21
- ok: 'OK',
22
- cancel: 'Cancel',
23
- loading: 'Loading…',
24
- saveClose: 'Save & Close',
25
- close: 'Close',
26
- dismiss: 'Dismiss',
27
- },
28
- ar: {
29
- save: 'حفظ',
30
- discard: 'تجاهل',
31
- saving: 'جارٍ الحفظ…',
32
- saved: 'تم الحفظ',
33
- submit: 'إرسال',
34
- next: 'التالي',
35
- back: 'رجوع',
36
- skip: 'تخطٍّ',
37
- steps: 'الخطوات',
38
- stepOf: 'الخطوة {n} من {total}',
39
- submitted: 'شكرًا لك. تم إرسال إجاباتك.',
40
- submitAnother: 'إرسال إجابة أخرى',
41
- draftFound: 'لديك إجابات غير محفوظة من {time}.',
42
- restore: 'استعادة',
43
- discardDraft: 'تجاهل',
44
- ok: 'موافق',
45
- cancel: 'إلغاء',
46
- loading: 'جارٍ التحميل…',
47
- saveClose: 'حفظ وإغلاق',
48
- close: 'إغلاق',
49
- dismiss: 'إخفاء',
50
- },
51
- de: {
52
- save: 'Speichern',
53
- discard: 'Verwerfen',
54
- saving: 'Wird gespeichert…',
55
- saved: 'Gespeichert',
56
- submit: 'Absenden',
57
- next: 'Weiter',
58
- back: 'Zurück',
59
- skip: 'Überspringen',
60
- steps: 'Schritte',
61
- stepOf: 'Schritt {n} von {total}',
62
- submitted: 'Vielen Dank. Ihre Antworten wurden gesendet.',
63
- submitAnother: 'Weitere Antwort senden',
64
- draftFound: 'Sie haben ungespeicherte Antworten vom {time}.',
65
- restore: 'Wiederherstellen',
66
- discardDraft: 'Verwerfen',
67
- ok: 'OK',
68
- cancel: 'Abbrechen',
69
- loading: 'Wird geladen…',
70
- saveClose: 'Speichern und schließen',
71
- close: 'Schließen',
72
- dismiss: 'Ausblenden',
73
- },
74
- fr: {
75
- save: 'Enregistrer',
76
- discard: 'Annuler les modifications',
77
- saving: 'Enregistrement…',
78
- saved: 'Enregistré',
79
- submit: 'Envoyer',
80
- next: 'Suivant',
81
- back: 'Retour',
82
- skip: 'Passer',
83
- steps: 'Étapes',
84
- stepOf: 'Étape {n} sur {total}',
85
- submitted: 'Merci. Vos réponses ont été envoyées.',
86
- submitAnother: 'Envoyer une autre réponse',
87
- draftFound: 'Vous avez des réponses non enregistrées du {time}.',
88
- restore: 'Restaurer',
89
- discardDraft: 'Ignorer',
90
- ok: 'OK',
91
- cancel: 'Annuler',
92
- loading: 'Chargement…',
93
- saveClose: 'Enregistrer et fermer',
94
- close: 'Fermer',
95
- dismiss: 'Masquer',
96
- },
97
- };
98
- /** English, kept under its old name. */
99
- export const DEFAULT_LABELS = VIEWER_LABELS.en;
3
+ import { pageDialogs } from './related.mjs';
4
+ import { VIEWER_LABELS } from './labels.mjs';
5
+ export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
100
6
  let mounts = 0;
101
7
  /**
102
8
  * Render a page into `host` and keep it in step with its form. Plain DOM: the
@@ -105,11 +11,12 @@ let mounts = 0;
105
11
  */
106
12
  export function mountViewer(host, options) {
107
13
  const doc = host.ownerDocument;
108
- const checked = validatePage(options.page);
14
+ // The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
15
+ const checked = checkPage(options.page);
109
16
  if (!checked.ok) {
110
17
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
111
18
  }
112
- const page = checked.page;
19
+ const page = options.translate ? translatePage(checked.page, options.translate) : checked.page;
113
20
  const locale = options.locale ?? 'en';
114
21
  const preferences = options.preferences ?? browserPreferences();
115
22
  const dialogs = pageDialogs(options);
@@ -147,6 +54,12 @@ export function mountViewer(host, options) {
147
54
  if (node.colspan)
148
55
  wrapper.style.setProperty('--fd-span', String(node.colspan));
149
56
  const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, node.label ?? def.label);
57
+ // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
58
+ const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? drawIcon(doc, 'check') : null;
59
+ if (mark) {
60
+ mark.classList.add('fd-valid-mark');
61
+ label.append(mark);
62
+ }
150
63
  const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
151
64
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
152
65
  // `for` stays: a custom field that puts the id on its own input is
@@ -158,7 +71,8 @@ export function mountViewer(host, options) {
158
71
  }
159
72
  const helpText = node.help ?? def.help;
160
73
  const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
161
- const error = el('div', { class: 'fd-error', id: `${id}-error`, role: 'alert', hidden: '' });
74
+ // Not an alert of its own: a refused save is announced once, naming every field to look at.
75
+ const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
162
76
  // A warning from the data source's onchange, beside the field whose change brought it.
163
77
  const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
164
78
  wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
@@ -174,10 +88,17 @@ export function mountViewer(host, options) {
174
88
  const warned = state.warning && state.warningField === node.field ? state.warning : '';
175
89
  warning.hidden = !warned;
176
90
  warning.textContent = warned;
91
+ if (mark) {
92
+ const value = state.values[node.field];
93
+ const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
94
+ const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
95
+ wrapper.classList.toggle('fd-valid', valid);
96
+ mark.toggleAttribute('hidden', !valid);
97
+ }
177
98
  widget.update({
178
99
  value: state.values[node.field],
179
100
  values: state.values,
180
- readonly: shown.readonly,
101
+ readonly: shown.readonly || locked,
181
102
  required: shown.required,
182
103
  invalid: !!message,
183
104
  describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
@@ -330,12 +251,101 @@ export function mountViewer(host, options) {
330
251
  }
331
252
  }
332
253
  // ---- shared chrome ----------------------------------------------------------
333
- const status = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
254
+ /** The whole form locked: every field read-only, no Save; buttons still run. */
255
+ let locked = options.readonly === true;
256
+ // Edit unlocks; Done saves what changed first, and locks only once that is saved.
257
+ const editSwitch = options.editSwitch ? el('button', { type: 'button', class: 'fd-button fd-edit-switch' }) : null;
258
+ editSwitch?.addEventListener('click', async () => {
259
+ if (!locked) {
260
+ if (form.getState().dirty.length) {
261
+ const saved = await form.save();
262
+ if (!saved) {
263
+ if (!form.getState().saveProblem)
264
+ announce(checkText(form.getState()));
265
+ focusFirstProblem();
266
+ return;
267
+ }
268
+ }
269
+ locked = true;
270
+ }
271
+ else
272
+ locked = false;
273
+ root.toggleAttribute('data-readonly', locked);
274
+ render(form.getState());
275
+ });
276
+ root.toggleAttribute('data-readonly', locked);
277
+ updaters.push(() => {
278
+ if (!editSwitch)
279
+ return;
280
+ editSwitch.textContent = locked ? labels.edit : labels.done;
281
+ editSwitch.classList.toggle('fd-button-primary', locked);
282
+ });
283
+ // The status says its words alone, so a screen reader hears them; Retry sits beside it.
284
+ const statusText = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
285
+ const retry = el('button', { type: 'button', class: 'fd-button fd-button-link fd-retry', hidden: '' }, labels.retry);
286
+ retry.addEventListener('click', () => void submitOrSave());
287
+ const status = el('div', { class: 'fd-status-box' }, statusText, retry);
288
+ const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
289
+ /** "Not saved. Check: Email, Phone": the fields with a problem, in the order they show. */
290
+ function checkText(state) {
291
+ const names = [];
292
+ for (const key of Object.keys(state.errors)) {
293
+ const field = key.split('.')[0];
294
+ const shown = root.querySelector(`.fd-field[data-field="${field}"]:not([hidden]) > .fd-label`);
295
+ const name = shown?.firstChild?.textContent ?? page.fields[field]?.label ?? field;
296
+ if (!names.includes(name))
297
+ names.push(name);
298
+ }
299
+ const inOrder = [...root.querySelectorAll('.fd-field[data-field] > .fd-label')].map((label) => label.firstChild?.textContent ?? '');
300
+ names.sort((a, b) => (inOrder.indexOf(a) + 1 || Infinity) - (inOrder.indexOf(b) + 1 || Infinity));
301
+ return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
302
+ }
303
+ let quietTimer;
304
+ updaters.push((state) => {
305
+ const problem = state.saveProblem;
306
+ const text = state.status === 'saving' ? labels.saving
307
+ : state.status === 'saved' ? labels.saved
308
+ : state.status === 'loading' ? labels.loading
309
+ : state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
310
+ : state.status === 'error' ? state.error ?? ''
311
+ : '';
312
+ statusText.textContent = text;
313
+ retry.hidden = !(state.status === 'error' && problem?.kind === 'other');
314
+ statusText.classList.toggle('fd-status-error', state.status === 'error');
315
+ statusText.classList.toggle('fd-status-saved', state.status === 'saved');
316
+ // A toast shows while there is something to say, and lets "Saved" go after a moment.
317
+ if (options.saveStatus === 'toast') {
318
+ clearTimeout(quietTimer);
319
+ status.hidden = !text;
320
+ if (state.status === 'saved')
321
+ quietTimer = setTimeout(() => (status.hidden = true), 2500);
322
+ }
323
+ });
324
+ // A save the network failed: a banner over the page, with Retry.
325
+ const bannerRetry = el('button', { type: 'button', class: 'fd-button fd-retry' }, labels.retry);
326
+ bannerRetry.addEventListener('click', () => void submitOrSave());
327
+ const banner = el('div', { class: 'fd-banner fd-tone-warning', role: 'alert', hidden: '' }, el('span', { class: 'fd-banner-text' }, labels.offline), bannerRetry);
328
+ /** Said at once to a screen reader: why a save or send did not go through. */
329
+ const announcer = el('div', { class: 'fd-announce', role: 'alert', 'aria-live': 'assertive' });
330
+ function announce(text) {
331
+ announcer.textContent = '';
332
+ setTimeout(() => (announcer.textContent = text), 0);
333
+ }
334
+ // Each new reason a save was refused, from Save or from autosave, is shown where it belongs.
335
+ let seenProblem = null;
334
336
  updaters.push((state) => {
335
- const text = state.status === 'saving' ? labels.saving : state.status === 'saved' ? labels.saved : state.status === 'loading' ? labels.loading : state.status === 'error' ? state.error ?? '' : '';
336
- status.textContent = text;
337
- status.classList.toggle('fd-status-error', state.status === 'error');
338
- status.classList.toggle('fd-status-saved', state.status === 'saved');
337
+ if (state.saveProblem === seenProblem)
338
+ return;
339
+ seenProblem = state.saveProblem;
340
+ banner.hidden = seenProblem?.kind !== 'network';
341
+ if (!seenProblem)
342
+ return;
343
+ if (seenProblem.kind === 'rule')
344
+ void dialogConfirm(seenProblem.message, false);
345
+ else if (seenProblem.kind === 'fields')
346
+ announce(checkText(state));
347
+ else if (seenProblem.kind === 'other')
348
+ announce(seenProblem.message);
339
349
  });
340
350
  const draft = el('div', { class: 'fd-draft', hidden: '' });
341
351
  const draftText = el('span');
@@ -369,6 +379,9 @@ export function mountViewer(host, options) {
369
379
  async function submitOrSave() {
370
380
  const saved = await form.save();
371
381
  if (!saved) {
382
+ // Stopped by the page's own checks: say which fields; a refused save says so above.
383
+ if (!form.getState().saveProblem)
384
+ announce(checkText(form.getState()));
372
385
  focusFirstProblem();
373
386
  return;
374
387
  }
@@ -403,11 +416,15 @@ export function mountViewer(host, options) {
403
416
  discard.addEventListener('click', () => form.reset());
404
417
  actions.insertBefore(discard, action);
405
418
  updaters.push((state) => {
406
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
419
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
407
420
  action.hidden = !dirty;
408
421
  discard.hidden = !dirty;
409
422
  });
410
423
  }
424
+ else
425
+ updaters.push(() => (action.hidden = locked));
426
+ if (editSwitch)
427
+ actions.prepend(editSwitch);
411
428
  if (options.showActions === false)
412
429
  return el('div', {}, box);
413
430
  if (page.actionsPosition === 'top') {
@@ -516,8 +533,11 @@ export function mountViewer(host, options) {
516
533
  }
517
534
  else
518
535
  actions.append(save, discard, ...(node.buttons ?? []).map(buttonItem), status);
536
+ // The Edit switch leads the header bar, before Save and the record's own buttons.
537
+ if (editSwitch && options.showActions !== false)
538
+ actions.prepend(editSwitch);
519
539
  updaters.push((state) => {
520
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
540
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
521
541
  save.hidden = !dirty;
522
542
  discard.hidden = !dirty;
523
543
  });
@@ -603,7 +623,7 @@ export function mountViewer(host, options) {
603
623
  if (atFoot && options.showActions !== false) {
604
624
  card.append(foot);
605
625
  updaters.push((state) => {
606
- foot.hidden = state.dirty.length === 0 && !status.textContent;
626
+ foot.hidden = state.dirty.length === 0 && !statusText.textContent;
607
627
  });
608
628
  }
609
629
  const layout = el('div', { class: 'fd-sheet-layout' }, card);
@@ -622,7 +642,7 @@ export function mountViewer(host, options) {
622
642
  barOptions['visibleStates'] = config.visibleStates;
623
643
  const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
624
644
  const widget = createWidget({ form, name: config.field, field: def, node, id: uid('statusbar'), document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
625
- updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly, required: false, invalid: false }));
645
+ updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false }));
626
646
  return widget.element;
627
647
  }
628
648
  let wizardForward = null;
@@ -637,8 +657,17 @@ export function mountViewer(host, options) {
637
657
  else
638
658
  body = sectionsLayout(layout);
639
659
  const head = layout.type === 'sheet' ? null : pageHead();
640
- content.append(...(head ? [head] : []), draft, body);
641
- root.append(content, done);
660
+ content.append(...(head ? [head] : []), banner, draft, body);
661
+ root.append(content, done, announcer);
662
+ // The status leaves its place beside Save when the page wants it in a corner or across the top.
663
+ if (options.saveStatus === 'toast') {
664
+ status.classList.add('fd-toast');
665
+ root.append(status);
666
+ }
667
+ else if (options.saveStatus === 'bar') {
668
+ status.classList.add('fd-status-bar');
669
+ content.prepend(status);
670
+ }
642
671
  root.addEventListener('submit', (event) => {
643
672
  event.preventDefault();
644
673
  if (wizardForward)
@@ -646,13 +675,68 @@ export function mountViewer(host, options) {
646
675
  else
647
676
  void submitOrSave();
648
677
  });
678
+ // ---- keys -------------------------------------------------------------------
679
+ /** Text typed and not yet taken, such as a tag without its Enter, is taken now. */
680
+ function commitTyping() {
681
+ const active = doc.activeElement;
682
+ if (active && root.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
683
+ active.dispatchEvent(new Event('change', { bubbles: true }));
684
+ }
685
+ }
686
+ /** The fields Enter can move between: on this page, shown, with a box to type or choose in, outside tables of lines. */
687
+ function nextField(from) {
688
+ const fields = [...root.querySelectorAll('.fd-field')].filter((field) => !field.closest('[hidden], [data-type="one2many"] .fd-field, .fd-form-dialog') && !field.parentElement?.closest('[data-type="one2many"]'));
689
+ const here = from.closest('.fd-field');
690
+ const after = fields.slice(fields.indexOf(here) + 1);
691
+ for (const field of after) {
692
+ const control = field.querySelector('input:not([type="hidden"]):not([readonly]):not([disabled]), select:not([disabled]), textarea:not([readonly]), [contenteditable="true"]');
693
+ if (control)
694
+ return control;
695
+ }
696
+ return null;
697
+ }
698
+ root.addEventListener('keydown', (event) => {
699
+ if (event.isComposing || event.key !== 'Enter')
700
+ return;
701
+ // An app's own content in a slot, such as a chatter's composer, keeps its keys.
702
+ if (event.target.closest('.fd-slot'))
703
+ return;
704
+ const keys = options.keys ?? {};
705
+ if (event.ctrlKey || event.metaKey) {
706
+ // Saves even after a field used the Enter (a tag box adding its tag); the grid keeps its own keys.
707
+ if (keys.saveWithCtrlEnter === false || options.showActions === false)
708
+ return;
709
+ if (event.target.closest('.ag-root-wrapper'))
710
+ return;
711
+ event.preventDefault();
712
+ commitTyping();
713
+ if (wizardForward)
714
+ void wizardForward();
715
+ else
716
+ void submitOrSave();
717
+ return;
718
+ }
719
+ // A plain Enter a field used itself, such as picking from an open list, is the field's.
720
+ if (event.defaultPrevented || !keys.enterMovesToNext || event.shiftKey || event.altKey)
721
+ return;
722
+ const from = event.target;
723
+ if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT')
724
+ return;
725
+ if (from.closest('[data-type="one2many"]'))
726
+ return;
727
+ if (['button', 'submit', 'reset', 'file'].includes(from.type))
728
+ return;
729
+ event.preventDefault();
730
+ nextField(from)?.focus();
731
+ });
649
732
  // ---- confirmation dialog -----------------------------------------------------
650
- function dialogConfirm(message) {
733
+ /** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
734
+ function dialogConfirm(message, withCancel = true) {
651
735
  return new Promise((resolve) => {
652
736
  const text = el('p', { id: `${prefix}-confirm` }, message);
653
737
  const cancel = el('button', { type: 'button', class: 'fd-button' }, labels.cancel);
654
738
  const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
655
- const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, cancel, ok));
739
+ const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, ...(withCancel ? [cancel, ok] : [ok])));
656
740
  const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
657
741
  const close = (answer) => {
658
742
  backdrop.remove();
@@ -696,6 +780,12 @@ export function mountViewer(host, options) {
696
780
  focusFirstProblem();
697
781
  return valid;
698
782
  },
783
+ setReadonly(readonly) {
784
+ locked = readonly;
785
+ root.toggleAttribute('data-readonly', locked);
786
+ render(form.getState());
787
+ },
788
+ isReadonly: () => locked,
699
789
  destroy() {
700
790
  unsubscribe();
701
791
  for (const cleanup of cleanups)
@@ -706,85 +796,6 @@ export function mountViewer(host, options) {
706
796
  },
707
797
  };
708
798
  }
709
- /** The field a page's records are named by: its title, or "name", or its first line of text. */
710
- function nameFieldOf(page) {
711
- if (page.layout.type === 'sheet' && page.layout.title?.field)
712
- return page.layout.title.field;
713
- if (page.fields['name'])
714
- return 'name';
715
- return Object.entries(page.fields).find(([, def]) => def.type === 'char')?.[0] ?? null;
716
- }
717
- /** A form made of fields, for values edited in a dialog (a line, for one). */
718
- function valuesPage(fields, title, readonly) {
719
- const shown = Object.fromEntries(Object.entries(fields).map(([name, def]) => [name, readonly ? { ...def, readonly: true } : def]));
720
- return {
721
- fieldia: '0.1',
722
- id: 'values',
723
- title,
724
- data: { kind: 'record', model: 'values' },
725
- fields: shown,
726
- layout: {
727
- type: 'sections',
728
- id: 'values',
729
- children: [{ type: 'section', id: 'values-section', columns: 2, children: Object.keys(shown).map((name) => ({ type: 'field', id: `values-${name}`, field: name })) }],
730
- },
731
- };
732
- }
733
- /** Only finding and making linked records: values edited in a dialog are no record of the source's to load, save or recalculate. */
734
- function lookupsOf(source) {
735
- if (!source)
736
- return undefined;
737
- return {
738
- ...(source.search ? { search: (request) => source.search(request) } : {}),
739
- ...(source.create ? { create: (request) => source.create(request) } : {}),
740
- };
741
- }
742
- /** The dialogs a page's widgets may open, made from the viewer's own options. */
743
- function pageDialogs(options) {
744
- const pageFor = (model) => (typeof options.relatedPages === 'function' ? options.relatedPages(model) : options.relatedPages?.[model]) ?? null;
745
- // A dialog's page looks and reads like the page that opened it, and can open dialogs of its own.
746
- const shared = {
747
- locale: options.locale,
748
- skin: options.skin,
749
- dir: options.dir,
750
- widgets: options.widgets,
751
- preferences: options.preferences,
752
- relatedPages: options.relatedPages,
753
- };
754
- return {
755
- canOpen: (model) => pageFor(model) !== null,
756
- async openRecord(model, request) {
757
- const related = pageFor(model);
758
- if (!related)
759
- return null;
760
- const nameField = nameFieldOf(related);
761
- const result = await openFormDialog({
762
- ...shared,
763
- page: related,
764
- dataSource: options.dataSource,
765
- recordId: request.recordId ?? null,
766
- values: request.name && nameField ? { [nameField]: request.name } : undefined,
767
- title: request.title,
768
- size: 'large',
769
- });
770
- if (!result.saved || result.recordId === null)
771
- return null;
772
- const name = nameField ? result.values[nameField] : null;
773
- return { id: result.recordId, label: typeof name === 'string' && name ? name : request.name ?? request.title };
774
- },
775
- searchMore: (request) => openSearchDialog({ title: request.title, search: request.search, locale: options.locale, skin: options.skin, dir: options.dir }),
776
- async editValues(request) {
777
- const result = await openFormDialog({
778
- ...shared,
779
- page: valuesPage(request.fields, request.title, request.readonly === true),
780
- dataSource: lookupsOf(options.dataSource),
781
- values: request.values,
782
- title: request.title,
783
- mode: 'values',
784
- ...(request.recompute ? { recompute: request.recompute } : {}),
785
- });
786
- return result.saved ? result.values : null;
787
- },
788
- };
789
- }
799
+ /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
800
+ const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
790
801
  //# sourceMappingURL=viewer.mjs.map