From 38eb4f5083ce55d158278e9ee802a5990f4bb261 Mon Sep 17 00:00:00 2001 From: kypham Date: Wed, 11 Jun 2025 16:42:30 +0700 Subject: [PATCH] Replace redux in setting project pages - remove modules/project - add query for get project - add mutation for user group - add mutation for rbac role - add mutation for github sso - update testcase, dummy Signed-off-by: kypham --- web/src/__fixtures__/dummy-project.ts | 19 + .../components/settings-page/piped/index.tsx | 9 +- .../components/github-sso-form/index.test.tsx | 23 + .../components/github-sso-form/index.tsx | 125 ++--- .../index.test.tsx | 10 + .../components/add-role-dialog/index.test.tsx | 57 +++ .../components/add-role-dialog/index.tsx | 17 +- .../add-user-group-dialog/index.test.tsx | 84 ++++ .../add-user-group-dialog/index.tsx | 5 +- .../delete-role-confirm-dialog/index.test.tsx | 46 ++ .../delete-role-confirm-dialog/index.tsx | 12 +- .../index.test.tsx | 43 ++ .../edit-role-dialog/index.test.tsx | 56 +++ .../components/edit-role-dialog/index.tsx | 37 +- .../components/role-table-row/index.tsx | 26 +- .../rbac-form/components/role/index.tsx | 98 ++-- .../rbac-form/components/user-group/index.tsx | 54 +-- .../components/rbac-form/index.test.tsx | 24 + .../static-admin-form/index.test.tsx | 50 +- .../components/static-admin-form/index.tsx | 47 +- .../settings-page/project/index.tsx | 10 +- web/src/constants/project.ts | 79 ++++ web/src/modules/index.ts | 2 - web/src/modules/project/index.test.ts | 433 ------------------ web/src/modules/project/index.ts | 353 -------------- .../project/use-add-project-rbac-role.tsx | 23 + .../queries/project/use-add-user-group.tsx | 22 + .../project/use-delete-project-rbac-role.tsx | 22 + .../queries/project/use-delete-user-group.tsx | 22 + web/src/queries/project/use-get-project.tsx | 76 +++ .../use-toggle-availability-static-admin.tsx | 26 ++ .../queries/project/use-update-github-sso.tsx | 23 + .../project/use-update-project-rbac-role.tsx | 23 + .../project/use-update-static-admin.tsx | 22 + web/src/utils/formalize-policies-list.ts | 46 ++ web/src/utils/parse-rbac-policies.ts | 66 +++ web/src/utils/rbac-action-types.ts | 9 + web/src/utils/rbac-resource-types.ts | 9 + web/test-utils/index.tsx | 9 +- 39 files changed, 1040 insertions(+), 1077 deletions(-) create mode 100644 web/src/components/settings-page/project/components/github-sso-form/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/project-setting-labeled-text/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/rbac-form/components/delete-user-group-confirm-dialog/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/rbac-form/components/edit-role-dialog/index.test.tsx create mode 100644 web/src/components/settings-page/project/components/rbac-form/index.test.tsx create mode 100644 web/src/constants/project.ts delete mode 100644 web/src/modules/project/index.test.ts delete mode 100644 web/src/modules/project/index.ts create mode 100644 web/src/queries/project/use-add-project-rbac-role.tsx create mode 100644 web/src/queries/project/use-add-user-group.tsx create mode 100644 web/src/queries/project/use-delete-project-rbac-role.tsx create mode 100644 web/src/queries/project/use-delete-user-group.tsx create mode 100644 web/src/queries/project/use-get-project.tsx create mode 100644 web/src/queries/project/use-toggle-availability-static-admin.tsx create mode 100644 web/src/queries/project/use-update-github-sso.tsx create mode 100644 web/src/queries/project/use-update-project-rbac-role.tsx create mode 100644 web/src/queries/project/use-update-static-admin.tsx create mode 100644 web/src/utils/formalize-policies-list.ts create mode 100644 web/src/utils/parse-rbac-policies.ts create mode 100644 web/src/utils/rbac-action-types.ts create mode 100644 web/src/utils/rbac-resource-types.ts diff --git a/web/src/__fixtures__/dummy-project.ts b/web/src/__fixtures__/dummy-project.ts index d477c6b61e..6b34d70ba2 100644 --- a/web/src/__fixtures__/dummy-project.ts +++ b/web/src/__fixtures__/dummy-project.ts @@ -1,6 +1,9 @@ import { Project, ProjectRBACConfig, + ProjectRBACPolicy, + ProjectRBACResource, + ProjectRBACRole, ProjectStaticUser, } from "pipecd/web/model/project_pb"; import { @@ -12,6 +15,22 @@ import { const [createdAt, updatedAt] = createRandTimes(2); +export const dummyRole: ProjectRBACRole.AsObject = { + name: "dummy-role", + policiesList: [ + { + resourcesList: [ + { + labelsMap: [["pipecd.dev/project", "dummy-project"]], + type: ProjectRBACResource.ResourceType.ALL, + }, + ], + actionsList: [ProjectRBACPolicy.Action.ALL], + }, + ], + isBuiltin: false, +}; + export const dummyProject: Project.AsObject = { id: randomUUID(), desc: randomWords(8), diff --git a/web/src/components/settings-page/piped/index.tsx b/web/src/components/settings-page/piped/index.tsx index db704e4e0f..512b88de26 100644 --- a/web/src/components/settings-page/piped/index.tsx +++ b/web/src/components/settings-page/piped/index.tsx @@ -54,6 +54,7 @@ import { FilterValues, PipedFilter } from "./components/piped-filter"; import { PipedTableRow } from "./components/piped-table-row"; import { UpgradePipedDialog } from "./components/upgrade-dialog"; import { TableCellNoWrap } from "../styles"; +import { useGetProject } from "~/queries/project/use-get-project"; const filterValue = ( _: AppState, @@ -87,7 +88,7 @@ export const SettingsPipedPage: FC = memo(function SettingsPipedPage() { enabled: true, }); const dispatch = useAppDispatch(); - const projectId = useAppSelector((state) => state.project.id); + const { data: projectDetail } = useGetProject(); const pipeds = useAppSelector((state) => selectFilteredPipeds(state, filterValues) ); @@ -97,10 +98,10 @@ export const SettingsPipedPage: FC = memo(function SettingsPipedPage() { }, [dispatch]); useEffect(() => { - if (projectId) { - dispatch(fetchBreakingChanges({ projectId: projectId })); + if (projectDetail?.id) { + dispatch(fetchBreakingChanges({ projectId: projectDetail.id })); } - }, [dispatch, projectId]); + }, [dispatch, projectDetail?.id]); const releasedVersions = useAppSelector( (state) => state.pipeds.releasedVersions diff --git a/web/src/components/settings-page/project/components/github-sso-form/index.test.tsx b/web/src/components/settings-page/project/components/github-sso-form/index.test.tsx new file mode 100644 index 0000000000..d15da1651e --- /dev/null +++ b/web/src/components/settings-page/project/components/github-sso-form/index.test.tsx @@ -0,0 +1,23 @@ +import { screen, render } from "~~/test-utils"; +import { GithubSSOForm } from "./index"; +import { server } from "~/mocks/server"; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("GithubSSOForm", () => { + it("renders without crashing", () => { + render(); + // Check for a heading, label, or unique element in the form + // Adjust the text below to match your component's actual content + expect( + screen.getByRole("heading", { name: /Single Sign-On/i }) + ).toBeInTheDocument(); + expect( + screen.getByText( + "Single sign-on (SSO) allows users to log in to PipeCD by relying on a trusted third party service. Currently, only GitHub is supported." + ) + ).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/settings-page/project/components/github-sso-form/index.tsx b/web/src/components/settings-page/project/components/github-sso-form/index.tsx index 7901e09235..ef9ceada67 100644 --- a/web/src/components/settings-page/project/components/github-sso-form/index.tsx +++ b/web/src/components/settings-page/project/components/github-sso-form/index.tsx @@ -1,4 +1,5 @@ import { + Box, Button, Dialog, DialogActions, @@ -16,9 +17,6 @@ import { FC, memo, useState } from "react"; import { SSO_DESCRIPTION } from "~/constants/text"; import { UPDATE_SSO_SUCCESS } from "~/constants/toast-text"; import { UI_TEXT_CANCEL, UI_TEXT_SAVE } from "~/constants/ui-text"; -import { useAppDispatch, useAppSelector } from "~/hooks/redux"; -import { fetchProject, GitHubSSO, updateGitHubSSO } from "~/modules/project"; -import { addToast } from "~/modules/toasts"; import { ProjectDescription, ProjectTitleWrap, @@ -27,6 +25,9 @@ import { ProjectValuesWrapper, } from "~/styles/project-setting"; import { ProjectSettingLabeledText } from "../project-setting-labeled-text"; +import { useUpdateGithubSso } from "~/queries/project/use-update-github-sso"; +import { useToast } from "~/contexts/toast-context"; +import { useGetProject } from "~/queries/project/use-get-project"; export interface GitHubSSOFormParams { clientId: string; clientSecret: string; @@ -42,34 +43,28 @@ const SECTION_TITLE = "Single Sign-On"; const DIALOG_TITLE = `Edit ${SECTION_TITLE}`; export const GithubSSOForm: FC = memo(function GithubSSOForm() { - const dispatch = useAppDispatch(); const [isEdit, setIsEdit] = useState(false); const [clientId, setClientID] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [baseUrl, setBaseUrl] = useState(""); const [uploadUrl, setUploadUrl] = useState(""); - const [sso, sharedSSO] = useAppSelector< - [GitHubSSO | null | undefined, string | null | undefined] - >((state) => [state.project.github, state.project.sharedSSO]); + const { data: projectDetail } = useGetProject(); + const { github: sso, sharedSSO: sharedSSO } = projectDetail || {}; + + const { mutateAsync: updateGithubSso } = useUpdateGithubSso(); + const { addToast } = useToast(); const handleClose = (): void => { setIsEdit(false); }; const handleSave = (e: React.FormEvent): void => { e.preventDefault(); - dispatch( - updateGitHubSSO({ clientId, clientSecret, baseUrl, uploadUrl }) - ).then((result) => { - if (updateGitHubSSO.fulfilled.match(result)) { - dispatch(fetchProject()); - dispatch( - addToast({ - message: UPDATE_SSO_SUCCESS, - severity: "success", - }) - ); - } + updateGithubSso({ clientId, clientSecret, baseUrl, uploadUrl }).then(() => { + addToast({ + message: UPDATE_SSO_SUCCESS, + severity: "success", + }); }); setIsEdit(false); }; @@ -128,51 +123,65 @@ export const GithubSSOForm: FC = memo(function GithubSSOForm() { { - setBaseUrl(sso?.baseUrl ?? ""); - setUploadUrl(sso?.uploadUrl ?? ""); + slotProps={{ + transition: { + onEnter: () => { + setBaseUrl(sso?.baseUrl ?? ""); + setUploadUrl(sso?.uploadUrl ?? ""); + }, }, }} >
{DIALOG_TITLE} - setClientID(e.currentTarget.value)} - /> - setClientSecret(e.currentTarget.value)} - /> - setBaseUrl(e.currentTarget.value)} - /> - setUploadUrl(e.currentTarget.value)} - /> + + setClientID(e.currentTarget.value)} + /> + setClientSecret(e.currentTarget.value)} + /> + setBaseUrl(e.currentTarget.value)} + /> + setUploadUrl(e.currentTarget.value)} + /> + diff --git a/web/src/components/settings-page/project/components/project-setting-labeled-text/index.test.tsx b/web/src/components/settings-page/project/components/project-setting-labeled-text/index.test.tsx new file mode 100644 index 0000000000..940753798f --- /dev/null +++ b/web/src/components/settings-page/project/components/project-setting-labeled-text/index.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { ProjectSettingLabeledText } from "./index"; + +describe("ProjectSettingLabeledText", () => { + it("renders the label and value", () => { + render(); + expect(screen.getByText("Client ID")).toBeInTheDocument(); + expect(screen.getByText("123456")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.test.tsx b/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.test.tsx new file mode 100644 index 0000000000..de3d648453 --- /dev/null +++ b/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, waitFor } from "~~/test-utils"; +import userEvent from "@testing-library/user-event"; +import { AddRoleDialog } from "."; + +describe("AddRoleDialog", () => { + const defaultProps = { + open: true, + onClose: jest.fn(), + onSubmit: jest.fn(), + }; + + it("renders dialog title and fields", async () => { + render(); + await waitFor(() => { + screen.findByRole("dialog"); + }); + expect(screen.getByText("Add Role")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /role/i })).toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /policies/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); + }); + + it("calls onClose when Close button is clicked", async () => { + render(); + await waitFor(() => { + screen.findByRole("dialog"); + }); + userEvent.click(screen.getByRole("button", { name: /close/i })); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it("calls onSubmit with correct values", async () => { + render(); + + const textInput = screen.getByRole("textbox", { name: /role/i }); + const policiesInput = screen.getByRole("textbox", { name: /policies/i }); + + userEvent.type(textInput, "dev-team"); + userEvent.type(policiesInput, "resources=application;actions=get"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /add/i })).toBeEnabled(); + }); + + userEvent.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalledWith({ + name: "dev-team", + policies: "resources=application;actions=get", + }); + }); + }); +}); diff --git a/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.tsx b/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.tsx index d1af91a29f..04c8ff4818 100644 --- a/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.tsx +++ b/web/src/components/settings-page/project/components/rbac-form/components/add-role-dialog/index.tsx @@ -8,8 +8,8 @@ import { } from "@mui/material"; import { useFormik } from "formik"; import { FC } from "react"; -import { rbacResourceTypes, rbacActionTypes } from "~/modules/project"; import * as yup from "yup"; +import { POLICIES_STRING_REGEX } from "~/constants/project"; export interface AddRoleDialogProps { open: boolean; @@ -17,24 +17,11 @@ export interface AddRoleDialogProps { onSubmit: (values: { name: string; policies: string }) => void; } -// resources=(\*|application|deployment|event|piped|deploymentChain|project|apiKey|insight|,)+;\s*actions=(\*|get|list|create|update|delete|,)+ -const validationRgex = new RegExp( - "resources=(" + - rbacResourceTypes() - .map((v) => v.replace(/\*/, "\\*")) - .join("|") + - "|,)+;\\s*actions=(" + - rbacActionTypes() - .map((v) => v.replace(/\*/, "\\*")) - .join("|") + - "|,)+" -); - const validationSchema = yup.object({ name: yup.string().min(1).required(), policies: yup .string() - .matches(validationRgex, "Invalid policy format") + .matches(POLICIES_STRING_REGEX, "Invalid policy format") .required(), }); diff --git a/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.test.tsx b/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.test.tsx new file mode 100644 index 0000000000..8279c1e38d --- /dev/null +++ b/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from "~~/test-utils"; +import { AddUserGroupDialog, AddUserGroupDialogProps } from "./index"; +import userEvent from "@testing-library/user-event"; + +// Mock useGetProject hook +jest.mock("~/queries/project/use-get-project", () => ({ + useGetProject: () => ({ + data: { + rbacRoles: [{ name: "Admin" }, { name: "Viewer" }], + }, + }), +})); + +describe("AddUserGroupDialog", () => { + const defaultProps: AddUserGroupDialogProps = { + open: true, + onClose: jest.fn(), + onSubmit: jest.fn(), + }; + + it("renders dialog title and fields", async () => { + render(); + await screen.findByRole("dialog"); + expect(screen.getByText("Add User Group")).toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /team\/group/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: /role/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); + }); + + it("calls onClose when Close button is clicked", async () => { + render(); + await screen.findByRole("dialog"); + userEvent.click(screen.getByRole("button", { name: /close/i })); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it("calls onSubmit with correct values", async () => { + render(); + await screen.findByRole("dialog"); + + const textInput = screen.getByRole("textbox", { name: /team\/group/i }); + userEvent.type(textInput, "dev-team"); + + userEvent.click(screen.getByRole("combobox")); + const adminOption = await screen.findByRole("option", { name: "Admin" }); + userEvent.click(adminOption); + + await waitFor(() => { + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole("button", { name: /add/i })); + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalledWith({ + ssoGroup: "dev-team", + role: "Admin", + }); + }); + }); + + it("disables Add button if form is invalid or pristine", async () => { + render(); + const addButton = screen.getByRole("button", { name: /add/i }); + expect(addButton).toBeDisabled(); + + const textInput = screen.getByRole("textbox", { name: /team\/group/i }); + userEvent.type(textInput, "dev-team"); + + userEvent.click(screen.getByRole("combobox")); + const adminOption = await screen.findByRole("option", { name: "Admin" }); + userEvent.click(adminOption); + + await waitFor(() => { + expect(screen.getByText("Admin")).toBeInTheDocument(); + }); + + // Now the form is valid and dirty + expect(addButton).not.toBeDisabled(); + }); +}); diff --git a/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.tsx b/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.tsx index d70278dd39..2484fbd60e 100644 --- a/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.tsx +++ b/web/src/components/settings-page/project/components/rbac-form/components/add-user-group-dialog/index.tsx @@ -13,7 +13,7 @@ import { import { useFormik } from "formik"; import { FC } from "react"; import * as yup from "yup"; -import { useAppSelector } from "~/hooks/redux"; +import { useGetProject } from "~/queries/project/use-get-project"; export interface AddUserGroupDialogProps { open: boolean; @@ -49,7 +49,8 @@ export const AddUserGroupDialog: FC = ({ onClose(); }, }); - const roles = useAppSelector((state) => state.project.rbacRoles); + const { data: project } = useGetProject(); + const roles = project?.rbacRoles || []; return ( diff --git a/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.test.tsx b/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.test.tsx new file mode 100644 index 0000000000..9526ef882b --- /dev/null +++ b/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { DeleteRoleConfirmDialog } from "."; + +describe("DeleteRoleConfirmDialog", () => { + const mockOnDelete = jest.fn(); + const mockOnClose = jest.fn(); + + beforeEach(() => { + render( + + ); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it("renders the correct title", () => { + expect(screen.getByText(/delete role/i)).toBeInTheDocument(); + }); + + it("renders the correct description", () => { + expect( + screen.getByText(/are you sure you want to delete the role/i) + ).toBeInTheDocument(); + expect(screen.getByText(/Developers/)).toBeInTheDocument(); + }); + + it("renders the delete button", () => { + expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); + }); + + it("calls onDelete with the correct role when delete button is clicked", () => { + screen.getByRole("button", { name: /delete/i }).click(); + expect(mockOnDelete).toHaveBeenCalledWith("Developers"); + }); + + it("calls onClose when the dialog is closed", () => { + screen.getByRole("button", { name: /cancel/i }).click(); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.tsx b/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.tsx index 8671d8131c..138ed79857 100644 --- a/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.tsx +++ b/web/src/components/settings-page/project/components/rbac-form/components/delete-role-confirm-dialog/index.tsx @@ -10,7 +10,7 @@ import Alert from "@mui/material/Alert"; import { FC, memo } from "react"; export interface DeleteRoleConfirmDialogProps { - role: string | null; + roleName: string | null; onClose: () => void; onDelete: (role: string) => void; } @@ -19,9 +19,9 @@ const DIALOG_TITLE = "Delete Role"; const DESCRIPTION = "Are you sure you want to delete the Role?"; export const DeleteRoleConfirmDialog: FC = memo( - function DeleteRoleConfirmDialog({ role, onDelete, onClose }) { + function DeleteRoleConfirmDialog({ roleName, onDelete, onClose }) { return ( - + {DIALOG_TITLE} @@ -35,7 +35,7 @@ export const DeleteRoleConfirmDialog: FC = memo( fontWeight: theme.typography.fontWeightMedium, })} > - {role} + {roleName} @@ -43,8 +43,8 @@ export const DeleteRoleConfirmDialog: FC = memo(