muffin-admin 13.3.0 → 13.5.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.
Files changed (38) hide show
  1. package/dist/MuffinAdmin.js +1 -1
  2. package/dist/MuffinDashboard.js +2 -0
  3. package/dist/MuffinDashboard.test.d.ts +1 -0
  4. package/dist/MuffinDashboard.test.js +23 -0
  5. package/dist/MuffinResource.js +1 -1
  6. package/dist/MuffinResourceEdit.js +2 -1
  7. package/dist/MuffinResourceShow.js +3 -1
  8. package/dist/buttons/ActionButton.d.ts +1 -0
  9. package/dist/buttons/ActionButton.js +32 -14
  10. package/dist/buttons/ActionButton.test.d.ts +1 -0
  11. package/dist/buttons/ActionButton.test.js +12 -0
  12. package/dist/dataprovider.js +4 -3
  13. package/dist/dataprovider.test.d.ts +1 -0
  14. package/dist/dataprovider.test.js +40 -0
  15. package/dist/fields/AvatarField.js +1 -1
  16. package/dist/fields/JsonField.js +3 -1
  17. package/dist/filters/Filter.js +11 -4
  18. package/dist/inputs/JsonInput.js +4 -2
  19. package/dist/types.d.ts +2 -0
  20. package/dist/version.d.ts +1 -1
  21. package/dist/version.js +1 -1
  22. package/package.json +5 -3
  23. package/src/MuffinAdmin.tsx +1 -1
  24. package/src/MuffinDashboard.test.tsx +24 -0
  25. package/src/MuffinDashboard.tsx +2 -0
  26. package/src/MuffinResource.tsx +1 -1
  27. package/src/MuffinResourceEdit.tsx +2 -1
  28. package/src/MuffinResourceShow.tsx +3 -1
  29. package/src/buttons/ActionButton.test.ts +17 -0
  30. package/src/buttons/ActionButton.tsx +34 -10
  31. package/src/dataprovider.test.ts +40 -0
  32. package/src/dataprovider.ts +5 -3
  33. package/src/fields/AvatarField.tsx +1 -1
  34. package/src/fields/JsonField.tsx +2 -1
  35. package/src/filters/Filter.tsx +10 -4
  36. package/src/inputs/JsonInput.tsx +4 -2
  37. package/src/types.ts +3 -0
  38. package/src/version.ts +1 -1
@@ -33,7 +33,7 @@ export function MuffinAdmin(_a) {
33
33
  ]))
34
34
  : muffinTranslations);
35
35
  const { basename, authProvider = findBuilder(["auth-provider"])(auth), dataProvider = findBuilder(["data-provider"])(apiUrl), catchAll, dashboard = findBuilder(["dashboard"]), disableTelemetry, error, i18nProvider = muffinI18nProvider, layout = findBuilder(["layout"]), loading, loginPage = findBuilder(["loginpage"]), authCallbackPage, notification, queryClient, requireAuth = auth.required, store = localStorageStore(), ready, theme, defaultTheme, title = "React Admin", } = props;
36
- return (_jsx(AdminContext, { authProvider: authProvider, basename: basename, dataProvider: dataProvider, i18nProvider: i18nProvider, store: store, queryClient: queryClient, theme: theme, lightTheme: lightTheme, darkTheme: darkTheme, defaultTheme: defaultTheme, children: _jsx(ConfirmationProvider, { children: _jsx(AdminUI, { layout: layout, dashboard: dashboard, disableTelemetry: disableTelemetry, catchAll: catchAll, error: error, title: title, loading: loading, loginPage: loginPage, authCallbackPage: authCallbackPage, notification: notification, requireAuth: requireAuth, ready: ready, children: resources === null || resources === void 0 ? void 0 : resources.map(({ name }) => buildAdmin(["resource", name], { name })) }) }) }));
36
+ return (_jsx(AdminContext, { authProvider: authProvider, basename: basename, dataProvider: dataProvider, i18nProvider: i18nProvider, store: store, queryClient: queryClient, theme: theme !== null && theme !== void 0 ? theme : adminProps === null || adminProps === void 0 ? void 0 : adminProps.theme, lightTheme: lightTheme, darkTheme: darkTheme, defaultTheme: defaultTheme, children: _jsx(ConfirmationProvider, { children: _jsx(AdminUI, { layout: layout, dashboard: dashboard, disableTelemetry: disableTelemetry, catchAll: catchAll, error: error, title: title, loading: loading, loginPage: loginPage, authCallbackPage: authCallbackPage, notification: notification, requireAuth: requireAuth, ready: ready, children: resources === null || resources === void 0 ? void 0 : resources.map(({ name }) => buildAdmin(["resource", name], { name })) }) }) }));
37
37
  }
