diff --git a/.changeset/chilly-bottles-grab.md b/.changeset/chilly-bottles-grab.md new file mode 100644 index 000000000000..f551fcb2e6da --- /dev/null +++ b/.changeset/chilly-bottles-grab.md @@ -0,0 +1,43 @@ +--- +"@refinedev/core": minor +--- + +feat: add [``](https://refine.dev/docs/routing/components/link/) component to navigate to a resource with a specific action. Under the hood, It uses [`useGo`](https://refine.dev/docs/routing/hooks/use-go/) to generate the URL. + +## Usage + +```tsx +import { Link } from "@refinedev/core"; + +const MyComponent = () => { + return ( + <> + {/* simple usage, navigates to `/posts` */} + Posts + {/* complex usage with more control, navigates to `/posts` with query filters */} + + Posts + + + ); +}; +``` + +[Fixes #6329](https://github.com/refinedev/refine/issues/6329) diff --git a/.changeset/itchy-moose-reflect.md b/.changeset/itchy-moose-reflect.md new file mode 100644 index 000000000000..0003c67fc500 --- /dev/null +++ b/.changeset/itchy-moose-reflect.md @@ -0,0 +1,9 @@ +--- +"@refinedev/core": minor +--- + +chore: From now on, [`useLink`](https://refine.dev/docs/routing/hooks/use-link/) returns [``](https://refine.dev/docs/routing/components/link/) component instead of returning [`routerProvider.Link`](https://refine.dev/docs/routing/router-provider/#link). + +Since the `` component uses `routerProvider.Link` under the hood with leveraging `useGo` hook to generate the URL there is no breaking change. It's recommended to use the `` component from the `@refinedev/core` package instead of `useLink` hook. This hook is used mostly for internal purposes and is only exposed for customization needs. + +[Fixes #6329](https://github.com/refinedev/refine/issues/6329) diff --git a/documentation/docs/routing/components/link/index.md b/documentation/docs/routing/components/link/index.md new file mode 100644 index 000000000000..10ed841dbfc3 --- /dev/null +++ b/documentation/docs/routing/components/link/index.md @@ -0,0 +1,97 @@ +--- +title: +--- + +`` is a component that is used to navigate to different pages in your application. + +It uses [`routerProvider.Link`](/docs/routing/router-provider/#link) under the hood, if [`routerProvider`](/docs/routing/router-provider) is not provided, it will be use [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) HTML element. + +## Usage + +```tsx +import { Link } from "@refinedev/core"; + +const MyComponent = () => { + return ( + <> + {/* simple usage, navigates to `/posts` */} + Posts + {/* complex usage with more control, navigates to `/posts` with query filters */} + + Posts + + + ); +}; +``` + +## Props + +The `` component takes all the props from the [`routerProvider.Link`](/docs/routing/router-provider/#link) and the props that an `` HTML element uses. In addition to these props, it also accepts the `go` +and `to` props to navigate to a specific `resource` defined in the `` component. + +### go + +When `go` prop is provided, this component will use [`useGo`](/docs/routing/hooks/use-go/) to create the URL to navigate to. It's accepts all the props that `useGo.go` accepts. + +It's useful to use this prop when you want to navigate to a resource with a specific action. + +:::caution + +- `routerProvider` is required to use this prop. +- When `to` prop is provided, `go` will be ignored. + +::: + +### to + +The URL to navigate to. + +## Type support with generics + +`` works with any routing library because it uses [`routerProvider.Link`](/docs/routing/router-provider/#link) internally. However, when importing it from `@refinedev/core`, it doesn't provide type support for your specific routing library. To enable full type support, you can use generics. + +```tsx +import type { LinkProps } from "react-router-dom"; +import { Link } from "@refinedev/core"; + +const MyComponent = () => { + return ( + // Omit 'to' prop from LinkProps (required by react-router-dom) since we use the 'go' prop + > + // Props from "react-router-dom" + // highlight-start + replace={true} + unstable_viewTransition={true} + preventScrollReset={true} + // highlight-end + // Props from "@refinedev/core" + go={{ + to: { + resource: "posts", + action: "list", + }, + }} + > + Posts + + ); +}; +``` diff --git a/documentation/docs/routing/hooks/use-link/index.md b/documentation/docs/routing/hooks/use-link/index.md index af8e9f253ef5..ff7b0d678af6 100644 --- a/documentation/docs/routing/hooks/use-link/index.md +++ b/documentation/docs/routing/hooks/use-link/index.md @@ -2,13 +2,11 @@ title: useLink --- -`useLink` is a hook that leverages the `Link` property of the [`routerProvider`][routerprovider] to create links compatible with the user's router library. +`useLink` is a hook that returns [``](/docs/routing/components/link/) component. It is used to navigate to different pages in your application. :::simple Good to know -It's recommended to use the `Link` component from your router library instead of this hook. This hook is used mostly for internal purposes and is only exposed for customization needs. - -The `Link` components or the equivalents from the router libraries has better type support and lets you use the full power of the router library. +- It's recommended to use the `` component from the `@refinedev/core` package instead of this hook. This hook is used mostly for internal purposes and is only exposed for customization needs. ::: @@ -20,14 +18,21 @@ import { useLink } from "@refinedev/core"; const MyComponent = () => { const Link = useLink(); - return Posts; + return ( + <> + Posts + {/* or */} + + Posts + + + ); }; ``` - -## Parameters - -### to - -This is the path that the link will navigate to. It should be a string. - -[routerprovider]: /docs/routing/router-provider diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 61f1ed177791..bcfc6fc1adeb 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -222,6 +222,12 @@ module.exports = { "routing/integrations/remix/index", ], }, + { + type: "category", + collapsed: false, + label: "Components", + items: ["routing/components/link/index"], + }, { type: "category", collapsed: false, diff --git a/packages/cli/src/commands/add/sub-commands/resource/index.test.ts b/packages/cli/src/commands/add/sub-commands/resource/index.test.ts index cc91d7aabf35..4ae48aafc5de 100644 --- a/packages/cli/src/commands/add/sub-commands/resource/index.test.ts +++ b/packages/cli/src/commands/add/sub-commands/resource/index.test.ts @@ -7,7 +7,7 @@ const srcDirPath = `${__dirname}/../../../..`; describe("add", () => { beforeAll(() => { - // usefull for speed up the tests. + // useful for speed up the tests. jest.spyOn(console, "log").mockImplementation(); jest.spyOn(testTargetModule, "installInferencer").mockImplementation(); diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index faa4f1688dcc..b1791650414b 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -8,3 +8,4 @@ export { RouteChangeHandler } from "./routeChangeHandler"; export { CanAccess, CanAccessProps } from "./canAccess"; export { GitHubBanner } from "./gh-banner"; export { AutoSaveIndicator, AutoSaveIndicatorProps } from "./autoSaveIndicator"; +export { Link, LinkProps } from "./link"; diff --git a/packages/core/src/components/link/index.spec.tsx b/packages/core/src/components/link/index.spec.tsx new file mode 100644 index 000000000000..5084e20523c7 --- /dev/null +++ b/packages/core/src/components/link/index.spec.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { TestWrapper, render } from "@test/index"; +import { Link } from "./index"; + +describe("Link", () => { + describe("with `to`", () => { + it("should render a tag without router provider", () => { + const { getByText } = render(Test); + + const link = getByText("Test"); + expect(link.tagName).toBe("A"); + expect(link.getAttribute("href")).toBe("/test"); + }); + + it("should render a tag with router provider", () => { + const { getByTestId } = render( + foo="bar" to="/test" aria-label="test-label"> + Test + , + { + wrapper: TestWrapper({ + routerProvider: { + Link: ({ to, children, ...props }) => ( + + {children} + + ), + }, + }), + }, + ); + + const link = getByTestId("test-link"); + expect(link.tagName).toBe("A"); + expect(link.getAttribute("href")).toBe("/test"); + expect(link.getAttribute("aria-label")).toBe("test-label"); + expect(link.getAttribute("foo")).toBe("bar"); + }); + }); + + describe("with `go`", () => { + it("should render a tag go.to as object", () => { + const { getByTestId } = render( + + Test + , + { + wrapper: TestWrapper({ + resources: [{ name: "test", show: "/test/:id" }], + routerProvider: { + go: () => () => { + return "/test/1"; + }, + Link: ({ to, children, ...props }) => ( + + {children} + + ), + }, + }), + }, + ); + + const link = getByTestId("test-link"); + expect(link.tagName).toBe("A"); + expect(link.getAttribute("href")).toBe("/test/1"); + expect(link.getAttribute("aria-label")).toBe("test-label"); + }); + + it("should render a tag go.to as string", () => { + const { getByTestId } = render( + + Test + , + { + wrapper: TestWrapper({ + routerProvider: { + go: () => () => { + return "/test/1"; + }, + Link: ({ to, children, ...props }) => ( + + {children} + + ), + }, + }), + }, + ); + + const link = getByTestId("test-link"); + expect(link.tagName).toBe("A"); + expect(link.getAttribute("href")).toBe("/test/1"); + expect(link.getAttribute("aria-label")).toBe("test-label"); + }); + }); +}); diff --git a/packages/core/src/components/link/index.tsx b/packages/core/src/components/link/index.tsx new file mode 100644 index 000000000000..5b4f922501f7 --- /dev/null +++ b/packages/core/src/components/link/index.tsx @@ -0,0 +1,74 @@ +import React, { type Ref, forwardRef, useContext } from "react"; +import { useGo } from "@hooks/router"; +import { RouterContext } from "@contexts/router"; +import type { GoConfigWithResource } from "../../hooks/router/use-go"; +import warnOnce from "warn-once"; + +type LinkPropsWithGo = { + go: Omit; +}; + +type LinkPropsWithTo = { + to: string; +}; + +export type LinkProps = React.PropsWithChildren< + (LinkPropsWithGo | LinkPropsWithTo) & + React.AnchorHTMLAttributes & + TProps +>; + +/** + * @param to The path to navigate to. + * @param go The useGo.go params to navigate to. If `to` provided, this will be ignored. + * @returns routerProvider.Link if it is provided, otherwise an anchor tag. + */ +const LinkComponent = ( + props: LinkProps, + ref: Ref, +) => { + const routerContext = useContext(RouterContext); + const LinkFromContext = routerContext?.Link; + + const goFunction = useGo(); + + let resolvedTo = ""; + if ("to" in props) { + resolvedTo = props.to; + } + if ("go" in props) { + if (!routerContext?.go) { + warnOnce( + true, + "[Link]: `routerProvider` is not found. To use `go`, Please make sure that you have provided the `routerProvider` for `` https://refine.dev/docs/routing/router-provider/ \n", + ); + } + resolvedTo = goFunction({ ...props.go, type: "path" }) as string; + } + + if (LinkFromContext) { + return ( + + ); + } + return ( + + ); +}; + +export const Link = forwardRef(LinkComponent) as ( + props: LinkProps & { ref?: Ref }, +) => ReturnType; diff --git a/packages/core/src/hooks/router/use-link/index.spec.tsx b/packages/core/src/hooks/router/use-link/index.spec.tsx index 022e036310e6..e3aaa1be935a 100644 --- a/packages/core/src/hooks/router/use-link/index.spec.tsx +++ b/packages/core/src/hooks/router/use-link/index.spec.tsx @@ -1,63 +1,32 @@ import React from "react"; -import { render, renderHook } from "@testing-library/react"; - -import { MockJSONServer, TestWrapper, mockRouterProvider } from "@test"; - +import { + MockJSONServer, + TestWrapper, + mockRouterProvider, + renderHook, + render, +} from "@test"; import { useLink } from "./"; +import "../../../components/link"; -describe("useLink Hook", () => { - it("should return routerProvider Link compotent", () => { - const mockLink = jest.fn(); - - const { result } = renderHook(() => useLink(), { - wrapper: TestWrapper({ - resources: [{ name: "posts" }], - dataProvider: MockJSONServer, - routerProvider: mockRouterProvider({ - fns: { - Link: mockLink, - }, - }), - }), - }); - - expect(result.current).toEqual(mockLink); - }); +jest.mock("../../../components/link", () => ({ + Link: jest.fn().mockReturnValue(
), +})); - it("if routerProvider go function is not defined, should return undefined", () => { +describe("useLink Hook", () => { + it("should return Link component", () => { const { result } = renderHook(() => useLink(), { wrapper: TestWrapper({ resources: [{ name: "posts" }], dataProvider: MockJSONServer, - routerProvider: mockRouterProvider({ - fns: { - Link: undefined, - }, - }), + routerProvider: mockRouterProvider(), }), }); - const Link = result.current; - - const { container } = render(); - - expect(container.querySelector("a")?.getAttribute("href")).toEqual( - "/posts", - ); - }); - - it("if it is used outside of router provider, should return undefined", () => { - jest.spyOn(React, "useContext").mockReturnValue(undefined); - - const { result } = renderHook(() => useLink()); - - const Link = result.current; - - const { container } = render(); + const Component = result.current; - expect(container.querySelector("a")?.getAttribute("href")).toEqual( - "/posts", - ); + const { getByTestId } = render(); + expect(getByTestId("mocked-link")).toBeInTheDocument(); }); }); diff --git a/packages/core/src/hooks/router/use-link/index.tsx b/packages/core/src/hooks/router/use-link/index.tsx index e1e1b58cc050..5615442a9033 100644 --- a/packages/core/src/hooks/router/use-link/index.tsx +++ b/packages/core/src/hooks/router/use-link/index.tsx @@ -1,17 +1,5 @@ -import { RouterContext } from "@contexts/router"; -import React, { useContext } from "react"; +import { Link } from "../../../components/link"; export const useLink = () => { - const routerContext = useContext(RouterContext); - - if (routerContext?.Link) { - return routerContext.Link; - } - - const FallbackLink: Required["Link"] = ({ - to, - ...rest - }) => ; - - return FallbackLink; + return Link; };