@multiplatform.one/frappe-ui 7.34.1 → 7.34.3

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.
Files changed (50) hide show
  1. package/dist/cjs/fields/Date/catalog.cjs +139 -0
  2. package/dist/cjs/fields/Date/catalog.native.js +165 -0
  3. package/dist/cjs/fields/Date/catalog.native.js.map +1 -0
  4. package/dist/cjs/fields/Date/dateTimePickerLoader.cjs +38 -0
  5. package/dist/cjs/fields/Date/dateTimePickerLoader.native.js +42 -0
  6. package/dist/cjs/fields/Date/dateTimePickerLoader.native.js.map +1 -0
  7. package/dist/cjs/fields/Date/index.cjs +2 -113
  8. package/dist/cjs/fields/Date/index.native.js +19 -15
  9. package/dist/cjs/fields/Date/index.native.js.map +1 -1
  10. package/dist/esm/fields/Date/catalog.mjs +114 -0
  11. package/dist/esm/fields/Date/catalog.mjs.map +1 -0
  12. package/dist/esm/fields/Date/catalog.native.js +137 -0
  13. package/dist/esm/fields/Date/catalog.native.js.map +1 -0
  14. package/dist/esm/fields/Date/dateTimePickerLoader.mjs +13 -0
  15. package/dist/esm/fields/Date/dateTimePickerLoader.mjs.map +1 -0
  16. package/dist/esm/fields/Date/dateTimePickerLoader.native.js +14 -0
  17. package/dist/esm/fields/Date/dateTimePickerLoader.native.js.map +1 -0
  18. package/dist/esm/fields/Date/index.mjs +1 -112
  19. package/dist/esm/fields/Date/index.mjs.map +1 -1
  20. package/dist/esm/fields/Date/index.native.js +17 -2
  21. package/dist/esm/fields/Date/index.native.js.map +1 -1
  22. package/dist/jsx/fields/Date/catalog.mjs +114 -0
  23. package/dist/jsx/fields/Date/catalog.mjs.map +1 -0
  24. package/dist/jsx/fields/Date/catalog.native.js +165 -0
  25. package/dist/jsx/fields/Date/catalog.native.js.map +1 -0
  26. package/dist/jsx/fields/Date/dateTimePickerLoader.mjs +13 -0
  27. package/dist/jsx/fields/Date/dateTimePickerLoader.mjs.map +1 -0
  28. package/dist/jsx/fields/Date/dateTimePickerLoader.native.js +42 -0
  29. package/dist/jsx/fields/Date/dateTimePickerLoader.native.js.map +1 -0
  30. package/dist/jsx/fields/Date/index.mjs +1 -112
  31. package/dist/jsx/fields/Date/index.mjs.map +1 -1
  32. package/dist/jsx/fields/Date/index.native.js +19 -15
  33. package/dist/jsx/fields/Date/index.native.js.map +1 -1
  34. package/package.json +27 -27
  35. package/src/FrappeTable.infiniteLoadError.spec.tsx +118 -0
  36. package/src/fields/Date/catalog.tsx +190 -0
  37. package/src/fields/Date/dateTimePickerLoader.ts +25 -0
  38. package/src/fields/Date/index.native.tsx +24 -3
  39. package/src/fields/Date/index.tsx +1 -188
  40. package/src/fields/Date/nativeFallback.spec.tsx +23 -0
  41. package/src/fields/Date/nativePresentation.spec.tsx +2 -2
  42. package/src/fields/Date/typeParity.spec.tsx +2 -2
  43. package/types/fields/Date/catalog.d.ts +25 -0
  44. package/types/fields/Date/catalog.d.ts.map +1 -0
  45. package/types/fields/Date/dateTimePickerLoader.d.ts +11 -0
  46. package/types/fields/Date/dateTimePickerLoader.d.ts.map +1 -0
  47. package/types/fields/Date/index.d.ts +1 -22
  48. package/types/fields/Date/index.d.ts.map +1 -1
  49. package/types/fields/Date/index.native.d.ts +2 -7
  50. package/types/fields/Date/index.native.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/frappe-ui",
3
- "version": "7.34.1",
3
+ "version": "7.34.3",
4
4
  "description": "Pre-wired Tamagui components for Frappe doctypes with live data",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -72,42 +72,42 @@