38
38
  setupAdmin(["admin"], MuffinAdmin);
39
39
  export function MuffinAdminLayout(props) {
@@ -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
+ });
@@ -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 { Link, Stack } from "@mui/material";
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 && CommonPayload);
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[0][1] = Object.assign(Object.assign({}, schema[0][1]), { autoFocus: true });
91
- const inputs = buildRA(schema);
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
- let url = `${apiUrl}${path}?f`;
108
- if (record)
109
- url = url.replace("{id}", record.id);
110
- const authorization = requestHeaders.Authorization;
111
- if (authorization)
112
- url += `&t=${authorization}`;
113
- if (Object.keys(filterValues).length)
114
- url += `&where=${prepareFilters(filterValues)}`;
115
- return (_jsx(Button, { href: url, label: label, component: Link, children: buildIcon(icon) }));
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
+ });
@@ -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
+ });
@@ -21,7 +21,7 @@ export function AvatarField(_a) {
21
21
  // Check value is a valid URL
22
22
  if (value) {
23
23
  try {
24
- new URL(value);
24
+ new URL(value, window.location.origin);
25
25
  }
26
26
  catch (_b) {
27
27
  // Not a valid URL, return loader
@@ -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 (!value || Object.keys(value).length == 0)
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) => {
@@ -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) => (value ? value[Object.keys(value)[0]] : "") }, props));
31
- const [op, setOp] = useState((field.value && Object.keys(field.value)[0]) || "$eq");
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
  }
@@ -51,8 +51,10 @@ export function JsonInput(props) {
51
51
  return undefined;
52
52
  return isJSON(value) ? undefined : errortext;
53
53
  };
54
- validate.push(validateJSON);
55
- rest.validate = validate;
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/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ThemeOptions } from "@mui/material";
1
2
  export type TID = string | number;
2
3
  export type TProps = {
3
4
  [key: string]: any;
@@ -83,6 +84,7 @@ export type AdminOpts = {
83
84
  title: string;
84
85
  disableTelemetry?: boolean;
85
86
  mutationMode?: "optimistic" | "pessimistic" | "undoable";
87
+ theme?: ThemeOptions;
86
88
  };
87
89
  apiUrl: string;
88
90
  appBarLinks: [{
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "13.3.0";
1
+ export declare const VERSION = "13.5.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "13.3.0";
1
+ export const VERSION = "13.5.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muffin-admin",
3
- "version": "13.3.0",
3
+ "version": "13.5.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": "echo \"Error: no test specified\" && exit 1"
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.2.17",
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"
@@ -73,7 +73,7 @@ export function MuffinAdmin({
73
73
  i18nProvider={i18nProvider}
74
74
  store={store}
75
75
  queryClient={queryClient}
76
- theme={theme}
76
+ theme={theme ?? adminProps?.theme}
77
77
  lightTheme={lightTheme}
78
78
  darkTheme={darkTheme}
79
79
  defaultTheme={defaultTheme}
@@ -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
+ })
@@ -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}>
@@ -20,7 +20,7 @@ export function MuffinResource({ name, ...props }: ResourceProps) {
20
20
  create={Create}
21
21
  edit={Edit}
22
22
  list={List}
23
- show={Show}
23
+ show={opts.list.show ? Show : undefined}
24
24
  {...props}
25
25
  />
26
26
  )
@@ -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 { Link, Stack } from "@mui/material"
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 = findBuilder(["action-form", id, resource]) || (schema && CommonPayload)
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
- let url = `${apiUrl}${path}?f`
199
- if (record) url = url.replace("{id}", record.id as string)
200
- const authorization = requestHeaders.Authorization
201
- if (authorization) url += `&t=${authorization}`
202
- if (Object.keys(filterValues).length) url += `&where=${prepareFilters(filterValues)}`
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 href={url} label={label} component={Link}>
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
+ })
@@ -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} />
@@ -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 (!value || Object.keys(value).length == 0) return null
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 = (
@@ -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) => (value ? value[Object.keys(value)[0]] : ""),
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((field.value && Object.keys(field.value)[0]) || "$eq")
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}
@@ -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.push(validateJSON)
49
- rest.validate = validate
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/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ThemeOptions } from "@mui/material"
2
+
1
3
  export type TID = string | number
2
4
 
3
5
  export type TProps = {
@@ -126,6 +128,7 @@ export type AdminOpts = {
126
128
  title: string
127
129
  disableTelemetry?: boolean
128
130
  mutationMode?: "optimistic" | "pessimistic" | "undoable"
131
+ theme?: ThemeOptions
129
132
  }
130
133
  apiUrl: string
131
134
  appBarLinks: [{ url: string; title: string; icon?: string }]
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "13.3.0"
1
+ export const VERSION = "13.5.0"