diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index 037b4671cc..e917fbdf85 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -29,6 +29,7 @@ "delete": "Delete", "discardAssessment": "Discard assessment/review", "downloadCsvTemplate": "Download CSV template", + "download": "Download {{what}}", "edit": "Edit", "export": "Export", "filterBy": "Filter by {{what}}", @@ -47,6 +48,7 @@ "selectNone": "Select none", "selectPage": "Select page", "submitReview": "Submit review", + "view": "View", "viewErrorReport": "View error report" }, "colors": { @@ -101,8 +103,11 @@ "copyApplicationAssessmentFrom": "Copy {{what}} assessment", "delete": "Delete {{what}}?", "discard": "Discard {{what}}?", + "download": "Download {{what}}", "edit": "Edit {{what}}", + "export": "Export {{what}}", "importApplicationFile": "Import application file", + "import": "Import {{what}}", "leavePage": "Leave page", "new": "New {{what}}", "newApplication": "New application", @@ -302,6 +307,8 @@ "proxyConfig": "Proxy configuration", "proxyConfigDetails": "Manage connections to proxy servers", "question": "Question", + "questionnaire": "Questionnaire", + "questionnaires": "Questionnaires", "rank": "Rank", "project": "Project", "refresh": "Refresh", @@ -356,7 +363,8 @@ "user": "User", "version": "Version", "workPriority": "Work priority", - "tag": "Tag" + "tag": "Tag", + "YAMLTemplate": "YAML template" }, "toastr": { "success": { diff --git a/client/src/app/Paths.ts b/client/src/app/Paths.ts index 2348228d3d..333cb5ba32 100644 --- a/client/src/app/Paths.ts +++ b/client/src/app/Paths.ts @@ -47,6 +47,7 @@ export enum Paths { repositoriesMvn = "/repositories/maven", proxies = "/proxies", migrationTargets = "/migration-targets", + assessment = "/assessment", jira = "/jira", } diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx index 3b8296cd05..72cfaeec7a 100644 --- a/client/src/app/Routes.tsx +++ b/client/src/app/Routes.tsx @@ -33,6 +33,7 @@ const AffectedApplications = lazy( () => import("./pages/issues/affected-applications") ); const Dependencies = lazy(() => import("./pages/dependencies")); +const Questionnaires = lazy(() => import("./pages/assessment")); export interface IRoute { path: string; @@ -120,6 +121,11 @@ export const devRoutes: IRoute[] = [ }, ] : []), + { + path: Paths.assessment, + comp: Questionnaires, + exact: false, + }, ]; export const adminRoutes: IRoute[] = [ diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index e8fb60568d..dd972f3eb6 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -218,10 +218,10 @@ export interface Assessment { status: AssessmentStatus; stakeholders?: number[]; stakeholderGroups?: number[]; - questionnaire: Questionnaire; + questionnaire: PathfinderQuestionnaire; } -export interface Questionnaire { +export interface PathfinderQuestionnaire { categories: QuestionnaireCategory[]; } @@ -704,3 +704,13 @@ export type HubFile = { name: string; path: string; }; + +export interface Questionnaire { + id: number; + required: boolean; + name: string; + questions: number; + rating: string; + dateImported: string; + system: boolean; +} diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 0bee530db9..f6f1558998 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -50,6 +50,7 @@ import { Rule, Target, HubFile, + Questionnaire, } from "./models"; import { QueryKey } from "@tanstack/react-query"; import { serializeRequestParamsForHub } from "@app/hooks/table-controls"; @@ -106,6 +107,8 @@ export const ANALYSIS_ISSUES = HUB + "/analyses/issues"; export const ANALYSIS_ISSUE_INCIDENTS = HUB + "/analyses/issues/:issueId/incidents"; +export const QUESTIONNAIRES = HUB + "/questionnaires"; + // PATHFINDER export const PATHFINDER = "/hub/pathfinder"; export const ASSESSMENTS = PATHFINDER + "/assessments"; @@ -739,3 +742,15 @@ export const getProxies = (): Promise => export const updateProxy = (obj: Proxy): Promise => axios.put(`${PROXIES}/${obj.id}`, obj); + +// Questionnaires + +export const getQuestionnaires = (): Promise => + axios.get(QUESTIONNAIRES).then((response) => response.data); + +export const updateQuestionnaire = ( + obj: Questionnaire +): Promise => axios.put(`${QUESTIONNAIRES}/${obj.id}`, obj); + +export const deleteQuestionnaire = (id: number): Promise => + axios.delete(`${QUESTIONNAIRES}/${id}`); diff --git a/client/src/app/layout/SidebarApp/SidebarApp.tsx b/client/src/app/layout/SidebarApp/SidebarApp.tsx index 37edea33eb..7203e5c35b 100644 --- a/client/src/app/layout/SidebarApp/SidebarApp.tsx +++ b/client/src/app/layout/SidebarApp/SidebarApp.tsx @@ -227,6 +227,11 @@ export const SidebarApp: React.FC = () => { ) : null} + + + Assessment + + )} diff --git a/client/src/app/pages/assessment/AssessmentSettings.tsx b/client/src/app/pages/assessment/AssessmentSettings.tsx new file mode 100644 index 0000000000..6f1b83bbbc --- /dev/null +++ b/client/src/app/pages/assessment/AssessmentSettings.tsx @@ -0,0 +1,476 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { + Button, + ButtonVariant, + Dropdown, + DropdownItem, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + MenuToggle, + MenuToggleElement, + Modal, + ModalVariant, + PageSection, + PageSectionVariants, + Switch, + Text, + TextContent, + Title, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import { AxiosError } from "axios"; +import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; +import CubesIcon from "@patternfly/react-icons/dist/esm/icons/cubes-icon"; +import EllipsisVIcon from "@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon"; + +import { + useDeleteQuestionnaireMutation, + useFetchQuestionnaires, + useUpdateQuestionnaireMutation, +} from "@app/queries/questionnaires"; +import { ConditionalRender } from "@app/components/ConditionalRender"; +import { AppPlaceholder } from "@app/components/AppPlaceholder"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { SimplePagination } from "@app/components/SimplePagination"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, + TableRowContentWithControls, +} from "@app/components/TableControls"; +import { ConditionalTooltip } from "@app/components/ConditionalTooltip"; +import { ConfirmDialog } from "@app/components/ConfirmDialog"; +import { useLocalTableControls } from "@app/hooks/table-controls"; +import { NotificationsContext } from "@app/components/NotificationsContext"; +import { getAxiosErrorMessage } from "@app/utils/utils"; +import { Questionnaire } from "@app/api/models"; + +export const AssessmentSettings: React.FC = () => { + const { t } = useTranslation(); + + const { pushNotification } = React.useContext(NotificationsContext); + + // TODO Replace with Hub API when ready + const [mockQuestionnaires, setMockQuestionnaires] = React.useState< + Questionnaire[] + >([ + { + id: 1, + name: "System questionnaire", + questions: 42, + rating: "5% Red, 25% Yellow", + dateImported: "8 Aug. 2023, 10:20 AM EST", + required: false, + system: true, + }, + { + id: 2, + name: "Custom questionnaire", + questions: 24, + rating: "15% Red, 35% Yellow", + dateImported: "9 Aug. 2023, 03:32 PM EST", + required: true, + system: false, + }, + { + id: 3, + name: "Ruby questionnaire", + questions: 34, + rating: "7% Red, 25% Yellow", + dateImported: "10 Aug. 2023, 11:23 PM EST", + required: true, + system: false, + }, + ]); + const { questionnaires, isFetching, fetchError } = + useFetchQuestionnaires(mockQuestionnaires); + + const onSaveQuestionnaireSuccess = () => {}; + + const onSaveQuestionnaireError = (error: AxiosError) => { + pushNotification({ + title: getAxiosErrorMessage(error), + variant: "danger", + }); + }; + + const { mutationFn: updateQuestionnaire } = useUpdateQuestionnaireMutation( + onSaveQuestionnaireSuccess, + onSaveQuestionnaireError + ); + + const onDeleteQuestionnaireSuccess = (name: string) => { + pushNotification({ + title: t("toastr.success.deletedWhat", { + what: name, + type: t("terms.questionnaire"), + }), + variant: "success", + }); + }; + + const { mutationFn: deleteQuestionnaire } = useDeleteQuestionnaireMutation( + onDeleteQuestionnaireSuccess, + onSaveQuestionnaireError + ); + + const [isImportModal, setIsImportModal] = React.useState(false); + const [isDownloadTemplateModal, setIsDownloadTemplateModal] = + React.useState(false); + + const [isKebabOpen, setIsKebabOpen] = React.useState(null); + const [questionnaireToExport, setQuestionnaireToExport] = React.useState< + number | null + >(null); + const [questionnaireToDelete, setQuestionnaireToDelete] = React.useState< + number | null + >(null); + + const tableControls = useLocalTableControls({ + idProperty: "id", + items: questionnaires, + columnNames: { + required: "Required", + name: "Name", + questions: "Questions", + rating: "Rating", + dateImported: "Date imported", + }, + isSelectable: false, + expandableVariant: null, + hasActionsColumn: true, + filterCategories: [ + { + key: "name", + title: t("terms.name"), + type: FilterType.search, + placeholderText: + t("actions.filterBy", { + what: t("terms.name").toLowerCase(), + }) + "...", + getItemValue: (item) => { + return item?.name || ""; + }, + }, + ], + sortableColumns: ["name", "dateImported"], + getSortValues: (assessment) => ({ + name: assessment.name || "", + dateImported: assessment.dateImported || "", + }), + initialSort: { columnKey: "name", direction: "asc" }, + hasPagination: true, + isLoading: isFetching, + }); + const { + currentPageItems, + numRenderedColumns, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTdProps, + }, + } = tableControls; + + return ( + <> + + + {t("terms.assessment")} + + + + } + > +
+ + + + + {/* */} + + + + {/* */} + { + //RBAC + // xxxxWriteAccess = checkAccess(userScopes, questionnaireWriteScopes); + true ? ( //TODO: Check RBAC access + + + + ) : null + } + + + + + + + + + + + + + + + + No questionnaire available + + + Use the import button above to add your questionnaire. + + + } + numRenderedColumns={numRenderedColumns} + > + {currentPageItems?.map((questionnaire, rowIndex) => { + return ( + + + + + + + + + + + + + ); + })} + +
+ + + + + +
+ {questionnaire.required} + { + updateQuestionnaire( + { + ...questionnaire, + required: !questionnaire.required, + }, + // TODO Remove mock when Hub API is ready + mockQuestionnaires, + setMockQuestionnaires + ); + }} + /> + + {questionnaire.name} + + {questionnaire.questions} + + {questionnaire.rating} + + {questionnaire.dateImported} + + setIsKebabOpen(null)} + onOpenChange={(_isOpen) => setIsKebabOpen(null)} + toggle={( + toggleRef: React.Ref + ) => ( + setIsKebabOpen(rowIndex)} + isExpanded={isKebabOpen === rowIndex} + > + + + )} + shouldFocusToggleOnSelect + > + + setQuestionnaireToExport(questionnaire.id) + } + > + {t("actions.export")} + + { + // TODO Link to questionnaire page + // history.push(Paths.questionnaire); + }} + > + {t("actions.view")} + + + + setQuestionnaireToDelete(questionnaire.id) + } + > + {t("actions.delete")} + + + +
+ +
+
+
+ setIsImportModal(false)} + > + TODO Import questionnaire component + + setIsDownloadTemplateModal(false)} + > + TODO Downlaod YAML Template component + + setQuestionnaireToExport(null)} + > + TODO Export questionnaire Id {questionnaireToExport} + {" "} + setIsDownloadTemplateModal(false)} + > + TODO Downlaod YAML Template component + + setQuestionnaireToDelete(null)} + onClose={() => setQuestionnaireToDelete(null)} + onConfirm={() => { + if (questionnaireToDelete) { + deleteQuestionnaire( + questionnaireToDelete, + // TODO Remove mock when Hub API is ready + mockQuestionnaires, + setMockQuestionnaires + ); + setQuestionnaireToDelete(null); + } + }} + /> + + ); +}; diff --git a/client/src/app/pages/assessment/index.ts b/client/src/app/pages/assessment/index.ts new file mode 100644 index 0000000000..c6217f3b20 --- /dev/null +++ b/client/src/app/pages/assessment/index.ts @@ -0,0 +1 @@ +export { AssessmentSettings as default } from "./AssessmentSettings"; diff --git a/client/src/app/queries/questionnaires.ts b/client/src/app/queries/questionnaires.ts new file mode 100644 index 0000000000..76a68bd45c --- /dev/null +++ b/client/src/app/queries/questionnaires.ts @@ -0,0 +1,114 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +// TODO Uncomment when Hub API is ready +import { + deleteQuestionnaire, + getQuestionnaires, + updateQuestionnaire, +} from "@app/api/rest"; +import { Questionnaire } from "@app/api/models"; + +export const QuestionnairesTasksQueryKey = "questionnaires"; + +// TODO Remove when Hub API is ready +export const useFetchQuestionnaires = (mockQuestionnaires: Questionnaire[]) => { + return { + questionnaires: mockQuestionnaires, + isFetching: false, + fetchError: null, + }; +}; + +// TODO Uncomment when Hub API is ready +// export const useFetchQuestionnaires = () => { +// const { isLoading, data, error } = useQuery({ +// queryKey: [QuestionnairesTasksQueryKey], +// queryFn: getQuestionnaires, +// onError: (error) => console.log("error, ", error), +// }); +// return { +// questionnaires: data || [], +// isFetching: isLoading, +// fetchError: error as AxiosErr_event + +// TODO Remove when Hub API is ready +const mockUpdateQuestionnaire = ( + obj: Questionnaire, + mockQuestionnaires: Questionnaire[], + setMockQuestionnaires: (questionnaires: Questionnaire[]) => void +) => { + const newMockQuestionnaires = mockQuestionnaires.map((questionnaire) => + questionnaire.id === obj.id ? obj : questionnaire + ); + setMockQuestionnaires(newMockQuestionnaires); +}; + +// TODO Remove when Hub API is ready +export const useUpdateQuestionnaireMutation = ( + onSuccess: () => void, + onError: (err: AxiosError) => void +) => { + return { + mutationFn: mockUpdateQuestionnaire, + }; +}; + +// TODO Uncomment when Hub API is ready +// export const useUpdateQuestionnaireMutation = ( +// onSuccess: () => void, +// onError: (err: AxiosError) => void +// ) => { +// const queryClient = useQueryClient(); +// return useMutation({ +// mutationFn: updateQuestionnaire, +// onSuccess: () => { +// onSuccess(); +// queryClient.invalidateQueries([QuestionnairesTasksQueryKey]); +// }, +// onError: onError, +// }); +// }; + +// TODO Remove when Hub API is ready +const mockDeleteQuestionnaire = ( + id: number, + mockQuestionnaires: Questionnaire[], + setMockQuestionnaires: (questionnaires: Questionnaire[]) => void +) => { + const newMockQuestionnaires = mockQuestionnaires.filter( + (questionnaire) => questionnaire.id !== id + ); + setMockQuestionnaires(newMockQuestionnaires); +}; + +// TODO Remove when Hub API is ready +export const useDeleteQuestionnaireMutation = ( + onSuccess: (name: string) => void, + onError: (err: AxiosError) => void +) => { + return { + mutationFn: mockDeleteQuestionnaire, + }; +}; + +// TODO Uncomment when Hub API is ready +// export const useDeleteQuestionnaireMutation = ( +// onSuccess: (name: string) => void, +// onError: (err: AxiosError) => void +// ) => { +// const queryClient = useQueryClient(); + +// return useMutation({ +// mutationFn: ({ questionnaire }: { questionnaire: Questionnaire }) => +// deleteQuestionnaire(questionnaire.id), +// onSuccess: (_, vars) => { +// onSuccess(vars.questionnaire.name); +// queryClient.invalidateQueries([QuestionnairesTasksQueryKey]); +// }, +// onError: (err: AxiosError) => { +// onError(err); +// queryClient.invalidateQueries([QuestionnairesTasksQueryKey]); +// }, +// }); +// };