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,2 @@
export * from "./useCan";
export * from "./useCanWithoutCache";

View File

@@ -0,0 +1,335 @@
import { renderHook, waitFor } from "@testing-library/react";
import { TestWrapper, queryClient } from "@test";
import { useCan } from ".";
import { useCanWithoutCache } from "..";
describe("useCan Hook", () => {
it("can should return the true ", async () => {
const { result } = renderHook(
() =>
useCan({
action: "list",
resource: "posts",
params: { id: 1 },
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: ({ resource, action, params }) => {
if (
action === "list" &&
resource === "posts" &&
params?.id === 1
) {
return Promise.resolve({
can: true,
reason: "Access granted",
});
}
return Promise.resolve({ can: false });
},
},
}),
},
);
await waitFor(() => {
expect(result.current?.isFetched).toBeTruthy();
});
expect(result.current?.data?.can).toBeTruthy();
expect(result.current?.data?.reason).toBe("Access granted");
});
it("can should return the false ", async () => {
const { result } = renderHook(
() =>
useCan({
action: "list",
resource: "posts",
params: { id: 10 },
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: ({ resource, action, params }) => {
if (
action === "list" &&
resource === "posts" &&
params?.id === 1
) {
return Promise.resolve({
can: true,
reason: "Access granted",
});
}
return Promise.resolve({
can: false,
reason: "Access Denied",
});
},
},
}),
},
);
await waitFor(() => {
expect(result.current?.isFetched).toBeTruthy();
});
expect(result.current?.data?.can).toBeFalsy();
expect(result.current?.data?.reason).toBe("Access Denied");
});
it("can should return the true if can is undefined ", async () => {
const { result } = renderHook(
() =>
useCan({
action: "list",
resource: "posts",
params: { id: 1 },
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: undefined,
},
}),
},
);
expect(result.current).toEqual({ data: { can: true } });
});
it("can should sanitize resource icon ", async () => {
const mockFn = jest.fn();
renderHook(
() =>
useCan({
action: "list",
resource: "posts",
params: {
id: 1,
resource: { icon: "test", name: "posts" } as any,
},
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: mockFn,
},
}),
},
);
expect(mockFn).toBeCalledWith({
action: "list",
params: {
id: 1,
resource: { name: "posts" },
},
resource: "posts",
});
});
it("should be disable by queryOptions", async () => {
const mockFn = jest.fn();
renderHook(
() =>
useCan({
action: "list",
resource: "posts",
params: { id: 1 },
queryOptions: {
enabled: false,
},
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: mockFn,
},
}),
},
);
expect(mockFn).not.toBeCalled();
});
it("should not throw error when accessControlProvider is undefined", async () => {
const { result } = renderHook(
() =>
useCan({
action: "list",
resource: "posts",
}),
{
wrapper: TestWrapper({}),
},
);
await waitFor(() => {
expect(result.current.data).toEqual({ can: true });
});
});
it("should override `queryKey` with `queryOptions.queryKey`", async () => {
const canMock = jest.fn().mockResolvedValue({
can: true,
reason: "Access granted",
});
const { result } = renderHook(
() =>
useCan({
action: "list",
resource: "posts",
queryOptions: {
queryKey: ["foo", "bar"],
},
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: canMock,
},
}),
},
);
await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy();
});
expect(
queryClient.getQueryCache().findAll({
queryKey: ["foo", "bar"],
}),
).toHaveLength(1);
});
it("should override `queryFn` with `queryOptions.queryFn`", async () => {
const canMock = jest.fn().mockResolvedValue({
can: true,
reason: "Access granted",
});
const queryFnMock = jest.fn().mockResolvedValue({
can: true,
reason: "Access granted",
});
const { result } = renderHook(
() =>
useCan({
action: "list",
resource: "posts",
queryOptions: {
queryFn: queryFnMock,
},
}),
{
wrapper: TestWrapper({
accessControlProvider: {
can: canMock,
},
}),
},
);
await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy();
});
expect(canMock).not.toBeCalled();
expect(queryFnMock).toBeCalled();
});
it("should use global queryOptions from AccessControlContext", async () => {
const mockFn = jest
.fn()
.mockResolvedValue({ can: true, reason: "Access granted" });
const globalQueryOptions = { enabled: false };
const { result } = renderHook(
() => useCan({ action: "list", resource: "posts" }),
{
wrapper: TestWrapper({
accessControlProvider: {
can: mockFn,
options: { queryOptions: globalQueryOptions },
},
}),
},
);
await waitFor(() => {
expect(result.current.isFetched).toBeFalsy();
});
expect(mockFn).not.toBeCalled();
});
});
describe("useCanWithoutCache", () => {
it("should return the can function from the AccessControlContext", () => {
const canMock = jest.fn();
const { result } = renderHook(() => useCanWithoutCache(), {
wrapper: TestWrapper({
accessControlProvider: {
can: canMock,
},
}),
});
result.current.can?.({
action: "list",
resource: "posts",
});
expect(canMock).toBeCalledWith({
action: "list",
resource: "posts",
});
});
it("should sanitize the `resource` if provided", () => {
const canMock = jest.fn();
const { result } = renderHook(() => useCanWithoutCache(), {
wrapper: TestWrapper({
accessControlProvider: {
can: canMock,
},
}),
});
result.current.can?.({
action: "list",
resource: "posts",
params: {
id: 1,
resource: {
name: "posts",
meta: { icon: "test" },
options: { icon: "test" },
icon: "test",
} as any,
},
});
expect(canMock).toBeCalledWith({
action: "list",
resource: "posts",
params: {
id: 1,
resource: {
name: "posts",
meta: {},
options: {},
},
},
});
});
});

