mirror of
https://github.com/stefanpejcic/openpanel
synced 2025-06-26 18:28:26 +00:00
packages
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { autoSaveIndicatorTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { AutoSaveIndicator } from "./";
|
||||
|
||||
describe("AutoSaveIndicator", () => {
|
||||
autoSaveIndicatorTests.bind(this)(AutoSaveIndicator);
|
||||
});
|
||||
80
packages/mantine/src/components/autoSaveIndicator/index.tsx
Normal file
80
packages/mantine/src/components/autoSaveIndicator/index.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type AutoSaveIndicatorProps,
|
||||
useTranslate,
|
||||
AutoSaveIndicator as AutoSaveIndicatorCore,
|
||||
} from "@refinedev/core";
|
||||
import { Text } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconRefresh,
|
||||
IconCircleCheck,
|
||||
IconExclamationCircle,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
export const AutoSaveIndicator: React.FC<AutoSaveIndicatorProps> = ({
|
||||
status,
|
||||
elements: {
|
||||
success = (
|
||||
<Message
|
||||
translationKey="autoSave.success"
|
||||
defaultMessage="saved"
|
||||
icon={<IconCircleCheck size="18px" />}
|
||||
/>
|
||||
),
|
||||
error = (
|
||||
<Message
|
||||
translationKey="autoSave.error"
|
||||
defaultMessage="auto save failure"
|
||||
icon={<IconExclamationCircle size="18px" />}
|
||||
/>
|
||||
),
|
||||
loading = (
|
||||
<Message
|
||||
translationKey="autoSave.loading"
|
||||
defaultMessage="saving..."
|
||||
icon={<IconRefresh size="18px" />}
|
||||
/>
|
||||
),
|
||||
idle = (
|
||||
<Message
|
||||
translationKey="autoSave.idle"
|
||||
defaultMessage="waiting for changes"
|
||||
icon={<IconDots size="18px" />}
|
||||
/>
|
||||
),
|
||||
} = {},
|
||||
}) => {
|
||||
return (
|
||||
<AutoSaveIndicatorCore
|
||||
status={status}
|
||||
elements={{
|
||||
success,
|
||||
error,
|
||||
loading,
|
||||
idle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Message = ({
|
||||
translationKey,
|
||||
defaultMessage,
|
||||
icon,
|
||||
}: {
|
||||
translationKey: string;
|
||||
defaultMessage: string;
|
||||
icon: React.ReactNode;
|
||||
}) => {
|
||||
const translate = useTranslate();
|
||||
|
||||
return (
|
||||
<Text size="sm" display="flex" align="center" mr="2" color="gray">
|
||||
{translate(translationKey, defaultMessage)}
|
||||
<span style={{ position: "relative", top: "3px", marginLeft: "3px" }}>
|
||||
{icon}
|
||||
</span>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
70
packages/mantine/src/components/breadcrumb/index.spec.tsx
Normal file
70
packages/mantine/src/components/breadcrumb/index.spec.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import {
|
||||
render,
|
||||
TestWrapper,
|
||||
type ITestWrapperProps,
|
||||
act,
|
||||
MockLegacyRouterProvider,
|
||||
} from "@test";
|
||||
|
||||
import { Breadcrumb } from "./";
|
||||
import { breadcrumbTests } from "@refinedev/ui-tests";
|
||||
|
||||
const renderBreadcrumb = (
|
||||
children: ReactNode,
|
||||
wrapperProps: ITestWrapperProps = {},
|
||||
) => {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/:resource/:action" element={children} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
...wrapperProps,
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const DummyDashboard = () => <div>Dashboard</div>;
|
||||
|
||||
describe("Breadcrumb", () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(console, "warn").mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
breadcrumbTests.bind(this)(Breadcrumb);
|
||||
|
||||
it("should render home icon", async () => {
|
||||
const { container } = renderBreadcrumb(<Breadcrumb />, {
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
DashboardPage: DummyDashboard,
|
||||
});
|
||||
|
||||
expect(container.querySelector("svg")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not render home icon with 'showhHome' props", async () => {
|
||||
const { container } = renderBreadcrumb(<Breadcrumb showHome={false} />, {
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
DashboardPage: DummyDashboard,
|
||||
});
|
||||
|
||||
expect(container.querySelector("svg")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should render breadcrumb items", async () => {
|
||||
const { getByText } = renderBreadcrumb(<Breadcrumb />, {
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
});
|
||||
|
||||
getByText("Posts");
|
||||
getByText("Create");
|
||||
});
|
||||
});
|
||||
83
packages/mantine/src/components/breadcrumb/index.tsx
Normal file
83
packages/mantine/src/components/breadcrumb/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import {
|
||||
matchResourceFromRoute,
|
||||
useBreadcrumb,
|
||||
useLink,
|
||||
useRefineContext,
|
||||
useResource,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
} from "@refinedev/core";
|
||||
import type { RefineBreadcrumbProps } from "@refinedev/ui-types";
|
||||
|
||||
import {
|
||||
Text,
|
||||
Breadcrumbs,
|
||||
type BreadcrumbsProps as MantineBreadcrumbProps,
|
||||
Anchor,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import { IconHome } from "@tabler/icons-react";
|
||||
|
||||
export type BreadcrumbProps = RefineBreadcrumbProps<MantineBreadcrumbProps>;
|
||||
|
||||
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
|
||||
breadcrumbProps,
|
||||
showHome = true,
|
||||
hideIcons = false,
|
||||
meta,
|
||||
}) => {
|
||||
const routerType = useRouterType();
|
||||
const { breadcrumbs } = useBreadcrumb({ meta });
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const { hasDashboard } = useRefineContext();
|
||||
|
||||
const { resources } = useResource();
|
||||
|
||||
const rootRouteResource = matchResourceFromRoute("/", resources);
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
if (breadcrumbs.length === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumbs
|
||||
aria-label="breadcrumb"
|
||||
styles={{
|
||||
separator: { marginRight: 8, marginLeft: 8, color: "dimgray" },
|
||||
}}
|
||||
{...breadcrumbProps}
|
||||
>
|
||||
{showHome && (hasDashboard || rootRouteResource.found) && (
|
||||
<Anchor component={ActiveLink as any} color="dimmed" to="/">
|
||||
{rootRouteResource?.resource?.meta?.icon ?? <IconHome size={18} />}
|
||||
</Anchor>
|
||||
)}
|
||||
{breadcrumbs.map(({ label, icon, href }) => {
|
||||
return (
|
||||
<Group key={label} spacing={4} align="center" noWrap>
|
||||
{!hideIcons && icon}
|
||||
{href ? (
|
||||
<Anchor
|
||||
component={ActiveLink as any}
|
||||
color="dimmed"
|
||||
to={href}
|
||||
size="sm"
|
||||
>
|
||||
{label}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text color="dimmed" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { buttonCloneTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { CloneButton } from "./";
|
||||
|
||||
describe("Clone Button", () => {
|
||||
buttonCloneTests.bind(this)(CloneButton);
|
||||
});
|
||||
91
packages/mantine/src/components/buttons/clone/index.tsx
Normal file
91
packages/mantine/src/components/buttons/clone/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { useCloneButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Anchor, Button } from "@mantine/core";
|
||||
import { IconSquarePlus } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { CloneButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<CloneButton>` uses Mantine {@link https://mantine.dev/core/button `<Button> component`}.
|
||||
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#clone `clone`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation useNavigation} under the hood.
|
||||
* It can be useful when redirecting the app to the create page with the record id route of resource.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/clone-button} for more details.
|
||||
*
|
||||
*/
|
||||
export const CloneButton: React.FC<CloneButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName,
|
||||
recordItemId,
|
||||
hideText = false,
|
||||
accessControl,
|
||||
svgIconProps,
|
||||
meta,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}) => {
|
||||
const { to, label, title, hidden, disabled, LinkComponent } = useCloneButton({
|
||||
resource: resourceNameFromProps ?? resourceNameOrRouteName,
|
||||
id: recordItemId,
|
||||
accessControl,
|
||||
meta,
|
||||
});
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
component={LinkComponent as any}
|
||||
to={to}
|
||||
replace={false}
|
||||
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
aria-label={label}
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
data-testid={RefineButtonTestIds.CloneButton}
|
||||
className={RefineButtonClassNames.CloneButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconSquarePlus size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
variant="default"
|
||||
leftIcon={<IconSquarePlus size={18} {...svgIconProps} />}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.CloneButton}
|
||||
className={RefineButtonClassNames.CloneButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { buttonCreateTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { CreateButton } from "./";
|
||||
|
||||
describe("Create Button", () => {
|
||||
buttonCreateTests.bind(this)(CreateButton);
|
||||
});
|
||||
85
packages/mantine/src/components/buttons/create/index.tsx
Normal file
85
packages/mantine/src/components/buttons/create/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { useCreateButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Anchor, Button } from "@mantine/core";
|
||||
import { IconSquarePlus } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { CreateButtonProps } from "../types";
|
||||
|
||||
export const CreateButton: React.FC<CreateButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName,
|
||||
hideText = false,
|
||||
accessControl,
|
||||
svgIconProps,
|
||||
meta,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}) => {
|
||||
const { to, label, title, disabled, hidden, LinkComponent } = useCreateButton(
|
||||
{
|
||||
resource: resourceNameFromProps ?? resourceNameOrRouteName,
|
||||
accessControl,
|
||||
meta,
|
||||
},
|
||||
);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
component={LinkComponent as any}
|
||||
to={to}
|
||||
replace={false}
|
||||
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
color="primary"
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "filled" })}
|
||||
data-testid={RefineButtonTestIds.CreateButton}
|
||||
className={RefineButtonClassNames.CreateButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconSquarePlus size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
leftIcon={<IconSquarePlus size={18} {...svgIconProps} />}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.CreateButton}
|
||||
className={RefineButtonClassNames.CreateButton}
|
||||
color="primary"
|
||||
variant="filled"
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonDeleteTests } from "@refinedev/ui-tests";
|
||||
import { DeleteButton } from "./";
|
||||
|
||||
describe("Delete Button", () => {
|
||||
buttonDeleteTests.bind(this)(DeleteButton);
|
||||
});
|
||||
137
packages/mantine/src/components/buttons/delete/index.tsx
Normal file
137
packages/mantine/src/components/buttons/delete/index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from "react";
|
||||
import { useDeleteButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { Group, Text, Button, Popover, ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { DeleteButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<DeleteButton>` uses Mantine {@link https://mantine.dev/core/button `<Button>`} and {@link https://mantine.dev/core/modal `<Modal>`} components.
|
||||
* When you try to delete something, a dialog modal shows up and asks for confirmation. When confirmed it executes the `useDelete` method provided by your `dataProvider`.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/delete-button} for more details.
|
||||
*/
|
||||
export const DeleteButton: React.FC<DeleteButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName,
|
||||
recordItemId,
|
||||
onSuccess,
|
||||
mutationMode,
|
||||
invalidates,
|
||||
children,
|
||||
successNotification,
|
||||
errorNotification,
|
||||
hideText = false,
|
||||
accessControl,
|
||||
meta,
|
||||
metaData,
|
||||
dataProviderName,
|
||||
confirmTitle,
|
||||
confirmOkText,
|
||||
confirmCancelText,
|
||||
svgIconProps,
|
||||
...rest
|
||||
}) => {
|
||||
const {
|
||||
title,
|
||||
label,
|
||||
hidden,
|
||||
disabled,
|
||||
loading,
|
||||
confirmTitle: defaultConfirmTitle,
|
||||
confirmOkLabel: defaultConfirmOkLabel,
|
||||
cancelLabel: defaultCancelLabel,
|
||||
onConfirm,
|
||||
} = useDeleteButton({
|
||||
resource: resourceNameFromProps ?? resourceNameOrRouteName,
|
||||
id: recordItemId,
|
||||
dataProviderName,
|
||||
errorNotification,
|
||||
successNotification,
|
||||
invalidates,
|
||||
mutationMode,
|
||||
accessControl,
|
||||
meta,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withArrow
|
||||
withinPortal
|
||||
disabled={
|
||||
typeof rest?.disabled !== "undefined" ? rest.disabled : disabled
|
||||
}
|
||||
>
|
||||
<Popover.Target>
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
color="red"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
disabled={loading || disabled}
|
||||
loading={loading}
|
||||
data-testid={RefineButtonTestIds.DeleteButton}
|
||||
className={RefineButtonClassNames.DeleteButton}
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "outline" })}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconTrash size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
color="red"
|
||||
variant="outline"
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
disabled={loading || disabled}
|
||||
loading={loading}
|
||||
title={title}
|
||||
leftIcon={<IconTrash size={18} {...svgIconProps} />}
|
||||
data-testid={RefineButtonTestIds.DeleteButton}
|
||||
className={RefineButtonClassNames.DeleteButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown py="xs">
|
||||
<Text size="sm" weight="bold">
|
||||
{confirmTitle ?? defaultConfirmTitle}
|
||||
</Text>
|
||||
<Group position="center" noWrap spacing="xs" mt="xs">
|
||||
<Button onClick={() => setOpened(false)} variant="default" size="xs">
|
||||
{confirmCancelText ?? defaultCancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
setOpened(false);
|
||||
}}
|
||||
autoFocus
|
||||
size="xs"
|
||||
>
|
||||
{confirmOkText ?? defaultConfirmOkLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonEditTests } from "@refinedev/ui-tests";
|
||||
import { EditButton } from "./";
|
||||
|
||||
describe("Edit Button", () => {
|
||||
buttonEditTests.bind(this)(EditButton);
|
||||
});
|
||||
90
packages/mantine/src/components/buttons/edit/index.tsx
Normal file
90
packages/mantine/src/components/buttons/edit/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { useEditButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Anchor, Button } from "@mantine/core";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { EditButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<EditButton>` uses Mantine {@link https://mantine.dev/core/button `<Button> component`}.
|
||||
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#edit `edit`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
|
||||
* It can be useful when redirecting the app to the edit page with the record id route of resource}.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/edit-button} for more details.
|
||||
*/
|
||||
export const EditButton: React.FC<EditButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName,
|
||||
recordItemId,
|
||||
hideText = false,
|
||||
accessControl,
|
||||
svgIconProps,
|
||||
meta,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}) => {
|
||||
const { to, label, title, disabled, hidden, LinkComponent } = useEditButton({
|
||||
resource: resourceNameFromProps ?? resourceNameOrRouteName,
|
||||
id: recordItemId,
|
||||
accessControl,
|
||||
meta,
|
||||
});
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
component={LinkComponent as any}
|
||||
to={to}
|
||||
replace={false}
|
||||
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
data-testid={RefineButtonTestIds.EditButton}
|
||||
className={RefineButtonClassNames.EditButton}
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconPencil size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
leftIcon={<IconPencil size={18} {...svgIconProps} />}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.EditButton}
|
||||
className={RefineButtonClassNames.EditButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonExportTests } from "@refinedev/ui-tests";
|
||||
import { ExportButton } from "./index";
|
||||
|
||||
describe("<ExportButton/>", () => {
|
||||
buttonExportTests.bind(this)(ExportButton);
|
||||
});
|
||||
57
packages/mantine/src/components/buttons/export/index.tsx
Normal file
57
packages/mantine/src/components/buttons/export/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { useExportButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Button } from "@mantine/core";
|
||||
import { IconFileExport } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { ExportButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<ExportButton>` uses Mantine {@link https://mantine.dev/core/button `<Button> `} component with a default export icon and a default text with "Export".
|
||||
* It only has presentational value.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/export-button} for more details.
|
||||
*/
|
||||
export const ExportButton: React.FC<ExportButtonProps> = ({
|
||||
hideText = false,
|
||||
children,
|
||||
loading = false,
|
||||
svgIconProps,
|
||||
...rest
|
||||
}) => {
|
||||
const { label } = useExportButton();
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
return hideText ? (
|
||||
<ActionIcon
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
loading={loading}
|
||||
aria-label={label}
|
||||
data-testid={RefineButtonTestIds.ExportButton}
|
||||
className={RefineButtonClassNames.ExportButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconFileExport size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={loading}
|
||||
leftIcon={<IconFileExport size={18} {...svgIconProps} />}
|
||||
data-testid={RefineButtonTestIds.ExportButton}
|
||||
className={RefineButtonClassNames.ExportButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { buttonImportTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { ImportButton } from "./";
|
||||
|
||||
describe("ImportButton", () => {
|
||||
buttonImportTests.bind(this)(ImportButton);
|
||||
});
|
||||
65
packages/mantine/src/components/buttons/import/index.tsx
Normal file
65
packages/mantine/src/components/buttons/import/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { useImportButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Button } from "@mantine/core";
|
||||
import { IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { ImportButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<ImportButton>` is compatible with the {@link https://refine.dev/docs/api-reference/core/hooks/import-export/useImport/ `useImport`} core hook.
|
||||
* It uses uses Mantine {@link https://mantine.dev/core/button `<Button> component`} and native html {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input `<input>`} element.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/import-button} for more details.
|
||||
*/
|
||||
export const ImportButton: React.FC<ImportButtonProps> = ({
|
||||
inputProps,
|
||||
hideText = false,
|
||||
loading = false,
|
||||
svgIconProps,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const { label } = useImportButton();
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
return (
|
||||
<label htmlFor="contained-button-file">
|
||||
<input {...inputProps} id="contained-button-file" multiple hidden />
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
aria-label={label}
|
||||
component="span"
|
||||
loading={loading}
|
||||
data-testid={RefineButtonTestIds.ImportButton}
|
||||
className={RefineButtonClassNames.ImportButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconFileImport size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
component="span"
|
||||
leftIcon={<IconFileImport size={18} {...svgIconProps} />}
|
||||
loading={loading}
|
||||
data-testid={RefineButtonTestIds.ImportButton}
|
||||
className={RefineButtonClassNames.ImportButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
11
packages/mantine/src/components/buttons/index.tsx
Normal file
11
packages/mantine/src/components/buttons/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export { CreateButton } from "./create";
|
||||
export { EditButton } from "./edit";
|
||||
export { DeleteButton } from "./delete";
|
||||
export { RefreshButton } from "./refresh";
|
||||
export { ShowButton } from "./show";
|
||||
export { ListButton } from "./list";
|
||||
export { ExportButton } from "./export";
|
||||
export { SaveButton } from "./save";
|
||||
export { CloneButton } from "./clone";
|
||||
export { ImportButton } from "./import";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonListTests } from "@refinedev/ui-tests";
|
||||
import { ListButton } from "./";
|
||||
|
||||
describe("List Button", () => {
|
||||
buttonListTests.bind(this)(ListButton);
|
||||
});
|
||||
88
packages/mantine/src/components/buttons/list/index.tsx
Normal file
88
packages/mantine/src/components/buttons/list/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { useListButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Anchor, Button } from "@mantine/core";
|
||||
import { IconList } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { ListButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<ListButton>` is using uses Mantine {@link https://mantine.dev/core/button `<Button> `} component.
|
||||
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#list `list`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
|
||||
* It can be useful when redirecting the app to the list page route of resource}.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/list-button} for more details.
|
||||
**/
|
||||
export const ListButton: React.FC<ListButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName,
|
||||
hideText = false,
|
||||
accessControl,
|
||||
svgIconProps,
|
||||
meta,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}) => {
|
||||
const { to, label, title, disabled, hidden, LinkComponent } = useListButton({
|
||||
resource: resourceNameFromProps ?? resourceNameOrRouteName,
|
||||
accessControl,
|
||||
meta,
|
||||
});
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
component={LinkComponent as any}
|
||||
to={to}
|
||||
replace={false}
|
||||
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.ListButton}
|
||||
className={RefineButtonClassNames.ListButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconList size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
leftIcon={<IconList size={18} {...svgIconProps} />}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.ListButton}
|
||||
className={RefineButtonClassNames.ListButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonRefreshTests } from "@refinedev/ui-tests";
|
||||
import { RefreshButton } from "./";
|
||||
|
||||
describe("Refresh Button", () => {
|
||||
buttonRefreshTests.bind(this)(RefreshButton);
|
||||
});
|
||||
73
packages/mantine/src/components/buttons/refresh/index.tsx
Normal file
73
packages/mantine/src/components/buttons/refresh/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { useRefreshButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Button } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { RefreshButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<RefreshButton>` uses Mantine {@link https://mantine.dev/core/button `<Button> `} component.
|
||||
* to update the data shown on the page via the {@link https://refine.dev/docs/api-reference/core/hooks/invalidate/useInvalidate `useInvalidate`} hook.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/refresh-button} for more details.
|
||||
*/
|
||||
export const RefreshButton: React.FC<RefreshButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName: propResourceNameOrRouteName,
|
||||
recordItemId,
|
||||
hideText = false,
|
||||
dataProviderName,
|
||||
svgIconProps,
|
||||
children,
|
||||
onClick,
|
||||
meta: _meta,
|
||||
metaData: _metaData,
|
||||
...rest
|
||||
}) => {
|
||||
const {
|
||||
onClick: onRefresh,
|
||||
label,
|
||||
loading,
|
||||
} = useRefreshButton({
|
||||
resource: resourceNameFromProps ?? propResourceNameOrRouteName,
|
||||
id: recordItemId,
|
||||
dataProviderName,
|
||||
});
|
||||
|
||||
const { variant, styles: _styles, ...commonProps } = rest;
|
||||
|
||||
return hideText ? (
|
||||
<ActionIcon
|
||||
onClick={onClick ? onClick : onRefresh}
|
||||
loading={loading}
|
||||
aria-label={label}
|
||||
data-testid={RefineButtonTestIds.RefreshButton}
|
||||
className={RefineButtonClassNames.RefreshButton}
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconRefresh size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={<IconRefresh size={18} {...svgIconProps} />}
|
||||
loading={loading}
|
||||
onClick={onClick ? onClick : onRefresh}
|
||||
data-testid={RefineButtonTestIds.RefreshButton}
|
||||
className={RefineButtonClassNames.RefreshButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonSaveTests } from "@refinedev/ui-tests";
|
||||
import { SaveButton } from "./";
|
||||
|
||||
describe("Save Button", () => {
|
||||
buttonSaveTests.bind(this)(SaveButton);
|
||||
});
|
||||
54
packages/mantine/src/components/buttons/save/index.tsx
Normal file
54
packages/mantine/src/components/buttons/save/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { useSaveButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Button } from "@mantine/core";
|
||||
import { IconDeviceFloppy } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { SaveButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<SaveButton>` uses Mantine {@link https://mantine.dev/core/button `<Button> `}.
|
||||
* It uses it for presantation purposes only. Some of the hooks that refine has adds features to this button.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/save-button} for more details.
|
||||
*/
|
||||
export const SaveButton: React.FC<SaveButtonProps> = ({
|
||||
hideText = false,
|
||||
svgIconProps,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const { label } = useSaveButton();
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
return hideText ? (
|
||||
<ActionIcon
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "filled", color: "primary" })}
|
||||
aria-label={label}
|
||||
data-testid={RefineButtonTestIds.SaveButton}
|
||||
className={RefineButtonClassNames.SaveButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconDeviceFloppy size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="filled"
|
||||
leftIcon={<IconDeviceFloppy size={18} {...svgIconProps} />}
|
||||
data-testid={RefineButtonTestIds.SaveButton}
|
||||
className={RefineButtonClassNames.SaveButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buttonShowTests } from "@refinedev/ui-tests";
|
||||
import { ShowButton } from "./";
|
||||
|
||||
describe("Show Button", () => {
|
||||
buttonShowTests.bind(this)(ShowButton);
|
||||
});
|
||||
89
packages/mantine/src/components/buttons/show/index.tsx
Normal file
89
packages/mantine/src/components/buttons/show/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useShowButton } from "@refinedev/core";
|
||||
import {
|
||||
RefineButtonClassNames,
|
||||
RefineButtonTestIds,
|
||||
} from "@refinedev/ui-types";
|
||||
import { ActionIcon, Anchor, Button } from "@mantine/core";
|
||||
import { IconEye } from "@tabler/icons-react";
|
||||
|
||||
import { mapButtonVariantToActionIconVariant } from "@definitions/button";
|
||||
import type { ShowButtonProps } from "../types";
|
||||
|
||||
/**
|
||||
* `<ShowButton>` uses Mantine {@link https://mantine.dev/core/button `<Button> `} component.
|
||||
* It uses the {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation#show `show`} method from {@link https://refine.dev/docs/api-reference/core/hooks/navigation/useNavigation `useNavigation`} under the hood.
|
||||
* It can be useful when redirecting the app to the show page with the record id route of resource.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/buttons/show-button} for more details.
|
||||
*/
|
||||
export const ShowButton: React.FC<ShowButtonProps> = ({
|
||||
resource: resourceNameFromProps,
|
||||
resourceNameOrRouteName,
|
||||
recordItemId,
|
||||
hideText = false,
|
||||
accessControl,
|
||||
svgIconProps,
|
||||
meta,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}) => {
|
||||
const { to, label, title, hidden, disabled, LinkComponent } = useShowButton({
|
||||
resource: resourceNameFromProps ?? resourceNameOrRouteName,
|
||||
id: recordItemId,
|
||||
accessControl,
|
||||
meta,
|
||||
});
|
||||
|
||||
const { variant, styles, ...commonProps } = rest;
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<Anchor
|
||||
component={LinkComponent as any}
|
||||
to={to}
|
||||
replace={false}
|
||||
onClick={(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hideText ? (
|
||||
<ActionIcon
|
||||
{...(variant
|
||||
? {
|
||||
variant: mapButtonVariantToActionIconVariant(variant),
|
||||
}
|
||||
: { variant: "default" })}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.ShowButton}
|
||||
className={RefineButtonClassNames.ShowButton}
|
||||
{...commonProps}
|
||||
>
|
||||
<IconEye size={18} {...svgIconProps} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
leftIcon={<IconEye size={18} {...svgIconProps} />}
|
||||
title={title}
|
||||
data-testid={RefineButtonTestIds.ShowButton}
|
||||
className={RefineButtonClassNames.ShowButton}
|
||||
{...rest}
|
||||
>
|
||||
{children ?? label}
|
||||
</Button>
|
||||
)}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
86
packages/mantine/src/components/buttons/types.ts
Normal file
86
packages/mantine/src/components/buttons/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ButtonProps } from "@mantine/core";
|
||||
import type { UseImportInputPropsType } from "@refinedev/core";
|
||||
import type {
|
||||
RefineCloneButtonProps,
|
||||
RefineCreateButtonProps,
|
||||
RefineDeleteButtonProps,
|
||||
RefineEditButtonProps,
|
||||
RefineExportButtonProps,
|
||||
RefineImportButtonProps,
|
||||
RefineListButtonProps,
|
||||
RefineRefreshButtonProps,
|
||||
RefineSaveButtonProps,
|
||||
RefineShowButtonProps,
|
||||
} from "@refinedev/ui-types";
|
||||
import type { IconProps } from "@tabler/icons-react";
|
||||
|
||||
export type ShowButtonProps = RefineShowButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type SaveButtonProps = RefineSaveButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type RefreshButtonProps = RefineRefreshButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ListButtonProps = RefineListButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ImportButtonProps = RefineImportButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
inputProps: UseImportInputPropsType;
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ExportButtonProps = RefineExportButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type EditButtonProps = RefineEditButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DeleteButtonProps = RefineDeleteButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type CreateButtonProps = RefineCreateButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
|
||||
export type CloneButtonProps = RefineCloneButtonProps<
|
||||
ButtonProps,
|
||||
{
|
||||
svgIconProps?: Omit<IconProps, "ref">;
|
||||
}
|
||||
>;
|
||||
82
packages/mantine/src/components/crud/create/index.spec.tsx
Normal file
82
packages/mantine/src/components/crud/create/index.spec.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import { render, TestWrapper } from "@test";
|
||||
import { Create } from "./";
|
||||
import { crudCreateTests } from "@refinedev/ui-tests";
|
||||
import { SaveButton } from "@components/buttons";
|
||||
import { RefineButtonTestIds } from "@refinedev/ui-types";
|
||||
|
||||
const renderCreate = (create: ReactNode) => {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/:resource/create" element={create} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("Create", () => {
|
||||
crudCreateTests.bind(this)(Create);
|
||||
|
||||
it("should render breadcrumb", async () => {
|
||||
const { getAllByLabelText } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action"
|
||||
element={<Create resource="posts" />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getAllByLabelText("breadcrumb")).not.toBeNull();
|
||||
});
|
||||
it("should not render breadcrumb", async () => {
|
||||
const { queryByLabelText } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action"
|
||||
element={<Create resource="posts" breadcrumb={null} />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText("breadcrumb")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should customize default buttons with default props", async () => {
|
||||
const { queryByTestId } = renderCreate(
|
||||
<Create
|
||||
saveButtonProps={{ className: "customize-test" }}
|
||||
footerButtons={({ saveButtonProps }) => {
|
||||
expect(saveButtonProps).toBeDefined();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SaveButton {...saveButtonProps} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.SaveButton)).toHaveClass(
|
||||
"customize-test",
|
||||
);
|
||||
});
|
||||
});
|
||||
151
packages/mantine/src/components/crud/create/index.tsx
Normal file
151
packages/mantine/src/components/crud/create/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Stack,
|
||||
Title,
|
||||
LoadingOverlay,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
useBack,
|
||||
useNavigation,
|
||||
useRefineContext,
|
||||
useResource,
|
||||
useUserFriendlyName,
|
||||
useRouterType,
|
||||
useTranslate,
|
||||
} from "@refinedev/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { SaveButton, Breadcrumb, type SaveButtonProps } from "@components";
|
||||
import type { CreateProps } from "../types";
|
||||
import { RefinePageHeaderClassNames } from "@refinedev/ui-types";
|
||||
|
||||
export const Create: React.FC<CreateProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
saveButtonProps: saveButtonPropsFromProps,
|
||||
isLoading,
|
||||
resource: resourceFromProps,
|
||||
footerButtons: footerButtonsFromProps,
|
||||
footerButtonProps,
|
||||
headerButtons: headerButtonsFromProps,
|
||||
headerButtonProps,
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
headerProps,
|
||||
goBack: goBackFromProps,
|
||||
breadcrumb: breadcrumbFromProps,
|
||||
title,
|
||||
} = props;
|
||||
const translate = useTranslate();
|
||||
const {
|
||||
options: { breadcrumb: globalBreadcrumb } = {},
|
||||
} = useRefineContext();
|
||||
|
||||
const routerType = useRouterType();
|
||||
const back = useBack();
|
||||
const { goBack } = useNavigation();
|
||||
const getUserFriendlyName = useUserFriendlyName();
|
||||
|
||||
const { resource, action, identifier } = useResource(resourceFromProps);
|
||||
|
||||
const breadcrumb =
|
||||
typeof breadcrumbFromProps === "undefined"
|
||||
? globalBreadcrumb
|
||||
: breadcrumbFromProps;
|
||||
|
||||
const breadcrumbComponent =
|
||||
typeof breadcrumb !== "undefined" ? (
|
||||
<>{breadcrumb}</> ?? undefined
|
||||
) : (
|
||||
<Breadcrumb />
|
||||
);
|
||||
|
||||
const saveButtonProps: SaveButtonProps = {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
...saveButtonPropsFromProps,
|
||||
};
|
||||
|
||||
const loadingOverlayVisible = isLoading ?? saveButtonProps?.disabled ?? false;
|
||||
|
||||
const defaultFooterButtons = <SaveButton {...saveButtonProps} />;
|
||||
|
||||
const buttonBack =
|
||||
goBackFromProps === (false || null) ? null : (
|
||||
<ActionIcon
|
||||
onClick={
|
||||
action !== "list" || typeof action !== "undefined"
|
||||
? routerType === "legacy"
|
||||
? goBack
|
||||
: back
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{typeof goBackFromProps !== "undefined" ? (
|
||||
goBackFromProps
|
||||
) : (
|
||||
<IconArrowLeft />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
const headerButtons = headerButtonsFromProps
|
||||
? typeof headerButtonsFromProps === "function"
|
||||
? headerButtonsFromProps({
|
||||
defaultButtons: null,
|
||||
})
|
||||
: headerButtonsFromProps
|
||||
: null;
|
||||
|
||||
const footerButtons = footerButtonsFromProps
|
||||
? typeof footerButtonsFromProps === "function"
|
||||
? footerButtonsFromProps({
|
||||
defaultButtons: defaultFooterButtons,
|
||||
saveButtonProps,
|
||||
})
|
||||
: footerButtonsFromProps
|
||||
: defaultFooterButtons;
|
||||
|
||||
return (
|
||||
<Card p="md" {...wrapperProps}>
|
||||
<LoadingOverlay visible={loadingOverlayVisible} />
|
||||
<Group position="apart" align="center" {...headerProps}>
|
||||
<Stack spacing="xs">
|
||||
{breadcrumbComponent}
|
||||
<Group spacing="xs">
|
||||
{buttonBack}
|
||||
{title ?? (
|
||||
<Title
|
||||
order={3}
|
||||
transform="capitalize"
|
||||
className={RefinePageHeaderClassNames.Title}
|
||||
>
|
||||
{translate(
|
||||
`${identifier}.titles.create`,
|
||||
`Create ${getUserFriendlyName(
|
||||
resource?.meta?.label ??
|
||||
resource?.options?.label ??
|
||||
resource?.label ??
|
||||
identifier,
|
||||
"singular",
|
||||
)}`,
|
||||
)}
|
||||
</Title>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group spacing="xs" {...headerButtonProps}>
|
||||
{headerButtons}
|
||||
</Group>
|
||||
</Group>
|
||||
<Box pt="sm" {...contentProps}>
|
||||
{children}
|
||||
</Box>
|
||||
<Group position="right" spacing="xs" mt="md" {...footerButtonProps}>
|
||||
{footerButtons}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
546
packages/mantine/src/components/crud/edit/index.spec.tsx
Normal file
546
packages/mantine/src/components/crud/edit/index.spec.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import type { AccessControlProvider } from "@refinedev/core";
|
||||
|
||||
import {
|
||||
MockLegacyRouterProvider,
|
||||
act,
|
||||
fireEvent,
|
||||
type ITestWrapperProps,
|
||||
MockJSONServer,
|
||||
render,
|
||||
TestWrapper,
|
||||
waitFor,
|
||||
} from "@test";
|
||||
import { Edit } from "./";
|
||||
import { crudEditTests } from "@refinedev/ui-tests";
|
||||
import { RefineButtonTestIds } from "@refinedev/ui-types";
|
||||
import {
|
||||
DeleteButton,
|
||||
ListButton,
|
||||
RefreshButton,
|
||||
SaveButton,
|
||||
} from "@components/buttons";
|
||||
import { useForm } from "@hooks/form";
|
||||
import { TextInput } from "@mantine/core";
|
||||
|
||||
const renderEdit = (
|
||||
edit: ReactNode,
|
||||
accessControlProvider?: AccessControlProvider,
|
||||
wrapperOptions?: ITestWrapperProps,
|
||||
) => {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/:resource/edit/:id" element={edit} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
accessControlProvider,
|
||||
...wrapperOptions,
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("Edit", () => {
|
||||
crudEditTests.bind(this)(Edit);
|
||||
|
||||
it("should render optional mutationMode with mutationModeProp prop", async () => {
|
||||
const container = renderEdit(<Edit mutationMode="undoable" />);
|
||||
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should render optional resource with resource prop", async () => {
|
||||
const { getByText } = render(
|
||||
<Routes>
|
||||
<Route path="/:resource" element={<Edit resource="posts" />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
routerInitialEntries: ["/custom"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
getByText("Edit Post");
|
||||
});
|
||||
|
||||
describe("render delete button", () => {
|
||||
it("should render delete button ", async () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/edit/:id"
|
||||
element={
|
||||
<Edit
|
||||
footerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts", canDelete: true }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
|
||||
getByText("Edit Post");
|
||||
});
|
||||
|
||||
it("should not render delete button on resource canDelete false", async () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/edit/:id"
|
||||
element={
|
||||
<Edit
|
||||
footerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeUndefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
|
||||
|
||||
getByText("Edit Post");
|
||||
});
|
||||
|
||||
it("should not render delete button on resource canDelete true & canDelete props false on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/edit/:id"
|
||||
element={
|
||||
<Edit
|
||||
canDelete={false}
|
||||
footerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeUndefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts", canDelete: true }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
|
||||
});
|
||||
|
||||
it("should render delete button on resource canDelete false & canDelete props true on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/edit/:id"
|
||||
element={
|
||||
<Edit
|
||||
canDelete={true}
|
||||
footerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should render delete button on resource canDelete false & deleteButtonProps props not null on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/edit/:id"
|
||||
element={<Edit deleteButtonProps={{ size: "lg" }} />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility of buttons by accessControlProvider", () => {
|
||||
it("should render disabled list button and not disabled delete button", async () => {
|
||||
const { queryByTestId } = renderEdit(
|
||||
<Edit
|
||||
canDelete
|
||||
headerButtons={({ defaultButtons, listButtonProps }) => {
|
||||
expect(listButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
footerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
can: ({ action }) => {
|
||||
switch (action) {
|
||||
case "list":
|
||||
return Promise.resolve({ can: true });
|
||||
default:
|
||||
return Promise.resolve({ can: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByTestId(RefineButtonTestIds.ListButton),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render disabled list button and delete button", async () => {
|
||||
const { queryByTestId } = renderEdit(
|
||||
<Edit
|
||||
canDelete
|
||||
headerButtons={({ defaultButtons, listButtonProps }) => {
|
||||
expect(listButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
footerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
can: ({ action }) => {
|
||||
switch (action) {
|
||||
case "list":
|
||||
case "delete":
|
||||
return Promise.resolve({ can: false });
|
||||
default:
|
||||
return Promise.resolve({ can: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).toBeDisabled(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Breadcrumb ", () => {
|
||||
it("should render breadcrumb", async () => {
|
||||
const { getAllByLabelText } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={<Edit recordItemId="1" />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getAllByLabelText("breadcrumb")).not.toBeNull();
|
||||
});
|
||||
it("should not render breadcrumb", async () => {
|
||||
const { queryByLabelText } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={<Edit recordItemId="1" breadcrumb={null} />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText("breadcrumb")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should customize default buttons with default props", async () => {
|
||||
const { queryByTestId } = renderEdit(
|
||||
<Edit
|
||||
canDelete
|
||||
saveButtonProps={{ className: "customize-test" }}
|
||||
headerButtons={({ listButtonProps, refreshButtonProps }) => {
|
||||
return (
|
||||
<>
|
||||
<RefreshButton {...refreshButtonProps} />
|
||||
<ListButton {...listButtonProps} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
footerButtons={({ deleteButtonProps, saveButtonProps }) => {
|
||||
return (
|
||||
<>
|
||||
<DeleteButton {...deleteButtonProps} />
|
||||
<SaveButton {...saveButtonProps} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
can: ({ action }) => {
|
||||
switch (action) {
|
||||
case "list":
|
||||
case "delete":
|
||||
return Promise.resolve({ can: false });
|
||||
default:
|
||||
return Promise.resolve({ can: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeDisabled(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).toBeDisabled(),
|
||||
);
|
||||
expect(queryByTestId(RefineButtonTestIds.SaveButton)).toHaveClass(
|
||||
"customize-test",
|
||||
);
|
||||
expect(queryByTestId(RefineButtonTestIds.RefreshButton)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("list button", () => {
|
||||
it("should render list button", async () => {
|
||||
const { queryByTestId } = renderEdit(<Edit />);
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).not.toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not render list button when list resource is undefined", async () => {
|
||||
const { queryByTestId } = renderEdit(
|
||||
<Edit
|
||||
headerButtons={({ defaultButtons, listButtonProps }) => {
|
||||
expect(listButtonProps).toBeUndefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
resources: [{ name: "posts", list: undefined }],
|
||||
},
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not render list button when has recordItemId", async () => {
|
||||
const { queryByTestId } = renderEdit(
|
||||
<Edit
|
||||
recordItemId="1"
|
||||
headerButtons={({ defaultButtons, listButtonProps }) => {
|
||||
expect(listButtonProps).toBeUndefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).toBeNull(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto save", () => {
|
||||
const EditPageWithAutoSave = () => {
|
||||
const {
|
||||
refineCore: { formLoading, autoSaveProps },
|
||||
getInputProps,
|
||||
} = useForm({
|
||||
initialValues: {
|
||||
title: "",
|
||||
},
|
||||
refineCoreProps: {
|
||||
action: "edit",
|
||||
autoSave: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Edit autoSaveProps={autoSaveProps}>
|
||||
{formLoading && <div>loading...</div>}
|
||||
<TextInput
|
||||
mt={8}
|
||||
id="title"
|
||||
data-testid="title"
|
||||
label="Title"
|
||||
placeholder="Title"
|
||||
{...getInputProps("title")}
|
||||
/>
|
||||
;
|
||||
</Edit>
|
||||
);
|
||||
};
|
||||
|
||||
it("check idle,loading,success statuses", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { getByText, getByTestId } = render(
|
||||
<Routes>
|
||||
<Route path="/:resource/edit/:id" element={<EditPageWithAutoSave />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: () => {
|
||||
return new Promise((res) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
res({
|
||||
data: {
|
||||
id: "1",
|
||||
title: "ok",
|
||||
} as any,
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
getByText("Edit Post");
|
||||
getByText("waiting for changes");
|
||||
|
||||
// update title and wait
|
||||
await act(async () => {
|
||||
fireEvent.change(getByTestId("title"), {
|
||||
target: { value: "test" },
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
// check saving message
|
||||
expect(getByText("saving...")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// check saved message
|
||||
expect(getByText("saved")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("check error status", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { getByText, getByTestId } = render(
|
||||
<Routes>
|
||||
<Route path="/:resource/edit/:id" element={<EditPageWithAutoSave />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/edit/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
dataProvider: {
|
||||
...MockJSONServer,
|
||||
update: () => {
|
||||
return new Promise((res, rej) => {
|
||||
setTimeout(() => rej("error"), 1000);
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
getByText("Edit Post");
|
||||
getByText("waiting for changes");
|
||||
|
||||
// update title and wait
|
||||
await act(async () => {
|
||||
fireEvent.change(getByTestId("title"), {
|
||||
target: { value: "test" },
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
// check saving message
|
||||
expect(getByText("saving...")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// check saved message
|
||||
expect(getByText("auto save failure")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
242
packages/mantine/src/components/crud/edit/index.tsx
Normal file
242
packages/mantine/src/components/crud/edit/index.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Stack,
|
||||
Title,
|
||||
LoadingOverlay,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
useBack,
|
||||
useGo,
|
||||
useMutationMode,
|
||||
useNavigation,
|
||||
useRefineContext,
|
||||
useResource,
|
||||
useUserFriendlyName,
|
||||
useRouterType,
|
||||
useToPath,
|
||||
useTranslate,
|
||||
} from "@refinedev/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import {
|
||||
DeleteButton,
|
||||
ListButton,
|
||||
RefreshButton,
|
||||
SaveButton,
|
||||
Breadcrumb,
|
||||
type ListButtonProps,
|
||||
type RefreshButtonProps,
|
||||
type DeleteButtonProps,
|
||||
type SaveButtonProps,
|
||||
AutoSaveIndicator,
|
||||
} from "@components";
|
||||
import type { EditProps } from "../types";
|
||||
import { RefinePageHeaderClassNames } from "@refinedev/ui-types";
|
||||
|
||||
export const Edit: React.FC<EditProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
resource: resourceFromProps,
|
||||
recordItemId,
|
||||
deleteButtonProps: deleteButtonPropsFromProps,
|
||||
mutationMode: mutationModeFromProps,
|
||||
saveButtonProps: saveButtonPropsFromProps,
|
||||
canDelete,
|
||||
dataProviderName,
|
||||
isLoading,
|
||||
footerButtons: footerButtonsFromProps,
|
||||
footerButtonProps,
|
||||
headerButtons: headerButtonsFromProps,
|
||||
headerButtonProps,
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
headerProps,
|
||||
goBack: goBackFromProps,
|
||||
breadcrumb: breadcrumbFromProps,
|
||||
title,
|
||||
autoSaveProps,
|
||||
} = props;
|
||||
const translate = useTranslate();
|
||||
const {
|
||||
options: { breadcrumb: globalBreadcrumb } = {},
|
||||
} = useRefineContext();
|
||||
const { mutationMode: mutationModeContext } = useMutationMode();
|
||||
const mutationMode = mutationModeFromProps ?? mutationModeContext;
|
||||
|
||||
const routerType = useRouterType();
|
||||
const back = useBack();
|
||||
const go = useGo();
|
||||
const { goBack, list: legacyGoList } = useNavigation();
|
||||
const getUserFriendlyName = useUserFriendlyName();
|
||||
|
||||
const {
|
||||
resource,
|
||||
action,
|
||||
id: idFromParams,
|
||||
identifier,
|
||||
} = useResource(resourceFromProps);
|
||||
|
||||
const goListPath = useToPath({
|
||||
resource,
|
||||
action: "list",
|
||||
});
|
||||
|
||||
const id = recordItemId ?? idFromParams;
|
||||
|
||||
const breadcrumb =
|
||||
typeof breadcrumbFromProps === "undefined"
|
||||
? globalBreadcrumb
|
||||
: breadcrumbFromProps;
|
||||
|
||||
const hasList = resource?.list && !recordItemId;
|
||||
|
||||
const isDeleteButtonVisible =
|
||||
canDelete ??
|
||||
((resource?.meta?.canDelete ?? resource?.canDelete) ||
|
||||
deleteButtonPropsFromProps);
|
||||
|
||||
const breadcrumbComponent =
|
||||
typeof breadcrumb !== "undefined" ? (
|
||||
<>{breadcrumb}</> ?? undefined
|
||||
) : (
|
||||
<Breadcrumb />
|
||||
);
|
||||
|
||||
const loadingOverlayVisible =
|
||||
isLoading ?? saveButtonPropsFromProps?.disabled ?? false;
|
||||
|
||||
const listButtonProps: ListButtonProps | undefined = hasList
|
||||
? {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const refreshButtonProps: RefreshButtonProps = {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
recordItemId: id,
|
||||
dataProviderName,
|
||||
};
|
||||
|
||||
const deleteButtonProps: DeleteButtonProps | undefined = isDeleteButtonVisible
|
||||
? ({
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
mutationMode,
|
||||
onSuccess: () => {
|
||||
if (routerType === "legacy") {
|
||||
legacyGoList(resource?.route ?? resource?.name ?? "");
|
||||
} else {
|
||||
go({ to: goListPath });
|
||||
}
|
||||
},
|
||||
recordItemId: id,
|
||||
dataProviderName,
|
||||
...deleteButtonPropsFromProps,
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
const saveButtonProps: SaveButtonProps = {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
...saveButtonPropsFromProps,
|
||||
};
|
||||
|
||||
const defaultHeaderButtons = (
|
||||
<>
|
||||
{autoSaveProps && <AutoSaveIndicator {...autoSaveProps} />}
|
||||
{hasList && <ListButton {...listButtonProps} />}
|
||||
<RefreshButton {...refreshButtonProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
const defaultFooterButtons = (
|
||||
<>
|
||||
{isDeleteButtonVisible && <DeleteButton {...deleteButtonProps} />}
|
||||
<SaveButton {...saveButtonProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
const buttonBack =
|
||||
goBackFromProps === (false || null) ? null : (
|
||||
<ActionIcon
|
||||
onClick={
|
||||
action !== "list" && typeof action !== "undefined"
|
||||
? routerType === "legacy"
|
||||
? goBack
|
||||
: back
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{typeof goBackFromProps !== "undefined" ? (
|
||||
goBackFromProps
|
||||
) : (
|
||||
<IconArrowLeft />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
const headerButtons = headerButtonsFromProps
|
||||
? typeof headerButtonsFromProps === "function"
|
||||
? headerButtonsFromProps({
|
||||
defaultButtons: defaultHeaderButtons,
|
||||
listButtonProps,
|
||||
refreshButtonProps,
|
||||
})
|
||||
: headerButtonsFromProps
|
||||
: defaultHeaderButtons;
|
||||
|
||||
const footerButtons = footerButtonsFromProps
|
||||
? typeof footerButtonsFromProps === "function"
|
||||
? footerButtonsFromProps({
|
||||
defaultButtons: defaultFooterButtons,
|
||||
deleteButtonProps,
|
||||
saveButtonProps,
|
||||
})
|
||||
: footerButtonsFromProps
|
||||
: defaultFooterButtons;
|
||||
|
||||
return (
|
||||
<Card p="md" {...wrapperProps}>
|
||||
<LoadingOverlay visible={loadingOverlayVisible} />
|
||||
<Group position="apart" {...headerProps}>
|
||||
<Stack spacing="xs">
|
||||
{breadcrumbComponent}
|
||||
<Group spacing="xs">
|
||||
{buttonBack}
|
||||
{title ?? (
|
||||
<Title
|
||||
order={3}
|
||||
transform="capitalize"
|
||||
className={RefinePageHeaderClassNames.Title}
|
||||
>
|
||||
{translate(
|
||||
`${identifier}.titles.edit`,
|
||||
`Edit ${getUserFriendlyName(
|
||||
resource?.meta?.label ??
|
||||
resource?.options?.label ??
|
||||
resource?.label ??
|
||||
identifier,
|
||||
"singular",
|
||||
)}`,
|
||||
)}
|
||||
</Title>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group spacing="xs" {...headerButtonProps}>
|
||||
{headerButtons}
|
||||
</Group>
|
||||
</Group>
|
||||
<Box pt="sm" {...contentProps}>
|
||||
{children}
|
||||
</Box>
|
||||
<Group position="right" spacing="xs" mt="md" {...footerButtonProps}>
|
||||
{footerButtons}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
5
packages/mantine/src/components/crud/index.ts
Normal file
5
packages/mantine/src/components/crud/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./list";
|
||||
export * from "./show";
|
||||
export * from "./edit";
|
||||
export * from "./create";
|
||||
export * from "./types";
|
||||
57
packages/mantine/src/components/crud/list/index.spec.tsx
Normal file
57
packages/mantine/src/components/crud/list/index.spec.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { crudListTests } from "@refinedev/ui-tests";
|
||||
import { RefineButtonTestIds } from "@refinedev/ui-types";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { CreateButton } from "@components/buttons";
|
||||
import { MockLegacyRouterProvider, render, TestWrapper } from "@test";
|
||||
import { List } from "./index";
|
||||
|
||||
const renderList = (list: ReactNode) => {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/:resource" element={list} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("<List/>", () => {
|
||||
beforeEach(() => {
|
||||
// This is an issue on `mantine` side rather than `refine`. Ignoring for now but might need to be fixed.
|
||||
jest.spyOn(console, "error").mockImplementation((message) => {
|
||||
if (message?.includes?.("validateDOMNesting")) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(message);
|
||||
});
|
||||
});
|
||||
|
||||
crudListTests.bind(this)(List);
|
||||
|
||||
it("should customize default buttons with default props", async () => {
|
||||
const { queryByTestId } = renderList(
|
||||
<List
|
||||
createButtonProps={{ className: "customize-test" }}
|
||||
headerButtons={({ createButtonProps }) => {
|
||||
expect(createButtonProps).toBeDefined();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateButton {...createButtonProps} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.CreateButton)).toHaveClass(
|
||||
"customize-test",
|
||||
);
|
||||
});
|
||||
});
|
||||
109
packages/mantine/src/components/crud/list/index.tsx
Normal file
109
packages/mantine/src/components/crud/list/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { Box, Card, Group, Stack, Title } from "@mantine/core";
|
||||
import {
|
||||
useRefineContext,
|
||||
useResource,
|
||||
useUserFriendlyName,
|
||||
useRouterType,
|
||||
useTranslate,
|
||||
} from "@refinedev/core";
|
||||
|
||||
import { CreateButton, Breadcrumb, type CreateButtonProps } from "@components";
|
||||
import type { ListProps } from "../types";
|
||||
import { RefinePageHeaderClassNames } from "@refinedev/ui-types";
|
||||
|
||||
export const List: React.FC<ListProps> = (props) => {
|
||||
const {
|
||||
canCreate,
|
||||
children,
|
||||
createButtonProps: createButtonPropsFromProps,
|
||||
resource: resourceFromProps,
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
headerProps,
|
||||
headerButtonProps,
|
||||
headerButtons: headerButtonsFromProps,
|
||||
breadcrumb: breadcrumbFromProps,
|
||||
title,
|
||||
} = props;
|
||||
const translate = useTranslate();
|
||||
const {
|
||||
options: { breadcrumb: globalBreadcrumb } = {},
|
||||
} = useRefineContext();
|
||||
|
||||
const routerType = useRouterType();
|
||||
const getUserFriendlyName = useUserFriendlyName();
|
||||
|
||||
const { resource, identifier } = useResource(resourceFromProps);
|
||||
|
||||
const isCreateButtonVisible =
|
||||
canCreate ??
|
||||
((resource?.canCreate ?? !!resource?.create) || createButtonPropsFromProps);
|
||||
|
||||
const breadcrumb =
|
||||
typeof breadcrumbFromProps === "undefined"
|
||||
? globalBreadcrumb
|
||||
: breadcrumbFromProps;
|
||||
|
||||
const createButtonProps: CreateButtonProps | undefined = isCreateButtonVisible
|
||||
? ({
|
||||
size: "sm",
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
...createButtonPropsFromProps,
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
const defaultHeaderButtons = isCreateButtonVisible ? (
|
||||
<CreateButton {...createButtonProps} />
|
||||
) : null;
|
||||
|
||||
const breadcrumbComponent =
|
||||
typeof breadcrumb !== "undefined" ? (
|
||||
<>{breadcrumb}</> ?? undefined
|
||||
) : (
|
||||
<Breadcrumb />
|
||||
);
|
||||
|
||||
const headerButtons = headerButtonsFromProps
|
||||
? typeof headerButtonsFromProps === "function"
|
||||
? headerButtonsFromProps({
|
||||
defaultButtons: defaultHeaderButtons,
|
||||
createButtonProps,
|
||||
})
|
||||
: headerButtonsFromProps
|
||||
: defaultHeaderButtons;
|
||||
|
||||
return (
|
||||
<Card p="md" {...wrapperProps}>
|
||||
<Group position="apart" align="center" {...headerProps}>
|
||||
<Stack spacing="xs">
|
||||
{breadcrumbComponent}
|
||||
{title ?? (
|
||||
<Title
|
||||
order={3}
|
||||
transform="capitalize"
|
||||
className={RefinePageHeaderClassNames.Title}
|
||||
>
|
||||
{translate(
|
||||
`${identifier}.titles.list`,
|
||||
getUserFriendlyName(
|
||||
resource?.meta?.label ??
|
||||
resource?.options?.label ??
|
||||
resource?.label ??
|
||||
identifier,
|
||||
"plural",
|
||||
),
|
||||
)}
|
||||
</Title>
|
||||
)}
|
||||
</Stack>
|
||||
<Group spacing="xs" {...headerButtonProps}>
|
||||
{headerButtons}
|
||||
</Group>
|
||||
</Group>
|
||||
<Box pt="sm" {...contentProps}>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
463
packages/mantine/src/components/crud/show/index.spec.tsx
Normal file
463
packages/mantine/src/components/crud/show/index.spec.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import type { AccessControlProvider } from "@refinedev/core";
|
||||
import { crudShowTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { MockLegacyRouterProvider, render, TestWrapper, waitFor } from "@test";
|
||||
|
||||
import { Show } from "./index";
|
||||
import { RefineButtonTestIds } from "@refinedev/ui-types";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
ListButton,
|
||||
RefreshButton,
|
||||
} from "@components/buttons";
|
||||
|
||||
const renderShow = (
|
||||
show: ReactNode,
|
||||
accessControlProvider?: AccessControlProvider,
|
||||
) => {
|
||||
return render(
|
||||
<Routes>
|
||||
<Route path="/:resource/:action/:id" element={show} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
accessControlProvider,
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe("Show", () => {
|
||||
crudShowTests.bind(this)(Show);
|
||||
|
||||
it("depending on the accessControlProvider it should get the buttons successfully", async () => {
|
||||
const { getByTestId } = renderShow(
|
||||
<Show
|
||||
canEdit
|
||||
canDelete
|
||||
headerButtons={({
|
||||
defaultButtons,
|
||||
deleteButtonProps,
|
||||
editButtonProps,
|
||||
}) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
expect(editButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
can: ({ action }) => {
|
||||
switch (action) {
|
||||
case "edit":
|
||||
case "list":
|
||||
return Promise.resolve({ can: true });
|
||||
default:
|
||||
return Promise.resolve({ can: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(RefineButtonTestIds.EditButton)).not.toBeDisabled(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(RefineButtonTestIds.ListButton)).not.toBeDisabled(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getByTestId(RefineButtonTestIds.DeleteButton)).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render optional recordItemId with resource prop, not render list button", async () => {
|
||||
const { getByText, queryByTestId } = renderShow(
|
||||
<Show
|
||||
recordItemId="1"
|
||||
headerButtons={({ defaultButtons, listButtonProps }) => {
|
||||
expect(listButtonProps).not.toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
getByText("Show Post");
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).toBeNull();
|
||||
});
|
||||
|
||||
describe("render edit button", () => {
|
||||
it("should render edit button", async () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
headerButtons={({ defaultButtons, editButtonProps }) => {
|
||||
expect(editButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", edit: () => null }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.EditButton)).not.toBeNull();
|
||||
|
||||
getByText("Show Post");
|
||||
});
|
||||
|
||||
it("should not render edit button on resource canEdit false", async () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
headerButtons={({ defaultButtons, editButtonProps }) => {
|
||||
expect(editButtonProps).not.toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.EditButton)).toBeNull();
|
||||
|
||||
getByText("Show Post");
|
||||
});
|
||||
|
||||
it("should not render edit button on resource canEdit true & canEdit props false on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
canEdit={false}
|
||||
headerButtons={({ defaultButtons, editButtonProps }) => {
|
||||
expect(editButtonProps).not.toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", edit: () => null }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.EditButton)).toBeNull();
|
||||
});
|
||||
|
||||
it("should render edit button on resource canEdit false & canEdit props true on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
canEdit={true}
|
||||
headerButtons={({ defaultButtons, editButtonProps }) => {
|
||||
expect(editButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.EditButton)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should render edit button with recordItemId prop", async () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
recordItemId="1"
|
||||
headerButtons={({ defaultButtons, editButtonProps }) => {
|
||||
expect(editButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", edit: () => null }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.EditButton)).not.toBeNull();
|
||||
|
||||
getByText("Show Post");
|
||||
});
|
||||
});
|
||||
|
||||
describe("render delete button", () => {
|
||||
it("should render delete button", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
headerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: true }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should not render delete button on resource canDelete false", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
headerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).not.toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
|
||||
});
|
||||
|
||||
it("should not render delete button on resource canDelete true & canDelete props false on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
canDelete={false}
|
||||
headerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).not.toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: true }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).toBeNull();
|
||||
});
|
||||
|
||||
it("should render delete button on resource canDelete false & canDelete props true on component", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
canDelete={true}
|
||||
headerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: false }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should render delete button with recordItemId prop", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
recordItemId="1"
|
||||
headerButtons={({ defaultButtons, deleteButtonProps }) => {
|
||||
expect(deleteButtonProps).toBeDefined();
|
||||
return <>{defaultButtons}</>;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts", canDelete: true }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
});
|
||||
|
||||
describe("Breadcrumb", () => {
|
||||
it("should render breadcrumb", async () => {
|
||||
const { getAllByLabelText } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={<Show recordItemId="1" />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(getAllByLabelText("breadcrumb")).not.toBeNull();
|
||||
});
|
||||
it("should not render breadcrumb", async () => {
|
||||
const { queryByLabelText } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={<Show recordItemId="1" breadcrumb={null} />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByLabelText("breadcrumb")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should customize default buttons with default props", async () => {
|
||||
const { queryByTestId } = render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:resource/:action/:id"
|
||||
element={
|
||||
<Show
|
||||
canEdit
|
||||
canDelete
|
||||
headerButtons={({
|
||||
deleteButtonProps,
|
||||
editButtonProps,
|
||||
listButtonProps,
|
||||
refreshButtonProps,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<DeleteButton {...deleteButtonProps} />
|
||||
<EditButton {...editButtonProps} />
|
||||
<ListButton {...listButtonProps} />
|
||||
<RefreshButton {...refreshButtonProps} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
resources: [{ name: "posts" }],
|
||||
routerInitialEntries: ["/posts/show/1"],
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByTestId(RefineButtonTestIds.DeleteButton)).not.toBeNull();
|
||||
expect(queryByTestId(RefineButtonTestIds.EditButton)).not.toBeNull();
|
||||
expect(queryByTestId(RefineButtonTestIds.ListButton)).not.toBeNull();
|
||||
expect(queryByTestId(RefineButtonTestIds.RefreshButton)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
225
packages/mantine/src/components/crud/show/index.tsx
Normal file
225
packages/mantine/src/components/crud/show/index.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Stack,
|
||||
Title,
|
||||
LoadingOverlay,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
useBack,
|
||||
useGo,
|
||||
useNavigation,
|
||||
useRefineContext,
|
||||
useResource,
|
||||
useUserFriendlyName,
|
||||
useRouterType,
|
||||
useToPath,
|
||||
useTranslate,
|
||||
} from "@refinedev/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
ListButton,
|
||||
RefreshButton,
|
||||
Breadcrumb,
|
||||
type ListButtonProps,
|
||||
type EditButtonProps,
|
||||
type DeleteButtonProps,
|
||||
type RefreshButtonProps,
|
||||
} from "@components";
|
||||
import type { ShowProps } from "../types";
|
||||
import { RefinePageHeaderClassNames } from "@refinedev/ui-types";
|
||||
|
||||
export const Show: React.FC<ShowProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
resource: resourceFromProps,
|
||||
recordItemId,
|
||||
canDelete,
|
||||
canEdit,
|
||||
dataProviderName,
|
||||
isLoading,
|
||||
footerButtons: footerButtonsFromProps,
|
||||
footerButtonProps,
|
||||
headerButtons: headerButtonsFromProps,
|
||||
headerButtonProps,
|
||||
wrapperProps,
|
||||
contentProps,
|
||||
headerProps,
|
||||
goBack: goBackFromProps,
|
||||
breadcrumb: breadcrumbFromProps,
|
||||
title,
|
||||
} = props;
|
||||
const translate = useTranslate();
|
||||
const {
|
||||
options: { breadcrumb: globalBreadcrumb } = {},
|
||||
} = useRefineContext();
|
||||
|
||||
const routerType = useRouterType();
|
||||
const back = useBack();
|
||||
const go = useGo();
|
||||
const { goBack, list: legacyGoList } = useNavigation();
|
||||
const getUserFriendlyName = useUserFriendlyName();
|
||||
|
||||
const {
|
||||
resource,
|
||||
action,
|
||||
id: idFromParams,
|
||||
identifier,
|
||||
} = useResource(resourceFromProps);
|
||||
|
||||
const goListPath = useToPath({
|
||||
resource,
|
||||
action: "list",
|
||||
});
|
||||
|
||||
const id = recordItemId ?? idFromParams;
|
||||
|
||||
const breadcrumb =
|
||||
typeof breadcrumbFromProps === "undefined"
|
||||
? globalBreadcrumb
|
||||
: breadcrumbFromProps;
|
||||
|
||||
const breadcrumbComponent =
|
||||
typeof breadcrumb !== "undefined" ? (
|
||||
<>{breadcrumb}</> ?? undefined
|
||||
) : (
|
||||
<Breadcrumb />
|
||||
);
|
||||
|
||||
const hasList = resource?.list && !recordItemId;
|
||||
const isDeleteButtonVisible =
|
||||
canDelete ?? resource?.meta?.canDelete ?? resource?.canDelete;
|
||||
const isEditButtonVisible = canEdit ?? resource?.canEdit ?? !!resource?.edit;
|
||||
|
||||
const listButtonProps: ListButtonProps | undefined = hasList
|
||||
? {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
}
|
||||
: undefined;
|
||||
const editButtonProps: EditButtonProps | undefined = isEditButtonVisible
|
||||
? {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
color: "primary",
|
||||
variant: "filled",
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
recordItemId: id,
|
||||
}
|
||||
: undefined;
|
||||
const deleteButtonProps: DeleteButtonProps | undefined = isDeleteButtonVisible
|
||||
? {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
recordItemId: id,
|
||||
onSuccess: () => {
|
||||
if (routerType === "legacy") {
|
||||
legacyGoList(resource?.route ?? resource?.name ?? "");
|
||||
} else {
|
||||
go({ to: goListPath });
|
||||
}
|
||||
},
|
||||
dataProviderName,
|
||||
}
|
||||
: undefined;
|
||||
const refreshButtonProps: RefreshButtonProps = {
|
||||
...(isLoading ? { disabled: true } : {}),
|
||||
resource: routerType === "legacy" ? resource?.route : identifier,
|
||||
recordItemId: id,
|
||||
dataProviderName,
|
||||
};
|
||||
|
||||
const loadingOverlayVisible = isLoading ?? false;
|
||||
|
||||
const defaultHeaderButtons = (
|
||||
<>
|
||||
{hasList && <ListButton {...listButtonProps} />}
|
||||
{isEditButtonVisible && <EditButton {...editButtonProps} />}
|
||||
{isDeleteButtonVisible && <DeleteButton {...deleteButtonProps} />}
|
||||
<RefreshButton {...refreshButtonProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
const buttonBack =
|
||||
goBackFromProps === (false || null) ? null : (
|
||||
<ActionIcon
|
||||
onClick={
|
||||
action !== "list" && typeof action !== "undefined"
|
||||
? routerType === "legacy"
|
||||
? goBack
|
||||
: back
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{typeof goBackFromProps !== "undefined" ? (
|
||||
goBackFromProps
|
||||
) : (
|
||||
<IconArrowLeft />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
const headerButtons = headerButtonsFromProps
|
||||
? typeof headerButtonsFromProps === "function"
|
||||
? headerButtonsFromProps({
|
||||
defaultButtons: defaultHeaderButtons,
|
||||
deleteButtonProps,
|
||||
editButtonProps,
|
||||
listButtonProps,
|
||||
refreshButtonProps,
|
||||
})
|
||||
: headerButtonsFromProps
|
||||
: defaultHeaderButtons;
|
||||
|
||||
const footerButtons = footerButtonsFromProps
|
||||
? typeof footerButtonsFromProps === "function"
|
||||
? footerButtonsFromProps({ defaultButtons: null })
|
||||
: footerButtonsFromProps
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card p="md" {...wrapperProps}>
|
||||
<LoadingOverlay visible={loadingOverlayVisible} />
|
||||
<Group position="apart" align="center" {...headerProps}>
|
||||
<Stack spacing="xs">
|
||||
{breadcrumbComponent}
|
||||
<Group spacing="xs">
|
||||
{buttonBack}
|
||||
{title ?? (
|
||||
<Title
|
||||
order={3}
|
||||
transform="capitalize"
|
||||
className={RefinePageHeaderClassNames.Title}
|
||||
>
|
||||
{translate(
|
||||
`${identifier}.titles.show`,
|
||||
`Show ${getUserFriendlyName(
|
||||
resource?.meta?.label ??
|
||||
resource?.options?.label ??
|
||||
resource?.label ??
|
||||
identifier,
|
||||
"singular",
|
||||
)}`,
|
||||
)}
|
||||
</Title>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group spacing="xs" {...headerButtonProps}>
|
||||
{headerButtons}
|
||||
</Group>
|
||||
</Group>
|
||||
<Box pt="sm" {...contentProps}>
|
||||
{children}
|
||||
</Box>
|
||||
<Group position="right" spacing="xs" mt="md" {...footerButtonProps}>
|
||||
{footerButtons}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
58
packages/mantine/src/components/crud/types.ts
Normal file
58
packages/mantine/src/components/crud/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
CreateButtonProps,
|
||||
DeleteButtonProps,
|
||||
EditButtonProps,
|
||||
ListButtonProps,
|
||||
RefreshButtonProps,
|
||||
SaveButtonProps,
|
||||
} from "../buttons/types";
|
||||
import type { BoxProps, CardProps, GroupProps } from "@mantine/core";
|
||||
import type {
|
||||
RefineCrudCreateProps,
|
||||
RefineCrudEditProps,
|
||||
RefineCrudListProps,
|
||||
RefineCrudShowProps,
|
||||
} from "@refinedev/ui-types";
|
||||
|
||||
export type ListProps = RefineCrudListProps<
|
||||
CreateButtonProps,
|
||||
GroupProps,
|
||||
CardProps,
|
||||
GroupProps,
|
||||
BoxProps
|
||||
>;
|
||||
|
||||
export type ShowProps = RefineCrudShowProps<
|
||||
GroupProps,
|
||||
GroupProps,
|
||||
CardProps,
|
||||
GroupProps,
|
||||
BoxProps,
|
||||
{},
|
||||
EditButtonProps,
|
||||
DeleteButtonProps,
|
||||
RefreshButtonProps,
|
||||
ListButtonProps
|
||||
>;
|
||||
|
||||
export type CreateProps = RefineCrudCreateProps<
|
||||
SaveButtonProps,
|
||||
GroupProps,
|
||||
GroupProps,
|
||||
CardProps,
|
||||
GroupProps,
|
||||
BoxProps
|
||||
>;
|
||||
|
||||
export type EditProps = RefineCrudEditProps<
|
||||
SaveButtonProps,
|
||||
DeleteButtonProps,
|
||||
GroupProps,
|
||||
GroupProps,
|
||||
CardProps,
|
||||
GroupProps,
|
||||
BoxProps,
|
||||
{},
|
||||
RefreshButtonProps,
|
||||
ListButtonProps
|
||||
>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { fieldBooleanTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { BooleanField } from "./";
|
||||
import { fireEvent, render } from "@test";
|
||||
|
||||
describe("BooleanField", () => {
|
||||
fieldBooleanTests.bind(this)(BooleanField);
|
||||
|
||||
describe("BooleanField with default props values", () => {
|
||||
const initialValues = [true, false, "true", "false", "", undefined];
|
||||
|
||||
const iconClass = [
|
||||
"tabler-icon-check",
|
||||
"tabler-icon-x",
|
||||
"tabler-icon-check",
|
||||
"tabler-icon-check",
|
||||
"tabler-icon-x",
|
||||
"tabler-icon-x",
|
||||
];
|
||||
|
||||
initialValues.forEach((element, index) => {
|
||||
const testName =
|
||||
index === 2 || index === 3 || index === 4
|
||||
? `"${initialValues[index]}"`
|
||||
: initialValues[index];
|
||||
|
||||
it(`renders boolean field value(${testName}) with correct tooltip text and icon`, async () => {
|
||||
const baseDom = render(
|
||||
<div data-testid="default-field">
|
||||
<BooleanField value={element} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.mouseOver(baseDom.getByTestId("default-field").children[0]);
|
||||
|
||||
expect(
|
||||
baseDom
|
||||
.getByTestId("default-field")
|
||||
.children[0].children[0].classList.contains(iconClass[index]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
30
packages/mantine/src/components/fields/boolean/index.tsx
Normal file
30
packages/mantine/src/components/fields/boolean/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconX, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { BooleanFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field is used to display boolean values. It uses the {@link https://mantine.dev/core/tooltip `<Tooltip>`} values from Mantine.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/boolean} for more details.
|
||||
*/
|
||||
export const BooleanField: React.FC<BooleanFieldProps> = ({
|
||||
value,
|
||||
valueLabelTrue = "true",
|
||||
valueLabelFalse = "false",
|
||||
trueIcon,
|
||||
falseIcon,
|
||||
svgIconProps,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip label={value ? valueLabelTrue : valueLabelFalse} {...rest}>
|
||||
<span>
|
||||
{value
|
||||
? trueIcon ?? <IconCheck size={18} {...svgIconProps} />
|
||||
: falseIcon ?? <IconX size={18} {...svgIconProps} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldDateTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { DateField } from "./";
|
||||
|
||||
describe("DateField", () => {
|
||||
fieldDateTests.bind(this)(DateField);
|
||||
});
|
||||
35
packages/mantine/src/components/fields/date/index.tsx
Normal file
35
packages/mantine/src/components/fields/date/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import LocalizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
dayjs.extend(LocalizedFormat);
|
||||
|
||||
const defaultLocale = dayjs.locale();
|
||||
|
||||
import type { DateFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field is used to display dates. It uses {@link https://day.js.org/docs/en/display/format `Day.js`} to display date format and
|
||||
* Mantine {@link https://mantine.dev/core/text `<Text>`} component
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/date} for more details.
|
||||
*/
|
||||
export const DateField: React.FC<DateFieldProps> = ({
|
||||
value,
|
||||
locales,
|
||||
format: dateFormat = "L",
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Text {...rest}>
|
||||
{value
|
||||
? dayjs(value)
|
||||
.locale(locales || defaultLocale)
|
||||
.format(dateFormat)
|
||||
: ""}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldEmailTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { EmailField } from "./";
|
||||
|
||||
describe("EmailField", () => {
|
||||
fieldEmailTests.bind(this)(EmailField);
|
||||
});
|
||||
18
packages/mantine/src/components/fields/email/index.tsx
Normal file
18
packages/mantine/src/components/fields/email/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Anchor } from "@mantine/core";
|
||||
|
||||
import type { EmailFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field is used to display email values. It uses the {@link https://mantine.dev/core/text `<Text>` }
|
||||
* and {@link https://mantine.dev/core/anchor <Anchor>`} components from Mantine.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/email} for more details.
|
||||
*/
|
||||
export const EmailField: React.FC<EmailFieldProps> = ({ value, ...rest }) => {
|
||||
return (
|
||||
<Anchor href={`mailto:${value}`} {...rest}>
|
||||
{value}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldFileTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { FileField } from "./";
|
||||
|
||||
describe("FileField", () => {
|
||||
fieldFileTests.bind(this)(FileField);
|
||||
});
|
||||
17
packages/mantine/src/components/fields/file/index.tsx
Normal file
17
packages/mantine/src/components/fields/file/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
import { UrlField } from "@components";
|
||||
|
||||
import type { FileFieldProps } from "../types";
|
||||
|
||||
export const FileField: React.FC<FileFieldProps> = ({
|
||||
title,
|
||||
src,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<UrlField value={src} title={title} {...rest}>
|
||||
{title ?? src}
|
||||
</UrlField>
|
||||
);
|
||||
};
|
||||
10
packages/mantine/src/components/fields/index.ts
Normal file
10
packages/mantine/src/components/fields/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from "./text";
|
||||
export * from "./tag";
|
||||
export * from "./email";
|
||||
export * from "./boolean";
|
||||
export * from "./date";
|
||||
export * from "./file";
|
||||
export * from "./url";
|
||||
export * from "./number";
|
||||
export * from "./markdown";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldMarkdownTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { MarkdownField } from "./";
|
||||
|
||||
describe("MarkdownField", () => {
|
||||
fieldMarkdownTests.bind(this)(MarkdownField);
|
||||
});
|
||||
25
packages/mantine/src/components/fields/markdown/index.tsx
Normal file
25
packages/mantine/src/components/fields/markdown/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import gfm from "remark-gfm";
|
||||
|
||||
import type { MarkdownFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field lets you display markdown content. It supports {@link https://github.github.com/gfm/ GitHub Flavored Markdown}.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/markdown} for more details.
|
||||
*/
|
||||
export const MarkdownField: React.FC<MarkdownFieldProps> = ({
|
||||
value = "",
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
// There's an issue related with the type inconsistency of the `remark-gfm` and `remark-rehype` packages, we need to cast the `gfm` as any. (https://github.com/orgs/rehypejs/discussions/63)
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[gfm] as unknown as ReactMarkdown.PluggableList}
|
||||
{...rest}
|
||||
>
|
||||
{value}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldNumberTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { NumberField } from "./";
|
||||
|
||||
describe("NumberField", () => {
|
||||
fieldNumberTests.bind(this)(NumberField);
|
||||
});
|
||||
33
packages/mantine/src/components/fields/number/index.tsx
Normal file
33
packages/mantine/src/components/fields/number/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import type { NumberFieldProps } from "../types";
|
||||
|
||||
function toLocaleStringSupportsOptions() {
|
||||
return !!(
|
||||
typeof Intl === "object" &&
|
||||
Intl &&
|
||||
typeof Intl.NumberFormat === "function"
|
||||
);
|
||||
}
|
||||
/**
|
||||
* This field is used to display a number formatted according to the browser locale, right aligned. and uses {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl `Intl`} to display date format
|
||||
* and Mantine {@link https://mantine.dev/core/text `<Text>`} component.
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/number/} for more details.
|
||||
*/
|
||||
export const NumberField: React.FC<NumberFieldProps> = ({
|
||||
value,
|
||||
locale,
|
||||
options,
|
||||
...rest
|
||||
}) => {
|
||||
const number = Number(value);
|
||||
|
||||
return (
|
||||
<Text {...rest}>
|
||||
{toLocaleStringSupportsOptions()
|
||||
? number.toLocaleString(locale, options)
|
||||
: number}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldTagTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { TagField } from "./";
|
||||
|
||||
describe("TagField", () => {
|
||||
fieldTagTests.bind(this)(TagField);
|
||||
});
|
||||
17
packages/mantine/src/components/fields/tag/index.tsx
Normal file
17
packages/mantine/src/components/fields/tag/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import { Chip } from "@mantine/core";
|
||||
|
||||
import type { TagFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field lets you display a value in a tag. It uses Mantine {@link https://mantine.dev/core/chip `<Chip>`} component.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/tag} for more details.
|
||||
*/
|
||||
export const TagField: React.FC<TagFieldProps> = ({ value, ...rest }) => {
|
||||
return (
|
||||
<Chip checked={false} {...rest}>
|
||||
{value?.toString()}
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldTextTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { TextField } from "./";
|
||||
|
||||
describe("TextField", () => {
|
||||
fieldTextTests.bind(this)(TextField);
|
||||
});
|
||||
13
packages/mantine/src/components/fields/text/index.tsx
Normal file
13
packages/mantine/src/components/fields/text/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import type { TextFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field lets you show basic text. It uses Mantine {@link https://mantine.dev/core/text `<Text>`} component.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/text} for more details.
|
||||
*/
|
||||
export const TextField: React.FC<TextFieldProps> = ({ value, ...rest }) => {
|
||||
return <Text {...rest}>{value}</Text>;
|
||||
};
|
||||
55
packages/mantine/src/components/fields/types.ts
Normal file
55
packages/mantine/src/components/fields/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ReactChild, ReactNode } from "react";
|
||||
import type {
|
||||
AnchorProps,
|
||||
ChipProps,
|
||||
TextProps,
|
||||
TooltipProps,
|
||||
} from "@mantine/core";
|
||||
import type {
|
||||
RefineFieldBooleanProps,
|
||||
RefineFieldDateProps,
|
||||
RefineFieldEmailProps,
|
||||
RefineFieldFileProps,
|
||||
RefineFieldMarkdownProps,
|
||||
RefineFieldNumberProps,
|
||||
RefineFieldTagProps,
|
||||
RefineFieldTextProps,
|
||||
RefineFieldUrlProps,
|
||||
} from "@refinedev/ui-types";
|
||||
import type { IconProps } from "@tabler/icons-react";
|
||||
import type { ConfigType } from "dayjs";
|
||||
import type { ReactMarkdownOptions } from "react-markdown";
|
||||
|
||||
export type BooleanFieldProps = RefineFieldBooleanProps<
|
||||
unknown,
|
||||
Omit<TooltipProps, "label" | "children">,
|
||||
{ svgIconProps?: Omit<IconProps, "ref"> }
|
||||
>;
|
||||
|
||||
export type DateFieldProps = RefineFieldDateProps<ConfigType, TextProps>;
|
||||
|
||||
export type EmailFieldProps = RefineFieldEmailProps<ReactNode, AnchorProps>;
|
||||
|
||||
export type FileFieldProps = RefineFieldFileProps<TextProps>;
|
||||
|
||||
export type MarkdownFieldProps = RefineFieldMarkdownProps<
|
||||
string | undefined,
|
||||
Partial<ReactMarkdownOptions>
|
||||
>;
|
||||
|
||||
export type NumberFieldProps = RefineFieldNumberProps<ReactChild, TextProps>;
|
||||
|
||||
export type TagFieldProps = RefineFieldTagProps<
|
||||
ReactNode,
|
||||
Omit<ChipProps, "children">
|
||||
>;
|
||||
|
||||
export type TextFieldProps = RefineFieldTextProps<ReactNode, TextProps>;
|
||||
|
||||
export type UrlFieldProps = RefineFieldUrlProps<
|
||||
string | undefined,
|
||||
AnchorProps & TextProps,
|
||||
{
|
||||
title?: string;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fieldUrlTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { UrlField } from "./";
|
||||
|
||||
describe("UrlField", () => {
|
||||
fieldUrlTests.bind(this)(UrlField);
|
||||
});
|
||||
24
packages/mantine/src/components/fields/url/index.tsx
Normal file
24
packages/mantine/src/components/fields/url/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { Anchor } from "@mantine/core";
|
||||
|
||||
import type { UrlFieldProps } from "../types";
|
||||
|
||||
/**
|
||||
* This field is used to display email values. It uses the {@link https://mantine.dev/core/text `<Text>` }
|
||||
* and {@link https://mantine.dev/core/anchor <Anchor>`} components from Mantine.
|
||||
* You can pass a URL in its `value` property and you can show a text in its place by passing any `children`.
|
||||
*
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/fields/url} for more details.
|
||||
*/
|
||||
export const UrlField: React.FC<UrlFieldProps> = ({
|
||||
children,
|
||||
value,
|
||||
title,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Anchor href={value} title={title} {...rest}>
|
||||
{children ?? value}
|
||||
</Anchor>
|
||||
);
|
||||
};
|
||||
32
packages/mantine/src/components/index.ts
Normal file
32
packages/mantine/src/components/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export * from "./ring-countdown";
|
||||
|
||||
export * from "./fields";
|
||||
|
||||
export * from "./buttons";
|
||||
|
||||
export * from "./crud";
|
||||
|
||||
export * from "./pages";
|
||||
|
||||
export * from "./breadcrumb";
|
||||
|
||||
export * from "./layout";
|
||||
export * from "./layout/header";
|
||||
export * from "./layout/sider";
|
||||
export { Title as RefineTitle } from "./layout/title";
|
||||
export * from "./layout/types";
|
||||
|
||||
export * from "./themedLayout";
|
||||
export * from "./themedLayout/header";
|
||||
export * from "./themedLayout/sider";
|
||||
export * from "./themedLayout/title";
|
||||
export * from "./themedLayout/types";
|
||||
|
||||
export * from "./themedLayoutV2";
|
||||
export * from "./themedLayoutV2/header";
|
||||
export * from "./themedLayoutV2/sider";
|
||||
export * from "./themedLayoutV2/title";
|
||||
export * from "./themedLayoutV2/types";
|
||||
export * from "./themedLayoutV2/hamburgerMenu";
|
||||
|
||||
export * from "./autoSaveIndicator";
|
||||
@@ -0,0 +1,7 @@
|
||||
import { layoutHeaderTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { Header } from "./index";
|
||||
|
||||
describe("Header", () => {
|
||||
layoutHeaderTests.bind(this)(Header);
|
||||
});
|
||||
23
packages/mantine/src/components/layout/header/index.tsx
Normal file
23
packages/mantine/src/components/layout/header/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { useGetIdentity, useActiveAuthProvider } from "@refinedev/core";
|
||||
import { Avatar, Group, Header as MantineHeader, Title } from "@mantine/core";
|
||||
|
||||
import type { RefineLayoutHeaderProps } from "../types";
|
||||
|
||||
export const Header: React.FC<RefineLayoutHeaderProps> = () => {
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { data: user } = useGetIdentity({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const shouldRenderHeader = user && (user.name || user.avatar);
|
||||
|
||||
return shouldRenderHeader ? (
|
||||
<MantineHeader height={50} py={6} px="sm">
|
||||
<Group position="right">
|
||||
<Title order={6}>{user?.name}</Title>
|
||||
<Avatar src={user?.avatar} alt={user?.name} radius="xl" />
|
||||
</Group>
|
||||
</MantineHeader>
|
||||
) : null;
|
||||
};
|
||||
6
packages/mantine/src/components/layout/index.spec.tsx
Normal file
6
packages/mantine/src/components/layout/index.spec.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { layoutLayoutTests } from "@refinedev/ui-tests";
|
||||
import { Layout } from "./index";
|
||||
|
||||
describe("Layout", () => {
|
||||
layoutLayoutTests.bind(this)(Layout);
|
||||
});
|
||||
53
packages/mantine/src/components/layout/index.tsx
Normal file
53
packages/mantine/src/components/layout/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import type { RefineLayoutLayoutProps } from "./types";
|
||||
import { Sider as DefaultSider } from "./sider";
|
||||
import { Header as DefaultHeader } from "./header";
|
||||
|
||||
/**
|
||||
* @deprecated use `<ThemedLayout>` instead with 100% backward compatibility.
|
||||
* @see https://refine.dev/docs/api-reference/mantine/components/mantine-themed-layout
|
||||
**/
|
||||
export const Layout: React.FC<RefineLayoutLayoutProps> = ({
|
||||
Sider,
|
||||
Header,
|
||||
Title,
|
||||
Footer,
|
||||
OffLayoutArea,
|
||||
children,
|
||||
}) => {
|
||||
const SiderToRender = Sider ?? DefaultSider;
|
||||
const HeaderToRender = Header ?? DefaultHeader;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<SiderToRender Title={Title} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<HeaderToRender />
|
||||
<Box
|
||||
component="main"
|
||||
sx={(theme) => ({
|
||||
padding: theme.spacing.sm,
|
||||
backgroundColor:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[8]
|
||||
: theme.colors.gray[0],
|
||||
minHeight: "100vh",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{Footer && <Footer />}
|
||||
</Box>
|
||||
{OffLayoutArea && <OffLayoutArea />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
315
packages/mantine/src/components/layout/sider/index.tsx
Normal file
315
packages/mantine/src/components/layout/sider/index.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
CanAccess,
|
||||
type ITreeMenu,
|
||||
useIsExistAuthentication,
|
||||
useLink,
|
||||
useLogout,
|
||||
useMenu,
|
||||
useActiveAuthProvider,
|
||||
useRefineContext,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useTitle,
|
||||
useTranslate,
|
||||
useWarnAboutChange,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Drawer,
|
||||
Navbar,
|
||||
NavLink,
|
||||
type NavLinkStylesNames,
|
||||
type NavLinkStylesParams,
|
||||
ScrollArea,
|
||||
MediaQuery,
|
||||
Button,
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
type Styles,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconList,
|
||||
IconMenu2,
|
||||
IconChevronRight,
|
||||
IconChevronLeft,
|
||||
IconPower,
|
||||
IconDashboard,
|
||||
} from "@tabler/icons-react";
|
||||
import type { RefineLayoutSiderProps } from "../types";
|
||||
|
||||
import { RefineTitle as DefaultTitle } from "@components";
|
||||
|
||||
const defaultNavIcon = <IconList size={18} />;
|
||||
|
||||
export const Sider: React.FC<RefineLayoutSiderProps> = ({
|
||||
render,
|
||||
meta,
|
||||
Title: TitleFromProps,
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const routerType = useRouterType();
|
||||
const NewLink = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
const Link = routerType === "legacy" ? LegacyLink : NewLink;
|
||||
|
||||
const { defaultOpenKeys, menuItems, selectedKey } = useMenu({ meta });
|
||||
const TitleFromContext = useTitle();
|
||||
const isExistAuthentication = useIsExistAuthentication();
|
||||
const t = useTranslate();
|
||||
const { hasDashboard } = useRefineContext();
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
||||
const { mutate: mutateLogout } = useLogout({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const RenderToTitle = TitleFromProps ?? TitleFromContext ?? DefaultTitle;
|
||||
|
||||
const drawerWidth = () => {
|
||||
if (collapsed) return 80;
|
||||
return 200;
|
||||
};
|
||||
|
||||
const commonNavLinkStyles: Styles<NavLinkStylesNames, NavLinkStylesParams> = {
|
||||
root: {
|
||||
display: "flex",
|
||||
color: "white",
|
||||
fontWeight: 500,
|
||||
"&:hover": {
|
||||
backgroundColor: "unset",
|
||||
},
|
||||
"&[data-active]": {
|
||||
backgroundColor: "#ffffff1a",
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
"&:hover": {
|
||||
backgroundColor: "#ffffff1a",
|
||||
},
|
||||
},
|
||||
justifyContent: collapsed && !opened ? "center" : "flex-start",
|
||||
},
|
||||
icon: {
|
||||
marginRight: collapsed && !opened ? 0 : 12,
|
||||
},
|
||||
body: {
|
||||
display: collapsed && !opened ? "none" : "flex",
|
||||
},
|
||||
};
|
||||
|
||||
const commonTooltipProps: Partial<TooltipProps> = {
|
||||
disabled: !collapsed || opened,
|
||||
position: "right",
|
||||
withinPortal: true,
|
||||
withArrow: true,
|
||||
arrowSize: 8,
|
||||
arrowOffset: 12,
|
||||
offset: 4,
|
||||
};
|
||||
|
||||
const renderTreeView = (tree: ITreeMenu[], selectedKey?: string) => {
|
||||
return tree.map((item) => {
|
||||
const { icon, label, route, name, children } = item;
|
||||
|
||||
const isSelected = item.key === selectedKey;
|
||||
const isParent = children.length > 0;
|
||||
|
||||
const additionalLinkProps = isParent
|
||||
? {}
|
||||
: { component: Link as any, to: route };
|
||||
|
||||
return (
|
||||
<CanAccess
|
||||
key={item.key}
|
||||
resource={name}
|
||||
action="list"
|
||||
params={{
|
||||
resource: item,
|
||||
}}
|
||||
>
|
||||
<Tooltip label={label} {...commonTooltipProps}>
|
||||
<NavLink
|
||||
key={item.key}
|
||||
label={collapsed && !opened ? null : label}
|
||||
icon={icon ?? defaultNavIcon}
|
||||
active={isSelected}
|
||||
childrenOffset={collapsed && !opened ? 0 : 12}
|
||||
defaultOpened={defaultOpenKeys.includes(item.key || "")}
|
||||
styles={commonNavLinkStyles}
|
||||
{...additionalLinkProps}
|
||||
>
|
||||
{isParent && renderTreeView(children, selectedKey)}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
</CanAccess>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const items = renderTreeView(menuItems, selectedKey);
|
||||
|
||||
const dashboard = hasDashboard ? (
|
||||
<CanAccess resource="dashboard" action="list">
|
||||
<Tooltip
|
||||
label={t("dashboard.title", "Dashboard")}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<NavLink
|
||||
key="dashboard"
|
||||
label={
|
||||
collapsed && !opened ? null : t("dashboard.title", "Dashboard")
|
||||
}
|
||||
icon={<IconDashboard size={18} />}
|
||||
component={Link as any}
|
||||
to="/"
|
||||
active={selectedKey === "/"}
|
||||
styles={commonNavLinkStyles}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CanAccess>
|
||||
) : null;
|
||||
|
||||
const handleLogout = () => {
|
||||
if (warnWhen) {
|
||||
const confirm = window.confirm(
|
||||
t(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm) {
|
||||
setWarnWhen(false);
|
||||
mutateLogout();
|
||||
}
|
||||
} else {
|
||||
mutateLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const logout = isExistAuthentication && (
|
||||
<Tooltip label={t("buttons.logout", "Logout")} {...commonTooltipProps}>
|
||||
<NavLink
|
||||
key="logout"
|
||||
label={collapsed && !opened ? null : t("buttons.logout", "Logout")}
|
||||
icon={<IconPower size={18} />}
|
||||
onClick={handleLogout}
|
||||
styles={commonNavLinkStyles}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const renderSider = () => {
|
||||
if (render) {
|
||||
return render({
|
||||
dashboard,
|
||||
logout,
|
||||
items,
|
||||
collapsed,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{dashboard}
|
||||
{items}
|
||||
{logout}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaQuery largerThan="md" styles={{ display: "none" }}>
|
||||
<Box sx={{ position: "fixed", top: 64, left: 0, zIndex: 1199 }}>
|
||||
<ActionIcon
|
||||
color="white"
|
||||
size={36}
|
||||
sx={{
|
||||
borderRadius: "0 6px 6px 0",
|
||||
backgroundColor: "#2A132E",
|
||||
color: "white",
|
||||
"&:hover": {
|
||||
backgroundColor: "#2A132E",
|
||||
},
|
||||
}}
|
||||
onClick={() => setOpened((prev) => !prev)}
|
||||
>
|
||||
<IconMenu2 />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery largerThan="md" styles={{ display: "none" }}>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
size={200}
|
||||
zIndex={1200}
|
||||
withCloseButton={false}
|
||||
styles={{
|
||||
drawer: {
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#2A132E",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Navbar.Section px="xs">
|
||||
<RenderToTitle collapsed={false} />
|
||||
</Navbar.Section>
|
||||
<Navbar.Section grow component={ScrollArea} mx="-xs" px="xs">
|
||||
{renderSider()}
|
||||
</Navbar.Section>
|
||||
</Drawer>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: drawerWidth(),
|
||||
transition: "width 200ms ease, min-width 200ms ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<Navbar
|
||||
width={{ base: drawerWidth() }}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease, min-width 200ms ease",
|
||||
backgroundColor: "#2A132E",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<Navbar.Section px="xs">
|
||||
<RenderToTitle collapsed={collapsed} />
|
||||
</Navbar.Section>
|
||||
<Navbar.Section grow mt="sm" component={ScrollArea} mx="-xs" px="xs">
|
||||
{renderSider()}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
<Button
|
||||
sx={{
|
||||
background: "rgba(0,0,0,.5)",
|
||||
borderRadius: 0,
|
||||
borderTop: "1px solid #ffffff1a",
|
||||
}}
|
||||
size="md"
|
||||
variant="gradient"
|
||||
fullWidth
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
|
||||
</Button>
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
</MediaQuery>
|
||||
</>
|
||||
);
|
||||
};
|
||||
36
packages/mantine/src/components/layout/title/index.tsx
Normal file
36
packages/mantine/src/components/layout/title/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useRouterContext,
|
||||
type TitleProps,
|
||||
useRouterType,
|
||||
useLink,
|
||||
} from "@refinedev/core";
|
||||
import { Center } from "@mantine/core";
|
||||
|
||||
export const Title: React.FC<TitleProps> = ({ collapsed }) => {
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
return (
|
||||
<ActiveLink to="/">
|
||||
<Center p="xs">
|
||||
{collapsed ? (
|
||||
<img
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/logo/refine-mini.svg"
|
||||
alt="Refine"
|
||||
style={{ maxHeight: "38px" }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/logo/refine.svg"
|
||||
alt="Refine"
|
||||
width="140px"
|
||||
/>
|
||||
)}
|
||||
</Center>
|
||||
</ActiveLink>
|
||||
);
|
||||
};
|
||||
11
packages/mantine/src/components/layout/types.ts
Normal file
11
packages/mantine/src/components/layout/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type {
|
||||
RefineLayoutSiderProps,
|
||||
RefineLayoutHeaderProps,
|
||||
RefineLayoutLayoutProps,
|
||||
} from "@refinedev/ui-types";
|
||||
|
||||
export type {
|
||||
RefineLayoutSiderProps,
|
||||
RefineLayoutHeaderProps,
|
||||
RefineLayoutLayoutProps,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { pageForgotPasswordTests } from "@refinedev/ui-tests";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { ForgotPasswordPage } from ".";
|
||||
import { MockAuthProvider, TestWrapper } from "@test/index";
|
||||
|
||||
describe("Auth Page Forgot Password", () => {
|
||||
pageForgotPasswordTests.bind(this)(ForgotPasswordPage);
|
||||
|
||||
it("should run 'onSubmit' callback if it is passed", async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getByText, getByLabelText } = render(
|
||||
<ForgotPasswordPage
|
||||
formProps={{
|
||||
onSubmit,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: MockAuthProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByText(/send reset instructions/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
email: "demo@refine.dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("should show the validation error if email is not valid", async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getByText, getByLabelText } = render(
|
||||
<ForgotPasswordPage
|
||||
formProps={{
|
||||
onSubmit,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: MockAuthProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByText(/send reset instructions/i));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
expect(getByText(/invalid email address/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type ForgotPasswordPageProps,
|
||||
type ForgotPasswordFormTypes,
|
||||
useRouterType,
|
||||
useLink,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
useTranslate,
|
||||
useRouterContext,
|
||||
useForgotPassword,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Space,
|
||||
TextInput,
|
||||
Title,
|
||||
Anchor,
|
||||
Button,
|
||||
Text,
|
||||
type BoxProps,
|
||||
type CardProps,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ThemedTitleV2 } from "@components";
|
||||
import { FormContext } from "@contexts/form-context";
|
||||
import {
|
||||
layoutStyles,
|
||||
cardStyles,
|
||||
titleStyles,
|
||||
pageTitleStyles,
|
||||
} from "../styles";
|
||||
import type { FormPropsType } from "../..";
|
||||
|
||||
type ResetPassworProps = ForgotPasswordPageProps<
|
||||
BoxProps,
|
||||
CardProps,
|
||||
FormPropsType
|
||||
>;
|
||||
|
||||
/**
|
||||
* The forgotPassword type is a page that allows users to reset their passwords. You can use this page to reset your password.
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/mantine-auth-page/#forgot-password} for more details.
|
||||
*/
|
||||
export const ForgotPasswordPage: React.FC<ResetPassworProps> = ({
|
||||
loginLink,
|
||||
contentProps,
|
||||
wrapperProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const { useForm, FormProvider } = FormContext;
|
||||
const { onSubmit: onSubmitProp, ...useFormProps } = formProps || {};
|
||||
const translate = useTranslate();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
validate: {
|
||||
email: (value: any) =>
|
||||
/^\S+@\S+$/.test(value)
|
||||
? null
|
||||
: translate(
|
||||
"pages.forgotPassword.errors.validEmail",
|
||||
"Invalid email address",
|
||||
),
|
||||
},
|
||||
...useFormProps,
|
||||
});
|
||||
const { getInputProps, onSubmit } = form;
|
||||
|
||||
const { mutate: forgotPassword, isLoading } =
|
||||
useForgotPassword<ForgotPasswordFormTypes>();
|
||||
|
||||
const PageTitle =
|
||||
title === false ? null : (
|
||||
<div style={pageTitleStyles}>
|
||||
{title ?? <ThemedTitleV2 collapsed={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardContent = (
|
||||
<Card style={cardStyles} {...(contentProps ?? {})}>
|
||||
<Title
|
||||
style={titleStyles}
|
||||
color={theme.colorScheme === "dark" ? "brand.5" : "brand.8"}
|
||||
>
|
||||
{translate("pages.forgotPassword.title", "Forgot your password?")}
|
||||
</Title>
|
||||
<Space h="lg" />
|
||||
<FormProvider form={form}>
|
||||
<form
|
||||
onSubmit={onSubmit((values: any) => {
|
||||
if (onSubmitProp) {
|
||||
return onSubmitProp(values);
|
||||
}
|
||||
return forgotPassword({ ...mutationVariables, ...values });
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
name="email"
|
||||
label={translate("pages.forgotPassword.fields.email", "Email")}
|
||||
placeholder={translate(
|
||||
"pages.forgotPassword.fields.email",
|
||||
"Email",
|
||||
)}
|
||||
{...getInputProps("email")}
|
||||
/>
|
||||
|
||||
{loginLink ?? (
|
||||
<Group mt="md" position={loginLink ? "left" : "right"}>
|
||||
<Text size="xs">
|
||||
{translate(
|
||||
"pages.forgotPassword.buttons.haveAccount",
|
||||
translate(
|
||||
"pages.login.forgotPassword.haveAccount",
|
||||
"Have an account? ",
|
||||
),
|
||||
)}{" "}
|
||||
<Anchor component={ActiveLink as any} to="/login" weight={700}>
|
||||
{translate("pages.forgotPassword.signin", "Sign in")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Button mt="lg" fullWidth size="md" type="submit" loading={isLoading}>
|
||||
{translate(
|
||||
"pages.forgotPassword.buttons.submit",
|
||||
"Send reset instructions",
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box style={layoutStyles} {...(wrapperProps ?? {})}>
|
||||
{renderContent ? (
|
||||
renderContent(CardContent, PageTitle)
|
||||
) : (
|
||||
<>
|
||||
{PageTitle}
|
||||
{CardContent}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./login";
|
||||
export * from "./register";
|
||||
export * from "./forgotPassword";
|
||||
export * from "./updatePassword";
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { pageLoginTests } from "@refinedev/ui-tests";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { LoginPage } from ".";
|
||||
import { MockAuthProvider, TestWrapper } from "@test/index";
|
||||
|
||||
describe("Auth Page Login", () => {
|
||||
pageLoginTests.bind(this)(LoginPage);
|
||||
|
||||
it("should run 'onSubmit' callback if it is passed", async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getAllByText, getByLabelText } = render(
|
||||
<LoginPage
|
||||
formProps={{
|
||||
onSubmit,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: MockAuthProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getByLabelText(/remember me/i));
|
||||
|
||||
fireEvent.click(getAllByText(/sign in/i)[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
email: "demo@refine.dev",
|
||||
password: "demo",
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type LoginPageProps,
|
||||
type LoginFormTypes,
|
||||
useRouterType,
|
||||
useLink,
|
||||
useActiveAuthProvider,
|
||||
} from "@refinedev/core";
|
||||
import { useLogin, useTranslate, useRouterContext } from "@refinedev/core";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Checkbox,
|
||||
PasswordInput,
|
||||
Space,
|
||||
TextInput,
|
||||
Title,
|
||||
Anchor,
|
||||
Button,
|
||||
Text,
|
||||
Divider,
|
||||
Stack,
|
||||
type BoxProps,
|
||||
type CardProps,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ThemedTitleV2 } from "@components";
|
||||
import { FormContext } from "@contexts/form-context";
|
||||
import {
|
||||
layoutStyles,
|
||||
cardStyles,
|
||||
titleStyles,
|
||||
pageTitleStyles,
|
||||
} from "../styles";
|
||||
import type { FormPropsType } from "../..";
|
||||
|
||||
type LoginProps = LoginPageProps<BoxProps, CardProps, FormPropsType>;
|
||||
|
||||
/**
|
||||
* **refine** has a default login page form which is served on `/login` route when the `authProvider` configuration is provided.
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/mantine-auth-page/#login} for more details.
|
||||
*/
|
||||
export const LoginPage: React.FC<LoginProps> = ({
|
||||
providers,
|
||||
registerLink,
|
||||
forgotPasswordLink,
|
||||
rememberMe,
|
||||
contentProps,
|
||||
wrapperProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title,
|
||||
hideForm,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const { useForm, FormProvider } = FormContext;
|
||||
const { onSubmit: onSubmitProp, ...useFormProps } = formProps || {};
|
||||
const translate = useTranslate();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
remember: false,
|
||||
},
|
||||
validate: {
|
||||
email: (value: any) =>
|
||||
/^\S+@\S+$/.test(value)
|
||||
? null
|
||||
: translate("pages.login.errors.validEmail", "Invalid email address"),
|
||||
password: (value: any) => value === "",
|
||||
},
|
||||
...useFormProps,
|
||||
});
|
||||
const { onSubmit, getInputProps } = form;
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: login, isLoading } = useLogin<LoginFormTypes>({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const PageTitle =
|
||||
title === false ? null : (
|
||||
<div style={pageTitleStyles}>
|
||||
{title ?? <ThemedTitleV2 collapsed={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderProviders = () => {
|
||||
if (providers && providers.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={8}>
|
||||
{providers.map((provider) => {
|
||||
return (
|
||||
<Button
|
||||
key={provider.name}
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftIcon={provider.icon}
|
||||
onClick={() =>
|
||||
login({
|
||||
...mutationVariables,
|
||||
providerName: provider.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{provider.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{!hideForm && (
|
||||
<Divider
|
||||
my="md"
|
||||
labelPosition="center"
|
||||
label={translate("pages.login.divider", "or")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CardContent = (
|
||||
<Card style={cardStyles} {...(contentProps ?? {})}>
|
||||
<Title
|
||||
style={titleStyles}
|
||||
color={theme.colorScheme === "dark" ? "brand.5" : "brand.8"}
|
||||
>
|
||||
{translate("pages.login.title", "Sign in to your account")}
|
||||
</Title>
|
||||
<Space h="sm" />
|
||||
<Space h="lg" />
|
||||
{renderProviders()}
|
||||
{!hideForm && (
|
||||
<FormProvider form={form}>
|
||||
<form
|
||||
onSubmit={onSubmit((values: any) => {
|
||||
if (onSubmitProp) {
|
||||
return onSubmitProp(values);
|
||||
}
|
||||
return login({ ...mutationVariables, ...values });
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
name="email"
|
||||
label={translate("pages.login.fields.email", "Email")}
|
||||
placeholder={translate("pages.login.fields.email", "Email")}
|
||||
{...getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
mt="md"
|
||||
label={translate("pages.login.fields.password", "Password")}
|
||||
placeholder="●●●●●●●●"
|
||||
{...getInputProps("password")}
|
||||
/>
|
||||
<Box
|
||||
mt="md"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{rememberMe ?? (
|
||||
<Checkbox
|
||||
label={translate(
|
||||
"pages.login.buttons.rememberMe",
|
||||
"Remember me",
|
||||
)}
|
||||
size="xs"
|
||||
{...getInputProps("remember", {
|
||||
type: "checkbox",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{forgotPasswordLink ?? (
|
||||
<Anchor
|
||||
component={ActiveLink as any}
|
||||
to="/forgot-password"
|
||||
size="xs"
|
||||
>
|
||||
{translate(
|
||||
"pages.login.buttons.forgotPassword",
|
||||
"Forgot password?",
|
||||
)}
|
||||
</Anchor>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
mt="md"
|
||||
fullWidth
|
||||
size="md"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
>
|
||||
{translate("pages.login.signin", "Sign in")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
{registerLink ?? (
|
||||
<Text mt="md" size="xs" align="center">
|
||||
{translate("pages.login.buttons.noAccount", "Don’t have an account?")}{" "}
|
||||
<Anchor component={ActiveLink as any} to="/register" weight={700}>
|
||||
{translate("pages.login.signup", "Sign up")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
...layoutStyles,
|
||||
justifyContent: hideForm ? "flex-start" : layoutStyles.justifyContent,
|
||||
paddingTop: hideForm ? "15dvh" : layoutStyles.padding,
|
||||
}}
|
||||
{...(wrapperProps ?? {})}
|
||||
>
|
||||
{renderContent ? (
|
||||
renderContent(CardContent, PageTitle)
|
||||
) : (
|
||||
<>
|
||||
{PageTitle}
|
||||
{CardContent}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { pageRegisterTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { RegisterPage } from ".";
|
||||
import { MockAuthProvider, TestWrapper } from "@test/index";
|
||||
|
||||
describe("Auth Page Register", () => {
|
||||
pageRegisterTests.bind(this)(RegisterPage);
|
||||
|
||||
it("should run 'onSubmit' callback if it is passed", async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getAllByText, getByLabelText } = render(
|
||||
<RegisterPage
|
||||
formProps={{
|
||||
onSubmit,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: MockAuthProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getByLabelText(/email/i), {
|
||||
target: { value: "demo@refine.dev" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getAllByText(/sign up/i)[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
email: "demo@refine.dev",
|
||||
password: "demo",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type RegisterPageProps,
|
||||
type RegisterFormTypes,
|
||||
useActiveAuthProvider,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
useTranslate,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useLink,
|
||||
useRegister,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
PasswordInput,
|
||||
Space,
|
||||
TextInput,
|
||||
Title,
|
||||
Anchor,
|
||||
Button,
|
||||
Text,
|
||||
type BoxProps,
|
||||
type CardProps,
|
||||
Group,
|
||||
Stack,
|
||||
Divider,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ThemedTitleV2 } from "@components";
|
||||
import { FormContext } from "@contexts/form-context";
|
||||
import {
|
||||
layoutStyles,
|
||||
cardStyles,
|
||||
titleStyles,
|
||||
pageTitleStyles,
|
||||
} from "../styles";
|
||||
import type { FormPropsType } from "../..";
|
||||
|
||||
type RegisterProps = RegisterPageProps<BoxProps, CardProps, FormPropsType>;
|
||||
|
||||
/**
|
||||
* The register page will be used to register new users. You can use the following props for the <AuthPage> component when the type is "register".
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/mantine-auth-page/#register} for more details.
|
||||
*/
|
||||
export const RegisterPage: React.FC<RegisterProps> = ({
|
||||
loginLink,
|
||||
contentProps,
|
||||
wrapperProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
providers,
|
||||
title,
|
||||
hideForm,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const { useForm, FormProvider } = FormContext;
|
||||
const { onSubmit: onSubmitProp, ...useFormProps } = formProps || {};
|
||||
const translate = useTranslate();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validate: {
|
||||
email: (value: any) =>
|
||||
/^\S+@\S+$/.test(value)
|
||||
? null
|
||||
: translate(
|
||||
"pages.register.errors.validEmail",
|
||||
"Invalid email address",
|
||||
),
|
||||
password: (value: any) => value === "",
|
||||
},
|
||||
...useFormProps,
|
||||
});
|
||||
const { onSubmit, getInputProps } = form;
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: register, isLoading } = useRegister<RegisterFormTypes>({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const PageTitle =
|
||||
title === false ? null : (
|
||||
<div style={pageTitleStyles}>
|
||||
{title ?? <ThemedTitleV2 collapsed={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderProviders = () => {
|
||||
if (providers && providers.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={8}>
|
||||
{providers.map((provider) => {
|
||||
return (
|
||||
<Button
|
||||
key={provider.name}
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftIcon={provider.icon}
|
||||
onClick={() =>
|
||||
register({
|
||||
...mutationVariables,
|
||||
providerName: provider.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{provider.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{!hideForm && (
|
||||
<Divider
|
||||
my="md"
|
||||
labelPosition="center"
|
||||
label={translate(
|
||||
"pages.register.divider",
|
||||
translate("pages.login.divider", "or"),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CardContent = (
|
||||
<Card style={cardStyles} {...(contentProps ?? {})}>
|
||||
<Title
|
||||
style={titleStyles}
|
||||
color={theme.colorScheme === "dark" ? "brand.5" : "brand.8"}
|
||||
>
|
||||
{translate("pages.register.title", "Sign up for your account")}
|
||||
</Title>
|
||||
<Space h="sm" />
|
||||
<Space h="lg" />
|
||||
{renderProviders()}
|
||||
{!hideForm && (
|
||||
<FormProvider form={form}>
|
||||
<form
|
||||
onSubmit={onSubmit((values: any) => {
|
||||
if (onSubmitProp) {
|
||||
return onSubmitProp(values);
|
||||
}
|
||||
return register({ ...mutationVariables, ...values });
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
name="email"
|
||||
label={translate("pages.register.fields.email", "Email")}
|
||||
placeholder={translate("pages.register.fields.email", "Email")}
|
||||
{...getInputProps("email")}
|
||||
/>
|
||||
<PasswordInput
|
||||
mt="md"
|
||||
name="password"
|
||||
label={translate("pages.register.fields.password", "Password")}
|
||||
placeholder="●●●●●●●●"
|
||||
{...getInputProps("password")}
|
||||
/>
|
||||
<Button
|
||||
mt="md"
|
||||
fullWidth
|
||||
size="md"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
>
|
||||
{translate("pages.register.buttons.submit", "Sign up")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
{loginLink ?? (
|
||||
<Group mt="md" position="center">
|
||||
<Text size="xs">
|
||||
{translate(
|
||||
"pages.register.buttons.haveAccount",
|
||||
"Have an account?",
|
||||
)}{" "}
|
||||
<Anchor component={ActiveLink as any} to="/login" weight={700}>
|
||||
{translate("pages.register.signin", "Sign in")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
...layoutStyles,
|
||||
justifyContent: hideForm ? "flex-start" : layoutStyles.justifyContent,
|
||||
paddingTop: hideForm ? "15dvh" : layoutStyles.padding,
|
||||
}}
|
||||
{...(wrapperProps ?? {})}
|
||||
>
|
||||
{renderContent ? (
|
||||
renderContent(CardContent, PageTitle)
|
||||
) : (
|
||||
<>
|
||||
{PageTitle}
|
||||
{CardContent}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export const layoutStyles: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100dvh",
|
||||
padding: "16px",
|
||||
};
|
||||
|
||||
export const cardStyles: CSSProperties = {
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
padding: "32px",
|
||||
boxShadow:
|
||||
"0px 17px 17px -7px rgba(0, 0, 0, 0.16), 0px 36px 28px -7px rgba(0, 0, 0, 0.2), 0px 1px 3px rgba(0, 0, 0, 0.2)",
|
||||
};
|
||||
|
||||
export const titleStyles: CSSProperties = {
|
||||
textAlign: "center",
|
||||
fontSize: "26px",
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
export const pageTitleStyles: CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: "32px",
|
||||
fontSize: "22px",
|
||||
fontWeight: 700,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { pageUpdatePasswordTests } from "@refinedev/ui-tests";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { UpdatePasswordPage } from ".";
|
||||
import { MockAuthProvider, TestWrapper } from "@test/index";
|
||||
|
||||
describe("Auth Page Update Password", () => {
|
||||
pageUpdatePasswordTests.bind(this)(UpdatePasswordPage);
|
||||
|
||||
it("should run 'onSubmit' callback if it is passed", async () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
const { getAllByText, getByLabelText, getAllByLabelText } = render(
|
||||
<UpdatePasswordPage
|
||||
formProps={{
|
||||
onSubmit,
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
authProvider: MockAuthProvider,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.change(getAllByLabelText(/password/i)[0], {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.change(getByLabelText(/confirm new password/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
|
||||
fireEvent.click(getAllByText(/update/i)[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(onSubmit).toBeCalledWith({
|
||||
password: "demo",
|
||||
confirmPassword: "demo",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type UpdatePasswordPageProps,
|
||||
type UpdatePasswordFormTypes,
|
||||
useActiveAuthProvider,
|
||||
} from "@refinedev/core";
|
||||
import { useUpdatePassword, useTranslate } from "@refinedev/core";
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
Space,
|
||||
TextInput,
|
||||
Title,
|
||||
Button,
|
||||
type BoxProps,
|
||||
type CardProps,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { FormContext } from "@contexts/form-context";
|
||||
import {
|
||||
layoutStyles,
|
||||
cardStyles,
|
||||
titleStyles,
|
||||
pageTitleStyles,
|
||||
} from "../styles";
|
||||
import type { FormPropsType } from "../..";
|
||||
import { ThemedTitleV2 } from "@components";
|
||||
|
||||
type UpdatePassworProps = UpdatePasswordPageProps<
|
||||
BoxProps,
|
||||
CardProps,
|
||||
FormPropsType
|
||||
>;
|
||||
|
||||
/**
|
||||
* The updatePassword type is the page used to update the password of the user.
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/mantine-auth-page/#update-password} for more details.
|
||||
*/
|
||||
export const UpdatePasswordPage: React.FC<UpdatePassworProps> = ({
|
||||
contentProps,
|
||||
wrapperProps,
|
||||
renderContent,
|
||||
formProps,
|
||||
title,
|
||||
mutationVariables,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const { useForm, FormProvider } = FormContext;
|
||||
const { onSubmit: onSubmitProp, ...useFormProps } = formProps || {};
|
||||
const translate = useTranslate();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validate: {
|
||||
password: (value: any) => value === "",
|
||||
confirmPassword: (value: any, values: any) =>
|
||||
value !== values.password
|
||||
? translate(
|
||||
"pages.updatePassword.errors.confirmPasswordNotMatch",
|
||||
"Passwords do not match",
|
||||
)
|
||||
: null,
|
||||
},
|
||||
...useFormProps,
|
||||
});
|
||||
const { getInputProps, onSubmit } = form;
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { mutate: updatePassword, isLoading } =
|
||||
useUpdatePassword<UpdatePasswordFormTypes>({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const PageTitle =
|
||||
title === false ? null : (
|
||||
<div style={pageTitleStyles}>
|
||||
{title ?? <ThemedTitleV2 collapsed={false} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CardContent = (
|
||||
<Card style={cardStyles} {...(contentProps ?? {})}>
|
||||
<Title
|
||||
style={titleStyles}
|
||||
color={theme.colorScheme === "dark" ? "brand.5" : "brand.8"}
|
||||
>
|
||||
{translate("pages.updatePassword.title", "Set New Password")}
|
||||
</Title>
|
||||
<Space h="lg" />
|
||||
<FormProvider form={form}>
|
||||
<form
|
||||
onSubmit={onSubmit((values: any) => {
|
||||
if (onSubmitProp) {
|
||||
return onSubmitProp(values);
|
||||
}
|
||||
return updatePassword({ ...mutationVariables, ...values });
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
name="password"
|
||||
type="password"
|
||||
label={translate(
|
||||
"pages.updatePassword.fields.password",
|
||||
"New Password",
|
||||
)}
|
||||
placeholder="●●●●●●●●"
|
||||
{...getInputProps("password")}
|
||||
/>
|
||||
<TextInput
|
||||
mt="md"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label={translate(
|
||||
"pages.updatePassword.fields.confirmPassword",
|
||||
"Confirm New Password",
|
||||
)}
|
||||
placeholder="●●●●●●●●"
|
||||
{...getInputProps("confirmPassword")}
|
||||
/>
|
||||
<Button mt="lg" fullWidth size="md" type="submit" loading={isLoading}>
|
||||
{translate("pages.updatePassword.buttons.submit", "Update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box style={layoutStyles} {...(wrapperProps ?? {})}>
|
||||
{renderContent ? (
|
||||
renderContent(CardContent, PageTitle)
|
||||
) : (
|
||||
<>
|
||||
{PageTitle}
|
||||
{CardContent}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { authPageTests } from "@refinedev/ui-tests";
|
||||
import { AuthPage } from ".";
|
||||
|
||||
describe("Auth Page", () => {
|
||||
authPageTests.bind(this)(AuthPage);
|
||||
});
|
||||
41
packages/mantine/src/components/pages/auth/index.tsx
Normal file
41
packages/mantine/src/components/pages/auth/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import type { AuthPageProps } from "@refinedev/core";
|
||||
import type { BoxProps, CardProps } from "@mantine/core";
|
||||
import type { UseFormInput } from "@mantine/form/lib/types";
|
||||
|
||||
import {
|
||||
LoginPage,
|
||||
RegisterPage,
|
||||
ForgotPasswordPage,
|
||||
UpdatePasswordPage,
|
||||
} from "./components";
|
||||
|
||||
export type FormPropsType = UseFormInput<unknown> & {
|
||||
onSubmit?: (values: any) => void;
|
||||
};
|
||||
|
||||
export type AuthProps = AuthPageProps<BoxProps, CardProps, FormPropsType>;
|
||||
|
||||
/**
|
||||
* **refine** has a default auth page form served on the `/login` route when the `authProvider` configuration is provided.
|
||||
* @param title is not implemented yet.
|
||||
* @see {@link https://refine.dev/docs/api-reference/mantine/components/mantine-auth-page/} for more details.
|
||||
*/
|
||||
export const AuthPage: React.FC<AuthProps> = (props) => {
|
||||
const { type } = props;
|
||||
|
||||
const renderView = () => {
|
||||
switch (type) {
|
||||
case "register":
|
||||
return <RegisterPage {...props} />;
|
||||
case "forgotPassword":
|
||||
return <ForgotPasswordPage {...props} />;
|
||||
case "updatePassword":
|
||||
return <UpdatePasswordPage {...props} />;
|
||||
default:
|
||||
return <LoginPage {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return <>{renderView()}</>;
|
||||
};
|
||||
94
packages/mantine/src/components/pages/error/index.spec.tsx
Normal file
94
packages/mantine/src/components/pages/error/index.spec.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { pageErrorTests } from "@refinedev/ui-tests";
|
||||
import type ReactRouterDom from "react-router-dom";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { ErrorComponent } from ".";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
TestWrapper as BaseTestWrapper,
|
||||
MockLegacyRouterProvider,
|
||||
type ITestWrapperProps,
|
||||
} from "@test";
|
||||
|
||||
const mHistory = jest.fn();
|
||||
|
||||
jest.mock("react-router-dom", () => ({
|
||||
...(jest.requireActual("react-router-dom") as typeof ReactRouterDom),
|
||||
useNavigate: () => mHistory,
|
||||
}));
|
||||
|
||||
const TestWrapper = (props: ITestWrapperProps) =>
|
||||
BaseTestWrapper({
|
||||
...props,
|
||||
legacyRouterProvider: MockLegacyRouterProvider,
|
||||
});
|
||||
|
||||
describe("ErrorComponent", () => {
|
||||
pageErrorTests.bind(this)(ErrorComponent);
|
||||
|
||||
it("renders subtitle successfully", async () => {
|
||||
const { getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
getByText("Sorry, the page you visited does not exist.");
|
||||
});
|
||||
|
||||
it("renders button successfully", async () => {
|
||||
const { container, getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
expect(container.querySelector("button")).toBeTruthy();
|
||||
getByText("Back Home");
|
||||
});
|
||||
|
||||
it("renders called function successfully if click the button", async () => {
|
||||
const { getByText } = render(<ErrorComponent />, {
|
||||
wrapper: TestWrapper({}),
|
||||
});
|
||||
|
||||
fireEvent.click(getByText("Back Home"));
|
||||
|
||||
expect(mHistory).toBeCalledWith("/");
|
||||
});
|
||||
|
||||
it("renders error messages if resources action's not found", async () => {
|
||||
const { getByTestId, findByText } = render(
|
||||
<Routes>
|
||||
<Route path="/:resource/:action" element={<ErrorComponent />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts/create"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
userEvent.hover(getByTestId("error-component-tooltip"));
|
||||
|
||||
expect(
|
||||
await findByText(
|
||||
`You may have forgotten to add the "create" component to "posts" resource.`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error messages if resource action's is different from 'create, edit, show'", async () => {
|
||||
const { getByText } = render(
|
||||
<Routes>
|
||||
<Route path="/:resource/:action" element={<ErrorComponent />} />
|
||||
</Routes>,
|
||||
{
|
||||
wrapper: TestWrapper({
|
||||
routerInitialEntries: ["/posts/invalidActionType"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
getByText("Sorry, the page you visited does not exist.");
|
||||
});
|
||||
});
|
||||
107
packages/mantine/src/components/pages/error/index.tsx
Normal file
107
packages/mantine/src/components/pages/error/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import type { RefineErrorPageProps } from "@refinedev/ui-types";
|
||||
import {
|
||||
useNavigation,
|
||||
useTranslate,
|
||||
useGo,
|
||||
useResource,
|
||||
useRouterType,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
Box,
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Button,
|
||||
Space,
|
||||
} from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
export const ErrorComponent: React.FC<RefineErrorPageProps> = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const translate = useTranslate();
|
||||
const { push } = useNavigation();
|
||||
const go = useGo();
|
||||
const routerType = useRouterType();
|
||||
|
||||
const { resource, action } = useResource();
|
||||
|
||||
useEffect(() => {
|
||||
if (resource && action) {
|
||||
setErrorMessage(
|
||||
translate(
|
||||
"pages.error.info",
|
||||
{
|
||||
action,
|
||||
resource: resource?.name,
|
||||
},
|
||||
`You may have forgotten to add the "${action}" component to "${resource?.name}" resource.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [resource, action]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
boxSizing: "border-box",
|
||||
minHeight: "calc(100vh - 150px)",
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
sx={(theme) => ({
|
||||
textAlign: "center",
|
||||
fontWeight: 900,
|
||||
fontSize: 220,
|
||||
lineHeight: 1,
|
||||
color:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[4]
|
||||
: theme.colors.gray[2],
|
||||
|
||||
[theme.fn.smallerThan("sm")]: {
|
||||
fontSize: 120,
|
||||
},
|
||||
})}
|
||||
>
|
||||
404
|
||||
</Title>
|
||||
<Group spacing={4} align="center" sx={{ justifyContent: "center" }}>
|
||||
<Text color="dimmed" size="lg" align="center" sx={{ maxWidth: 500 }}>
|
||||
{translate(
|
||||
"pages.error.404",
|
||||
"Sorry, the page you visited does not exist.",
|
||||
)}
|
||||
</Text>
|
||||
{errorMessage && (
|
||||
<Tooltip openDelay={0} label={errorMessage}>
|
||||
<ActionIcon data-testid="error-component-tooltip">
|
||||
<IconInfoCircle />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Space h="md" />
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
if (routerType === "legacy") {
|
||||
push("/");
|
||||
} else {
|
||||
go({ to: "/" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{translate("pages.error.backHome", "Back Home")}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
4
packages/mantine/src/components/pages/index.ts
Normal file
4
packages/mantine/src/components/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./error";
|
||||
export * from "./ready";
|
||||
export * from "./welcome";
|
||||
export * from "./auth";
|
||||
37
packages/mantine/src/components/pages/ready/index.spec.tsx
Normal file
37
packages/mantine/src/components/pages/ready/index.spec.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { pageReadyTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { render } from "@test";
|
||||
|
||||
import { ReadyPage } from "./index";
|
||||
|
||||
describe("ReadyPage", () => {
|
||||
pageReadyTests.bind(this)(ReadyPage);
|
||||
|
||||
it("should render 3 texts", async () => {
|
||||
const { getByText } = render(<ReadyPage />);
|
||||
|
||||
getByText("Welcome on board");
|
||||
getByText("Your configuration is completed.");
|
||||
});
|
||||
|
||||
it("should render 3 buttons", async () => {
|
||||
const { getByText } = render(<ReadyPage />);
|
||||
|
||||
expect(Button).toBeDefined();
|
||||
|
||||
expect(getByText("Documentation").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://refine.dev",
|
||||
);
|
||||
expect(getByText("Examples").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://refine.dev/examples",
|
||||
);
|
||||
expect(getByText("Community").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"https://discord.gg/refine",
|
||||
);
|
||||
});
|
||||
});
|
||||
69
packages/mantine/src/components/pages/ready/index.tsx
Normal file
69
packages/mantine/src/components/pages/ready/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from "react";
|
||||
import type { RefineReadyPageProps } from "@refinedev/ui-types";
|
||||
import {
|
||||
BackgroundImage,
|
||||
Code,
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
Space,
|
||||
Button,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
|
||||
/**
|
||||
* @deprecated `ReadyPage` is deprecated and will be removed in the next major release.
|
||||
*/
|
||||
export const ReadyPage: React.FC<RefineReadyPageProps> = () => {
|
||||
return (
|
||||
<BackgroundImage
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "#2A132E",
|
||||
}}
|
||||
py="xl"
|
||||
px="sm"
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/login-background/background.png"
|
||||
>
|
||||
<img
|
||||
src="https://refine.ams3.cdn.digitaloceanspaces.com/logo/refine.svg"
|
||||
alt="Refine Logo"
|
||||
/>
|
||||
<Space h={24} />
|
||||
<Title align="center" sx={{ color: "white", fontSize: "3rem" }}>
|
||||
Welcome on board
|
||||
</Title>
|
||||
<Text size="xl" sx={{ color: "white" }} mt="md" align="center">
|
||||
Your configuration is completed.
|
||||
</Text>
|
||||
<Text size="lg" sx={{ color: "white" }} mt="md" align="center">
|
||||
Now you can get started by adding your resources to the{" "}
|
||||
<Code>resources</Code> property of <Code>Refine</Code>.
|
||||
</Text>
|
||||
<Space h={48} />
|
||||
<Group position="center">
|
||||
<Anchor href="https://refine.dev" target="_blank" rel="noreferrer">
|
||||
<Button variant="default">Documentation</Button>
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="https://refine.dev/examples"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="default">Examples</Button>
|
||||
</Anchor>
|
||||
<Anchor
|
||||
href="https://discord.gg/refine"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="default">Community</Button>
|
||||
</Anchor>
|
||||
</Group>
|
||||
</BackgroundImage>
|
||||
);
|
||||
};
|
||||
10
packages/mantine/src/components/pages/welcome/index.tsx
Normal file
10
packages/mantine/src/components/pages/welcome/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { WelcomePage as WelcomePageFromCore } from "@refinedev/core";
|
||||
|
||||
/**
|
||||
* It is a page that welcomes you after the configuration is completed.
|
||||
* @deprecated `WelcomePage` is deprecated. Use `WelcomePage` from `@refinedev/core` instead.
|
||||
*/
|
||||
export const WelcomePage: React.FC = () => {
|
||||
return <WelcomePageFromCore />;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
import { render } from "@test";
|
||||
import { RingCountdown } from ".";
|
||||
|
||||
describe("Ring Countdown", () => {
|
||||
it("should render undoableTimeout count successfuly", async () => {
|
||||
const { container } = render(<RingCountdown undoableTimeout={5} />);
|
||||
|
||||
expect(
|
||||
container.getElementsByClassName("mantine-Text-root")[0].innerHTML,
|
||||
).toBe("5");
|
||||
});
|
||||
});
|
||||
24
packages/mantine/src/components/ring-countdown/index.tsx
Normal file
24
packages/mantine/src/components/ring-countdown/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { RingProgress, Text } from "@mantine/core";
|
||||
|
||||
export type RingCountdownProps = {
|
||||
undoableTimeout: number;
|
||||
};
|
||||
|
||||
export const RingCountdown: React.FC<RingCountdownProps> = ({
|
||||
undoableTimeout,
|
||||
}) => {
|
||||
return (
|
||||
<RingProgress
|
||||
size={55}
|
||||
thickness={4}
|
||||
roundCaps
|
||||
sections={[{ value: undoableTimeout * 20, color: "primary" }]}
|
||||
label={
|
||||
<Text weight={700} align="center">
|
||||
{undoableTimeout}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { render, TestWrapper } from "@test";
|
||||
import { ThemedHeader } from "./index";
|
||||
import type { AuthProvider, LegacyAuthProvider } from "@refinedev/core";
|
||||
|
||||
const mockLegacyAuthProvider: LegacyAuthProvider = {
|
||||
login: () => Promise.resolve(),
|
||||
logout: () => Promise.resolve(),
|
||||
checkError: () => Promise.resolve(),
|
||||
checkAuth: () => Promise.resolve(),
|
||||
getPermissions: () => Promise.resolve(["admin"]),
|
||||
getUserIdentity: () =>
|
||||
Promise.resolve({ name: "John Doe", avatar: "localhost:3000" }),
|
||||
};
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
}),
|
||||
logout: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
}),
|
||||
onError: () => Promise.resolve({}),
|
||||
check: () =>
|
||||
Promise.resolve({
|
||||
authenticated: true,
|
||||
}),
|
||||
getIdentity: () =>
|
||||
Promise.resolve({ name: "John Doe", avatar: "localhost:3000" }),
|
||||
};
|
||||
|
||||
describe("ThemedHeader", () => {
|
||||
it("should render successfull user name and avatar fallback in header", async () => {
|
||||
const { findByText, getByTitle } = render(<ThemedHeader />, {
|
||||
wrapper: TestWrapper({
|
||||
authProvider: mockAuthProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await findByText("John Doe");
|
||||
getByTitle("John Doe");
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: Will be removed in the refine v5
|
||||
describe("ThemedHeader with legacyAuthProvider", () => {
|
||||
it("should render successfull user name and avatar fallback in header", async () => {
|
||||
const { findByText, getByTitle } = render(<ThemedHeader />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyAuthProvider: mockLegacyAuthProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await findByText("John Doe");
|
||||
getByTitle("John Doe");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { useGetIdentity, useActiveAuthProvider } from "@refinedev/core";
|
||||
import {
|
||||
Avatar,
|
||||
Group,
|
||||
Header as MantineHeader,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { RefineThemedLayoutHeaderProps } from "../types";
|
||||
|
||||
/**
|
||||
* @deprecated It is recommended to use the improved `ThemedLayoutV2`. Review migration guidelines. https://refine.dev/docs/api-reference/mantine/components/mantine-themed-layout/#migrate-themedlayout-to-themedlayoutv2
|
||||
*/
|
||||
export const ThemedHeader: React.FC<RefineThemedLayoutHeaderProps> = () => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { data: user } = useGetIdentity({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const borderColor =
|
||||
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[2];
|
||||
|
||||
return (
|
||||
<MantineHeader
|
||||
zIndex={199}
|
||||
height={64}
|
||||
py={6}
|
||||
px="sm"
|
||||
sx={{
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
position="right"
|
||||
align="center"
|
||||
sx={{
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Title
|
||||
order={6}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{user?.name}
|
||||
</Title>
|
||||
<Avatar src={user?.avatar} alt={user?.name} radius="xl" />
|
||||
</Group>
|
||||
</MantineHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { layoutLayoutTests } from "@refinedev/ui-tests";
|
||||
import { ThemedLayout } from "./index";
|
||||
|
||||
describe("ThemedLayout", () => {
|
||||
layoutLayoutTests.bind(this)(ThemedLayout);
|
||||
});
|
||||
47
packages/mantine/src/components/themedLayout/index.tsx
Normal file
47
packages/mantine/src/components/themedLayout/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import type { RefineThemedLayoutProps } from "./types";
|
||||
import { ThemedSider as DefaultSider } from "./sider";
|
||||
import { ThemedHeader as DefaultHeader } from "./header";
|
||||
|
||||
/**
|
||||
* @deprecated It is recommended to use the improved `ThemedLayoutV2`. Review migration guidelines. https://refine.dev/docs/api-reference/mantine/components/mantine-themed-layout/#migrate-themedlayout-to-themedlayoutv2
|
||||
*/
|
||||
export const ThemedLayout: React.FC<RefineThemedLayoutProps> = ({
|
||||
Sider,
|
||||
Header,
|
||||
Title,
|
||||
Footer,
|
||||
OffLayoutArea,
|
||||
children,
|
||||
}) => {
|
||||
const SiderToRender = Sider ?? DefaultSider;
|
||||
const HeaderToRender = Header ?? DefaultHeader;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<SiderToRender Title={Title} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<HeaderToRender />
|
||||
<Box
|
||||
component="main"
|
||||
sx={(theme) => ({
|
||||
padding: theme.spacing.sm,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{Footer && <Footer />}
|
||||
</Box>
|
||||
{OffLayoutArea && <OffLayoutArea />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
343
packages/mantine/src/components/themedLayout/sider/index.tsx
Normal file
343
packages/mantine/src/components/themedLayout/sider/index.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
CanAccess,
|
||||
type ITreeMenu,
|
||||
useIsExistAuthentication,
|
||||
useLink,
|
||||
useLogout,
|
||||
useMenu,
|
||||
useActiveAuthProvider,
|
||||
useRefineContext,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useTitle,
|
||||
useTranslate,
|
||||
useWarnAboutChange,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Drawer,
|
||||
Navbar,
|
||||
NavLink,
|
||||
type NavLinkStylesNames,
|
||||
type NavLinkStylesParams,
|
||||
ScrollArea,
|
||||
MediaQuery,
|
||||
Button,
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
type Styles,
|
||||
useMantineTheme,
|
||||
Flex,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconList,
|
||||
IconMenu2,
|
||||
IconIndentDecrease,
|
||||
IconIndentIncrease,
|
||||
IconPower,
|
||||
IconDashboard,
|
||||
} from "@tabler/icons-react";
|
||||
import type { RefineThemedLayoutSiderProps } from "../types";
|
||||
|
||||
import { ThemedTitle as DefaultTitle } from "@components";
|
||||
|
||||
const defaultNavIcon = <IconList size={20} />;
|
||||
|
||||
/**
|
||||
* @deprecated It is recommended to use the improved `ThemedLayoutV2`. Review migration guidelines. https://refine.dev/docs/api-reference/mantine/components/mantine-themed-layout/#migrate-themedlayout-to-themedlayoutv2
|
||||
*/
|
||||
export const ThemedSider: React.FC<RefineThemedLayoutSiderProps> = ({
|
||||
render,
|
||||
meta,
|
||||
Title: TitleFromProps,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const routerType = useRouterType();
|
||||
const NewLink = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
const Link = routerType === "legacy" ? LegacyLink : NewLink;
|
||||
|
||||
const { defaultOpenKeys, menuItems, selectedKey } = useMenu({ meta });
|
||||
const TitleFromContext = useTitle();
|
||||
const isExistAuthentication = useIsExistAuthentication();
|
||||
const t = useTranslate();
|
||||
const { hasDashboard } = useRefineContext();
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
||||
const { mutate: mutateLogout } = useLogout({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const RenderToTitle = TitleFromProps ?? TitleFromContext ?? DefaultTitle;
|
||||
|
||||
const drawerWidth = () => {
|
||||
if (collapsed) return 80;
|
||||
return 200;
|
||||
};
|
||||
|
||||
const borderColor =
|
||||
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[2];
|
||||
|
||||
const commonNavLinkStyles: Styles<NavLinkStylesNames, NavLinkStylesParams> = {
|
||||
root: {
|
||||
display: "flex",
|
||||
marginTop: "12px",
|
||||
justifyContent: collapsed && !opened ? "center" : "flex-start",
|
||||
},
|
||||
icon: {
|
||||
marginRight: collapsed && !opened ? 0 : 12,
|
||||
},
|
||||
body: {
|
||||
display: collapsed && !opened ? "none" : "flex",
|
||||
},
|
||||
};
|
||||
|
||||
const commonTooltipProps: Partial<TooltipProps> = {
|
||||
disabled: !collapsed || opened,
|
||||
position: "right",
|
||||
withinPortal: true,
|
||||
withArrow: true,
|
||||
arrowSize: 8,
|
||||
arrowOffset: 12,
|
||||
offset: 4,
|
||||
};
|
||||
|
||||
const renderTreeView = (tree: ITreeMenu[], selectedKey?: string) => {
|
||||
return tree.map((item) => {
|
||||
const { icon, label, route, name, children } = item;
|
||||
|
||||
const isSelected = item.key === selectedKey;
|
||||
const isParent = children.length > 0;
|
||||
|
||||
const additionalLinkProps = isParent
|
||||
? {}
|
||||
: { component: Link as any, to: route };
|
||||
|
||||
return (
|
||||
<CanAccess
|
||||
key={item.key}
|
||||
resource={name}
|
||||
action="list"
|
||||
params={{
|
||||
resource: item,
|
||||
}}
|
||||
>
|
||||
<Tooltip label={label} {...commonTooltipProps}>
|
||||
<NavLink
|
||||
key={item.key}
|
||||
label={collapsed && !opened ? null : label}
|
||||
icon={icon ?? defaultNavIcon}
|
||||
active={isSelected}
|
||||
childrenOffset={collapsed && !opened ? 0 : 12}
|
||||
defaultOpened={defaultOpenKeys.includes(item.key || "")}
|
||||
pl={collapsed || opened ? "12px" : "18px"}
|
||||
styles={commonNavLinkStyles}
|
||||
{...additionalLinkProps}
|
||||
>
|
||||
{isParent && renderTreeView(children, selectedKey)}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
</CanAccess>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const items = renderTreeView(menuItems, selectedKey);
|
||||
|
||||
const dashboard = hasDashboard ? (
|
||||
<CanAccess resource="dashboard" action="list">
|
||||
<Tooltip
|
||||
label={t("dashboard.title", "Dashboard")}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<NavLink
|
||||
key="dashboard"
|
||||
label={
|
||||
collapsed && !opened ? null : t("dashboard.title", "Dashboard")
|
||||
}
|
||||
icon={<IconDashboard size={20} />}
|
||||
component={Link as any}
|
||||
to="/"
|
||||
active={selectedKey === "/"}
|
||||
styles={commonNavLinkStyles}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CanAccess>
|
||||
) : null;
|
||||
|
||||
const handleLogout = () => {
|
||||
if (warnWhen) {
|
||||
const confirm = window.confirm(
|
||||
t(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm) {
|
||||
setWarnWhen(false);
|
||||
mutateLogout();
|
||||
}
|
||||
} else {
|
||||
mutateLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const logout = isExistAuthentication && (
|
||||
<Tooltip label={t("buttons.logout", "Logout")} {...commonTooltipProps}>
|
||||
<NavLink
|
||||
key="logout"
|
||||
label={collapsed && !opened ? null : t("buttons.logout", "Logout")}
|
||||
icon={<IconPower size={20} />}
|
||||
pl={collapsed || opened ? "12px" : "18px"}
|
||||
onClick={handleLogout}
|
||||
styles={commonNavLinkStyles}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const renderSider = () => {
|
||||
if (render) {
|
||||
return render({
|
||||
dashboard,
|
||||
logout,
|
||||
items,
|
||||
collapsed,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{dashboard}
|
||||
{items}
|
||||
{logout}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaQuery largerThan="md" styles={{ display: "none" }}>
|
||||
<Box sx={{ position: "fixed", top: 16, left: 16, zIndex: 1199 }}>
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
variant="filled"
|
||||
size={32}
|
||||
onClick={() => setOpened((prev) => !prev)}
|
||||
>
|
||||
<IconMenu2 />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery largerThan="md" styles={{ display: "none" }}>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
size={200}
|
||||
zIndex={1200}
|
||||
withCloseButton={false}
|
||||
styles={{
|
||||
drawer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Navbar.Section
|
||||
pl={8}
|
||||
sx={{
|
||||
height: "64px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: "10px",
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<RenderToTitle collapsed={false} />
|
||||
</Navbar.Section>
|
||||
<Navbar.Section component={ScrollArea} grow mx="-xs" px="xs">
|
||||
{renderSider()}
|
||||
</Navbar.Section>
|
||||
</Drawer>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: drawerWidth(),
|
||||
transition: "width 200ms ease, min-width 200ms ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<Navbar
|
||||
width={{ base: drawerWidth() }}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease, min-width 200ms ease",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
height: "100vh",
|
||||
borderRight: 0,
|
||||
zIndex: 199,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
h="64px"
|
||||
pl={collapsed ? 0 : "16px"}
|
||||
align="center"
|
||||
justify={collapsed ? "center" : "flex-start"}
|
||||
sx={{
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<RenderToTitle collapsed={collapsed} />
|
||||
</Flex>
|
||||
<Navbar.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
mx="-xs"
|
||||
px="xs"
|
||||
sx={{
|
||||
".mantine-ScrollArea-viewport": {
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderSider()}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
fullWidth
|
||||
sx={{
|
||||
border: "none",
|
||||
}}
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<IconIndentIncrease size={16} />
|
||||
) : (
|
||||
<IconIndentDecrease size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
</MediaQuery>
|
||||
</>
|
||||
);
|
||||
};
|
||||
76
packages/mantine/src/components/themedLayout/title/index.tsx
Normal file
76
packages/mantine/src/components/themedLayout/title/index.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { useRouterContext, useRouterType, useLink } from "@refinedev/core";
|
||||
import { Center, Text, useMantineTheme } from "@mantine/core";
|
||||
import type { RefineLayoutThemedTitleProps } from "../types";
|
||||
|
||||
const defaultText = "Refine Project";
|
||||
|
||||
const defaultIcon = (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
data-testid="refine-logo"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.7889 0.422291C12.6627 -0.140764 11.3373 -0.140764 10.2111 0.422291L2.21115 4.42229C0.85601 5.09986 0 6.48491 0 8V16C0 17.5151 0.85601 18.9001 2.21115 19.5777L10.2111 23.5777C11.3373 24.1408 12.6627 24.1408 13.7889 23.5777L21.7889 19.5777C23.144 18.9001 24 17.5151 24 16V8C24 6.48491 23.144 5.09986 21.7889 4.42229L13.7889 0.422291ZM8 8C8 5.79086 9.79086 4 12 4C14.2091 4 16 5.79086 16 8V16C16 18.2091 14.2091 20 12 20C9.79086 20 8 18.2091 8 16V8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14 8C14 9.10457 13.1046 10 12 10C10.8954 10 10 9.10457 10 8C10 6.89543 10.8954 6 12 6C13.1046 6 14 6.89543 14 8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated It is recommended to use the improved `ThemedLayoutV2`. Review migration guidelines. https://refine.dev/docs/api-reference/mantine/components/mantine-themed-layout/#migrate-themedlayout-to-themedlayoutv2
|
||||
*/
|
||||
export const ThemedTitle: React.FC<RefineLayoutThemedTitleProps> = ({
|
||||
collapsed,
|
||||
icon = defaultIcon,
|
||||
text = defaultText,
|
||||
wrapperStyles = {},
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
return (
|
||||
<ActiveLink to="/" style={{ all: "unset" }}>
|
||||
<Center
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: "8px",
|
||||
...wrapperStyles,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
lh={0}
|
||||
fz="inherit"
|
||||
color={theme.colorScheme === "dark" ? "brand.5" : "brand.6"}
|
||||
>
|
||||
{icon}
|
||||
</Text>
|
||||
{!collapsed && (
|
||||
<Text
|
||||
fz="inherit"
|
||||
color={theme.colorScheme === "dark" ? "white" : "black"}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
</ActiveLink>
|
||||
);
|
||||
};
|
||||
13
packages/mantine/src/components/themedLayout/types.ts
Normal file
13
packages/mantine/src/components/themedLayout/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
RefineThemedLayoutSiderProps,
|
||||
RefineThemedLayoutHeaderProps,
|
||||
RefineThemedLayoutProps,
|
||||
RefineLayoutThemedTitleProps,
|
||||
} from "@refinedev/ui-types";
|
||||
|
||||
export type {
|
||||
RefineThemedLayoutSiderProps,
|
||||
RefineThemedLayoutHeaderProps,
|
||||
RefineThemedLayoutProps,
|
||||
RefineLayoutThemedTitleProps,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { ActionIcon, MediaQuery } from "@mantine/core";
|
||||
import {
|
||||
IconMenu2,
|
||||
IconIndentDecrease,
|
||||
IconIndentIncrease,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useThemedLayoutContext } from "@hooks";
|
||||
|
||||
export const HamburgerMenu: React.FC = () => {
|
||||
const {
|
||||
siderCollapsed,
|
||||
setSiderCollapsed,
|
||||
mobileSiderOpen,
|
||||
setMobileSiderOpen,
|
||||
} = useThemedLayoutContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
sx={{
|
||||
border: "none",
|
||||
}}
|
||||
size="lg"
|
||||
onClick={() => setSiderCollapsed(!siderCollapsed)}
|
||||
>
|
||||
{siderCollapsed ? (
|
||||
<IconIndentIncrease size={20} />
|
||||
) : (
|
||||
<IconIndentDecrease size={20} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</MediaQuery>
|
||||
<MediaQuery largerThan="md" styles={{ display: "none" }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
sx={{
|
||||
border: "none",
|
||||
}}
|
||||
size="lg"
|
||||
onClick={() => setMobileSiderOpen(!mobileSiderOpen)}
|
||||
>
|
||||
<IconMenu2 size={20} />
|
||||
</ActionIcon>
|
||||
</MediaQuery>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from "react";
|
||||
import { render, TestWrapper, waitFor } from "@test";
|
||||
import { ThemedHeaderV2 } from "./index";
|
||||
import type { AuthProvider, LegacyAuthProvider } from "@refinedev/core";
|
||||
|
||||
const mockLegacyAuthProvider: LegacyAuthProvider = {
|
||||
login: () => Promise.resolve(),
|
||||
logout: () => Promise.resolve(),
|
||||
checkError: () => Promise.resolve(),
|
||||
checkAuth: () => Promise.resolve(),
|
||||
getPermissions: () => Promise.resolve(["admin"]),
|
||||
getUserIdentity: () =>
|
||||
Promise.resolve({ name: "John Doe", avatar: "localhost:3000" }),
|
||||
};
|
||||
|
||||
const mockAuthProvider: AuthProvider = {
|
||||
login: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
}),
|
||||
logout: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
}),
|
||||
onError: () => Promise.resolve({}),
|
||||
check: () =>
|
||||
Promise.resolve({
|
||||
authenticated: true,
|
||||
}),
|
||||
getIdentity: () =>
|
||||
Promise.resolve({ name: "John Doe", avatar: "localhost:3000" }),
|
||||
};
|
||||
|
||||
describe("ThemedHeaderV2", () => {
|
||||
it("should render successfull user name and avatar fallback in header", async () => {
|
||||
const { getByTestId, container } = render(<ThemedHeaderV2 />, {
|
||||
wrapper: TestWrapper({
|
||||
authProvider: mockAuthProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("header-user-name")).toHaveTextContent("John Doe");
|
||||
expect(container.querySelector("img")).toHaveAttribute(
|
||||
"src",
|
||||
"localhost:3000",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should only render user name in header", async () => {
|
||||
const { getByTestId, container } = render(<ThemedHeaderV2 />, {
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
getIdentity: () => Promise.resolve({ name: "John Doe" }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("header-user-name")).toHaveTextContent("John Doe");
|
||||
expect(container.querySelector("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should only render user avatar in header", async () => {
|
||||
const { queryByTestId, container } = render(<ThemedHeaderV2 />, {
|
||||
wrapper: TestWrapper({
|
||||
authProvider: {
|
||||
...mockAuthProvider,
|
||||
getIdentity: () => Promise.resolve({ avatar: "localhost:3000" }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("img")).toHaveAttribute(
|
||||
"src",
|
||||
"localhost:3000",
|
||||
);
|
||||
expect(queryByTestId("header-user-name")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: Will be removed in the refine v5
|
||||
describe("ThemedHeaderV2 with legacyAuthProvider", () => {
|
||||
it("should render successfull user name and avatar fallback in header", async () => {
|
||||
const { getByTestId, container } = render(<ThemedHeaderV2 />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyAuthProvider: mockLegacyAuthProvider,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("header-user-name")).toHaveTextContent("John Doe");
|
||||
expect(container.querySelector("img")).toHaveAttribute(
|
||||
"src",
|
||||
"localhost:3000",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should only render user name in header", async () => {
|
||||
const { getByTestId, container } = render(<ThemedHeaderV2 />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyAuthProvider: {
|
||||
...mockLegacyAuthProvider,
|
||||
getUserIdentity: () => Promise.resolve({ name: "John Doe" }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId("header-user-name")).toHaveTextContent("John Doe");
|
||||
expect(container.querySelector("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should only render user avatar in header", async () => {
|
||||
const { queryByTestId, container } = render(<ThemedHeaderV2 />, {
|
||||
wrapper: TestWrapper({
|
||||
legacyAuthProvider: {
|
||||
...mockLegacyAuthProvider,
|
||||
getUserIdentity: () => Promise.resolve({ avatar: "localhost:3000" }),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector("img")).toHaveAttribute(
|
||||
"src",
|
||||
"localhost:3000",
|
||||
);
|
||||
expect(queryByTestId("header-user-name")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useGetIdentity,
|
||||
useActiveAuthProvider,
|
||||
pickNotDeprecated,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
Avatar,
|
||||
Flex,
|
||||
Header as MantineHeader,
|
||||
type Sx,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { RefineThemedLayoutV2HeaderProps } from "../types";
|
||||
import { HamburgerMenu } from "../hamburgerMenu";
|
||||
|
||||
export const ThemedHeaderV2: React.FC<RefineThemedLayoutV2HeaderProps> = ({
|
||||
isSticky,
|
||||
sticky,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { data: user } = useGetIdentity({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const borderColor =
|
||||
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[2];
|
||||
|
||||
let stickyStyles: Sx = {};
|
||||
if (pickNotDeprecated(sticky, isSticky)) {
|
||||
stickyStyles = {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<MantineHeader
|
||||
zIndex={199}
|
||||
height={64}
|
||||
py={6}
|
||||
px="sm"
|
||||
sx={{
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
...stickyStyles,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
sx={{
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<HamburgerMenu />
|
||||
<Flex align="center" gap="sm">
|
||||
{user?.name && (
|
||||
<Title order={6} data-testid="header-user-name">
|
||||
{user?.name}
|
||||
</Title>
|
||||
)}
|
||||
{user?.avatar && (
|
||||
<Avatar src={user?.avatar} alt={user?.name} radius="xl" />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</MantineHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { layoutLayoutTests } from "@refinedev/ui-tests";
|
||||
import { ThemedLayoutV2 } from "./index";
|
||||
|
||||
describe("ThemedLayoutV2", () => {
|
||||
layoutLayoutTests.bind(this)(ThemedLayoutV2);
|
||||
});
|
||||
47
packages/mantine/src/components/themedLayoutV2/index.tsx
Normal file
47
packages/mantine/src/components/themedLayoutV2/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import type { RefineThemedLayoutV2Props } from "./types";
|
||||
import { ThemedSiderV2 as DefaultSider } from "./sider";
|
||||
import { ThemedHeaderV2 as DefaultHeader } from "./header";
|
||||
import { ThemedLayoutContextProvider } from "../../contexts";
|
||||
|
||||
export const ThemedLayoutV2: React.FC<RefineThemedLayoutV2Props> = ({
|
||||
Sider,
|
||||
Header,
|
||||
Title,
|
||||
Footer,
|
||||
OffLayoutArea,
|
||||
initialSiderCollapsed,
|
||||
children,
|
||||
}) => {
|
||||
const SiderToRender = Sider ?? DefaultSider;
|
||||
const HeaderToRender = Header ?? DefaultHeader;
|
||||
|
||||
return (
|
||||
<ThemedLayoutContextProvider initialSiderCollapsed={initialSiderCollapsed}>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<SiderToRender Title={Title} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<HeaderToRender />
|
||||
<Box
|
||||
component="main"
|
||||
sx={(theme) => ({
|
||||
padding: theme.spacing.sm,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{Footer && <Footer />}
|
||||
</Box>
|
||||
{OffLayoutArea && <OffLayoutArea />}
|
||||
</Box>
|
||||
</ThemedLayoutContextProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { layoutSiderTests } from "@refinedev/ui-tests";
|
||||
|
||||
import { ThemedSiderV2 } from "./index";
|
||||
|
||||
describe("ThemedSiderV2", () => {
|
||||
layoutSiderTests.bind(this)(ThemedSiderV2);
|
||||
});
|
||||
309
packages/mantine/src/components/themedLayoutV2/sider/index.tsx
Normal file
309
packages/mantine/src/components/themedLayoutV2/sider/index.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { type CSSProperties } from "react";
|
||||
import {
|
||||
CanAccess,
|
||||
type ITreeMenu,
|
||||
useIsExistAuthentication,
|
||||
useLink,
|
||||
useLogout,
|
||||
useMenu,
|
||||
useActiveAuthProvider,
|
||||
useRefineContext,
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useTitle,
|
||||
useTranslate,
|
||||
useWarnAboutChange,
|
||||
} from "@refinedev/core";
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
Navbar,
|
||||
NavLink,
|
||||
type NavLinkStylesNames,
|
||||
type NavLinkStylesParams,
|
||||
ScrollArea,
|
||||
MediaQuery,
|
||||
Tooltip,
|
||||
type TooltipProps,
|
||||
type Styles,
|
||||
useMantineTheme,
|
||||
Flex,
|
||||
} from "@mantine/core";
|
||||
import { IconList, IconPower, IconDashboard } from "@tabler/icons-react";
|
||||
|
||||
import { ThemedTitleV2 as DefaultTitle } from "@components";
|
||||
import { useThemedLayoutContext } from "@hooks";
|
||||
|
||||
import type { RefineThemedLayoutV2SiderProps } from "../types";
|
||||
|
||||
const defaultNavIcon = <IconList size={20} />;
|
||||
|
||||
export const ThemedSiderV2: React.FC<RefineThemedLayoutV2SiderProps> = ({
|
||||
render,
|
||||
meta,
|
||||
Title: TitleFromProps,
|
||||
activeItemDisabled = false,
|
||||
}) => {
|
||||
const theme = useMantineTheme();
|
||||
const { siderCollapsed, mobileSiderOpen, setMobileSiderOpen } =
|
||||
useThemedLayoutContext();
|
||||
|
||||
const routerType = useRouterType();
|
||||
const NewLink = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
const Link = routerType === "legacy" ? LegacyLink : NewLink;
|
||||
|
||||
const { defaultOpenKeys, menuItems, selectedKey } = useMenu({ meta });
|
||||
const TitleFromContext = useTitle();
|
||||
const isExistAuthentication = useIsExistAuthentication();
|
||||
const t = useTranslate();
|
||||
const { hasDashboard } = useRefineContext();
|
||||
const authProvider = useActiveAuthProvider();
|
||||
const { warnWhen, setWarnWhen } = useWarnAboutChange();
|
||||
const { mutate: mutateLogout } = useLogout({
|
||||
v3LegacyAuthProviderCompatible: Boolean(authProvider?.isLegacy),
|
||||
});
|
||||
|
||||
const RenderToTitle = TitleFromProps ?? TitleFromContext ?? DefaultTitle;
|
||||
|
||||
const drawerWidth = () => {
|
||||
if (siderCollapsed) return 80;
|
||||
return 200;
|
||||
};
|
||||
|
||||
const borderColor =
|
||||
theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[2];
|
||||
|
||||
const commonNavLinkStyles: Styles<NavLinkStylesNames, NavLinkStylesParams> = {
|
||||
root: {
|
||||
display: "flex",
|
||||
marginTop: "12px",
|
||||
justifyContent:
|
||||
siderCollapsed && !mobileSiderOpen ? "center" : "flex-start",
|
||||
},
|
||||
icon: {
|
||||
marginRight: siderCollapsed && !mobileSiderOpen ? 0 : 12,
|
||||
},
|
||||
body: {
|
||||
display: siderCollapsed && !mobileSiderOpen ? "none" : "flex",
|
||||
},
|
||||
};
|
||||
|
||||
const commonTooltipProps: Partial<TooltipProps> = {
|
||||
disabled: !siderCollapsed || mobileSiderOpen,
|
||||
position: "right",
|
||||
withinPortal: true,
|
||||
withArrow: true,
|
||||
arrowSize: 8,
|
||||
arrowOffset: 12,
|
||||
offset: 4,
|
||||
};
|
||||
|
||||
const renderTreeView = (tree: ITreeMenu[], selectedKey?: string) => {
|
||||
return tree.map((item) => {
|
||||
const { icon, label, route, name, children } = item;
|
||||
|
||||
const isSelected = item.key === selectedKey;
|
||||
const isParent = children.length > 0;
|
||||
|
||||
const additionalLinkProps = isParent
|
||||
? {}
|
||||
: { component: Link as any, to: route };
|
||||
|
||||
const disablePointerStyle: CSSProperties =
|
||||
activeItemDisabled && isSelected ? { pointerEvents: "none" } : {};
|
||||
|
||||
return (
|
||||
<CanAccess
|
||||
key={item.key}
|
||||
resource={name}
|
||||
action="list"
|
||||
params={{
|
||||
resource: item,
|
||||
}}
|
||||
>
|
||||
<Tooltip label={label} {...commonTooltipProps}>
|
||||
<NavLink
|
||||
key={item.key}
|
||||
label={siderCollapsed && !mobileSiderOpen ? null : label}
|
||||
icon={icon ?? defaultNavIcon}
|
||||
active={isSelected}
|
||||
childrenOffset={siderCollapsed && !mobileSiderOpen ? 0 : 12}
|
||||
defaultOpened={defaultOpenKeys.includes(item.key || "")}
|
||||
pl={siderCollapsed || mobileSiderOpen ? "12px" : "18px"}
|
||||
styles={commonNavLinkStyles}
|
||||
style={disablePointerStyle}
|
||||
{...additionalLinkProps}
|
||||
>
|
||||
{isParent && renderTreeView(children, selectedKey)}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
</CanAccess>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const items = renderTreeView(menuItems, selectedKey);
|
||||
|
||||
const dashboard = hasDashboard ? (
|
||||
<CanAccess resource="dashboard" action="list">
|
||||
<Tooltip
|
||||
label={t("dashboard.title", "Dashboard")}
|
||||
{...commonTooltipProps}
|
||||
>
|
||||
<NavLink
|
||||
key="dashboard"
|
||||
label={
|
||||
siderCollapsed && !mobileSiderOpen
|
||||
? null
|
||||
: t("dashboard.title", "Dashboard")
|
||||
}
|
||||
icon={<IconDashboard size={20} />}
|
||||
component={Link as any}
|
||||
to="/"
|
||||
active={selectedKey === "/"}
|
||||
styles={commonNavLinkStyles}
|
||||
/>
|
||||
</Tooltip>
|
||||
</CanAccess>
|
||||
) : null;
|
||||
|
||||
const handleLogout = () => {
|
||||
if (warnWhen) {
|
||||
const confirm = window.confirm(
|
||||
t(
|
||||
"warnWhenUnsavedChanges",
|
||||
"Are you sure you want to leave? You have unsaved changes.",
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm) {
|
||||
setWarnWhen(false);
|
||||
mutateLogout();
|
||||
}
|
||||
} else {
|
||||
mutateLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const logout = isExistAuthentication && (
|
||||
<Tooltip label={t("buttons.logout", "Logout")} {...commonTooltipProps}>
|
||||
<NavLink
|
||||
key="logout"
|
||||
label={
|
||||
siderCollapsed && !mobileSiderOpen
|
||||
? null
|
||||
: t("buttons.logout", "Logout")
|
||||
}
|
||||
icon={<IconPower size={20} />}
|
||||
pl={siderCollapsed || mobileSiderOpen ? "12px" : "18px"}
|
||||
onClick={handleLogout}
|
||||
styles={commonNavLinkStyles}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const renderSider = () => {
|
||||
if (render) {
|
||||
return render({
|
||||
dashboard,
|
||||
logout,
|
||||
items,
|
||||
collapsed: siderCollapsed,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{dashboard}
|
||||
{items}
|
||||
{logout}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaQuery largerThan="md" styles={{ display: "none" }}>
|
||||
<Drawer
|
||||
opened={mobileSiderOpen}
|
||||
onClose={() => setMobileSiderOpen(false)}
|
||||
size={200}
|
||||
zIndex={1200}
|
||||
withCloseButton={false}
|
||||
styles={{
|
||||
drawer: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Navbar.Section
|
||||
pl={8}
|
||||
sx={{
|
||||
height: "64px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingLeft: "10px",
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<RenderToTitle collapsed={false} />
|
||||
</Navbar.Section>
|
||||
<Navbar.Section component={ScrollArea} grow mx="-xs" px="xs">
|
||||
{renderSider()}
|
||||
</Navbar.Section>
|
||||
</Drawer>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: drawerWidth(),
|
||||
transition: "width 200ms ease, min-width 200ms ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</MediaQuery>
|
||||
|
||||
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
|
||||
<Navbar
|
||||
width={{ base: drawerWidth() }}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease, min-width 200ms ease",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
height: "100vh",
|
||||
borderRight: 0,
|
||||
zIndex: 199,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
h="64px"
|
||||
pl={siderCollapsed ? 0 : "16px"}
|
||||
align="center"
|
||||
justify={siderCollapsed ? "center" : "flex-start"}
|
||||
sx={{
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<RenderToTitle collapsed={siderCollapsed} />
|
||||
</Flex>
|
||||
<Navbar.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
mx="-xs"
|
||||
px="xs"
|
||||
sx={{
|
||||
".mantine-ScrollArea-viewport": {
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderSider()}
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
</MediaQuery>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { layoutTitleTests } from "@refinedev/ui-tests";
|
||||
import { ThemedTitleV2 } from "./index";
|
||||
|
||||
describe("ThemedTitleV2", () => {
|
||||
layoutTitleTests.bind(this)(ThemedTitleV2);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useRouterContext,
|
||||
useRouterType,
|
||||
useLink,
|
||||
useRefineOptions,
|
||||
} from "@refinedev/core";
|
||||
import { Center, Text, useMantineTheme } from "@mantine/core";
|
||||
import type { RefineLayoutThemedTitleProps } from "../types";
|
||||
|
||||
export const ThemedTitleV2: React.FC<RefineLayoutThemedTitleProps> = ({
|
||||
collapsed,
|
||||
icon: iconFromProps,
|
||||
text: textFromProps,
|
||||
wrapperStyles = {},
|
||||
}) => {
|
||||
const {
|
||||
title: { icon: defaultIcon, text: defaultText } = {},
|
||||
} = useRefineOptions();
|
||||
const icon =
|
||||
typeof iconFromProps === "undefined" ? defaultIcon : iconFromProps;
|
||||
const text =
|
||||
typeof textFromProps === "undefined" ? defaultText : textFromProps;
|
||||
const theme = useMantineTheme();
|
||||
const routerType = useRouterType();
|
||||
const Link = useLink();
|
||||
const { Link: LegacyLink } = useRouterContext();
|
||||
|
||||
const ActiveLink = routerType === "legacy" ? LegacyLink : Link;
|
||||
|
||||
return (
|
||||
<ActiveLink to="/" style={{ all: "unset" }}>
|
||||
<Center
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: collapsed ? "center" : "flex-start",
|
||||
gap: "8px",
|
||||
...wrapperStyles,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
lh={0}
|
||||
fz="inherit"
|
||||
color={theme.colorScheme === "dark" ? "brand.5" : "brand.6"}
|
||||
>
|
||||
{icon}
|
||||
</Text>
|
||||
{!collapsed && (
|
||||
<Text
|
||||
fz="inherit"
|
||||
color={theme.colorScheme === "dark" ? "white" : "black"}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
</ActiveLink>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user