This commit is contained in:
Stefan Pejcic
2024-11-07 19:03:37 +01:00
parent c6df945ed5
commit 09f9f9502d
2472 changed files with 620417 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
import { autoSaveIndicatorTests } from "@refinedev/ui-tests";
import { AutoSaveIndicator } from "./";
describe("AutoSaveIndicator", () => {
autoSaveIndicatorTests.bind(this)(AutoSaveIndicator);
});

View 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>
);
};

View 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");
});
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { buttonCloneTests } from "@refinedev/ui-tests";
import { CloneButton } from "./";
describe("Clone Button", () => {
buttonCloneTests.bind(this)(CloneButton);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { buttonCreateTests } from "@refinedev/ui-tests";
import { CreateButton } from "./";
describe("Create Button", () => {
buttonCreateTests.bind(this)(CreateButton);
});

View 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>
);
};

View File

@@ -0,0 +1,6 @@
import { buttonDeleteTests } from "@refinedev/ui-tests";
import { DeleteButton } from "./";
describe("Delete Button", () => {
buttonDeleteTests.bind(this)(DeleteButton);
});

View 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>
);
};

View File

@@ -0,0 +1,6 @@
import { buttonEditTests } from "@refinedev/ui-tests";
import { EditButton } from "./";
describe("Edit Button", () => {
buttonEditTests.bind(this)(EditButton);
});

View 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>
);
};

View File

@@ -0,0 +1,6 @@
import { buttonExportTests } from "@refinedev/ui-tests";
import { ExportButton } from "./index";
describe("<ExportButton/>", () => {
buttonExportTests.bind(this)(ExportButton);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { buttonImportTests } from "@refinedev/ui-tests";
import { ImportButton } from "./";
describe("ImportButton", () => {
buttonImportTests.bind(this)(ImportButton);
});

View 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>
);
};

View 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";

View File

@@ -0,0 +1,6 @@
import { buttonListTests } from "@refinedev/ui-tests";
import { ListButton } from "./";
describe("List Button", () => {
buttonListTests.bind(this)(ListButton);
});

View 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>
);
};

View File

@@ -0,0 +1,6 @@
import { buttonRefreshTests } from "@refinedev/ui-tests";
import { RefreshButton } from "./";
describe("Refresh Button", () => {
buttonRefreshTests.bind(this)(RefreshButton);
});

View 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>
);
};

View File

@@ -0,0 +1,6 @@
import { buttonSaveTests } from "@refinedev/ui-tests";
import { SaveButton } from "./";
describe("Save Button", () => {
buttonSaveTests.bind(this)(SaveButton);
});

View 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>
);
};

View File

@@ -0,0 +1,6 @@
import { buttonShowTests } from "@refinedev/ui-tests";
import { ShowButton } from "./";
describe("Show Button", () => {
buttonShowTests.bind(this)(ShowButton);
});

View 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>
);
};

View 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">;
}
>;

View 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",
);
});
});

View 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>
);
};

View 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();
});
});

View 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>
);
};

View File

@@ -0,0 +1,5 @@
export * from "./list";
export * from "./show";
export * from "./edit";
export * from "./create";
export * from "./types";

View 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",
);
});
});

View 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>
);
};

View 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();
});
});

View 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>
);
};

View 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
>;

View File

