@multiplatform.one/frappe-ui 7.8.0 → 7.10.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/dist/cjs/FormSidebar.cjs +7 -7
- package/dist/cjs/FormSidebar.native.js +7 -7
- package/dist/cjs/FormSidebar.native.js.map +1 -1
- package/dist/cjs/FrappeChartCard.cjs +29 -3
- package/dist/cjs/FrappeChartCard.native.js +29 -3
- package/dist/cjs/FrappeChartCard.native.js.map +1 -1
- package/dist/cjs/FrappeKPICard.cjs +6 -5
- package/dist/cjs/FrappeKPICard.native.js +6 -5
- package/dist/cjs/FrappeKPICard.native.js.map +1 -1
- package/dist/cjs/FrappeListView.cjs +6 -6
- package/dist/cjs/FrappeListView.native.js +6 -6
- package/dist/cjs/FrappeListView.native.js.map +1 -1
- package/dist/cjs/fields/DateRange/index.cjs +30 -2
- package/dist/cjs/fields/DateRange/index.native.js +34 -2
- package/dist/cjs/fields/DateRange/index.native.js.map +1 -1
- package/dist/cjs/shared/devWarn.native.js.map +1 -1
- package/dist/esm/FormSidebar.mjs +7 -7
- package/dist/esm/FormSidebar.mjs.map +1 -1
- package/dist/esm/FormSidebar.native.js +7 -7
- package/dist/esm/FormSidebar.native.js.map +1 -1
- package/dist/esm/FrappeChartCard.mjs +30 -4
- package/dist/esm/FrappeChartCard.mjs.map +1 -1
- package/dist/esm/FrappeChartCard.native.js +30 -4
- package/dist/esm/FrappeChartCard.native.js.map +1 -1
- package/dist/esm/FrappeKPICard.mjs +6 -5
- package/dist/esm/FrappeKPICard.mjs.map +1 -1
- package/dist/esm/FrappeKPICard.native.js +6 -5
- package/dist/esm/FrappeKPICard.native.js.map +1 -1
- package/dist/esm/FrappeListView.mjs +6 -6
- package/dist/esm/FrappeListView.mjs.map +1 -1
- package/dist/esm/FrappeListView.native.js +6 -6
- package/dist/esm/FrappeListView.native.js.map +1 -1
- package/dist/esm/fields/DateRange/index.mjs +30 -2
- package/dist/esm/fields/DateRange/index.mjs.map +1 -1
- package/dist/esm/fields/DateRange/index.native.js +34 -2
- package/dist/esm/fields/DateRange/index.native.js.map +1 -1
- package/dist/esm/shared/devWarn.mjs.map +1 -1
- package/dist/esm/shared/devWarn.native.js.map +1 -1
- package/dist/jsx/FormSidebar.mjs +7 -7
- package/dist/jsx/FormSidebar.mjs.map +1 -1
- package/dist/jsx/FormSidebar.native.js +7 -7
- package/dist/jsx/FormSidebar.native.js.map +1 -1
- package/dist/jsx/FrappeChartCard.mjs +30 -4
- package/dist/jsx/FrappeChartCard.mjs.map +1 -1
- package/dist/jsx/FrappeChartCard.native.js +29 -3
- package/dist/jsx/FrappeChartCard.native.js.map +1 -1
- package/dist/jsx/FrappeKPICard.mjs +6 -5
- package/dist/jsx/FrappeKPICard.mjs.map +1 -1
- package/dist/jsx/FrappeKPICard.native.js +6 -5
- package/dist/jsx/FrappeKPICard.native.js.map +1 -1
- package/dist/jsx/FrappeListView.mjs +6 -6
- package/dist/jsx/FrappeListView.mjs.map +1 -1
- package/dist/jsx/FrappeListView.native.js +6 -6
- package/dist/jsx/FrappeListView.native.js.map +1 -1
- package/dist/jsx/fields/DateRange/index.mjs +30 -2
- package/dist/jsx/fields/DateRange/index.mjs.map +1 -1
- package/dist/jsx/fields/DateRange/index.native.js +34 -2
- package/dist/jsx/fields/DateRange/index.native.js.map +1 -1
- package/dist/jsx/shared/devWarn.mjs.map +1 -1
- package/dist/jsx/shared/devWarn.native.js.map +1 -1
- package/package.json +16 -16
- package/src/FormSidebar.tsx +7 -7
- package/src/FrappeChartCard.spec.tsx +179 -0
- package/src/FrappeChartCard.tsx +55 -6
- package/src/FrappeKPICard.spec.tsx +25 -0
- package/src/FrappeKPICard.tsx +17 -11
- package/src/FrappeListView.tsx +6 -6
- package/src/fields/Date/Date.spec.tsx +8 -1
- package/src/fields/Date/nativePresentation.spec.tsx +8 -1
- package/src/fields/Date/typeParity.spec.tsx +8 -1
- package/src/fields/DateRange/DateRange.spec.tsx +87 -15
- package/src/fields/DateRange/index.tsx +47 -3
- package/src/shared/devWarn.ts +3 -1
- package/types/FrappeChartCard.d.ts +17 -1
- package/types/FrappeChartCard.d.ts.map +1 -1
- package/types/FrappeKPICard.d.ts +11 -6
- package/types/FrappeKPICard.d.ts.map +1 -1
- package/types/fields/DateRange/index.d.ts.map +1 -1
- package/types/shared/devWarn.d.ts +1 -1
- package/types/shared/devWarn.d.ts.map +1 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MPO-287: a `dataSource` with no data source in scope is a misconfiguration,
|
|
3
|
+
* not static mode — the same defect MPO-237 fixed one component over. The
|
|
4
|
+
* card asked for a computed series and there is nowhere to compute it, so
|
|
5
|
+
* `config.data` must never be painted as the series: a plausible shape
|
|
6
|
+
* presented confidently is worse than a visible gap, because nothing
|
|
7
|
+
* downstream can tell it was never computed.
|
|
8
|
+
*
|
|
9
|
+
* Static mode (no `dataSource` at all) is the documented, intended path and
|
|
10
|
+
* is asserted here too, so the fix cannot over-correct into it. A fixture
|
|
11
|
+
* corpus is a data source (MPO-228), so it stands in for a baseURL and must
|
|
12
|
+
* not trip the guard.
|
|
13
|
+
*
|
|
14
|
+
* The chart body itself is not assertable here: ChartSurface measures its
|
|
15
|
+
* own width through `onLayout`, which never fires in happy-dom, so no SVG
|
|
16
|
+
* reaches the DOM. Two things stand in. The card's plain title renders only
|
|
17
|
+
* when a series reached `ChartCard` — the skeleton, the empty surface and
|
|
18
|
+
* the not-configured surface each carry different text. And `formatLabel`
|
|
19
|
+
* is called once per computed group, so spying on it proves whether the
|
|
20
|
+
* aggregate ran at all.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { InMemoryFixtureProvider } from "@multiplatform.one/frappe";
|
|
24
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
25
|
+
import { cleanup, screen, waitFor } from "@testing-library/react";
|
|
26
|
+
import i18next from "i18next";
|
|
27
|
+
import { initReactI18next } from "react-i18next";
|
|
28
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
29
|
+
import { FrappeChartCard } from "./FrappeChartCard";
|
|
30
|
+
import { FrappeProvider } from "./FrappeProvider";
|
|
31
|
+
import { __resetFrappeDevWarnSeen } from "./shared/devWarn";
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
await i18next.use(initReactI18next).init({
|
|
35
|
+
lng: "en",
|
|
36
|
+
interpolation: { escapeValue: false },
|
|
37
|
+
resources: { en: { translation: {} } },
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
__resetFrappeDevWarnSeen();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
cleanup();
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const config = {
|
|
51
|
+
id: "todo-by-status",
|
|
52
|
+
title: "ToDos by Status",
|
|
53
|
+
type: "bar" as const,
|
|
54
|
+
data: [
|
|
55
|
+
{ label: "Open", value: 41 },
|
|
56
|
+
{ label: "Closed", value: 7 },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const dataSource = { doctype: "ToDo", groupBy: "status" };
|
|
61
|
+
|
|
62
|
+
const todos = [
|
|
63
|
+
{ name: "TD-1", doctype: "ToDo", status: "Open" },
|
|
64
|
+
{ name: "TD-2", doctype: "ToDo", status: "Open" },
|
|
65
|
+
{ name: "TD-3", doctype: "ToDo", status: "Closed" },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
describe("FrappeChartCard with a dataSource and no baseURL (MPO-287)", () => {
|
|
69
|
+
it("does not render config.data as though it were the computed series", () => {
|
|
70
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
71
|
+
renderWithProviders(<FrappeChartCard config={config} dataSource={dataSource} />);
|
|
72
|
+
|
|
73
|
+
expect(screen.queryByText("ToDos by Status")).toBeNull();
|
|
74
|
+
expect(screen.getByText("ToDos by Status is not configured")).toBeInTheDocument();
|
|
75
|
+
expect(screen.getByText(/no Frappe baseURL is in scope/)).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("logs a development warning naming the missing baseURL", () => {
|
|
79
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
80
|
+
renderWithProviders(<FrappeChartCard config={config} dataSource={dataSource} />);
|
|
81
|
+
|
|
82
|
+
const line = warn.mock.calls.map((call) => String(call[0])).join("\n");
|
|
83
|
+
expect(line).toContain("[frappe-ui] chart-datasource-without-baseurl");
|
|
84
|
+
expect(line).toContain("baseURL");
|
|
85
|
+
expect(line).toContain("component=FrappeChartCard");
|
|
86
|
+
expect(line).toContain("id=todo-by-status");
|
|
87
|
+
expect(line).toContain("doctype=ToDo");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("warns once per card, not once per render", () => {
|
|
91
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
92
|
+
const { rerender } = renderWithProviders(
|
|
93
|
+
<FrappeChartCard config={config} dataSource={dataSource} />,
|
|
94
|
+
);
|
|
95
|
+
rerender(<FrappeChartCard config={{ ...config }} dataSource={dataSource} />);
|
|
96
|
+
rerender(<FrappeChartCard config={{ ...config }} dataSource={dataSource} />);
|
|
97
|
+
|
|
98
|
+
const lines = warn.mock.calls.filter((call) =>
|
|
99
|
+
String(call[0]).includes("chart-datasource-without-baseurl"),
|
|
100
|
+
);
|
|
101
|
+
expect(lines).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("stays silent in production", () => {
|
|
105
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
106
|
+
const previous = process.env.NODE_ENV;
|
|
107
|
+
process.env.NODE_ENV = "production";
|
|
108
|
+
try {
|
|
109
|
+
renderWithProviders(<FrappeChartCard config={config} dataSource={dataSource} />);
|
|
110
|
+
} finally {
|
|
111
|
+
process.env.NODE_ENV = previous;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
warn.mock.calls.filter((call) =>
|
|
116
|
+
String(call[0]).includes("chart-datasource-without-baseurl"),
|
|
117
|
+
),
|
|
118
|
+
).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("FrappeChartCard configured paths stay untouched (MPO-287)", () => {
|
|
123
|
+
it("renders config.data with no warning when no dataSource is set", () => {
|
|
124
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
125
|
+
renderWithProviders(<FrappeChartCard config={config} />);
|
|
126
|
+
|
|
127
|
+
expect(screen.getByText("ToDos by Status")).toBeInTheDocument();
|
|
128
|
+
expect(screen.queryByText("ToDos by Status is not configured")).toBeNull();
|
|
129
|
+
expect(
|
|
130
|
+
warn.mock.calls.filter((call) =>
|
|
131
|
+
String(call[0]).includes("chart-datasource-without-baseurl"),
|
|
132
|
+
),
|
|
133
|
+
).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("computes the series, and does not warn, when the provider supplies a baseURL", async () => {
|
|
137
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
138
|
+
const formatLabel = vi.fn((label: string) => label);
|
|
139
|
+
renderWithProviders(
|
|
140
|
+
<FrappeProvider baseURL="http://test" fixtures={new InMemoryFixtureProvider({ ToDo: todos })}>
|
|
141
|
+
<FrappeChartCard config={config} dataSource={{ ...dataSource, formatLabel }} />
|
|
142
|
+
</FrappeProvider>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await waitFor(() => expect(screen.getByText("ToDos by Status")).toBeInTheDocument());
|
|
146
|
+
expect([...new Set(formatLabel.mock.calls.map((call) => call[0]))].sort()).toEqual([
|
|
147
|
+
"Closed",
|
|
148
|
+
"Open",
|
|
149
|
+
]);
|
|
150
|
+
expect(screen.queryByText("ToDos by Status is not configured")).toBeNull();
|
|
151
|
+
expect(
|
|
152
|
+
warn.mock.calls.filter((call) =>
|
|
153
|
+
String(call[0]).includes("chart-datasource-without-baseurl"),
|
|
154
|
+
),
|
|
155
|
+
).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("treats a fixture corpus as the data source when no baseURL is set (MPO-228)", async () => {
|
|
159
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
160
|
+
const formatLabel = vi.fn((label: string) => label);
|
|
161
|
+
renderWithProviders(
|
|
162
|
+
<FrappeProvider fixtures={new InMemoryFixtureProvider({ ToDo: todos })}>
|
|
163
|
+
<FrappeChartCard config={config} dataSource={{ ...dataSource, formatLabel }} />
|
|
164
|
+
</FrappeProvider>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await waitFor(() => expect(screen.getByText("ToDos by Status")).toBeInTheDocument());
|
|
168
|
+
expect([...new Set(formatLabel.mock.calls.map((call) => call[0]))].sort()).toEqual([
|
|
169
|
+
"Closed",
|
|
170
|
+
"Open",
|
|
171
|
+
]);
|
|
172
|
+
expect(screen.queryByText("ToDos by Status is not configured")).toBeNull();
|
|
173
|
+
expect(
|
|
174
|
+
warn.mock.calls.filter((call) =>
|
|
175
|
+
String(call[0]).includes("chart-datasource-without-baseurl"),
|
|
176
|
+
),
|
|
177
|
+
).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
package/src/FrappeChartCard.tsx
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
* (permission failures called out), catalog empty state when the aggregate
|
|
16
16
|
* returns no groups, and a subtle stale dim while a post-event refetch is
|
|
17
17
|
* in flight.
|
|
18
|
+
*
|
|
19
|
+
* A `dataSource` with no data source in scope — no `baseURL` from the prop
|
|
20
|
+
* or a FrappeProvider, and no fixture corpus either — is a misconfiguration,
|
|
21
|
+
* not static mode: the caller asked for a computed series and there is
|
|
22
|
+
* nowhere to compute it. The card renders the not-configured surface and
|
|
23
|
+
* warns in dev. It never falls back to `config.data`, which would present
|
|
24
|
+
* an uncomputed shape as the series. A fixture corpus answers every read
|
|
25
|
+
* locally, so it stands in for a baseURL (MPO-228).
|
|
18
26
|
*/
|
|
19
27
|
import {
|
|
20
28
|
AsyncBoundary,
|
|
@@ -23,12 +31,13 @@ import {
|
|
|
23
31
|
type ChartCardProps,
|
|
24
32
|
type ChartConfig,
|
|
25
33
|
} from "@multiplatform.one/components";
|
|
26
|
-
import React, { useMemo } from "react";
|
|
34
|
+
import React, { useEffect, useMemo } from "react";
|
|
27
35
|
import { useTranslation } from "react-i18next";
|
|
28
36
|
import { YStack, type YStackProps } from "tamagui";
|
|
29
37
|
import { useResolvedKnobs } from "@multiplatform.one/theme";
|
|
30
38
|
import { ChartCard } from "./ChartCard";
|
|
31
39
|
import { useFrappeConfig } from "./FrappeProvider";
|
|
40
|
+
import { frappeDevWarn } from "./shared/devWarn";
|
|
32
41
|
import {
|
|
33
42
|
useFrappeAggregate,
|
|
34
43
|
type FrappeAggregateConnection,
|
|
@@ -52,10 +61,18 @@ export interface FrappeChartDataSource {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export interface FrappeChartCardProps extends Omit<ChartCardProps, "config"> {
|
|
55
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Chart chrome (title, type, height…). In live mode `data` is overridden.
|
|
66
|
+
* With a `dataSource` and no data source in scope, `data` is not rendered
|
|
67
|
+
* at all.
|
|
68
|
+
*/
|
|
56
69
|
config: Omit<ChartConfig, "data"> & { data?: ChartConfig["data"] };
|
|
57
70
|
/** When set, the chart series goes live against this doctype. */
|
|
58
71
|
dataSource?: FrappeChartDataSource;
|
|
72
|
+
/**
|
|
73
|
+
* Falls back to the FrappeProvider's. Required whenever `dataSource` is
|
|
74
|
+
* set, unless the provider carries a fixture corpus.
|
|
75
|
+
*/
|
|
59
76
|
baseURL?: string;
|
|
60
77
|
auth?: { useToken?: boolean; token?: string; type?: string };
|
|
61
78
|
syncOptions?: unknown;
|
|
@@ -94,8 +111,9 @@ export function FrappeChartCard({
|
|
|
94
111
|
const { t } = useTranslation();
|
|
95
112
|
|
|
96
113
|
const finalBaseURL = baseURL ?? providerConfig?.baseURL;
|
|
114
|
+
const fixtures = providerConfig?.fixtures;
|
|
97
115
|
const connection: FrappeAggregateConnection | undefined =
|
|
98
|
-
dataSource && finalBaseURL
|
|
116
|
+
dataSource && (finalBaseURL || fixtures)
|
|
99
117
|
? {
|
|
100
118
|
baseURL: finalBaseURL,
|
|
101
119
|
socketPort: providerConfig?.socketPort,
|
|
@@ -104,10 +122,22 @@ export function FrappeChartCard({
|
|
|
104
122
|
auth: auth ?? providerConfig?.auth,
|
|
105
123
|
syncOptions: syncOptions ?? providerConfig?.syncOptions,
|
|
106
124
|
realtime: providerConfig?.realtime,
|
|
107
|
-
fixtures
|
|
125
|
+
fixtures,
|
|
108
126
|
}
|
|
109
127
|
: undefined;
|
|
110
128
|
|
|
129
|
+
const missingConnection = !!dataSource && !connection;
|
|
130
|
+
const doctype = dataSource?.doctype;
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!missingConnection) return;
|
|
133
|
+
frappeDevWarn("chart-datasource-without-baseurl", {
|
|
134
|
+
component: "FrappeChartCard",
|
|
135
|
+
id: config.id,
|
|
136
|
+
doctype,
|
|
137
|
+
suggest: "set baseURL on FrappeChartCard or on the FrappeProvider above it",
|
|
138
|
+
});
|
|
139
|
+
}, [missingConnection, config.id, doctype]);
|
|
140
|
+
|
|
111
141
|
const { groups, isLoading, isStale, error, isPermissionError } = useFrappeAggregate(connection, {
|
|
112
142
|
doctype: dataSource?.doctype ?? "",
|
|
113
143
|
aggregate: dataSource?.aggregate,
|
|
@@ -128,11 +158,30 @@ export function FrappeChartCard({
|
|
|
128
158
|
return { ...config, data } as ChartConfig;
|
|
129
159
|
}, [config, dataSource, groups]);
|
|
130
160
|
|
|
131
|
-
// Static passthrough — sanitizing bridge, palette untouched.
|
|
132
|
-
|
|
161
|
+
// Static passthrough — sanitizing bridge, palette untouched. The caller
|
|
162
|
+
// supplied the series and asked for no computation.
|
|
163
|
+
if (!dataSource) {
|
|
133
164
|
return <ChartCard config={liveConfig} {...chartCardProps} />;
|
|
134
165
|
}
|
|
135
166
|
|
|
167
|
+
// A requested series with nowhere to compute it. `config.data` is the
|
|
168
|
+
// caller's placeholder, so drawing it here would publish a shape nothing
|
|
169
|
+
// downstream could tell was never computed.
|
|
170
|
+
if (!connection) {
|
|
171
|
+
return (
|
|
172
|
+
<AsyncBoundary
|
|
173
|
+
compact
|
|
174
|
+
error
|
|
175
|
+
errorTitle={t("{{title}} is not configured", { title: config.title })}
|
|
176
|
+
errorDescription={t(
|
|
177
|
+
"This chart reads {{doctype}} live, but no Frappe baseURL is in scope, so the series was never computed.",
|
|
178
|
+
{ doctype: dataSource.doctype },
|
|
179
|
+
)}
|
|
180
|
+
{...(chartCardProps as YStackProps)}
|
|
181
|
+
/>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
136
185
|
if (isLoading) {
|
|
137
186
|
return (
|
|
138
187
|
<ChartCardSkeleton
|
|
@@ -125,4 +125,29 @@ describe("FrappeKPICard configured paths stay untouched (MPO-237)", () => {
|
|
|
125
125
|
warn.mock.calls.filter((call) => String(call[0]).includes("kpi-datasource-without-baseurl")),
|
|
126
126
|
).toHaveLength(0);
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
it("treats a fixture corpus as the data source when no baseURL is set (MPO-228)", async () => {
|
|
130
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
131
|
+
renderWithProviders(
|
|
132
|
+
<FrappeProvider
|
|
133
|
+
fixtures={
|
|
134
|
+
new InMemoryFixtureProvider({
|
|
135
|
+
ToDo: [
|
|
136
|
+
{ name: "TD-1", doctype: "ToDo", status: "Open" },
|
|
137
|
+
{ name: "TD-2", doctype: "ToDo", status: "Open" },
|
|
138
|
+
],
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
<FrappeKPICard config={config} dataSource={{ doctype: "ToDo" }} />
|
|
143
|
+
</FrappeProvider>,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(screen.getByText("2")).toBeInTheDocument());
|
|
147
|
+
expect(screen.queryByText("999")).toBeNull();
|
|
148
|
+
expect(screen.queryByText("Open ToDos is not configured")).toBeNull();
|
|
149
|
+
expect(
|
|
150
|
+
warn.mock.calls.filter((call) => String(call[0]).includes("kpi-datasource-without-baseurl")),
|
|
151
|
+
).toHaveLength(0);
|
|
152
|
+
});
|
|
128
153
|
});
|
package/src/FrappeKPICard.tsx
CHANGED
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
* (the card dims while a post-event refetch is in flight), and "—" when the
|
|
13
13
|
* aggregate has no result (e.g. avg over an empty set).
|
|
14
14
|
*
|
|
15
|
-
* A `dataSource` with no
|
|
16
|
-
*
|
|
17
|
-
* for a computed figure and there is
|
|
18
|
-
*
|
|
19
|
-
* `config.value`, which would present
|
|
15
|
+
* A `dataSource` with no data source in scope — no `baseURL` from the prop
|
|
16
|
+
* or a FrappeProvider, and no fixture corpus either — is a misconfiguration,
|
|
17
|
+
* not static mode: the caller asked for a computed figure and there is
|
|
18
|
+
* nowhere to compute it. The card renders the not-configured surface and
|
|
19
|
+
* warns in dev. It never falls back to `config.value`, which would present
|
|
20
|
+
* an uncomputed number as the KPI. A fixture corpus answers every read
|
|
21
|
+
* locally, so it stands in for a baseURL (MPO-228).
|
|
20
22
|
*/
|
|
21
23
|
import {
|
|
22
24
|
AsyncBoundary,
|
|
@@ -57,7 +59,10 @@ export interface FrappeKPICardProps extends Omit<YStackProps, "children"> {
|
|
|
57
59
|
config: KPICardConfig;
|
|
58
60
|
/** When set, the card's value goes live against this doctype. */
|
|
59
61
|
dataSource?: FrappeKPIDataSource;
|
|
60
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Falls back to the FrappeProvider's. Required whenever `dataSource` is
|
|
64
|
+
* set, unless the provider carries a fixture corpus.
|
|
65
|
+
*/
|
|
61
66
|
baseURL?: string;
|
|
62
67
|
auth?: { useToken?: boolean; token?: string; type?: string };
|
|
63
68
|
syncOptions?: unknown;
|
|
@@ -87,8 +92,9 @@ export function FrappeKPICard({
|
|
|
87
92
|
const { t } = useTranslation();
|
|
88
93
|
|
|
89
94
|
const finalBaseURL = baseURL ?? providerConfig?.baseURL;
|
|
95
|
+
const fixtures = providerConfig?.fixtures;
|
|
90
96
|
const connection: FrappeAggregateConnection | undefined =
|
|
91
|
-
dataSource && finalBaseURL
|
|
97
|
+
dataSource && (finalBaseURL || fixtures)
|
|
92
98
|
? {
|
|
93
99
|
baseURL: finalBaseURL,
|
|
94
100
|
socketPort: providerConfig?.socketPort,
|
|
@@ -97,21 +103,21 @@ export function FrappeKPICard({
|
|
|
97
103
|
auth: auth ?? providerConfig?.auth,
|
|
98
104
|
syncOptions: syncOptions ?? providerConfig?.syncOptions,
|
|
99
105
|
realtime: providerConfig?.realtime,
|
|
100
|
-
fixtures
|
|
106
|
+
fixtures,
|
|
101
107
|
}
|
|
102
108
|
: undefined;
|
|
103
109
|
|
|
104
|
-
const
|
|
110
|
+
const missingConnection = !!dataSource && !connection;
|
|
105
111
|
const doctype = dataSource?.doctype;
|
|
106
112
|
useEffect(() => {
|
|
107
|
-
if (!
|
|
113
|
+
if (!missingConnection) return;
|
|
108
114
|
frappeDevWarn("kpi-datasource-without-baseurl", {
|
|
109
115
|
component: "FrappeKPICard",
|
|
110
116
|
id: config.id,
|
|
111
117
|
doctype,
|
|
112
118
|
suggest: "set baseURL on FrappeKPICard or on the FrappeProvider above it",
|
|
113
119
|
});
|
|
114
|
-
}, [
|
|
120
|
+
}, [missingConnection, config.id, doctype]);
|
|
115
121
|
|
|
116
122
|
const { value, isLoading, isStale, error, isPermissionError } = useFrappeAggregate(connection, {
|
|
117
123
|
doctype: dataSource?.doctype ?? "",
|
package/src/FrappeListView.tsx
CHANGED
|
@@ -298,7 +298,7 @@ export function FrappeListView<TDoc extends { name: string; doctype: string }>({
|
|
|
298
298
|
{title}
|
|
299
299
|
</Text>
|
|
300
300
|
{subtitleParts.length > 0 && (
|
|
301
|
-
<Text {...knobProps.body} color={
|
|
301
|
+
<Text {...knobProps.body} color={knobProps.textAccentColor} numberOfLines={1}>
|
|
302
302
|
{subtitleParts.map((part, index) => (
|
|
303
303
|
<React.Fragment key={index}>
|
|
304
304
|
{index > 0 ? " · " : null}
|
|
@@ -432,7 +432,7 @@ export function FrappeListView<TDoc extends { name: string; doctype: string }>({
|
|
|
432
432
|
{...stackProps}
|
|
433
433
|
>
|
|
434
434
|
<Spinner size="large" />
|
|
435
|
-
<Text {...knobProps.body} color={
|
|
435
|
+
<Text {...knobProps.body} color={knobProps.textAccentColor} marginTop="$2">
|
|
436
436
|
{t("Loading {{doctype}} schema...", { doctype })}
|
|
437
437
|
</Text>
|
|
438
438
|
</YStack>
|
|
@@ -517,17 +517,17 @@ export function FrappeListView<TDoc extends { name: string; doctype: string }>({
|
|
|
517
517
|
{...knobProps.panelPadding}
|
|
518
518
|
{...knobProps.gap}
|
|
519
519
|
>
|
|
520
|
-
<Text {...knobProps.label} color={
|
|
520
|
+
<Text {...knobProps.label} color={knobProps.textAccentColor}>
|
|
521
521
|
{t("Filters")}
|
|
522
522
|
</Text>
|
|
523
523
|
{standardFilterFields.length > 0 && (
|
|
524
524
|
<YStack {...knobProps.gap}>
|
|
525
525
|
{standardFilterFields.map((field) => (
|
|
526
526
|
<YStack key={field.fieldname} {...knobProps.gap}>
|
|
527
|
-
<Text {...knobProps.label} color={
|
|
527
|
+
<Text {...knobProps.label} color={knobProps.textAccentColor}>
|
|
528
528
|
{field.label || field.fieldname}
|
|
529
529
|
</Text>
|
|
530
|
-
<Text {...knobProps.body} color={
|
|
530
|
+
<Text {...knobProps.body} color={knobProps.textAccentColor}>
|
|
531
531
|
{field.fieldtype === "Select" ? field.options || "—" : "—"}
|
|
532
532
|
</Text>
|
|
533
533
|
</YStack>
|
|
@@ -536,7 +536,7 @@ export function FrappeListView<TDoc extends { name: string; doctype: string }>({
|
|
|
536
536
|
)}
|
|
537
537
|
{savedFiltersHook.filters.length > 0 && (
|
|
538
538
|
<YStack {...knobProps.gap}>
|
|
539
|
-
<Text {...knobProps.label} color={
|
|
539
|
+
<Text {...knobProps.label} color={knobProps.textAccentColor}>
|
|
540
540
|
{t("Saved Filters")}
|
|
541
541
|
</Text>
|
|
542
542
|
<SavedFilterDropdown
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* a seeded value is not a user edit).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { describe, it, expect, vi } from "vitest";
|
|
13
|
+
import { describe, it, expect, vi, beforeAll } from "vitest";
|
|
14
14
|
|
|
15
15
|
// The first field-module import in a cold fork transforms the whole fields
|
|
16
16
|
// graph (same budget as tests/fieldRenderers.spec.tsx).
|
|
@@ -79,6 +79,13 @@ function getDayButton(root: ParentNode, day: number): HTMLButtonElement | undefi
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Warm the graph here, not inside the first `it`: the transform is setup cost
|
|
83
|
+
// (110-155s quiet, past 240s under load), and charging it to an assertion made
|
|
84
|
+
// whichever case ran first time out.
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
await loadDateField();
|
|
87
|
+
}, 900_000);
|
|
88
|
+
|
|
82
89
|
describe("Date wrapper — late-arriving value display", () => {
|
|
83
90
|
it("a value arriving after mount displays, without onChange", async () => {
|
|
84
91
|
const DateField = await loadDateField();
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* than rendered) and the platform flags.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
21
|
+
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
|
22
22
|
|
|
23
23
|
// Same cold-fork budget as the sibling Date.spec.tsx: the first field-module
|
|
24
24
|
// import transforms the whole fields graph.
|
|
@@ -132,6 +132,13 @@ async function press(root: ParentNode, text: string) {
|
|
|
132
132
|
const lastPick = () => mockState.picks[mockState.picks.length - 1];
|
|
133
133
|
const waitForPick = (predicate: () => void) => waitFor(predicate, { timeout: 30_000 });
|
|
134
134
|
|
|
135
|
+
// Warm the graph here, not inside the first `it`: the transform is setup cost
|
|
136
|
+
// (110-155s quiet, past 240s under load), and charging it to an assertion made
|
|
137
|
+
// whichever case ran first time out.
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
await loadNativeDateField();
|
|
140
|
+
}, 900_000);
|
|
141
|
+
|
|
135
142
|
beforeEach(() => {
|
|
136
143
|
// The iOS sheet is a portal, so a previous case's chrome would otherwise
|
|
137
144
|
// still be in the document when the next one asserts on it.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* rather than rendered) and the platform flags. The web half imports neither.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
20
|
+
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
|
21
21
|
|
|
22
22
|
// Same cold-fork budget as the sibling Date specs: the first field-module
|
|
23
23
|
// import transforms the whole fields graph.
|
|
@@ -176,6 +176,13 @@ async function selectPeriod(period: "AM" | "PM") {
|
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// Warm the graph here, not inside the first `it`: the transform is setup cost
|
|
180
|
+
// (110-155s quiet, past 240s under load), and charging it to an assertion made
|
|
181
|
+
// whichever case ran first time out.
|
|
182
|
+
beforeAll(async () => {
|
|
183
|
+
await Promise.all([loadWebDateField(), loadNativeDateField()]);
|
|
184
|
+
}, 900_000);
|
|
185
|
+
|
|
179
186
|
beforeEach(() => {
|
|
180
187
|
// Both halves portal their surfaces, so a previous case's chrome would
|
|
181
188
|
// otherwise still be in the document when the next one queries it.
|
|
@@ -2,19 +2,16 @@
|
|
|
2
2
|
* DateRangeField contract specs. The `Date Range` fieldtype composes the forms
|
|
3
3
|
* DateRangePicker, so the adapter's contract is which props survive the hop.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* tests describe today's behaviour and will go red the moment someone wires it
|
|
11
|
-
* up — which is the point: that is the signal to update them alongside the fix.
|
|
12
|
-
* Reported on MPO-123.
|
|
5
|
+
* `value` and `onChange` used to be DECLARED on DateRangeFieldProps and then
|
|
6
|
+
* destructured away instead of being handed to the picker, so a saved range
|
|
7
|
+
* never painted and a picked one never emitted (MPO-137, found by the MPO-123
|
|
8
|
+
* spec pass). Both are now wired, and the range is carried as the two calendar
|
|
9
|
+
* dates comma-separated — the shape the registry feeds in and reads back.
|
|
13
10
|
*/
|
|
14
11
|
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
15
|
-
import { cleanup } from "@testing-library/react";
|
|
12
|
+
import { act, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
|
16
13
|
import type React from "react";
|
|
17
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
18
15
|
import { getFieldComponent, type FieldRenderProps } from "../../fieldRegistry";
|
|
19
16
|
import { DateRangeField } from ".";
|
|
20
17
|
|
|
@@ -27,6 +24,24 @@ const trigger = (container: HTMLElement) =>
|
|
|
27
24
|
const combobox = (container: HTMLElement) =>
|
|
28
25
|
container.querySelector('[data-testid="date-picker-trigger"]');
|
|
29
26
|
|
|
27
|
+
/**
|
|
28
|
+
* An enabled day cell in the month the picker opened on. The panel is
|
|
29
|
+
* portaled, and the wide layout renders the previous month first, so the
|
|
30
|
+
* range's own month is the LAST calendar panel in the document.
|
|
31
|
+
*/
|
|
32
|
+
function getDayButton(day: number): HTMLButtonElement | undefined {
|
|
33
|
+
const panels = document.body.querySelectorAll('[data-testid="date-picker-calendar"]');
|
|
34
|
+
const panel = panels[panels.length - 1];
|
|
35
|
+
if (!panel) return undefined;
|
|
36
|
+
return Array.from(panel.querySelectorAll('button, [role="button"]')).find((btn) => {
|
|
37
|
+
const disabled =
|
|
38
|
+
btn.hasAttribute("disabled") ||
|
|
39
|
+
btn.getAttribute("aria-disabled") === "true" ||
|
|
40
|
+
btn.hasAttribute("data-disabled");
|
|
41
|
+
return btn.textContent?.trim() === String(day) && !disabled;
|
|
42
|
+
}) as HTMLButtonElement | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
describe("DateRangeField composes the forms DateRangePicker", () => {
|
|
31
46
|
it("renders a single range trigger, not two date inputs", () => {
|
|
32
47
|
const result = renderWithProviders(<DateRangeField label="Period" />);
|
|
@@ -84,21 +99,77 @@ describe("DateRangeField composes the forms DateRangePicker", () => {
|
|
|
84
99
|
});
|
|
85
100
|
});
|
|
86
101
|
|
|
87
|
-
describe("DateRangeField value
|
|
102
|
+
describe("DateRangeField controlled value (MPO-137)", () => {
|
|
88
103
|
it("declares value on its props type", () => {
|
|
89
104
|
// Type-level promise: the adapter advertises a controlled value.
|
|
90
105
|
const props: React.ComponentProps<typeof DateRangeField> = { value: "2026-01-01" };
|
|
91
106
|
expect(props.value).toBe("2026-01-01");
|
|
92
107
|
});
|
|
93
108
|
|
|
94
|
-
it("
|
|
109
|
+
it("paints a saved range on the trigger rather than the placeholder", () => {
|
|
110
|
+
const result = renderWithProviders(
|
|
111
|
+
<DateRangeField label="Period" placeholder="From – To" value="2026-01-01,2026-01-31" />,
|
|
112
|
+
);
|
|
113
|
+
expect(trigger(result.container)?.value).toBe("Jan 1, 2026 – Jan 31, 2026");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("paints a range that arrives after mount, without emitting (LC-14)", async () => {
|
|
117
|
+
const onChange = vi.fn();
|
|
118
|
+
const result = renderWithProviders(
|
|
119
|
+
<DateRangeField label="Period" value="" onChange={onChange} />,
|
|
120
|
+
);
|
|
121
|
+
expect(trigger(result.container)?.value ?? "").toBe("");
|
|
122
|
+
|
|
123
|
+
// Late seed: the engine applies create defaults once meta loads.
|
|
124
|
+
result.rerender(
|
|
125
|
+
<DateRangeField label="Period" value="2026-01-01,2026-01-31" onChange={onChange} />,
|
|
126
|
+
);
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(trigger(result.container)?.value).toBe("Jan 1, 2026 – Jan 31, 2026");
|
|
129
|
+
});
|
|
130
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("emits the comma-separated wire string once a range is picked", async () => {
|
|
134
|
+
const onChange = vi.fn();
|
|
135
|
+
const result = renderWithProviders(
|
|
136
|
+
<DateRangeField label="Period" value="2026-03-05,2026-03-31" onChange={onChange} />,
|
|
137
|
+
);
|
|
138
|
+
await act(async () => {
|
|
139
|
+
fireEvent.click(trigger(result.container)!);
|
|
140
|
+
});
|
|
141
|
+
// The panel opens on the range's own month; in the wide (dual-calendar)
|
|
142
|
+
// layout that is the LAST panel, the previous month being the first.
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(getDayButton(15)).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
// A complete range restarts on the next click, so this one only re-anchors.
|
|
147
|
+
await act(async () => {
|
|
148
|
+
fireEvent.click(getDayButton(15)!);
|
|
149
|
+
});
|
|
150
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
151
|
+
|
|
152
|
+
await act(async () => {
|
|
153
|
+
fireEvent.click(getDayButton(20)!);
|
|
154
|
+
});
|
|
155
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(onChange).toHaveBeenCalledWith("2026-03-15,2026-03-20");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("clearing the range emits the empty wire value", async () => {
|
|
160
|
+
const onChange = vi.fn();
|
|
95
161
|
const result = renderWithProviders(
|
|
96
|
-
<DateRangeField label="Period" value="2026-
|
|
162
|
+
<DateRangeField label="Period" value="2026-03-05,2026-03-31" onChange={onChange} />,
|
|
97
163
|
);
|
|
98
|
-
|
|
164
|
+
await act(async () => {
|
|
165
|
+
fireEvent.click(
|
|
166
|
+
result.container.querySelector('[data-testid="date-picker-clear"]') as HTMLElement,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
expect(onChange).toHaveBeenCalledWith("");
|
|
99
170
|
});
|
|
100
171
|
|
|
101
|
-
it("
|
|
172
|
+
it("the registry hands value and onChange straight to the adapter", () => {
|
|
102
173
|
const Component = getFieldComponent("Date Range")!;
|
|
103
174
|
const element = (Component as Function)({
|
|
104
175
|
value: "2026-01-01,2026-01-31",
|
|
@@ -106,6 +177,7 @@ describe("DateRangeField value gap (MPO-123)", () => {
|
|
|
106
177
|
} as FieldRenderProps) as React.ReactElement<Record<string, any>>;
|
|
107
178
|
expect(element.type).toBe(DateRangeField);
|
|
108
179
|
expect(element.props.value).toBe("2026-01-01,2026-01-31");
|
|
180
|
+
expect(typeof element.props.onChange).toBe("function");
|
|
109
181
|
});
|
|
110
182
|
});
|
|
111
183
|
|