72
72
  "access": "public"
73
73
  },
74
74
  "dependencies": {
75
- "@multiplatform.one/components": "7.34.1",
76
- "@multiplatform.one/desktop": "7.34.1",
77
- "@multiplatform.one/forms": "7.34.1",
78
- "@multiplatform.one/frappe": "7.34.1",
79
- "@multiplatform.one/i18n": "7.34.1",
80
- "@multiplatform.one/markdown": "7.34.1",
81
- "@multiplatform.one/platform": "7.34.1",
82
- "@multiplatform.one/rich-text": "7.34.1",
83
- "@multiplatform.one/router": "7.34.1",
84
- "@multiplatform.one/store": "7.34.1",
85
- "@multiplatform.one/table": "7.34.1",
86
- "@multiplatform.one/theme": "7.34.1",
75
+ "@multiplatform.one/components": "^7.34.3",
76
+ "@multiplatform.one/desktop": "^7.34.3",
77
+ "@multiplatform.one/forms": "^7.34.3",
78
+ "@multiplatform.one/frappe": "^7.34.3",
79
+ "@multiplatform.one/i18n": "^7.34.3",
80
+ "@multiplatform.one/markdown": "^7.34.3",
81
+ "@multiplatform.one/platform": "^7.34.3",
82
+ "@multiplatform.one/rich-text": "^7.34.3",
83
+ "@multiplatform.one/router": "^7.34.3",
84
+ "@multiplatform.one/store": "^7.34.3",
85
+ "@multiplatform.one/table": "^7.34.3",
86
+ "@multiplatform.one/theme": "^7.34.3",
87
87
  "@phosphor-icons/react": "^2.1.10",
88
88
  "@tamagui/colors": "2.7.6",
89
89
  "@tanstack/db": "0.8.0",
90
- "@tanstack/form-core": "^1.29.1",
90
+ "@tanstack/form-core": "^1.33.5",
91
91
  "@tanstack/react-hotkeys": "^0.5.1",
92
- "@tiptap/extension-image": "^3.30.0",
93
- "@tiptap/extension-link": "^3.30.0",
94
- "@tiptap/extension-placeholder": "^3.30.0",
95
- "@tiptap/pm": "^3.30.0",
96
- "@tiptap/react": "^3.30.0",
97
- "@tiptap/starter-kit": "^3.30.0",
92
+ "@tiptap/extension-image": "^3.32.0",
93
+ "@tiptap/extension-link": "^3.32.0",
94
+ "@tiptap/extension-placeholder": "^3.32.0",
95
+ "@tiptap/pm": "^3.32.0",
96
+ "@tiptap/react": "^3.32.0",
97
+ "@tiptap/starter-kit": "^3.32.0",
98
98
  "html5-qrcode": "^2.3.8",
99
- "signature_pad": "^5.1.3",
99
+ "signature_pad": "^5.1.4",
100
100
  "tamagui": "2.7.6"
101
101
  },
