From e07b603a3be1f0c3cc4cf098d1e0cc66f91107f1 Mon Sep 17 00:00:00 2001 From: Gilles Dubreuil Date: Wed, 9 Aug 2023 20:38:28 +0200 Subject: [PATCH] :sparkles: Initial Assessment page (#1268) The hub is going to provide Questionnaire type so the legacy Questionnaire (in model.ts) is renamed PathfinderQuestionnaire which is likely to become obsolete. This uses PF5 Dropdown for Kebab menu instead of deprecated Dropdown. ![image](https://github.com/konveyor/tackle2-ui/assets/1901741/4e847ee6-a175-4364-80b2-3ced296f8a5a) Resolves https://github.com/konveyor/tackle2-ui/issues/1260 --- client/public/locales/en/translation.json | 10 +- client/src/app/Paths.ts | 1 + client/src/app/Routes.tsx | 6 + client/src/app/api/models.ts | 14 +- client/src/app/api/rest.ts | 15 + .../src/app/layout/SidebarApp/SidebarApp.tsx | 5 + .../pages/assessment/AssessmentSettings.tsx | 476 ++++++++++++++++++ client/src/app/pages/assessment/index.ts | 1 + client/src/app/queries/questionnaires.ts | 114 +++++ 9 files changed, 639 insertions(+), 3 deletions(-) create mode 100644 client/src/app/pages/assessment/AssessmentSettings.tsx create mode 100644 client/src/app/pages/assessment/index.ts create mode 100644 client/src/app/queries/questionnaires.ts 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]); +// }, +// }); +// };