muffin-admin 13.3.0 → 13.4.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/MuffinDashboard.js +2 -0
- package/dist/MuffinDashboard.test.d.ts +1 -0
- package/dist/MuffinDashboard.test.js +23 -0
- package/dist/MuffinResource.js +1 -1
- package/dist/MuffinResourceEdit.js +2 -1
- package/dist/MuffinResourceShow.js +3 -1
- package/dist/buttons/ActionButton.d.ts +1 -0
- package/dist/buttons/ActionButton.js +32 -14
- package/dist/buttons/ActionButton.test.d.ts +1 -0
- package/dist/buttons/ActionButton.test.js +12 -0
- package/dist/dataprovider.js +4 -3
- package/dist/dataprovider.test.d.ts +1 -0
- package/dist/dataprovider.test.js +40 -0
- package/dist/fields/AvatarField.js +1 -1
- package/dist/fields/JsonField.js +3 -1
- package/dist/filters/Filter.js +11 -4
- package/dist/inputs/JsonInput.js +4 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -3
- package/src/MuffinDashboard.test.tsx +24 -0
- package/src/MuffinDashboard.tsx +2 -0
- package/src/MuffinResource.tsx +1 -1
- package/src/MuffinResourceEdit.tsx +2 -1
- package/src/MuffinResourceShow.tsx +3 -1
- package/src/buttons/ActionButton.test.ts +17 -0
- package/src/buttons/ActionButton.tsx +34 -10
- package/src/dataprovider.test.ts +40 -0
- package/src/dataprovider.ts +5 -3
- package/src/fields/AvatarField.tsx +1 -1
- package/src/fields/JsonField.tsx +2 -1
- package/src/filters/Filter.tsx +10 -4
- package/src/inputs/JsonInput.tsx +4 -2
- package/src/version.ts +1 -1
package/dist/MuffinDashboard.js
CHANGED
|
@@ -22,6 +22,8 @@ setupAdmin(["dashboard"], MuffinDashboard);
|
|
|
22
22
|
setupAdmin(["dashboard-actions"], () => null);
|
|
23
23
|
setupAdmin(["dashboard-content"], () => null);
|
|
24
24
|
function AdminCards({ src }) {
|
|
25
|
+
if (!src)
|
|
26
|
+
return null;
|
|
25
27
|
if (Array.isArray(src))
|
|
26
28
|
return (_jsx(Grid, { container: true, spacing: 2, children: src.map((card, idx) => (_jsx(AdminCards, { src: card }, idx))) }));
|
|
27
29
|
return _jsx(DashboardCard, Object.assign({}, src));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
11
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
12
|
+
import { expect, it, vi } from "vitest";
|
|
13
|
+
vi.mock("react-admin", (importOriginal) => __awaiter(void 0, void 0, void 0, function* () {
|
|
14
|
+
const actual = yield importOriginal();
|
|
15
|
+
return Object.assign(Object.assign({}, actual), { useTranslate: () => (key, options) => { var _a; return (_a = options === null || options === void 0 ? void 0 : options._) !== null && _a !== void 0 ? _a : key; } });
|
|
16
|
+
}));
|
|
17
|
+
import { MuffinAdminContext } from "./context";
|
|
18
|
+
import { MuffinDashboard } from "./MuffinDashboard";
|
|
19
|
+
it("renders no card without dashboard data", () => {
|
|
20
|
+
const html = renderToStaticMarkup(_jsx(MuffinAdminContext.Provider, { value: { dashboard: undefined, help: undefined }, children: _jsx(MuffinDashboard, {}) }));
|
|
21
|
+
expect(html).not.toContain("undefined");
|
|
22
|
+
expect(html).not.toContain("MuiCard");
|
|
23
|
+
});
|
package/dist/MuffinResource.js
CHANGED
|
@@ -23,6 +23,6 @@ export function MuffinResource(_a) {
|
|
|
23
23
|
const Edit = findBuilder(["edit", name]);
|
|
24
24
|
const List = findBuilder(["list", name]);
|
|
25
25
|
const Show = findBuilder(["show", name]);
|
|
26
|
-
return (_jsx(Resource, Object.assign({ name: name, icon: findIcon(opts.icon), create: Create, edit: Edit, list: List, show: Show }, props), name));
|
|
26
|
+
return (_jsx(Resource, Object.assign({ name: name, icon: findIcon(opts.icon), create: Create, edit: Edit, list: List, show: opts.list.show ? Show : undefined }, props), name));
|
|
27
27
|
}
|
|
28
28
|
setupAdmin(["resource"], MuffinResource);
|
|
@@ -65,8 +65,9 @@ export function MuffinEditToolbar({ children }) {
|
|
|
65
65
|
const resource = useResourceContext();
|
|
66
66
|
const record = useRecordContext();
|
|
67
67
|
const translate = useTranslate();
|
|
68
|
+
const { list } = useMuffinResourceOpts();
|
|
68
69
|
const Actions = findBuilder(["edit-actions", resource]);
|
|
69
|
-
return (_jsx(Box, { sx: { display: "flex", flexDirection: "column", width: "100%" }, children: _jsxs(TopToolbar, { sx: { pl: 0, pr: 0, width: "100%", justifyContent: "space-between" }, children: [_jsxs(Typography, { variant: "h6", sx: { mr: 2 }, children: [translate(`resources.${resource}.name`, { smart_count: 1, _: resource }), (record === null || record === void 0 ? void 0 : record.id) ? ` #${record.id}` : ""] }), _jsxs(Box, { sx: { display: "flex", alignItems: "center" }, children: [_jsx(Actions, {}), children, _jsx(ShowButton, {}), _jsx(ListButton, {})] })] }) }));
|
|
70
|
+
return (_jsx(Box, { sx: { display: "flex", flexDirection: "column", width: "100%" }, children: _jsxs(TopToolbar, { sx: { pl: 0, pr: 0, width: "100%", justifyContent: "space-between" }, children: [_jsxs(Typography, { variant: "h6", sx: { mr: 2 }, children: [translate(`resources.${resource}.name`, { smart_count: 1, _: resource }), (record === null || record === void 0 ? void 0 : record.id) ? ` #${record.id}` : ""] }), _jsxs(Box, { sx: { display: "flex", alignItems: "center" }, children: [_jsx(Actions, {}), children, list.show && _jsx(ShowButton, {}), _jsx(ListButton, {})] })] }) }));
|
|
70
71
|
}
|
|
71
72
|
setupAdmin(["edit-toolbar"], MuffinEditToolbar);
|
|
72
73
|
export function MuffinEditActions({ children }) {
|
|
@@ -9,11 +9,13 @@ import { AdminModal } from "./common";
|
|
|
9
9
|
import { useMuffinResourceOpts } from "./hooks";
|
|
10
10
|
import { buildAdmin, findBuilder, setupAdmin } from "./utils";
|
|
11
11
|
export function MuffinShow({ children }) {
|
|
12
|
-
const { show, name, key } = useMuffinResourceOpts();
|
|
12
|
+
const { show, list, name, key } = useMuffinResourceOpts();
|
|
13
13
|
const location = useLocation();
|
|
14
14
|
const navigate = useNavigate();
|
|
15
15
|
const ShowToolbar = findBuilder(["show-toolbar", name]);
|
|
16
16
|
const returnTo = new URLSearchParams(location.search).get("returnTo");
|
|
17
|
+
if (!list.show)
|
|
18
|
+
return null;
|
|
17
19
|
const handleClose = () => {
|
|
18
20
|
if (returnTo) {
|
|
19
21
|
navigate(returnTo);
|
|
@@ -8,3 +8,4 @@ export declare function ActionButton({ paths, confirm, file, ...props }: AdminAc
|
|
|
8
8
|
export declare function ListActionButton({ paths, confirm, file, ...props }: AdminAction): import("react").JSX.Element;
|
|
9
9
|
export declare function BulkActionButton({ paths, confirm, ...props }: AdminAction): import("react").JSX.Element;
|
|
10
10
|
export declare function CommonPayload({ active, onClose, onHandle, schema, title, help, }: AdminPayloadProps): import("react").JSX.Element;
|
|
11
|
+
export declare function buildFileUrl(apiUrl: string, path: string, id?: any, filterValues?: any): string;
|
|
@@ -19,9 +19,9 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
19
19
|
return t;
|
|
20
20
|
};
|
|
21
21
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
22
|
-
import {
|
|
22
|
+
import { Stack } from "@mui/material";
|
|
23
23
|
import { useState } from "react";
|
|
24
|
-
import { Button, FormGroupsProvider, useAugmentedForm, useListContext, useRecordContext, useResourceContext, useTranslate, } from "react-admin";
|
|
24
|
+
import { Button, FormGroupsProvider, useAugmentedForm, useListContext, useNotify, useRecordContext, useResourceContext, useTranslate, } from "react-admin";
|
|
25
25
|
import { FormProvider } from "react-hook-form";
|
|
26
26
|
import { buildRA } from "../buildRA";
|
|
27
27
|
import { AdminModal, PayloadButtons, useConfirmation } from "../common";
|
|
@@ -81,14 +81,15 @@ export function BulkActionButton(_a) {
|
|
|
81
81
|
function ActionButtonBase({ label, title, onHandle, isPending, icon, schema, id, help, }) {
|
|
82
82
|
const resource = useResourceContext();
|
|
83
83
|
const [payloadActive, setPayloadActive] = useState(false);
|
|
84
|
-
const PayloadForm = findBuilder(["action-form", id, resource]) || (schema
|
|
84
|
+
const PayloadForm = findBuilder(["action-form", id, resource]) || ((schema === null || schema === void 0 ? void 0 : schema.length) ? CommonPayload : null);
|
|
85
85
|
if (!PayloadForm)
|
|
86
86
|
return (_jsx(Button, { label: label, title: title, onClick: () => onHandle(), disabled: isPending, children: buildIcon(icon) }));
|
|
87
87
|
return (_jsxs(_Fragment, { children: [_jsx(Button, { label: label, title: title, onClick: () => setPayloadActive(true), disabled: isPending, children: buildIcon(icon) }), _jsx(PayloadForm, { active: payloadActive, onHandle: onHandle, onClose: () => setPayloadActive(false), title: title || label, schema: schema, help: help })] }));
|
|
88
88
|
}
|
|
89
89
|
export function CommonPayload({ active, onClose, onHandle, schema, title, help, }) {
|
|
90
|
-
schema
|
|
91
|
-
|
|
90
|
+
if (schema === null || schema === void 0 ? void 0 : schema.length)
|
|
91
|
+
schema[0][1] = Object.assign(Object.assign({}, schema[0][1]), { autoFocus: true });
|
|
92
|
+
const inputs = buildRA(schema || []);
|
|
92
93
|
const translate = useTranslate();
|
|
93
94
|
const { form, formHandleSubmit } = useAugmentedForm({
|
|
94
95
|
onSubmit: (data) => {
|
|
@@ -102,15 +103,32 @@ export function CommonPayload({ active, onClose, onHandle, schema, title, help,
|
|
|
102
103
|
? (_jsxs(Stack, { sx: { alignItems: "flex-start" }, children: [_jsx("span", { children: translate(title, { _: title }) }), _jsx(HelpLink, { href: help, style: { alignSelf: "flex-end" } })] }))
|
|
103
104
|
: translate(title, { _: title }), actions: _jsx(PayloadButtons, { onClose: onClose, onSubmit: formHandleSubmit, isValid: form.formState.isValid }), children: inputs }) }) })));
|
|
104
105
|
}
|
|
106
|
+
export function buildFileUrl(apiUrl, path, id, filterValues) {
|
|
107
|
+
let url = `${apiUrl}${path.replace("{id}", encodeURIComponent(String(id !== null && id !== void 0 ? id : "")))}?f`;
|
|
108
|
+
if (filterValues && Object.keys(filterValues).length) {
|
|
109
|
+
url += `&where=${encodeURIComponent(prepareFilters(filterValues))}`;
|
|
110
|
+
}
|
|
111
|
+
return url;
|
|
112
|
+
}
|
|
105
113
|
function FileButton({ path, record, label, icon, filterValues, }) {
|
|
106
114
|
const { apiUrl } = useMuffinAdminOpts();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
url =
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
const notify = useNotify();
|
|
116
|
+
const onClick = () => __awaiter(this, void 0, void 0, function* () {
|
|
117
|
+
const url = buildFileUrl(apiUrl, path, record === null || record === void 0 ? void 0 : record.id, filterValues);
|
|
118
|
+
try {
|
|
119
|
+
const response = yield fetch(url, { headers: new Headers(requestHeaders) });
|
|
120
|
+
if (!response.ok)
|
|
121
|
+
throw new Error(`Download failed (${response.status})`);
|
|
122
|
+
const blob = yield response.blob();
|
|
123
|
+
const link = document.createElement("a");
|
|
124
|
+
link.href = URL.createObjectURL(blob);
|
|
125
|
+
link.download = path.split("/").pop() || "download";
|
|
126
|
+
link.click();
|
|
127
|
+
URL.revokeObjectURL(link.href);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
notify((err === null || err === void 0 ? void 0 : err.message) || "Download failed", { type: "error" });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
return (_jsx(Button, { onClick: onClick, label: label, children: buildIcon(icon) }));
|
|
116
134
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildFileUrl } from "./ActionButton";
|
|
3
|
+
describe("buildFileUrl", () => {
|
|
4
|
+
it("encodes ids and filters without adding the token", () => {
|
|
5
|
+
const url = buildFileUrl("/admin/api", "/message/{id}/data.csv", "a/b", { name: "A & B" });
|
|
6
|
+
expect(url).toBe(`/admin/api/message/a%2Fb/data.csv?f&where=${encodeURIComponent('{"name":"A & B"}')}`);
|
|
7
|
+
expect(url).not.toContain("t=");
|
|
8
|
+
});
|
|
9
|
+
it("works for list downloads without filters", () => {
|
|
10
|
+
expect(buildFileUrl("/admin/api", "/user/export")).toBe("/admin/api/user/export?f");
|
|
11
|
+
});
|
|
12
|
+
});
|
package/dist/dataprovider.js
CHANGED
|
@@ -19,6 +19,7 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
19
19
|
return t;
|
|
20
20
|
};
|
|
21
21
|
import { makeRequest, prepareFilters, setupAdmin } from "./utils";
|
|
22
|
+
const encodeId = (id) => encodeURIComponent(String(id));
|
|
22
23
|
export function MuffinDataprovider(apiUrl) {
|
|
23
24
|
function request(url, options) {
|
|
24
25
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -66,7 +67,7 @@ export function MuffinDataprovider(apiUrl) {
|
|
|
66
67
|
}),
|
|
67
68
|
getOne: (resource_1, _a) => __awaiter(this, [resource_1, _a], void 0, function* (resource, { id, meta }) {
|
|
68
69
|
var _b;
|
|
69
|
-
const response = yield request(`${resource}/${id}`);
|
|
70
|
+
const response = yield request(`${resource}/${encodeId(id)}`);
|
|
70
71
|
const { data } = response;
|
|
71
72
|
if (meta === null || meta === void 0 ? void 0 : meta.key) {
|
|
72
73
|
data.id = (_b = data.id) !== null && _b !== void 0 ? _b : data[meta.key];
|
|
@@ -77,14 +78,14 @@ export function MuffinDataprovider(apiUrl) {
|
|
|
77
78
|
return yield request(resource, Object.assign({ method: "POST" }, prepareDataParams(data)));
|
|
78
79
|
}),
|
|
79
80
|
update: (resource_1, _a) => __awaiter(this, [resource_1, _a], void 0, function* (resource, { id, data }) {
|
|
80
|
-
return yield makeRequest(`${apiUrl}/${resource}/${id}`, Object.assign({ method: "PUT" }, prepareDataParams(data))).then(({ json }) => ({ data: json }));
|
|
81
|
+
return yield makeRequest(`${apiUrl}/${resource}/${encodeId(id)}`, Object.assign({ method: "PUT" }, prepareDataParams(data))).then(({ json }) => ({ data: json }));
|
|
81
82
|
}),
|
|
82
83
|
updateMany: (resource_1, _a) => __awaiter(this, [resource_1, _a], void 0, function* (resource, { ids, data }) {
|
|
83
84
|
yield Promise.all(ids.map((id) => methods.update(resource, { id, data })));
|
|
84
85
|
return { data: ids };
|
|
85
86
|
}),
|
|
86
87
|
delete: (resource_1, _a) => __awaiter(this, [resource_1, _a], void 0, function* (resource, { id }) {
|
|
87
|
-
yield request(`${resource}/${id}`, { method: "DELETE" });
|
|
88
|
+
yield request(`${resource}/${encodeId(id)}`, { method: "DELETE" });
|
|
88
89
|
return { data: { id } };
|
|
89
90
|
}),
|
|
90
91
|
deleteMany: (resource_1, _a) => __awaiter(this, [resource_1, _a], void 0, function* (resource, { ids }) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { MuffinDataprovider } from "./dataprovider";
|
|
12
|
+
const jsonResponse = (body, headers = {}) => new Response(JSON.stringify(body), {
|
|
13
|
+
headers: Object.assign({ "content-type": "application/json" }, headers),
|
|
14
|
+
status: 200,
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
describe("MuffinDataprovider", () => {
|
|
20
|
+
it("encodes resource ids in the URL", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
21
|
+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "a/b" }));
|
|
22
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
23
|
+
const dataProvider = MuffinDataprovider("/admin/api");
|
|
24
|
+
yield dataProvider.getOne("user", { id: "a/b" });
|
|
25
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/admin/api/user/a%2Fb");
|
|
26
|
+
}));
|
|
27
|
+
it("uses the resource meta key as the react-admin id", () => __awaiter(void 0, void 0, void 0, function* () {
|
|
28
|
+
const fetchMock = vi
|
|
29
|
+
.fn()
|
|
30
|
+
.mockResolvedValue(jsonResponse([{ key: "web::42" }], { "x-total": "1" }));
|
|
31
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
32
|
+
const dataProvider = MuffinDataprovider("/admin/api");
|
|
33
|
+
const result = yield dataProvider.getList("order", {
|
|
34
|
+
meta: { key: "key" },
|
|
35
|
+
pagination: { page: 1, perPage: 25 },
|
|
36
|
+
});
|
|
37
|
+
expect(result.data).toEqual([{ id: "web::42", key: "web::42" }]);
|
|
38
|
+
expect(result.total).toBe(1);
|
|
39
|
+
}));
|
|
40
|
+
});
|
package/dist/fields/JsonField.js
CHANGED
|
@@ -6,7 +6,9 @@ export function JsonField({ label, source }) {
|
|
|
6
6
|
const [expand, setExpand] = useState(false);
|
|
7
7
|
const record = useRecordContext();
|
|
8
8
|
const value = get(record, source);
|
|
9
|
-
if (
|
|
9
|
+
if (value === undefined || value === null)
|
|
10
|
+
return null;
|
|
11
|
+
if (typeof value === "object" && Object.keys(value).length === 0)
|
|
10
12
|
return null;
|
|
11
13
|
const src = JSON.stringify(value, null, 2);
|
|
12
14
|
const retval = (_jsxs("div", { children: [_jsx("p", {}), label, _jsx(Button, { size: "small", color: "primary", variant: "contained", label: expand ? "colapse" : "expand", onClick: (e) => {
|
package/dist/filters/Filter.js
CHANGED
|
@@ -27,17 +27,24 @@ const OPERATORS = {
|
|
|
27
27
|
};
|
|
28
28
|
export function Filter(_a) {
|
|
29
29
|
var { type = "text" } = _a, props = __rest(_a, ["type"]);
|
|
30
|
-
const { field } = useInput(Object.assign({ format: (value) =>
|
|
31
|
-
|
|
30
|
+
const { field } = useInput(Object.assign({ format: (value) => {
|
|
31
|
+
var _a;
|
|
32
|
+
if (value && typeof value === "object") {
|
|
33
|
+
const op = Object.keys(value)[0] || "$eq";
|
|
34
|
+
return { op, text: (_a = value[op]) !== null && _a !== void 0 ? _a : "" };
|
|
35
|
+
}
|
|
36
|
+
return { op: "$eq", text: value !== null && value !== void 0 ? value : "" };
|
|
37
|
+
} }, props));
|
|
38
|
+
const [op, setOp] = useState(field.value.op);
|
|
32
39
|
const onChange = (e) => {
|
|
33
40
|
field.onChange({ [op]: e.target.value });
|
|
34
41
|
};
|
|
35
42
|
const [menuEl, setMenuEl] = useState(null);
|
|
36
43
|
const open = Boolean(menuEl);
|
|
37
44
|
const { label, source, resource, isRequired } = props;
|
|
38
|
-
return (_jsxs(_Fragment, { children: [_jsxs(FormGroup, { row: true, sx: { flexWrap: "nowrap" }, children: [_jsx(Button, { size: "small", disableElevation: true, sx: { minWidth: 30 }, onClick: (e) => setMenuEl(e.currentTarget), children: _jsx("span", { style: { fontSize: "1.5em" }, children: OPERATORS[op] }) }), _jsx(TextField, Object.assign({}, field, { onChange: onChange, value: field.value, variant: "outlined", type: type, label: _jsx(FieldTitle, { label: label, source: source, resource: resource, isRequired: isRequired }) }))] }), _jsx(Menu, { open: open, onClose: () => setMenuEl(null), anchorEl: menuEl, children: Object.entries(OPERATORS).map(([key, value]) => (_jsx(MenuItem, { selected: op === value, onClick: () => {
|
|
45
|
+
return (_jsxs(_Fragment, { children: [_jsxs(FormGroup, { row: true, sx: { flexWrap: "nowrap" }, children: [_jsx(Button, { size: "small", disableElevation: true, sx: { minWidth: 30 }, onClick: (e) => setMenuEl(e.currentTarget), children: _jsx("span", { style: { fontSize: "1.5em" }, children: OPERATORS[op] }) }), _jsx(TextField, Object.assign({}, field, { onChange: onChange, value: field.value.text, variant: "outlined", type: type, label: _jsx(FieldTitle, { label: label, source: source, resource: resource, isRequired: isRequired }) }))] }), _jsx(Menu, { open: open, onClose: () => setMenuEl(null), anchorEl: menuEl, children: Object.entries(OPERATORS).map(([key, value]) => (_jsx(MenuItem, { selected: op === value, onClick: () => {
|
|
39
46
|
setOp(key);
|
|
40
47
|
setMenuEl(null);
|
|
41
|
-
field.onChange({ [key]: field.value });
|
|
48
|
+
field.onChange({ [key]: field.value.text });
|
|
42
49
|
}, children: value }, key))) })] }));
|
|
43
50
|
}
|
package/dist/inputs/JsonInput.js
CHANGED
|
@@ -51,8 +51,10 @@ export function JsonInput(props) {
|
|
|
51
51
|
return undefined;
|
|
52
52
|
return isJSON(value) ? undefined : errortext;
|
|
53
53
|
};
|
|
54
|
-
validate
|
|
55
|
-
|
|
54
|
+
rest.validate = [
|
|
55
|
+
...(Array.isArray(validate) ? validate : validate ? [validate] : []),
|
|
56
|
+
validateJSON,
|
|
57
|
+
];
|
|
56
58
|
const formatJSON = (json) => {
|
|
57
59
|
let retval = json;
|
|
58
60
|
if (retval && typeof retval === "object")
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "13.
|
|
1
|
+
export declare const VERSION = "13.4.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "13.
|
|
1
|
+
export const VERSION = "13.4.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muffin-admin",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.4.0",
|
|
4
4
|
"description": "NPM package for https://github.com/klen/muffin-admin",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"types": "dist/index.d.js",
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
-
"test": "
|
|
13
|
+
"test": "vitest run"
|
|
14
14
|
},
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@biomejs/biome": "^2.5.5",
|
|
39
39
|
"@types/lodash": "^4.17.20",
|
|
40
40
|
"@types/node": "26.1.1",
|
|
41
|
-
"@types/react": "19.
|
|
41
|
+
"@types/react": "19.3.0",
|
|
42
42
|
"@types/react-dom": "19.2.3",
|
|
43
43
|
"@types/react-window": "^2.0.0",
|
|
44
44
|
"css-loader": "^7.1.2",
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"husky": "^9.0.11",
|
|
49
49
|
"style-loader": "^4.0.0",
|
|
50
50
|
"typescript": "^7.0.2",
|
|
51
|
+
"vite": "^8.3.5",
|
|
52
|
+
"vitest": "^5.0.5",
|
|
51
53
|
"webpack": "^5.91.0",
|
|
52
54
|
"webpack-cli": "^7.2.1",
|
|
53
55
|
"webpack-dev-server": "^6.0.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
2
|
+
import { expect, it, vi } from "vitest"
|
|
3
|
+
|
|
4
|
+
vi.mock("react-admin", async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import("react-admin")>()
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
useTranslate: () => (key: string, options?: { _?: string }) => options?._ ?? key,
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
import { MuffinAdminContext } from "./context"
|
|
13
|
+
import { MuffinDashboard } from "./MuffinDashboard"
|
|
14
|
+
|
|
15
|
+
it("renders no card without dashboard data", () => {
|
|
16
|
+
const html = renderToStaticMarkup(
|
|
17
|
+
<MuffinAdminContext.Provider value={{ dashboard: undefined, help: undefined } as any}>
|
|
18
|
+
<MuffinDashboard />
|
|
19
|
+
</MuffinAdminContext.Provider>
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
expect(html).not.toContain("undefined")
|
|
23
|
+
expect(html).not.toContain("MuiCard")
|
|
24
|
+
})
|
package/src/MuffinDashboard.tsx
CHANGED
|
@@ -41,6 +41,8 @@ setupAdmin(["dashboard-actions"], () => null)
|
|
|
41
41
|
setupAdmin(["dashboard-content"], () => null)
|
|
42
42
|
|
|
43
43
|
function AdminCards({ src }: { src: AdminDashboardBlock | AdminDashboardBlock[] }) {
|
|
44
|
+
if (!src) return null
|
|
45
|
+
|
|
44
46
|
if (Array.isArray(src))
|
|
45
47
|
return (
|
|
46
48
|
<Grid container spacing={2}>
|
package/src/MuffinResource.tsx
CHANGED
|
@@ -119,6 +119,7 @@ export function MuffinEditToolbar({ children }: PropsWithChildren) {
|
|
|
119
119
|
const resource = useResourceContext()
|
|
120
120
|
const record = useRecordContext()
|
|
121
121
|
const translate = useTranslate()
|
|
122
|
+
const { list } = useMuffinResourceOpts()
|
|
122
123
|
const Actions = findBuilder(["edit-actions", resource])
|
|
123
124
|
return (
|
|
124
125
|
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
|
@@ -130,7 +131,7 @@ export function MuffinEditToolbar({ children }: PropsWithChildren) {
|
|
|
130
131
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
|
131
132
|
<Actions />
|
|
132
133
|
{children}
|
|
133
|
-
<ShowButton />
|
|
134
|
+
{list.show && <ShowButton />}
|
|
134
135
|
<ListButton />
|
|
135
136
|
</Box>
|
|
136
137
|
</TopToolbar>
|
|
@@ -18,12 +18,14 @@ import { useMuffinResourceOpts } from "./hooks"
|
|
|
18
18
|
import { buildAdmin, findBuilder, setupAdmin } from "./utils"
|
|
19
19
|
|
|
20
20
|
export function MuffinShow({ children }: PropsWithChildren) {
|
|
21
|
-
const { show, name, key } = useMuffinResourceOpts()
|
|
21
|
+
const { show, list, name, key } = useMuffinResourceOpts()
|
|
22
22
|
const location = useLocation()
|
|
23
23
|
const navigate = useNavigate()
|
|
24
24
|
const ShowToolbar = findBuilder(["show-toolbar", name])
|
|
25
25
|
const returnTo = new URLSearchParams(location.search).get("returnTo")
|
|
26
26
|
|
|
27
|
+
if (!list.show) return null
|
|
28
|
+
|
|
27
29
|
const handleClose = () => {
|
|
28
30
|
if (returnTo) {
|
|
29
31
|
navigate(returnTo)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { buildFileUrl } from "./ActionButton"
|
|
3
|
+
|
|
4
|
+
describe("buildFileUrl", () => {
|
|
5
|
+
it("encodes ids and filters without adding the token", () => {
|
|
6
|
+
const url = buildFileUrl("/admin/api", "/message/{id}/data.csv", "a/b", { name: "A & B" })
|
|
7
|
+
|
|
8
|
+
expect(url).toBe(
|
|
9
|
+
`/admin/api/message/a%2Fb/data.csv?f&where=${encodeURIComponent('{"name":"A & B"}')}`
|
|
10
|
+
)
|
|
11
|
+
expect(url).not.toContain("t=")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("works for list downloads without filters", () => {
|
|
15
|
+
expect(buildFileUrl("/admin/api", "/user/export")).toBe("/admin/api/user/export?f")
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Stack } from "@mui/material"
|
|
2
2
|
import { useState } from "react"
|
|
3
3
|
import {
|
|
4
4
|
Button,
|
|
5
5
|
FormGroupsProvider,
|
|
6
6
|
useAugmentedForm,
|
|
7
7
|
useListContext,
|
|
8
|
+
useNotify,
|
|
8
9
|
useRecordContext,
|
|
9
10
|
useResourceContext,
|
|
10
11
|
useTranslate,
|
|
@@ -99,7 +100,8 @@ function ActionButtonBase({
|
|
|
99
100
|
const resource = useResourceContext()
|
|
100
101
|
const [payloadActive, setPayloadActive] = useState(false)
|
|
101
102
|
|
|
102
|
-
const PayloadForm =
|
|
103
|
+
const PayloadForm =
|
|
104
|
+
findBuilder(["action-form", id, resource]) || (schema?.length ? CommonPayload : null)
|
|
103
105
|
if (!PayloadForm)
|
|
104
106
|
return (
|
|
105
107
|
<Button label={label} title={title} onClick={() => onHandle()} disabled={isPending}>
|
|
@@ -137,8 +139,8 @@ export function CommonPayload({
|
|
|
137
139
|
title,
|
|
138
140
|
help,
|
|
139
141
|
}: AdminPayloadProps) {
|
|
140
|
-
schema[0][1] = { ...schema[0][1], autoFocus: true }
|
|
141
|
-
const inputs = buildRA(schema)
|
|
142
|
+
if (schema?.length) schema[0][1] = { ...schema[0][1], autoFocus: true }
|
|
143
|
+
const inputs = buildRA(schema || [])
|
|
142
144
|
const translate = useTranslate()
|
|
143
145
|
const { form, formHandleSubmit } = useAugmentedForm({
|
|
144
146
|
onSubmit: (data) => {
|
|
@@ -181,6 +183,14 @@ export function CommonPayload({
|
|
|
181
183
|
)
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
export function buildFileUrl(apiUrl: string, path: string, id?: any, filterValues?: any) {
|
|
187
|
+
let url = `${apiUrl}${path.replace("{id}", encodeURIComponent(String(id ?? "")))}?f`
|
|
188
|
+
if (filterValues && Object.keys(filterValues).length) {
|
|
189
|
+
url += `&where=${encodeURIComponent(prepareFilters(filterValues))}`
|
|
190
|
+
}
|
|
191
|
+
return url
|
|
192
|
+
}
|
|
193
|
+
|
|
184
194
|
function FileButton({
|
|
185
195
|
path,
|
|
186
196
|
record,
|
|
@@ -195,14 +205,28 @@ function FileButton({
|
|
|
195
205
|
filterValues?: any
|
|
196
206
|
}) {
|
|
197
207
|
const { apiUrl } = useMuffinAdminOpts()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
208
|
+
const notify = useNotify()
|
|
209
|
+
|
|
210
|
+
const onClick = async () => {
|
|
211
|
+
const url = buildFileUrl(apiUrl, path, record?.id, filterValues)
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const response = await fetch(url, { headers: new Headers(requestHeaders) })
|
|
215
|
+
if (!response.ok) throw new Error(`Download failed (${response.status})`)
|
|
216
|
+
|
|
217
|
+
const blob = await response.blob()
|
|
218
|
+
const link = document.createElement("a")
|
|
219
|
+
link.href = URL.createObjectURL(blob)
|
|
220
|
+
link.download = path.split("/").pop() || "download"
|
|
221
|
+
link.click()
|
|
222
|
+
URL.revokeObjectURL(link.href)
|
|
223
|
+
} catch (err: any) {
|
|
224
|
+
notify(err?.message || "Download failed", { type: "error" })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
203
227
|
|
|
204
228
|
return (
|
|
205
|
-
<Button
|
|
229
|
+
<Button onClick={onClick} label={label}>
|
|
206
230
|
{buildIcon(icon)}
|
|
207
231
|
</Button>
|
|
208
232
|
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { MuffinDataprovider } from "./dataprovider"
|
|
3
|
+
|
|
4
|
+
const jsonResponse = (body: any, headers: Record<string, string> = {}) =>
|
|
5
|
+
new Response(JSON.stringify(body), {
|
|
6
|
+
headers: { "content-type": "application/json", ...headers },
|
|
7
|
+
status: 200,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("MuffinDataprovider", () => {
|
|
15
|
+
it("encodes resource ids in the URL", async () => {
|
|
16
|
+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: "a/b" }))
|
|
17
|
+
vi.stubGlobal("fetch", fetchMock)
|
|
18
|
+
|
|
19
|
+
const dataProvider = MuffinDataprovider("/admin/api")
|
|
20
|
+
await dataProvider.getOne("user", { id: "a/b" })
|
|
21
|
+
|
|
22
|
+
expect(fetchMock.mock.calls[0][0]).toBe("/admin/api/user/a%2Fb")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("uses the resource meta key as the react-admin id", async () => {
|
|
26
|
+
const fetchMock = vi
|
|
27
|
+
.fn()
|
|
28
|
+
.mockResolvedValue(jsonResponse([{ key: "web::42" }], { "x-total": "1" }))
|
|
29
|
+
vi.stubGlobal("fetch", fetchMock)
|
|
30
|
+
|
|
31
|
+
const dataProvider = MuffinDataprovider("/admin/api")
|
|
32
|
+
const result = await dataProvider.getList("order", {
|
|
33
|
+
meta: { key: "key" },
|
|
34
|
+
pagination: { page: 1, perPage: 25 },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(result.data).toEqual([{ id: "web::42", key: "web::42" }])
|
|
38
|
+
expect(result.total).toBe(1)
|
|
39
|
+
})
|
|
40
|
+
})
|
package/src/dataprovider.ts
CHANGED
|
@@ -5,6 +5,8 @@ type TQueryMeta = {
|
|
|
5
5
|
key?: string
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
const encodeId = (id: TID) => encodeURIComponent(String(id))
|
|
9
|
+
|
|
8
10
|
export function MuffinDataprovider(apiUrl: string) {
|
|
9
11
|
async function request(url: string, options?: APIParams) {
|
|
10
12
|
if (!url.startsWith("/")) url = `${apiUrl}/${url}`
|
|
@@ -50,7 +52,7 @@ export function MuffinDataprovider(apiUrl: string) {
|
|
|
50
52
|
},
|
|
51
53
|
|
|
52
54
|
getOne: async (resource: string, { id, meta }: { id: TID; meta?: TQueryMeta }) => {
|
|
53
|
-
const response = await request(`${resource}/${id}`)
|
|
55
|
+
const response = await request(`${resource}/${encodeId(id)}`)
|
|
54
56
|
const { data } = response
|
|
55
57
|
if (meta?.key) {
|
|
56
58
|
data.id = data.id ?? data[meta.key]
|
|
@@ -66,7 +68,7 @@ export function MuffinDataprovider(apiUrl: string) {
|
|
|
66
68
|
},
|
|
67
69
|
|
|
68
70
|
update: async (resource: string, { id, data }: { id: TID; data: any }) => {
|
|
69
|
-
return await makeRequest(`${apiUrl}/${resource}/${id}`, {
|
|
71
|
+
return await makeRequest(`${apiUrl}/${resource}/${encodeId(id)}`, {
|
|
70
72
|
method: "PUT",
|
|
71
73
|
...prepareDataParams(data),
|
|
72
74
|
}).then(({ json }) => ({ data: json }))
|
|
@@ -78,7 +80,7 @@ export function MuffinDataprovider(apiUrl: string) {
|
|
|
78
80
|
},
|
|
79
81
|
|
|
80
82
|
delete: async (resource: string, { id }: { id: TID }) => {
|
|
81
|
-
await request(`${resource}/${id}`, { method: "DELETE" })
|
|
83
|
+
await request(`${resource}/${encodeId(id)}`, { method: "DELETE" })
|
|
82
84
|
return { data: { id } }
|
|
83
85
|
},
|
|
84
86
|
|
|
@@ -11,7 +11,7 @@ export function AvatarField({ source, alt, style, nameProp, ...props }) {
|
|
|
11
11
|
// Check value is a valid URL
|
|
12
12
|
if (value) {
|
|
13
13
|
try {
|
|
14
|
-
new URL(value)
|
|
14
|
+
new URL(value, window.location.origin)
|
|
15
15
|
} catch {
|
|
16
16
|
// Not a valid URL, return loader
|
|
17
17
|
return <CircularProgress size={24} />
|
package/src/fields/JsonField.tsx
CHANGED
|
@@ -6,7 +6,8 @@ export function JsonField({ label, source }) {
|
|
|
6
6
|
const [expand, setExpand] = useState(false)
|
|
7
7
|
const record = useRecordContext()
|
|
8
8
|
const value = get(record, source)
|
|
9
|
-
if (
|
|
9
|
+
if (value === undefined || value === null) return null
|
|
10
|
+
if (typeof value === "object" && Object.keys(value).length === 0) return null
|
|
10
11
|
|
|
11
12
|
const src = JSON.stringify(value, null, 2)
|
|
12
13
|
const retval = (
|
package/src/filters/Filter.tsx
CHANGED
|
@@ -17,10 +17,16 @@ const OPERATORS = {
|
|
|
17
17
|
|
|
18
18
|
export function Filter({ type = "text", ...props }: InputProps) {
|
|
19
19
|
const { field } = useInput({
|
|
20
|
-
format: (value: any) =>
|
|
20
|
+
format: (value: any) => {
|
|
21
|
+
if (value && typeof value === "object") {
|
|
22
|
+
const op = Object.keys(value)[0] || "$eq"
|
|
23
|
+
return { op, text: value[op] ?? "" }
|
|
24
|
+
}
|
|
25
|
+
return { op: "$eq", text: value ?? "" }
|
|
26
|
+
},
|
|
21
27
|
...props,
|
|
22
28
|
})
|
|
23
|
-
const [op, setOp] = useState(
|
|
29
|
+
const [op, setOp] = useState(field.value.op)
|
|
24
30
|
|
|
25
31
|
const onChange = (e: any) => {
|
|
26
32
|
field.onChange({ [op]: e.target.value })
|
|
@@ -44,7 +50,7 @@ export function Filter({ type = "text", ...props }: InputProps) {
|
|
|
44
50
|
<TextField
|
|
45
51
|
{...field}
|
|
46
52
|
onChange={onChange}
|
|
47
|
-
value={field.value}
|
|
53
|
+
value={field.value.text}
|
|
48
54
|
variant="outlined"
|
|
49
55
|
type={type}
|
|
50
56
|
label={
|
|
@@ -60,7 +66,7 @@ export function Filter({ type = "text", ...props }: InputProps) {
|
|
|
60
66
|
onClick={() => {
|
|
61
67
|
setOp(key)
|
|
62
68
|
setMenuEl(null)
|
|
63
|
-
field.onChange({ [key]: field.value })
|
|
69
|
+
field.onChange({ [key]: field.value.text })
|
|
64
70
|
}}
|
|
65
71
|
>
|
|
66
72
|
{value}
|
package/src/inputs/JsonInput.tsx
CHANGED
|
@@ -45,8 +45,10 @@ export function JsonInput(props) {
|
|
|
45
45
|
if (!value || typeof value === "object") return undefined
|
|
46
46
|
return isJSON(value) ? undefined : errortext
|
|
47
47
|
}
|
|
48
|
-
validate
|
|
49
|
-
|
|
48
|
+
rest.validate = [
|
|
49
|
+
...(Array.isArray(validate) ? validate : validate ? [validate] : []),
|
|
50
|
+
validateJSON,
|
|
51
|
+
]
|
|
50
52
|
|
|
51
53
|
const formatJSON = (json) => {
|
|
52
54
|
let retval = json
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "13.
|
|
1
|
+
export const VERSION = "13.4.0"
|