View File

@@ -0,0 +1,89 @@
import { useContext } from "react";
import { getXRay } from "@refinedev/devtools-internal";
import {
type UseQueryOptions,
type UseQueryResult,
useQuery,
} from "@tanstack/react-query";
import { AccessControlContext } from "@contexts/accessControl";
import { sanitizeResource } from "@definitions/helpers/sanitize-resource";
import { useKeys } from "@hooks/useKeys";
import type {
CanParams,
CanReturnType,
} from "../../../contexts/accessControl/types";
export type UseCanProps = CanParams & {
/**
* react-query's [useQuery](https://tanstack.com/query/v4/docs/reference/useQuery) options
*/
queryOptions?: UseQueryOptions<CanReturnType>;
};
/**
* `useCan` uses the `can` as the query function for `react-query`'s {@link https://react-query.tanstack.com/guides/queries `useQuery`}. It takes the parameters that `can` takes. It can also be configured with `queryOptions` for `useQuery`. Returns the result of `useQuery`.
* @see {@link https://refine.dev/docs/api-reference/core/hooks/accessControl/useCan} for more details.
*
* @typeParam CanParams {@link https://refine.dev/docs/core/interfaceReferences#canparams}
* @typeParam CanReturnType {@link https://refine.dev/docs/core/interfaceReferences#canreturntype}
*
*/
export const useCan = ({
action,
resource,
params,
queryOptions: hookQueryOptions,
}: UseCanProps): UseQueryResult<CanReturnType> => {
const { can, options: globalOptions } = useContext(AccessControlContext);
const { keys, preferLegacyKeys } = useKeys();
const { queryOptions: globalQueryOptions } = globalOptions || {};
const mergedQueryOptions = {
...globalQueryOptions,
...hookQueryOptions,
};
/**
* Since `react-query` stringifies the query keys, it will throw an error for a circular dependency if we include `React.ReactNode` elements inside the keys.
* The feature in #2220(https://github.com/refinedev/refine/issues/2220) includes such change and to fix this, we need to remove `icon` property in the `resource`
*/
const { resource: _resource, ...paramsRest } = params ?? {};
const sanitizedResource = sanitizeResource(_resource);
const queryResponse = useQuery<CanReturnType>({
queryKey: keys()
.access()
.resource(resource)
.action(action)
.params({
params: { ...paramsRest, resource: sanitizedResource },
enabled: mergedQueryOptions?.enabled,
})
.get(preferLegacyKeys),
// Enabled check for `can` is enough to be sure that it's defined in the query function but TS is not smart enough to know that.
queryFn: () =>
can?.({
action,
resource,
params: { ...paramsRest, resource: sanitizedResource },
}) ?? Promise.resolve({ can: true }),
enabled: typeof can !== "undefined",
...mergedQueryOptions,
meta: {
...mergedQueryOptions?.meta,
...getXRay("useCan", preferLegacyKeys, resource, [
"useButtonCanAccess",
"useNavigationButton",
]),
},
retry: false,
});
return typeof can === "undefined"
? ({ data: { can: true } } as typeof queryResponse)
: queryResponse;
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { AccessControlContext } from "@contexts/accessControl";
import { sanitizeResource } from "@definitions/helpers/sanitize-resource";
import type { IAccessControlContext } from "../../contexts/accessControl/types";
export const useCanWithoutCache = (): IAccessControlContext => {
const { can: canFromContext } = React.useContext(AccessControlContext);
const can = React.useMemo(() => {
if (!canFromContext) {
return undefined;
}
const canWithSanitizedResource: NonNullable<typeof canFromContext> =
async ({ params, ...rest }) => {
const sanitizedResource = params?.resource
? sanitizeResource(params.resource)
: undefined;
return canFromContext({
...rest,
...(params
? {
params: {
...params,
resource: sanitizedResource,
},
}
: {}),
});
};
return canWithSanitizedResource;
}, [canFromContext]);
return { can };
};