diff --git a/.changeset/five-pianos-march.md b/.changeset/five-pianos-march.md new file mode 100644 index 000000000000..fea5c89bbcd2 --- /dev/null +++ b/.changeset/five-pianos-march.md @@ -0,0 +1,44 @@ +--- +"@refinedev/react-router-v6": minor +--- + +feat: [``](https://refine.dev/docs/routing/integrations/react-router/#documenttitlehandler) should populated `resource.meta.label` field if it's not provided on the Refine's resource definition. +From now on, users be able to use the `resource.meta.label` field to customize document title more easily. + +```tsx +import { + BrowserRouter, + DocumentTitleHandler, +} from "@refinedev/react-router-v6"; +import { Refine } from "@refinedev/core"; + +const App = () => { + return ( + + + {/* ... */} + { + const id = params?.id ?? ""; + + const actionPrefixMatcher = { + create: "Create new ", + clone: `#${id} Clone ${resource?.meta?.label}`, + edit: `#${id} Edit ${resource?.meta?.label}`, + show: `#${id} Show ${resource?.meta?.label}`, + list: `${resource?.meta?.label}`, + }; + + const suffix = " | "; + const title = actionPrefixMatcher[action || "list"] + suffix; + + return title; + }} + /> + + + ); +}; +``` diff --git a/.changeset/four-carpets-fix.md b/.changeset/four-carpets-fix.md new file mode 100644 index 000000000000..3711dc3f484f --- /dev/null +++ b/.changeset/four-carpets-fix.md @@ -0,0 +1,44 @@ +--- +"@refinedev/nextjs-router": minor +--- + +feat: [``](https://refine.dev/docs/routing/integrations/next-js/#documenttitlehandler) should populated `resource.meta.label` field if it's not provided on the Refine's resource definition. +From now on, users be able to use the `resource.meta.label` field to customize document title more easily. + +```tsx +import { + BrowserRouter, + DocumentTitleHandler, +} from "@refinedev/react-router-v6"; +import { Refine } from "@refinedev/core"; + +const App = () => { + return ( + + + {/* ... */} + { + const id = params?.id ?? ""; + + const actionPrefixMatcher = { + create: "Create new ", + clone: `#${id} Clone ${resource?.meta?.label}`, + edit: `#${id} Edit ${resource?.meta?.label}`, + show: `#${id} Show ${resource?.meta?.label}`, + list: `${resource?.meta?.label}`, + }; + + const suffix = " | "; + const title = actionPrefixMatcher[action || "list"] + suffix; + + return title; + }} + /> + + + ); +}; +``` diff --git a/documentation/docs/routing/integrations/next-js/index.md b/documentation/docs/routing/integrations/next-js/index.md index 426f55743937..0b796d68367e 100644 --- a/documentation/docs/routing/integrations/next-js/index.md +++ b/documentation/docs/routing/integrations/next-js/index.md @@ -1563,6 +1563,48 @@ Default paths are: - `edit`: `/resources/edit/:id` - `show`: `/resources/show/:id` +### How to change the document title? + +By default [``](#documenttitlehandler) component will generate the document title based on current resource and action with the "Refine" suffix. You can customize the title generation process by providing a custom `handler` function. + +```tsx +import { + BrowserRouter, + DocumentTitleHandler, +} from "@refinedev/react-router-v6"; +import { Refine } from "@refinedev/core"; + +const App = () => { + return ( + + + {/* ... */} + { + const id = params?.id ?? ""; + + const actionPrefixMatcher = { + create: "Create new ", + clone: `#${id} Clone ${resource?.meta?.label}`, + edit: `#${id} Edit ${resource?.meta?.label}`, + show: `#${id} Show ${resource?.meta?.label}`, + list: `${resource?.meta?.label}`, + }; + + const suffix = " | "; + const title = actionPrefixMatcher[action || "list"] + suffix; + + return title; + }} + /> + + + ); +}; +``` + ## Example (`/app`) diff --git a/documentation/docs/routing/integrations/react-router/index.md b/documentation/docs/routing/integrations/react-router/index.md index 2e0ed9e6d615..1e2de5f15d0e 100644 --- a/documentation/docs/routing/integrations/react-router/index.md +++ b/documentation/docs/routing/integrations/react-router/index.md @@ -1377,6 +1377,44 @@ Default paths are: [routerprovider]: /docs/routing/router-provider [resources]: /docs/guides-concepts/general-concepts/#resource-concept -``` +### How to change the document title? + +By default [``](#documenttitlehandler) component will generate the document title based on current resource and action with the "Refine" suffix. You can customize the title generation process by providing a custom `handler` function. + +```tsx +import { + BrowserRouter, + DocumentTitleHandler, +} from "@refinedev/react-router-v6"; +import { Refine } from "@refinedev/core"; +const App = () => { + return ( + + + {/* ... */} + { + const id = params?.id ?? ""; + + const actionPrefixMatcher = { + create: "Create new ", + clone: `#${id} Clone ${resource?.meta?.label}`, + edit: `#${id} Edit ${resource?.meta?.label}`, + show: `#${id} Show ${resource?.meta?.label}`, + list: `${resource?.meta?.label}`, + }; + + const suffix = " | "; + const title = actionPrefixMatcher[action || "list"] + suffix; + + return title; + }} + /> + + + ); +}; ``` diff --git a/packages/nextjs-router/jest.config.js b/packages/nextjs-router/jest.config.js index 1926a5ad2695..d5d722b0b590 100644 --- a/packages/nextjs-router/jest.config.js +++ b/packages/nextjs-router/jest.config.js @@ -3,4 +3,10 @@ module.exports = { rootDir: "./", displayName: "nextjs-router", testEnvironment: "jsdom", + setupFilesAfterEnv: ["/src/test/jest.setup.ts"], + testPathIgnorePatterns: [ + "/node_modules/", + "/example/", + "/dist/", + ], }; diff --git a/packages/nextjs-router/src/pages/document-title-handler.test.tsx b/packages/nextjs-router/src/pages/document-title-handler.test.tsx new file mode 100644 index 000000000000..7a0eaa50bfde --- /dev/null +++ b/packages/nextjs-router/src/pages/document-title-handler.test.tsx @@ -0,0 +1,202 @@ +import React, { type ReactNode } from "react"; + +import { DocumentTitleHandler } from "./document-title-handler"; +import { render, TestWrapper, type ITestWrapperProps } from "../test/index"; +import { mockRouterBindings } from "../test/dataMocks"; + +jest.mock("next/head", () => { + return { + __esModule: true, + default: ({ children }: { children: Array }) => { + return <>{children}; + }, + }; +}); + +const assertNextHeadTitle = (text: string) => { + const title = document.querySelector("title"); + expect(title?.textContent).toBe(text); +}; + +const renderDocumentTitleHandler = ( + children: ReactNode, + wrapperProps: ITestWrapperProps = {}, +) => { + return render(<>{children}, { + wrapper: TestWrapper(wrapperProps), + }); +}; + +describe("", () => { + it("should render default list title", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts", list: "/posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts" }, + }), + }); + + assertNextHeadTitle("Posts | Refine"); + }); + + it("should render default create title", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts", create: "/posts/create" }], + routerInitialEntries: ["/posts/create"], + routerProvider: mockRouterBindings({ + action: "create", + resource: { name: "posts", create: "/posts/create" }, + }), + }); + + assertNextHeadTitle("Create new Post | Refine"); + }); + + it("should render default edit title", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts", edit: "/posts/edit/:id" }], + routerInitialEntries: ["/posts/edit/1"], + routerProvider: mockRouterBindings({ + action: "edit", + resource: { name: "posts", edit: "/posts/edit/1" }, + id: "1", + }), + }); + + assertNextHeadTitle("#1 Edit Post | Refine"); + }); + + it("should render default show title", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts", show: "/posts/show/:id" }], + routerInitialEntries: ["/posts/show/1"], + routerProvider: mockRouterBindings({ + action: "show", + resource: { name: "posts", show: "/posts/show/1" }, + id: "1", + }), + }); + + assertNextHeadTitle("#1 Show Post | Refine"); + }); + + it("should render default clone title", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts", clone: "/posts/clone/:id" }], + routerInitialEntries: ["/posts/clone/1"], + routerProvider: mockRouterBindings({ + action: "clone", + resource: { name: "posts", clone: "/posts/clone/1" }, + id: "1", + }), + }); + + assertNextHeadTitle("#1 Clone Post | Refine"); + }); + + it("should render default title for unknown resource", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts" }], + routerInitialEntries: ["/unknown"], + routerProvider: mockRouterBindings({ + action: "list", + resource: undefined, + }), + }); + + assertNextHeadTitle("Refine"); + }); + + it("should render default title for unknown action", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts" }], + routerInitialEntries: ["/posts/unknown"], + routerProvider: mockRouterBindings({ + action: undefined, + resource: { + name: "posts", + }, + }), + }); + + assertNextHeadTitle("Refine"); + }); + + it("should use identifier", async () => { + renderDocumentTitleHandler(, { + resources: [ + { name: "posts", list: "/posts", identifier: "Awesome Posts" }, + ], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { + name: "posts", + list: "/posts", + identifier: "Awesome Posts", + }, + }), + }); + + assertNextHeadTitle("Awesome posts | Refine"); + }); + + it("should render custom title", async () => { + renderDocumentTitleHandler( + { + return "Custom Title"; + }} + />, + { + resources: [{ name: "posts", list: "/posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts" }, + }), + }, + ); + + assertNextHeadTitle("Custom Title"); + }); + + it("should label be populated with friendly resource name on handler function", async () => { + renderDocumentTitleHandler( + { + const label = resource?.label; + const labelMeta = resource?.meta?.label; + + expect(labelMeta).toBe(label); + expect(labelMeta).toBe("Posts"); + + return autoGeneratedTitle; + }} + />, + { + resources: [{ name: "posts", list: "/posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts" }, + }), + }, + ); + }); + + it("should use label from resource if its provided", async () => { + renderDocumentTitleHandler(, { + resources: [{ name: "posts", list: "/posts", label: "Labeled Posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts", label: "Labeled Posts" }, + }), + }); + + assertNextHeadTitle("Labeled Posts | Refine"); + }); +}); diff --git a/packages/nextjs-router/src/pages/document-title-handler.tsx b/packages/nextjs-router/src/pages/document-title-handler.tsx index 85bfd6eca0f6..62e84388e8c7 100644 --- a/packages/nextjs-router/src/pages/document-title-handler.tsx +++ b/packages/nextjs-router/src/pages/document-title-handler.tsx @@ -26,10 +26,14 @@ export const DocumentTitleHandler = ({ handler }: Props) => { const getUserFriendlyName = useUserFriendlyName(); const identifier = resource?.identifier ?? resource?.name; + const preferredLabel = resource?.label ?? resource?.meta?.label; const resourceName = - resource?.label ?? - resource?.meta?.label ?? + preferredLabel ?? getUserFriendlyName(identifier, action === "list" ? "plural" : "singular"); + const populatedLabel = translate( + `${resource?.name}.${resource?.name}`, + resourceName, + ); const autoGeneratedTitle = generateDefaultDocumentTitle( translate, @@ -43,7 +47,14 @@ export const DocumentTitleHandler = ({ handler }: Props) => { {handler?.({ - resource, + resource: { + ...(resource! ?? {}), + label: populatedLabel, + meta: { + ...resource?.meta, + label: populatedLabel, + }, + }, params, action, autoGeneratedTitle, diff --git a/packages/nextjs-router/src/test/dataMocks.ts b/packages/nextjs-router/src/test/dataMocks.ts new file mode 100644 index 000000000000..c5fc698c9c52 --- /dev/null +++ b/packages/nextjs-router/src/test/dataMocks.ts @@ -0,0 +1,110 @@ +import type { + ParsedParams, + IResourceItem, + Action, + RouterBindings, +} from "@refinedev/core"; + +export const posts = [ + { + id: "1", + title: + "Necessitatibus necessitatibus id et cupiditate provident est qui amet.", + slug: "ut-ad-et", + content: + "Cupiditate labore quaerat cum incidunt vel et consequatur modi illo. Et maxime aut commodi occaecati omnis. Est voluptatem quibusdam aliquam. Esse tenetur omnis eaque. Consequatur necessitatibus illum ipsum aspernatur architecto qui. Ut temporibus qui nobis. Reiciendis est magnam ipsa quasi dolor ipsa error. Et eaque cumque est. Eos et odit corporis delectus aut corrupti tempora velit. Perferendis ratione voluptas corrupti id temporibus nam.", + categoryId: 1, + category: { + id: 1, + }, + status: "active", + userId: 5, + tags: [16, 31, 45], + }, + { + id: "2", + title: "Recusandae consectetur aut atque est.", + slug: "consequatur-molestiae-rerum", + content: + "Quia ut autem. Hic dolorum magni est quisquam. Modi est id et est. Est sapiente velit iure non voluptatem natus enim. Distinctio ipsa repellendus est. Sunt ipsam dignissimos vero error est cumque eaque. Consequatur voluptas suscipit optio incidunt doloremque quia harum harum. Totam voluptatibus aperiam quia. Est omnis deleniti et aut at fugit temporibus debitis modi. Magni aut vel quod magnam.", + category: { + id: 38, + }, + status: "active", + userId: 36, + tags: [16, 30, 46], + }, +]; + +const MockDataProvider = () => { + return { + create: async () => ({ data: posts[0] }), + createMany: () => Promise.resolve({ data: posts }), + deleteOne: () => Promise.resolve({ data: posts[0] }), + deleteMany: () => Promise.resolve({ data: [] }), + getList: () => Promise.resolve({ data: posts, total: 2 }), + getMany: () => Promise.resolve({ data: [...posts] }), + getOne: () => Promise.resolve({ data: posts[0] }), + update: () => Promise.resolve({ data: posts[0] }), + updateMany: () => Promise.resolve({ data: [] }), + getApiUrl: () => "https://api.fake-rest.refine.dev", + custom: () => Promise.resolve({ data: [...posts] }), + }; +}; + +export const MockJSONServer = MockDataProvider() as any; + +export const mockRouterBindings = ({ + pathname, + params, + resource, + action, + id, + fns, +}: { + pathname?: string; + params?: ParsedParams; + resource?: IResourceItem; + action?: Action; + id?: string; + fns?: Partial<RouterBindings>; +} = {}): RouterBindings => { + const bindings: RouterBindings = { + go: () => { + return ({ type }) => { + if (type === "path") return ""; + return undefined; + }; + }, + parse: () => { + return () => { + return { + params: { + ...params, + }, + pathname, + resource: resource, + action: action, + id: id || undefined, + }; + }; + }, + back: () => { + return () => undefined; + }, + Link: () => null, + ...fns, + }; + + return bindings; +}; + +export const MockAccessControlProvider: any = { + can: () => Promise.resolve({ can: true }), +}; + +export const MockLiveProvider: any = { + subscribe: () => ({}), + unsubscribe: () => ({}), + publish: () => ({}), +}; diff --git a/packages/nextjs-router/src/test/index.tsx b/packages/nextjs-router/src/test/index.tsx new file mode 100644 index 000000000000..22d16a73be85 --- /dev/null +++ b/packages/nextjs-router/src/test/index.tsx @@ -0,0 +1,96 @@ +import React, { type ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; + +import { + type AccessControlProvider, + type AuthProvider, + type NotificationProvider, + Refine, + type I18nProvider, + type DataProvider, + type IResourceItem, + type RouterBindings, + type IRefineOptions, +} from "@refinedev/core"; + +import { MockJSONServer, mockRouterBindings } from "./dataMocks"; + +export interface ITestWrapperProps { + dataProvider?: DataProvider; + routerProvider?: RouterBindings; + authProvider?: AuthProvider; + resources?: IResourceItem[]; + notificationProvider?: NotificationProvider; + accessControlProvider?: AccessControlProvider; + i18nProvider?: I18nProvider; + routerInitialEntries?: string[]; + options?: IRefineOptions; +} + +export const TestWrapper: ( + props: ITestWrapperProps, +) => React.FC<{ children?: ReactNode }> = ({ + dataProvider, + authProvider, + routerProvider, + resources, + notificationProvider, + accessControlProvider, + routerInitialEntries, + i18nProvider, + options, +}) => { + // Previously, MemoryRouter was used in this wrapper. However, the + // recommendation by react-router developers (see + // https://github.com/remix-run/react-router/discussions/8241#discussioncomment-159686) + // is essentially to use the same router as your actual application. Besides + // that, it's impossible to check for location changes with MemoryRouter if + // needed. + if (routerInitialEntries) { + routerInitialEntries.forEach((route) => { + window.history.replaceState({}, "", route); + }); + } + + return ({ children }): React.ReactElement => { + return ( + <BrowserRouter> + <Refine + dataProvider={dataProvider ?? MockJSONServer} + i18nProvider={i18nProvider} + routerProvider={routerProvider ?? mockRouterBindings()} + authProvider={authProvider} + notificationProvider={notificationProvider} + resources={resources ?? [{ name: "posts", list: "/posts" }]} + accessControlProvider={accessControlProvider} + options={{ + ...options, + disableTelemetry: true, + reactQuery: { + clientConfig: { + defaultOptions: { + queries: { + cacheTime: 0, + staleTime: 0, + networkMode: "always", + }, + }, + }, + }, + }} + > + {children} + </Refine> + </BrowserRouter> + ); + }; +}; + +export { + MockJSONServer, + MockAccessControlProvider, + MockLiveProvider, +} from "./dataMocks"; + +// re-export everything +export * from "@testing-library/react"; diff --git a/packages/nextjs-router/src/test/jest.setup.ts b/packages/nextjs-router/src/test/jest.setup.ts new file mode 100644 index 000000000000..fe867e3c7b37 --- /dev/null +++ b/packages/nextjs-router/src/test/jest.setup.ts @@ -0,0 +1,25 @@ +import "@testing-library/jest-dom"; +import "@testing-library/jest-dom/extend-expect"; +import { configure } from "@testing-library/dom"; + +jest.retryTimes(3, { logErrorsBeforeRetry: true }); + +configure({ + asyncUtilTimeout: 10000, +}); + +/** Antd mocks */ +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +window.scroll = jest.fn(); +window.alert = jest.fn(); + +jest.setTimeout(20000); diff --git a/packages/react-router-v6/jest.config.js b/packages/react-router-v6/jest.config.js index 9c3b8ff7d280..3c240e29a536 100644 --- a/packages/react-router-v6/jest.config.js +++ b/packages/react-router-v6/jest.config.js @@ -3,4 +3,10 @@ module.exports = { rootDir: "./", displayName: "react-router-v6", testEnvironment: "jsdom", + setupFilesAfterEnv: ["<rootDir>/src/test/jest.setup.ts"], + testPathIgnorePatterns: [ + "<rootDir>/node_modules/", + "<rootDir>/example/", + "<rootDir>/dist/", + ], }; diff --git a/packages/react-router-v6/src/document-title-handler.test.tsx b/packages/react-router-v6/src/document-title-handler.test.tsx new file mode 100644 index 000000000000..947c7847341b --- /dev/null +++ b/packages/react-router-v6/src/document-title-handler.test.tsx @@ -0,0 +1,202 @@ +import React, { type ReactNode } from "react"; +import { Route, Routes } from "react-router-dom"; + +import { DocumentTitleHandler } from "./document-title-handler"; +import { render, TestWrapper, type ITestWrapperProps } from "./test/index"; +import { mockRouterBindings } from "./test/dataMocks"; + +const renderDocumentTitleHandler = ( + children: ReactNode, + wrapperProps: ITestWrapperProps = {}, +) => { + return render( + <> + <Routes> + <Route path="/:resource" element={<h1>Dummy resource page</h1>} /> + <Route + path="/:resource/:action/:id" + element={<h1>Dummy resource action page</h1>} + /> + <Route path="*" element={<h1>Refine</h1>} /> + </Routes> + {children} + </>, + { + wrapper: TestWrapper(wrapperProps), + }, + ); +}; + +describe("<DocumentTitleHandler />", () => { + it("should render default list title", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts", list: "/posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts" }, + }), + }); + + expect(document.title).toBe("Posts | Refine"); + }); + + it("should render default create title", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts", create: "/posts/create" }], + routerInitialEntries: ["/posts/create"], + routerProvider: mockRouterBindings({ + action: "create", + resource: { name: "posts", create: "/posts/create" }, + }), + }); + + expect(document.title).toBe("Create new Post | Refine"); + }); + + it("should render default edit title", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts", edit: "/posts/edit/:id" }], + routerInitialEntries: ["/posts/edit/1"], + routerProvider: mockRouterBindings({ + action: "edit", + resource: { name: "posts", edit: "/posts/edit/1" }, + id: "1", + }), + }); + + expect(document.title).toBe("#1 Edit Post | Refine"); + }); + + it("should render default show title", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts", show: "/posts/show/:id" }], + routerInitialEntries: ["/posts/show/1"], + routerProvider: mockRouterBindings({ + action: "show", + resource: { name: "posts", show: "/posts/show/1" }, + id: "1", + }), + }); + + expect(document.title).toBe("#1 Show Post | Refine"); + }); + + it("should render default clone title", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts", clone: "/posts/clone/:id" }], + routerInitialEntries: ["/posts/clone/1"], + routerProvider: mockRouterBindings({ + action: "clone", + resource: { name: "posts", clone: "/posts/clone/1" }, + id: "1", + }), + }); + + expect(document.title).toBe("#1 Clone Post | Refine"); + }); + + it("should render default title for unknown resource", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts" }], + routerInitialEntries: ["/unknown"], + routerProvider: mockRouterBindings({ + action: "list", + resource: undefined, + }), + }); + + expect(document.title).toBe("Refine"); + }); + + it("should render default title for unknown action", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts" }], + routerInitialEntries: ["/posts/unknown"], + routerProvider: mockRouterBindings({ + action: undefined, + resource: { + name: "posts", + }, + }), + }); + + expect(document.title).toBe("Refine"); + }); + + it("should use identifier", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [ + { name: "posts", list: "/posts", identifier: "Awesome Posts" }, + ], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { + name: "posts", + list: "/posts", + identifier: "Awesome Posts", + }, + }), + }); + + expect(document.title).toBe("Awesome posts | Refine"); + }); + + it("should render custom title", async () => { + renderDocumentTitleHandler( + <DocumentTitleHandler + handler={() => { + return "Custom Title"; + }} + />, + { + resources: [{ name: "posts", list: "/posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts" }, + }), + }, + ); + + expect(document.title).toBe("Custom Title"); + }); + + it("should label be populated with friendly resource name on handler function", async () => { + renderDocumentTitleHandler( + <DocumentTitleHandler + handler={({ resource, autoGeneratedTitle }) => { + const label = resource?.label; + const labelMeta = resource?.meta?.label; + + expect(labelMeta).toBe(label); + expect(labelMeta).toBe("Posts"); + + return autoGeneratedTitle; + }} + />, + { + resources: [{ name: "posts", list: "/posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts" }, + }), + }, + ); + }); + + it("should use label from resource if its provided", async () => { + renderDocumentTitleHandler(<DocumentTitleHandler />, { + resources: [{ name: "posts", list: "/posts", label: "Labeled Posts" }], + routerInitialEntries: ["/posts"], + routerProvider: mockRouterBindings({ + action: "list", + resource: { name: "posts", list: "/posts", label: "Labeled Posts" }, + }), + }); + + expect(document.title).toBe("Labeled Posts | Refine"); + }); +}); diff --git a/packages/react-router-v6/src/document-title-handler.tsx b/packages/react-router-v6/src/document-title-handler.tsx index bb984fd264a7..202dbbb06e8d 100644 --- a/packages/react-router-v6/src/document-title-handler.tsx +++ b/packages/react-router-v6/src/document-title-handler.tsx @@ -26,10 +26,14 @@ export function DocumentTitleHandler({ handler }: Props) { const getUserFriendlyName = useUserFriendlyName(); const identifier = resource?.identifier ?? resource?.name; + const preferredLabel = resource?.label ?? resource?.meta?.label; const resourceName = - resource?.label ?? - resource?.meta?.label ?? + preferredLabel ?? getUserFriendlyName(identifier, action === "list" ? "plural" : "singular"); + const populatedLabel = translate( + `${resource?.name}.${resource?.name}`, + resourceName, + ); useLayoutEffect(() => { const autoGeneratedTitle = generateDefaultDocumentTitle( @@ -42,7 +46,14 @@ export function DocumentTitleHandler({ handler }: Props) { if (handler) { document.title = handler({ action, - resource, + resource: { + ...(resource! ?? {}), + label: populatedLabel, + meta: { + ...resource?.meta, + label: populatedLabel, + }, + }, params, pathname, autoGeneratedTitle, diff --git a/packages/react-router-v6/src/test/dataMocks.ts b/packages/react-router-v6/src/test/dataMocks.ts new file mode 100644 index 000000000000..c71ee6943eb5 --- /dev/null +++ b/packages/react-router-v6/src/test/dataMocks.ts @@ -0,0 +1,110 @@ +import type { + ParsedParams, + IResourceItem, + Action, + RouterBindings, +} from "@refinedev/core"; + +export const posts = [ + { + id: "1", + title: + "Necessitatibus necessitatibus id et cupiditate provident est qui amet.", + slug: "ut-ad-et", + content: + "Cupiditate labore quaerat cum incidunt vel et consequatur modi illo. Et maxime aut commodi occaecati omnis. Est voluptatem quibusdam aliquam. Esse tenetur omnis eaque. Consequatur necessitatibus illum ipsum aspernatur architecto qui. Ut temporibus qui nobis. Reiciendis est magnam ipsa quasi dolor ipsa error. Et eaque cumque est. Eos et odit corporis delectus aut corrupti tempora velit. Perferendis ratione voluptas corrupti id temporibus nam.", + categoryId: 1, + category: { + id: 1, + }, + status: "active", + userId: 5, + tags: [16, 31, 45], + }, + { + id: "2", + title: "Recusandae consectetur aut atque est.", + slug: "consequatur-molestiae-rerum", + content: + "Quia ut autem. Hic dolorum magni est quisquam. Modi est id et est. Est sapiente velit iure non voluptatem natus enim. Distinctio ipsa repellendus est. Sunt ipsam dignissimos vero error est cumque eaque. Consequatur voluptas suscipit optio incidunt doloremque quia harum harum. Totam voluptatibus aperiam quia. Est omnis deleniti et aut at fugit temporibus debitis modi. Magni aut vel quod magnam.", + category: { + id: 38, + }, + status: "active", + userId: 36, + tags: [16, 30, 46], + }, +]; + +const MockDataProvider = () => { + return { + create: () => Promise.resolve({ data: posts[0] }), + createMany: () => Promise.resolve({ data: posts }), + deleteOne: () => Promise.resolve({ data: posts[0] }), + deleteMany: () => Promise.resolve({ data: [] }), + getList: () => Promise.resolve({ data: posts, total: 2 }), + getMany: () => Promise.resolve({ data: [...posts] }), + getOne: () => Promise.resolve({ data: posts[0] }), + update: () => Promise.resolve({ data: posts[0] }), + updateMany: () => Promise.resolve({ data: [] }), + getApiUrl: () => "https://api.fake-rest.refine.dev", + custom: () => Promise.resolve({ data: [...posts] }), + }; +}; + +export const MockJSONServer = MockDataProvider() as any; + +export const mockRouterBindings = ({ + pathname, + params, + resource, + action, + id, + fns, +}: { + pathname?: string; + params?: ParsedParams; + resource?: IResourceItem; + action?: Action; + id?: string; + fns?: Partial<RouterBindings>; +} = {}): RouterBindings => { + const bindings: RouterBindings = { + go: () => { + return ({ type }) => { + if (type === "path") return ""; + return undefined; + }; + }, + parse: () => { + return () => { + return { + params: { + ...params, + }, + pathname, + resource: resource, + action: action, + id: id || undefined, + }; + }; + }, + back: () => { + return () => undefined; + }, + Link: () => null, + ...fns, + }; + + return bindings; +}; + +export const MockAccessControlProvider: any = { + can: () => Promise.resolve({ can: true }), +}; + +export const MockLiveProvider: any = { + subscribe: () => ({}), + unsubscribe: () => ({}), + publish: () => ({}), +}; diff --git a/packages/react-router-v6/src/test/index.tsx b/packages/react-router-v6/src/test/index.tsx new file mode 100644 index 000000000000..22d16a73be85 --- /dev/null +++ b/packages/react-router-v6/src/test/index.tsx @@ -0,0 +1,96 @@ +import React, { type ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; + +import { + type AccessControlProvider, + type AuthProvider, + type NotificationProvider, + Refine, + type I18nProvider, + type DataProvider, + type IResourceItem, + type RouterBindings, + type IRefineOptions, +} from "@refinedev/core"; + +import { MockJSONServer, mockRouterBindings } from "./dataMocks"; + +export interface ITestWrapperProps { + dataProvider?: DataProvider; + routerProvider?: RouterBindings; + authProvider?: AuthProvider; + resources?: IResourceItem[]; + notificationProvider?: NotificationProvider; + accessControlProvider?: AccessControlProvider; + i18nProvider?: I18nProvider; + routerInitialEntries?: string[]; + options?: IRefineOptions; +} + +export const TestWrapper: ( + props: ITestWrapperProps, +) => React.FC<{ children?: ReactNode }> = ({ + dataProvider, + authProvider, + routerProvider, + resources, + notificationProvider, + accessControlProvider, + routerInitialEntries, + i18nProvider, + options, +}) => { + // Previously, MemoryRouter was used in this wrapper. However, the + // recommendation by react-router developers (see + // https://github.com/remix-run/react-router/discussions/8241#discussioncomment-159686) + // is essentially to use the same router as your actual application. Besides + // that, it's impossible to check for location changes with MemoryRouter if + // needed. + if (routerInitialEntries) { + routerInitialEntries.forEach((route) => { + window.history.replaceState({}, "", route); + }); + } + + return ({ children }): React.ReactElement => { + return ( + <BrowserRouter> + <Refine + dataProvider={dataProvider ?? MockJSONServer} + i18nProvider={i18nProvider} + routerProvider={routerProvider ?? mockRouterBindings()} + authProvider={authProvider} + notificationProvider={notificationProvider} + resources={resources ?? [{ name: "posts", list: "/posts" }]} + accessControlProvider={accessControlProvider} + options={{ + ...options, + disableTelemetry: true, + reactQuery: { + clientConfig: { + defaultOptions: { + queries: { + cacheTime: 0, + staleTime: 0, + networkMode: "always", + }, + }, + }, + }, + }} + > + {children} + </Refine> + </BrowserRouter> + ); + }; +}; + +export { + MockJSONServer, + MockAccessControlProvider, + MockLiveProvider, +} from "./dataMocks"; + +// re-export everything +export * from "@testing-library/react"; diff --git a/packages/react-router-v6/src/test/jest.setup.ts b/packages/react-router-v6/src/test/jest.setup.ts new file mode 100644 index 000000000000..fe867e3c7b37 --- /dev/null +++ b/packages/react-router-v6/src/test/jest.setup.ts @@ -0,0 +1,25 @@ +import "@testing-library/jest-dom"; +import "@testing-library/jest-dom/extend-expect"; +import { configure } from "@testing-library/dom"; + +jest.retryTimes(3, { logErrorsBeforeRetry: true }); + +configure({ + asyncUtilTimeout: 10000, +}); + +/** Antd mocks */ +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + }; +}); + +window.scroll = jest.fn(); +window.alert = jest.fn(); + +jest.setTimeout(20000);