@@ -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);
});
});
});
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { fieldDateTests } from "@refinedev/ui-tests";
import { DateField } from "./";
describe("DateField", () => {
fieldDateTests.bind(this)(DateField);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { fieldEmailTests } from "@refinedev/ui-tests";
import { EmailField } from "./";
describe("EmailField", () => {
fieldEmailTests.bind(this)(EmailField);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { fieldFileTests } from "@refinedev/ui-tests";
import { FileField } from "./";
describe("FileField", () => {
fieldFileTests.bind(this)(FileField);
});

View 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>
);
};

View 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";

View File

@@ -0,0 +1,7 @@
import { fieldMarkdownTests } from "@refinedev/ui-tests";
import { MarkdownField } from "./";
describe("MarkdownField", () => {
fieldMarkdownTests.bind(this)(MarkdownField);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { fieldNumberTests } from "@refinedev/ui-tests";
import { NumberField } from "./";
describe("NumberField", () => {
fieldNumberTests.bind(this)(NumberField);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { fieldTagTests } from "@refinedev/ui-tests";
import { TagField } from "./";
describe("TagField", () => {
fieldTagTests.bind(this)(TagField);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { fieldTextTests } from "@refinedev/ui-tests";
import { TextField } from "./";
describe("TextField", () => {
fieldTextTests.bind(this)(TextField);
});

View 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>;
};

View 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;
}
>;

View File

@@ -0,0 +1,7 @@
import { fieldUrlTests } from "@refinedev/ui-tests";
import { UrlField } from "./";
describe("UrlField", () => {
fieldUrlTests.bind(this)(UrlField);
});

View 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>
);
};

View 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";

View File

@@ -0,0 +1,7 @@
import { layoutHeaderTests } from "@refinedev/ui-tests";
import { Header } from "./index";
describe("Header", () => {
layoutHeaderTests.bind(this)(Header);
});

View 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;
};

View File

@@ -0,0 +1,6 @@
import { layoutLayoutTests } from "@refinedev/ui-tests";
import { Layout } from "./index";
describe("Layout", () => {
layoutLayoutTests.bind(this)(Layout);
});

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View File

@@ -0,0 +1,11 @@
import type {
RefineLayoutSiderProps,
RefineLayoutHeaderProps,
RefineLayoutLayoutProps,
} from "@refinedev/ui-types";
export type {
RefineLayoutSiderProps,
RefineLayoutHeaderProps,
RefineLayoutLayoutProps,
};

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./login";
export * from "./register";
export * from "./forgotPassword";
export * from "./updatePassword";

View File

@@ -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,
});
});
});

View File

@@ -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", "Dont 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>
);
};

View File

@@ -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",
});
});
});

View File

@@ -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>
);
};

View File

@@ -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,
};

View File

@@ -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",
});
});
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,6 @@
import { authPageTests } from "@refinedev/ui-tests";
import { AuthPage } from ".";
describe("Auth Page", () => {
authPageTests.bind(this)(AuthPage);
});

View 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()}</>;
};

View 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.");
});
});

View 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>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./error";
export * from "./ready";
export * from "./welcome";
export * from "./auth";

View 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",
);
});
});

View 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>
);
};

View 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 />;
};

View File

@@ -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");
});
});

View 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>
}
/>
);
};

View File

@@ -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");
});
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,6 @@
import { layoutLayoutTests } from "@refinedev/ui-tests";
import { ThemedLayout } from "./index";
describe("ThemedLayout", () => {
layoutLayoutTests.bind(this)(ThemedLayout);
});

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View File

@@ -0,0 +1,13 @@
import type {
RefineThemedLayoutSiderProps,
RefineThemedLayoutHeaderProps,
RefineThemedLayoutProps,
RefineLayoutThemedTitleProps,
} from "@refinedev/ui-types";
export type {
RefineThemedLayoutSiderProps,
RefineThemedLayoutHeaderProps,
RefineThemedLayoutProps,
RefineLayoutThemedTitleProps,
};

View File

@@ -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>
</>
);
};

View File

@@ -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();
});
});
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1,6 @@
import { layoutLayoutTests } from "@refinedev/ui-tests";
import { ThemedLayoutV2 } from "./index";
describe("ThemedLayoutV2", () => {
layoutLayoutTests.bind(this)(ThemedLayoutV2);
});

View 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>
);
};

View File

@@ -0,0 +1,7 @@
import { layoutSiderTests } from "@refinedev/ui-tests";
import { ThemedSiderV2 } from "./index";
describe("ThemedSiderV2", () => {
layoutSiderTests.bind(this)(ThemedSiderV2);
});

View 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>
</>
);
};

View File

@@ -0,0 +1,6 @@
import { layoutTitleTests } from "@refinedev/ui-tests";
import { ThemedTitleV2 } from "./index";
describe("ThemedTitleV2", () => {
layoutTitleTests.bind(this)(ThemedTitleV2);
});

View File

@@ -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