102
102
  "devDependencies": {
103
- "@multiplatform.one/config": "7.34.1",
104
- "@multiplatform.one/storybook": "7.34.1",
105
- "@multiplatform.one/test-utils": "7.34.1",
103
+ "@multiplatform.one/config": "7.34.3",
104
+ "@multiplatform.one/storybook": "7.34.3",
105
+ "@multiplatform.one/test-utils": "7.34.3",
106
106
  "@tamagui/build": "2.7.6",
107
- "@testing-library/jest-dom": "^6.9.1",
108
- "@testing-library/react": "^16.3.2",
107
+ "@testing-library/jest-dom": "^7.0.1",
108
+ "@testing-library/react": "^16.3.3",
109
109
  "@types/react-dom": "^19.2.3",
110
- "@vitejs/plugin-react": "^6.0.1",
110
+ "@vitejs/plugin-react": "^6.1.2",
111
111
  "@vitest/coverage-v8": "^4.1.5",
112
112
  "i18next": "^25.10.10",
113
113
  "react": "19.2.5",
@@ -0,0 +1,118 @@
1
+ // MPO-485 / LC-84: infinite serverSide hands loading and empty chrome to
2
+ // DataTable, whose page loader swallowed a rejected page 0. A list whose read
3
+ // failed said "No data available". A failed read is an error with the
4
+ // server's reason and a Retry, and Retry loads the rows once the bench answers.
5
+ import type { DataTableColumn } from "@multiplatform.one/table";
6
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
7
+ import { cleanup, screen, waitFor } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import i18next from "i18next";
10
+ import { initReactI18next } from "react-i18next";
11
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { FrappeProvider } from "./FrappeProvider";
13
+ import { FrappeTable } from "./FrappeTable";
14
+
15
+ const doctype = "Pokemon";
16
+
17
+ interface Pokemon {
18
+ name: string;
19
+ doctype: string;
20
+ pokemon_name: string;
21
+ }
22
+
23
+ const columns: DataTableColumn<Partial<Pokemon>>[] = [
24
+ { id: "name", accessorKey: "name", header: "ID" },
25
+ { id: "pokemon_name", accessorKey: "pokemon_name", header: "Pokemon Name" },
26
+ ];
27
+
28
+ const filterFields = [{ id: "pokemon_name", label: "Pokemon Name", type: "text" as const }];
29
+ const readOnly = { create: false, write: false, delete: false };
30
+ const settle = { timeout: 8000 };
31
+
32
+ let run = 0;
33
+ let failing: "count" | "rows" | null = null;
34
+
35
+ function json(body: unknown, status = 200) {
36
+ return new Response(JSON.stringify(body), {
37
+ status,
38
+ headers: { "content-type": "application/json" },
39
+ });
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ await i18next.use(initReactI18next).init({
44
+ lng: "en",
45
+ interpolation: { escapeValue: false },
46
+ resources: { en: { translation: {} } },
47
+ });
48
+ });
49
+
50
+ beforeEach(() => {
51
+ run += 1;
52
+ failing = null;
53
+ vi.stubGlobal("fetch", async (input: string | URL | Request) => {
54
+ const url = new URL(String(input instanceof Request ? input.url : input));
55
+ if (url.pathname === "/api/method/frappe.client.get_count") {
56
+ return failing === "count"
57
+ ? json({ exc_type: "ValidationError", exception: "Count timed out" }, 417)
58
+ : json({ message: 2 });
59
+ }
60
+ if (url.pathname === `/api/resource/${doctype}`) {
61
+ if (failing === "rows") {
62
+ return json({ exc_type: "ValidationError", exception: "Pokemon is being migrated" }, 417);
63
+ }
64
+ return json({
65
+ data: [
66
+ { name: "bulbasaur", pokemon_name: "Bulbasaur" },
67
+ { name: "ivysaur", pokemon_name: "Ivysaur" },
68
+ ],
69
+ });
70
+ }
71
+ return json({ exc_type: "PermissionError" }, 403);
72
+ });
73
+ });
74
+
75
+ afterEach(() => {
76
+ cleanup();
77
+ vi.unstubAllGlobals();
78
+ });
79
+
80
+ function renderTable() {
81
+ return renderWithProviders(
82
+ <FrappeProvider baseURL={`https://infinite-load-error-${run}.spec.test`} realtime={false}>
83
+ <FrappeTable<Pokemon>
84
+ doctype={doctype}
85
+ columns={columns}
86
+ filterableFields={filterFields}
87
+ rights={readOnly}
88
+ serverSide
89
+ enablePagination
90
+ paginationMode="infinite"
91
+ />
92
+ </FrappeProvider>,
93
+ );
94
+ }
95
+
96
+ describe("FrappeTable infinite server mode with a failed read", () => {
97
+ it("shows the server's reason with Retry when get_count answers 417", async () => {
98
+ failing = "count";
99
+ renderTable();
100
+ await waitFor(() => expect(screen.getByText(/Count timed out/)).toBeTruthy(), settle);
101
+ expect(screen.getByText("Retry")).toBeTruthy();
102
+ expect(screen.queryByText("No data available")).toBeNull();
103
+ expect(screen.queryByText("Bulbasaur")).toBeNull();
104
+ });
105
+
106
+ it("shows the server's reason when the /api/resource read fails, and Retry loads the rows", async () => {
107
+ failing = "rows";
108
+ const user = userEvent.setup();
109
+ renderTable();
110
+ await waitFor(() => expect(screen.getByText(/Pokemon is being migrated/)).toBeTruthy(), settle);
111
+ expect(screen.queryByText("No data available")).toBeNull();
112
+
113
+ failing = null;
114
+ await user.click(screen.getByText("Retry"));
115
+ await waitFor(() => expect(screen.getByText("Bulbasaur")).toBeTruthy(), settle);
116
+ expect(screen.queryByText(/Pokemon is being migrated/)).toBeNull();
117
+ });
118
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Web `Date` field for the three Frappe date fieldtypes, and the native one
3
+ * when the optional `@react-native-community/datetimepicker` peer is absent.
4
+ *
5
+ * The field used to render ONE date-only calendar for all three types, with
6
+ * only the placeholder varying — so a `Datetime` silently dropped its time
7
+ * component from the picker and a `Time` rendered EMPTY, because the calendar
8
+ * adopts its value through `coerceToDate`, for which a bare `HH:mm` is not a
9
+ * date (SB-M-617). Each type now gets the house primitive that matches it:
10
+ *
11
+ * date → forms `DatePicker` (month grid)
12
+ * datetime → forms `DatetimePicker` (Date/Time tabs — grid, then columns)
13
+ * time → the package's own `TimePicker`, which is also what the field
14
+ * registry routes the Frappe `Time` fieldtype to, so a
15
+ * `Date type="time"` and a `Time` field are one control
16
+ *
17
+ * This mirrors what the native half already does (calendar / wheel /
18
+ * calendar-then-wheel), and every branch serializes through the one shared
19
+ * `wire.ts` body, so a web pick and a native pick of the same moment persist
20
+ * byte-identical strings.
21
+ */
22
+
23
+ import {
24
+ DatePicker as FormsDatePicker,
25
+ DatetimePicker as FormsDatetimePicker,
26
+ } from "@multiplatform.one/forms";
27
+ import { useMemo } from "react";
28
+ import { useProps } from "tamagui";
29
+ import { TimePicker, type TimePickerProps } from "../TimePicker";
30
+ import type { DateProps } from "./types";
31
+ import { fromFrappeDateValue, toFrappeDateValue } from "./wire";
32
+
33
+ export type { DateProps } from "./types";
34
+
35
+ /**
36
+ * Adopt a Frappe datetime wire string through the shared read body rather
37
+ * than handing the raw string to the picker, whose `coerceToDate` falls
38
+ * through to `new Date(string)` for anything that is not date-only —
39
+ * implementation-defined for the space-separated shape Frappe writes.
40
+ * Memoized by the caller so the Date identity is stable across renders: the
41
+ * picker re-seeds its display state from every new `value` identity, which
42
+ * would otherwise discard an in-progress edit on an unrelated re-render.
43
+ */
44
+ function toPickerDate(value: string | undefined): Date | null {
45
+ if (!value) return null;
46
+ const date = fromFrappeDateValue(value, "datetime");
47
+ return Number.isNaN(date.getTime()) ? null : date;
48
+ }
49
+
50
+ /**
51
+ * `TimePicker` speaks `HH:mm`; Frappe stores `HH:mm:ss`. Round-tripping
52
+ * through the shared wire body — rather than padding the string here — is
53
+ * what keeps the two platform halves byte-identical, since the native half
54
+ * serializes its wheel pick through exactly this call.
55
+ */
56
+ function toTimeWireValue(picked: string): string {
57
+ if (!picked) return "";
58
+ const date = fromFrappeDateValue(picked, "time");
59
+ return Number.isNaN(date.getTime()) ? "" : toFrappeDateValue(date, "time");
60
+ }
61
+
62
+ export function Date<
63
+ TData extends Record<string, unknown> = Record<string, unknown>,
64
+ TName extends string = string,
65
+ >(props: DateProps<TData, TName>) {
66
+ const {
67
+ defaultValue,
68
+ form,
69
+ inputProps: _inputProps,
70
+ mode,
71
+ name,
72
+ onBlur,
73
+ onChange,
74
+ preserveValue,
75
+ validators,
76
+ disabled,
77
+ value,
78
+ type = "date",
79
+ ...fieldProps
80
+ } = useProps(props) as DateProps<TData, TName> & Record<string, unknown>;
81
+
82
+ const wireValue = value as string | undefined;
83
+ const emit = onChange as ((v: string) => void) | undefined;
84
+ const datetimeValue = useMemo(
85
+ () => (type === "datetime" ? toPickerDate(wireValue) : null),
86
+ [type, wireValue],
87
+ );
88
+
89
+ const placeholder =
90
+ (fieldProps.placeholder as string | undefined) ??
91
+ (type === "time" ? "Select time" : type === "datetime" ? "Select date & time" : "Select date");
92
+
93
+ if (type === "time") {
94
+ return (
95
+ <TimePicker
96
+ // `TimePicker` is not generic over the form's data shape, so the
97
+ // three form props are re-cast to its own non-generic surface.
98
+ name={name as TimePickerProps["name"]}
99
+ form={form as TimePickerProps["form"]}
100
+ validators={validators as TimePickerProps["validators"]}
101
+ // The wire shape is what this control already speaks: `HH:mm[:ss]`,
102
+ // 24h. It adopts the string directly, so nothing has to widen the
103
+ // shared date parser to let a Time field display its own value.
104
+ value={wireValue}
105
+ onChange={(picked) => emit?.(toTimeWireValue(picked))}
106
+ defaultValue={defaultValue as string | undefined}
107
+ label={fieldProps.label}
108
+ labelProps={fieldProps.labelProps}
109
+ error={fieldProps.error}
110
+ helperText={fieldProps.helperText}
111
+ readOnly={fieldProps.readOnly}
112
+ skeleton={fieldProps.skeleton}
113
+ compact={fieldProps.compact}
114
+ required={fieldProps.required}
115
+ id={fieldProps.id}
116
+ size={fieldProps.size}
117
+ disabled={disabled}
118
+ placeholder={placeholder}
119
+ onBlur={onBlur}
120
+ />
121
+ );
122
+ }
123
+
124
+ if (type === "datetime") {
125
+ return (
126
+ <FormsDatetimePicker
127
+ name={name}
128
+ form={form}
129
+ validators={validators}
130
+ value={datetimeValue}
131
+ // No `previousValue`: this picker owns both halves of the value and
132
+ // preserves an existing time across a day change itself, so carrying
133
+ // the old time part here would overwrite a time the user just set.
134
+ // The native half passes nothing for the same reason.
135
+ onChange={(date) => emit?.(date ? toFrappeDateValue(date, "datetime") : "")}
136
+ defaultValue={defaultValue as string}
137
+ label={fieldProps.label}
138
+ labelProps={fieldProps.labelProps}
139
+ error={fieldProps.error}
140
+ helperText={fieldProps.helperText}
141
+ readOnly={fieldProps.readOnly}
142
+ skeleton={fieldProps.skeleton}
143
+ compact={fieldProps.compact}
144
+ required={fieldProps.required}
145
+ id={fieldProps.id}
146
+ size={fieldProps.size}
147
+ disabled={disabled}
148
+ minDate={fieldProps.minDate}
149
+ maxDate={fieldProps.maxDate}
150
+ placeholder={placeholder}
151
+ onBlur={onBlur}
152
+ mode={mode}
153
+ preserveValue={preserveValue}
154
+ />
155
+ );
156
+ }
157
+
158
+ return (
159
+ <FormsDatePicker
160
+ name={name}
161
+ form={form}
162
+ validators={validators}
163
+ // Controlled pass-through: folding `value` into `defaultValue` was
164
+ // mount-only, so a value arriving after first paint (create-mode
165
+ // engine seeding) never displayed — and `onChange` was dropped, so
166
+ // picks never reached the consumer doc. `undefined` stays undefined so
167
+ // uncontrolled (defaultValue-only) use keeps its semantics.
168
+ value={wireValue}
169
+ onChange={(date) => emit?.(date ? toFrappeDateValue(date, "date") : "")}
170
+ defaultValue={defaultValue as string}
171
+ label={fieldProps.label}
172
+ labelProps={fieldProps.labelProps}
173
+ error={fieldProps.error}
174
+ helperText={fieldProps.helperText}
175
+ readOnly={fieldProps.readOnly}
176
+ skeleton={fieldProps.skeleton}
177
+ compact={fieldProps.compact}
178
+ required={fieldProps.required}
179
+ id={fieldProps.id}
180
+ size={fieldProps.size}
181
+ disabled={disabled}
182
+ minDate={fieldProps.minDate}
183
+ maxDate={fieldProps.maxDate}
184
+ placeholder={placeholder}
185
+ onBlur={onBlur}
186
+ mode={mode}
187
+ preserveValue={preserveValue}
188
+ />
189
+ );
190
+ }
@@ -0,0 +1,25 @@
1
+ export type DateTimePickerComponent =
2
+ typeof import("@react-native-community/datetimepicker").default;
3
+ export type { DateTimePickerEvent } from "@react-native-community/datetimepicker";
4
+
5
+ let cached: DateTimePickerComponent | null | undefined;
6
+
7
+ /**
8
+ * Loads the optional `@react-native-community/datetimepicker` peer at runtime,
9
+ * the same contract as forms' `nativeDateTime/dateTimePickerLoader.ts`. A
10
+ * static import of an absent peer stays external in the native bundle and
11
+ * hermesc rejects the leftover `import` statement (FZ-7357); the guarded
12
+ * `require` returns null instead, and the field renders its catalog half.
13
+ */
14
+ export function loadDateTimePicker(): DateTimePickerComponent | null {
15
+ if (cached !== undefined) return cached;
16
+ try {
17
+ const mod = require("@react-native-community/datetimepicker") as {
18
+ default?: DateTimePickerComponent;
19
+ };
20
+ cached = mod?.default ?? null;
21
+ } catch {
22
+ cached = null;
23
+ }
24
+ return cached;
25
+ }
@@ -25,9 +25,14 @@ import {
25
25
  } from "react-native";
26
26
  import { SafeAreaInsetsContext, initialWindowMetrics } from "react-native-safe-area-context";
27
27
  import { platform } from "@multiplatform.one/platform";
28
- import DateTimePicker, { type DateTimePickerEvent } from "@react-native-community/datetimepicker";
29
28
  import { Text, View, XStack, YStack, useProps, useTheme, useThemeName } from "tamagui";
30
29
  import { useKeyboardInset } from "../../shared/useKeyboardInset";
30
+ import { Date as CatalogDate } from "./catalog";
31
+ import {
32
+ type DateTimePickerComponent,
33
+ type DateTimePickerEvent,
34
+ loadDateTimePicker,
35
+ } from "./dateTimePickerLoader";
31
36
  import { datePickerHeight, dateSheetBottomPadding, dateSheetMaxHeight } from "./sheetKeyboard";
32
37
  import type { DateProps } from "./types";
33
38
  import { fromFrappeDateValue, toFrappeDateValue } from "./wire";
@@ -104,10 +109,13 @@ function useNativePickerScheme(): "light" | "dark" {
104
109
  * written as `function Date` so the global `Date` constructor stays reachable
105
110
  * inside this module.
106
111
  */
107
- function DateField<
112
+ function OsDateField<
108
113
  TData extends Record<string, unknown> = Record<string, unknown>,
109
114
  TName extends string = string,
110
- >(props: DateProps<TData, TName>) {
115
+ >({
116
+ DateTimePicker,
117
+ ...props
118
+ }: DateProps<TData, TName> & { DateTimePicker: DateTimePickerComponent }) {
111
119
  const {
112
120
  compact,
113
121
  defaultValue,
@@ -421,4 +429,17 @@ function DateField<
421
429
  );
422
430
  }
423
431
 
432
+ /**
433
+ * Without the optional `@react-native-community/datetimepicker` peer the
434
+ * field keeps working as the catalog picker the web half renders.
435
+ */
436
+ function DateField<
437
+ TData extends Record<string, unknown> = Record<string, unknown>,
438
+ TName extends string = string,
439
+ >(props: DateProps<TData, TName>) {
440
+ const DateTimePicker = loadDateTimePicker();
441
+ if (!DateTimePicker) return <CatalogDate {...props} />;
442
+ return <OsDateField {...props} DateTimePicker={DateTimePicker} />;
443
+ }
444
+
424
445
  export { DateField as Date };
@@ -1,189 +1,2 @@
1
- /**
2
- * Web `Date` field for the three Frappe date fieldtypes.
3
- *
4
- * The field used to render ONE date-only calendar for all three types, with
5
- * only the placeholder varying — so a `Datetime` silently dropped its time
6
- * component from the picker and a `Time` rendered EMPTY, because the calendar
7
- * adopts its value through `coerceToDate`, for which a bare `HH:mm` is not a
8
- * date (SB-M-617). Each type now gets the house primitive that matches it:
9
- *
10
- * date → forms `DatePicker` (month grid)
11
- * datetime → forms `DatetimePicker` (Date/Time tabs — grid, then columns)
12
- * time → the package's own `TimePicker`, which is also what the field
13
- * registry routes the Frappe `Time` fieldtype to, so a
14
- * `Date type="time"` and a `Time` field are one control
15
- *
16
- * This mirrors what the native half already does (calendar / wheel /
17
- * calendar-then-wheel), and every branch serializes through the one shared
18
- * `wire.ts` body, so a web pick and a native pick of the same moment persist
19
- * byte-identical strings.
20
- */
21
-
22
- import {
23
- DatePicker as FormsDatePicker,
24
- DatetimePicker as FormsDatetimePicker,
25
- } from "@multiplatform.one/forms";
26
- import { useMemo } from "react";
27
- import { useProps } from "tamagui";
28
- import { TimePicker, type TimePickerProps } from "../TimePicker";
29
- import type { DateProps } from "./types";
30
- import { fromFrappeDateValue, toFrappeDateValue } from "./wire";
31
-
1
+ export { Date } from "./catalog";
32
2
  export type { DateProps } from "./types";
33
-
34
- /**
35
- * Adopt a Frappe datetime wire string through the shared read body rather
36
- * than handing the raw string to the picker, whose `coerceToDate` falls
37
- * through to `new Date(string)` for anything that is not date-only —
38
- * implementation-defined for the space-separated shape Frappe writes.
39
- * Memoized by the caller so the Date identity is stable across renders: the
40
- * picker re-seeds its display state from every new `value` identity, which
41
- * would otherwise discard an in-progress edit on an unrelated re-render.
42
- */
43
- function toPickerDate(value: string | undefined): Date | null {
44
- if (!value) return null;
45
- const date = fromFrappeDateValue(value, "datetime");
46
- return Number.isNaN(date.getTime()) ? null : date;
47
- }
48
-
49
- /**
50
- * `TimePicker` speaks `HH:mm`; Frappe stores `HH:mm:ss`. Round-tripping
51
- * through the shared wire body — rather than padding the string here — is
52
- * what keeps the two platform halves byte-identical, since the native half
53
- * serializes its wheel pick through exactly this call.
54
- */
55
- function toTimeWireValue(picked: string): string {
56
- if (!picked) return "";
57
- const date = fromFrappeDateValue(picked, "time");
58
- return Number.isNaN(date.getTime()) ? "" : toFrappeDateValue(date, "time");
59
- }
60
-
61
- export function Date<
62
- TData extends Record<string, unknown> = Record<string, unknown>,
63
- TName extends string = string,
64
- >(props: DateProps<TData, TName>) {
65
- const {
66
- defaultValue,
67
- form,
68
- inputProps: _inputProps,
69
- mode,
70
- name,
71
- onBlur,
72
- onChange,
73
- preserveValue,
74
- validators,
75
- disabled,
76
- value,
77
- type = "date",
78
- ...fieldProps
79
- } = useProps(props) as DateProps<TData, TName> & Record<string, unknown>;
80
-
81
- const wireValue = value as string | undefined;
82
- const emit = onChange as ((v: string) => void) | undefined;
83
- const datetimeValue = useMemo(
84
- () => (type === "datetime" ? toPickerDate(wireValue) : null),
85
- [type, wireValue],
86
- );
87
-
88
- const placeholder =
89
- (fieldProps.placeholder as string | undefined) ??
90
- (type === "time" ? "Select time" : type === "datetime" ? "Select date & time" : "Select date");
91
-
92
- if (type === "time") {
93
- return (
94
- <TimePicker
95
- // `TimePicker` is not generic over the form's data shape, so the
96
- // three form props are re-cast to its own non-generic surface.
97
- name={name as TimePickerProps["name"]}
98
- form={form as TimePickerProps["form"]}
99
- validators={validators as TimePickerProps["validators"]}
100
- // The wire shape is what this control already speaks: `HH:mm[:ss]`,
101
- // 24h. It adopts the string directly, so nothing has to widen the
102
- // shared date parser to let a Time field display its own value.
103
- value={wireValue}
104
- onChange={(picked) => emit?.(toTimeWireValue(picked))}
105
- defaultValue={defaultValue as string | undefined}
106
- label={fieldProps.label}
107
- labelProps={fieldProps.labelProps}
108
- error={fieldProps.error}
109
- helperText={fieldProps.helperText}
110
- readOnly={fieldProps.readOnly}
111
- skeleton={fieldProps.skeleton}
112
- compact={fieldProps.compact}
113
- required={fieldProps.required}
114
- id={fieldProps.id}
115
- size={fieldProps.size}
116
- disabled={disabled}
117
- placeholder={placeholder}
118
- onBlur={onBlur}
119
- />
120
- );
121
- }
122
-
123
- if (type === "datetime") {
124
- return (
125
- <FormsDatetimePicker
126
- name={name}
127
- form={form}
128
- validators={validators}
129
- value={datetimeValue}
130
- // No `previousValue`: this picker owns both halves of the value and
131
- // preserves an existing time across a day change itself, so carrying
132
- // the old time part here would overwrite a time the user just set.
133
- // The native half passes nothing for the same reason.
134
- onChange={(date) => emit?.(date ? toFrappeDateValue(date, "datetime") : "")}
135
- defaultValue={defaultValue as string}
136
- label={fieldProps.label}
137
- labelProps={fieldProps.labelProps}
138
- error={fieldProps.error}
139
- helperText={fieldProps.helperText}
140
- readOnly={fieldProps.readOnly}
141
- skeleton={fieldProps.skeleton}
142
- compact={fieldProps.compact}
143
- required={fieldProps.required}
144
- id={fieldProps.id}
145
- size={fieldProps.size}
146
- disabled={disabled}
147
- minDate={fieldProps.minDate}
148
- maxDate={fieldProps.maxDate}
149
- placeholder={placeholder}
150
- onBlur={onBlur}
151
- mode={mode}
152
- preserveValue={preserveValue}
153
- />
154
- );
155
- }
156
-
157
- return (
158
- <FormsDatePicker
159
- name={name}
160
- form={form}
161
- validators={validators}
162
- // Controlled pass-through: folding `value` into `defaultValue` was
163
- // mount-only, so a value arriving after first paint (create-mode
164
- // engine seeding) never displayed — and `onChange` was dropped, so
165
- // picks never reached the consumer doc. `undefined` stays undefined so
166
- // uncontrolled (defaultValue-only) use keeps its semantics.
167
- value={wireValue}
168
- onChange={(date) => emit?.(date ? toFrappeDateValue(date, "date") : "")}
169
- defaultValue={defaultValue as string}
170
- label={fieldProps.label}
171
- labelProps={fieldProps.labelProps}
172
- error={fieldProps.error}
173
- helperText={fieldProps.helperText}
174
- readOnly={fieldProps.readOnly}
175
- skeleton={fieldProps.skeleton}
176
- compact={fieldProps.compact}
177
- required={fieldProps.required}
178
- id={fieldProps.id}
179
- size={fieldProps.size}
180
- disabled={disabled}
181
- minDate={fieldProps.minDate}
182
- maxDate={fieldProps.maxDate}
183
- placeholder={placeholder}
184
- onBlur={onBlur}
185
- mode={mode}
186
- preserveValue={preserveValue}
187
- />
188
- );
189
- }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The native `Date` field without the optional
3
+ * `@react-native-community/datetimepicker` peer (FZ-7357): it renders the
4
+ * catalog half instead of an OS picker it cannot load.
5
+ */
6
+
7
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
8
+ import { describe, expect, it, vi } from "vitest";
9
+
10
+ vi.setConfig({ testTimeout: 240_000 });
11
+
12
+ vi.mock("./dateTimePickerLoader", () => ({ loadDateTimePicker: () => null }));
13
+ vi.mock("./catalog", () => ({
14
+ Date: ({ type }: { type?: string }) => <div data-testid="catalog-date" data-type={type} />,
15
+ }));
16
+
17
+ describe("native Date without the datetimepicker peer", () => {
18
+ it.each(["date", "datetime", "time"] as const)("renders the catalog %s field", async (type) => {
19
+ const { Date: DateField } = await import("./index.native");
20
+ const result = renderWithProviders(<DateField label="When" type={type} />);
21
+ expect(result.getByTestId("catalog-date").dataset.type).toBe(type);
22
+ });
23
+ });