@ultimat3/admin 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/widgets.tsx CHANGED
@@ -2,6 +2,7 @@
2
2
  // switch, so money is a Money widget and a timestamp is a DateTime widget with a zone in
3
3
  // every one of those places — there is no second place to get it wrong.
4
4
 
5
+ import { safeUrl } from '@ultimat3/core';
5
6
  import { t } from '@ultimat3/i18n';
6
7
  import {
7
8
  Checkbox,
@@ -53,10 +54,17 @@ const wiring = (control: FieldControl | undefined): ControlWiring =>
53
54
  /**
54
55
  * A `date` column has no time of day, and ui's default formatter always renders one. A
55
56
  * rendered midnight is wrong in every zone but the one the value was stored in.
57
+ *
58
+ * `'UTC'`, never `options.zone`: a calendar date is zone-INDEPENDENT by construction — the value
59
+ * is `YYYY-MM-DD` and the spec parses it as UTC midnight (`date-time-view.ts` says so where it
60
+ * refuses an offsetless date-TIME). Formatting that instant in the viewer's zone moves it: an
61
+ * `effective_on` of `2026-08-18` read as `Aug 17, 2026` for every operator west of Greenwich,
62
+ * which is the exact bug `date()` exists to prevent — measured under `TZ=America/Los_Angeles`.
63
+ * The instant branch keeps `options.zone`, because an instant genuinely has one.
56
64
  */
57
- const dateOnly: DateTimeFormatter = (at, options) =>
65
+ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
58
66
  new Intl.DateTimeFormat(options.locale, {
59
- timeZone: options.zone,
67
+ timeZone: 'UTC',
60
68
  dateStyle: 'medium',
61
69
  }).format(at);
62
70
 
@@ -65,7 +73,7 @@ const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
65
73
  iso.slice(0, precision === 'date' ? 10 : 16);
66
74
 
67
75
  /** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
68
- function readView(props: WidgetProps, field: AdminField): JSX.Element {
76
+ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
69
77
  switch (props.widget) {
70
78
  case 'money':
71
79
  return props.value === null ? (
@@ -80,7 +88,7 @@ function readView(props: WidgetProps, field: AdminField): JSX.Element {
80
88
  <DateTime
81
89
  value={props.value}
82
90
  timeZone={props.timeZone}
83
- format={props.precision === 'date' ? dateOnly : undefined}
91
+ format={props.precision === 'date' ? formatCalendarDate : undefined}
84
92
  />
85
93
  );
86
94
  case 'checkbox':
@@ -95,17 +103,18 @@ function readView(props: WidgetProps, field: AdminField): JSX.Element {
95
103
  );
96
104
  case 'json-editor':
97
105
  return <pre class="x-admin-json">{props.value}</pre>;
98
- case 'reference':
99
- return props.value === null ? (
100
- <span>{t('admin.value.empty')}</span>
101
- ) : (
102
- <a href={`/${props.entity}s/${props.value}`}>{props.value}</a>
103
- );
106
+ case 'reference': {
107
+ if (props.value === null) return <span>{t('admin.value.empty')}</span>;
108
+ // `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
109
+ // the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
110
+ const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
111
+ return href === null ? <span>{props.value}</span> : <a href={href}>{props.value}</a>;
112
+ }
104
113
  case 'upload':
105
114
  return props.value === null ? (
106
115
  <span>{t('admin.value.empty')}</span>
107
116
  ) : (
108
- <a href={props.value.url}>{props.value.name}</a>
117
+ <a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
109
118
  );
110
119
  default:
111
120
  return <span>{String(props.value ?? '')}</span>;
@@ -258,5 +267,5 @@ function locales(): readonly string[] {
258
267
 
259
268
  export function Widget(input: WidgetInput): JSX.Element {
260
269
  const props = widgetProps(input.field, input.value, input.ctx);
261
- return input.mode === 'read' ? readView(props, input.field) : editView(props, input);
270
+ return input.mode === 'read' ? readView(props, input.field, input.ctx) : editView(props, input);
262
271
  }