diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index af3fd3c14..db158506a 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -114,6 +114,7 @@ "import": "Import {{what}}", "leavePage": "Leave page", "new": "New {{what}}", + "newArchetype": "Create new archetype", "newAssessment": "New assessment", "newApplication": "New application", "newBusinessService": "New business service", @@ -139,6 +140,8 @@ "small": "Small" }, "message": { + "archetypeApplicationCount": "{{count}} applications", + "archetypeNoApplications": "No applications currently match the criteria tags.", "appNotAssesedTitle": "Assessment has not been completed", "appNotAssessedBody": "In order to review an application it must be assessed first. Assess the application and try again.", "assessmentStakeholderHeader": "Select the stakeholder(s) or stakeholder group(s) associated with this assessment.", @@ -188,6 +191,7 @@ "sidebar": { "administrator": "Administration", "applicationInventory": "Application inventory", + "archetypes": "Archetypes", "controls": "Controls", "developer": "Migration", "reports": "Reports", @@ -209,6 +213,7 @@ "applicationImports": "Application imports", "applicationName": "Application name", "applications": "Applications", + "archetypes": "Archetypes", "artifact": "Artifact", "artifactAssociated": "Associated artifact", "artifactNotAssociated": "No associated artifact", @@ -289,6 +294,7 @@ "label": "Label", "loading": "Loading", "lowRisk": "Low risk", + "maintainers": "Maintainers", "mavenConfig": "Maven configuration", "mediumRisk": "Medium risk", "member(s)": "Member(s)", diff --git a/client/src/app/Paths.ts b/client/src/app/Paths.ts index e3018ac2b..fc4d1a05e 100644 --- a/client/src/app/Paths.ts +++ b/client/src/app/Paths.ts @@ -22,6 +22,7 @@ export enum Paths { assessmentActions = "/applications/assessment-actions/:applicationId", applicationsReview = "/applications/application/:applicationId/review", applicationsAnalysis = "/applications/analysis", + archetypes = "/archetypes", controls = "/controls", controlsBusinessServices = "/controls/business-services", controlsStakeholders = "/controls/stakeholders", diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx index 06ff41189..df23bd9de 100644 --- a/client/src/app/Routes.tsx +++ b/client/src/app/Routes.tsx @@ -47,6 +47,8 @@ const AssessmentActions = lazy( () => import("./pages/applications/assessment-actions/assessment-actions-page") ); +const Archetypes = lazy(() => import("./pages/archetypes/archetypes-page")); + export interface IRoute { path: string; comp: React.ComponentType; @@ -149,6 +151,11 @@ export const devRoutes: IRoute[] = [ comp: Questionnaire, exact: false, }, + { + path: Paths.archetypes, + comp: Archetypes, + exact: false, + }, ]; export const adminRoutes: IRoute[] = [ @@ -189,6 +196,7 @@ export const adminRoutes: IRoute[] = [ ] : []), ]; + export const AppRoutes = () => { const location = useLocation(); diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 14559c87f..2d805a623 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -744,3 +744,16 @@ export interface AssessmentConfidence { applicationId: number; confidence: number; } + +export interface Archetype { + id: number; + name: string; + description: string; + comments: string; + criteriaTags: Tag[]; + archetypeTags: Tag[]; + assessmentTags?: Tag[]; + stakeholders?: Stakeholder[]; + stakeholderGroups?: StakeholderGroup[]; + applications?: Application[]; +} diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 11e6263e6..c4b2c805a 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -40,14 +40,13 @@ import { Ref, TrackerProject, TrackerProjectIssuetype, - Fact, UnstructuredFact, AnalysisAppDependency, AnalysisAppReport, - Rule, Target, HubFile, Questionnaire, + Archetype, InitialAssessment, } from "./models"; import { QueryKey } from "@tanstack/react-query"; @@ -107,6 +106,8 @@ export const ANALYSIS_ISSUE_INCIDENTS = export const QUESTIONNAIRES = HUB + "/questionnaires"; +export const ARCHETYPES = HUB + "/archetypes"; + // PATHFINDER export const PATHFINDER = "/hub/pathfinder"; export const ASSESSMENTS = HUB + "/assessments"; @@ -548,7 +549,10 @@ export const getFileReports = ( ) : Promise.reject(); -export const getIncidents = (issueId?: number, params: HubRequestParams = {}) => +export const getIncidents = ( + issueId?: number, + params: HubRequestParams = {} +) => issueId ? getHubPaginatedResult( ANALYSIS_ISSUE_INCIDENTS.replace("/:issueId/", `/${String(issueId)}/`), @@ -740,3 +744,24 @@ export const updateQuestionnaire = ( // TODO: of 204 - NoContext) ... the return type does not make sense. export const deleteQuestionnaire = (id: number): Promise => axios.delete(`${QUESTIONNAIRES}/${id}`); + +// --------------------------------------- +// Archetypes +// +export const getArchetypes = (): Promise => + axios.get(ARCHETYPES).then(({ data }) => data); + +export const getArchetypeById = (id: number): Promise => + axios.get(`${ARCHETYPES}/${id}`).then(({ data }) => data); + +// success with code 201 and created entity as response data +export const createArchetype = (archetype: Archetype): Promise => + axios.post(ARCHETYPES, archetype); + +// success with code 204 and therefore no response content +export const updateArchetype = (archetype: Archetype): Promise => + axios.put(`${ARCHETYPES}/${archetype.id}`, archetype); + +// success with code 204 and therefore no response content +export const deleteArchetype = (id: number): Promise => + axios.delete(`${ARCHETYPES}/${id}`); diff --git a/client/src/app/layout/SidebarApp/SidebarApp.tsx b/client/src/app/layout/SidebarApp/SidebarApp.tsx index 7203e5c35..53beedfbe 100644 --- a/client/src/app/layout/SidebarApp/SidebarApp.tsx +++ b/client/src/app/layout/SidebarApp/SidebarApp.tsx @@ -116,6 +116,14 @@ export const SidebarApp: React.FC = () => { {t("sidebar.applicationInventory")} + + + {t("sidebar.archetypes")} + + { + const { t } = useTranslation(); + const history = useHistory(); + const { pushNotification } = React.useContext(NotificationsContext); + + const { archetypes, isFetching, error: fetchError } = useFetchArchetypes(); + + const tableControls = useLocalTableControls({ + idProperty: "id", + items: archetypes, + isLoading: isFetching, + hasActionsColumn: true, + + columnNames: { + name: t("terms.name"), + description: t("terms.description"), + tags: t("terms.tags"), + maintainers: t("terms.maintainers"), + applications: t("terms.applications"), + }, + + filterCategories: [ + { + key: "name", + title: t("terms.name"), + type: FilterType.search, + placeholderText: + t("actions.filterBy", { + what: t("terms.name").toLowerCase(), + }) + "...", + getItemValue: (archetype) => { + return archetype?.name ?? ""; + }, + }, + // TODO: Add filter for archetype tags + ], + + sortableColumns: ["name"], + getSortValues: (archetype) => ({ + name: archetype.name ?? "", + }), + initialSort: { columnKey: "name", direction: "asc" }, + + hasPagination: false, // TODO: Add pagination + }); + const { + currentPageItems, + numRenderedColumns, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTdProps, + }, + } = tableControls; + + // TODO: RBAC access checks need to be added. Only Architect (and Administrator) personas + // TODO: should be able to create/edit archetypes. Every persona should be able to view + // TODO: the archetypes. + + const CreateButton = () => ( + + ); + + return ( + <> + + + {t("terms.archetypes")} + + + + } + > +
+ + + + + + + + + {/* TODO: Add pagination */} + + + + + + + + + + + } + /> + + Create a new archetype to get started. + + + + + + } + numRenderedColumns={numRenderedColumns} + > + {currentPageItems?.map((archetype, rowIndex) => ( + + + + + + + + + + + + + ))} + +
+ + + + + +
+ {archetype.name} + + + + + + + + + {/* TODO: Add kebab action menu */}
+ + {/* TODO: Add pagination */} +
+
+
+ + {/* TODO: Add create/edit modal */} + {/* TODO: Add duplicate confirm modal */} + {/* TODO: Add delete confirm modal */} + + ); +}; + +export default Archetypes; diff --git a/client/src/app/pages/archetypes/components/archetype-applications-column.tsx b/client/src/app/pages/archetypes/components/archetype-applications-column.tsx new file mode 100644 index 000000000..3068eb4bc --- /dev/null +++ b/client/src/app/pages/archetypes/components/archetype-applications-column.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Text } from "@patternfly/react-core"; + +import type { Archetype } from "@app/api/models"; + +// TODO: When count > 0 render a link to navigate to the application inventory assessment page +// with filters set to show the applications for the archetype? +const ArchetypeApplicationsColumn: React.FC<{ archetype: Archetype }> = ({ + archetype, +}) => { + const { t } = useTranslation(); + + return (archetype?.applications?.length ?? 0) > 0 ? ( + + {t("message.archetypeApplicationCount", { + count: archetype.applications?.length ?? 0, + })} + + ) : ( + {t("message.archetypeNoApplications")} + ); +}; + +export default ArchetypeApplicationsColumn; diff --git a/client/src/app/pages/archetypes/components/archetype-description-column.tsx b/client/src/app/pages/archetypes/components/archetype-description-column.tsx new file mode 100644 index 000000000..ae6a2d09d --- /dev/null +++ b/client/src/app/pages/archetypes/components/archetype-description-column.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Text } from "@patternfly/react-core"; + +import type { Archetype } from "@app/api/models"; + +// TODO: Truncate length and add tooltip with full text +const ArchetypeDescriptionColumn: React.FC<{ archetype: Archetype }> = ({ + archetype, +}) => {archetype.description}; + +export default ArchetypeDescriptionColumn; diff --git a/client/src/app/pages/archetypes/components/archetype-maintainers-column.tsx b/client/src/app/pages/archetypes/components/archetype-maintainers-column.tsx new file mode 100644 index 000000000..dfc440471 --- /dev/null +++ b/client/src/app/pages/archetypes/components/archetype-maintainers-column.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { LabelGroup, Label } from "@patternfly/react-core"; + +import type { Archetype } from "@app/api/models"; + +// TODO: Don't show the full name, generate initials +// TODO: Sort individual stakeholders with stakeholder groups +// TODO: Add tooltips for each Label with the full name +const ArchetypeMaintainersColumn: React.FC<{ archetype: Archetype }> = ({ + archetype, +}) => ( + + {archetype.stakeholders?.map((sh) => )} + {archetype.stakeholderGroups?.map((shg) => ( + + ))} + +); + +export default ArchetypeMaintainersColumn; diff --git a/client/src/app/pages/archetypes/components/archetype-tags-column.tsx b/client/src/app/pages/archetypes/components/archetype-tags-column.tsx new file mode 100644 index 000000000..e3bed86d3 --- /dev/null +++ b/client/src/app/pages/archetypes/components/archetype-tags-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { LabelGroup } from "@patternfly/react-core"; +import { LabelCustomColor } from "@migtools/lib-ui"; + +import { COLOR_HEX_VALUES_BY_NAME } from "@app/Constants"; +import type { Archetype, TagCategory, Tag } from "@app/api/models"; + +// copied from application-tag-label.tsx +export const getTagCategoryFallbackColor = (category?: TagCategory) => { + if (!category?.id) return COLOR_HEX_VALUES_BY_NAME.gray; + const colorValues = Object.values(COLOR_HEX_VALUES_BY_NAME); + return colorValues[category?.id % colorValues.length]; +}; + +// copied from application-tag-label.tsx +const TagLabel: React.FC<{ + tag: Tag; + category?: TagCategory; +}> = ({ tag, category }) => ( + + {tag.name} + +); + +// TODO: Refactor the application-tags-label.tsx so applications and archetypes can share `TagLabel` +// TODO: Sort tags? +// TODO: Group tags by categories? +const ArchetypeTagsColumn: React.FC<{ archetype: Archetype }> = ({ + archetype, +}) => ( + + {archetype.archetypeTags?.map((tag) => )} + +); + +export default ArchetypeTagsColumn; diff --git a/client/src/app/queries/archetypes.ts b/client/src/app/queries/archetypes.ts new file mode 100644 index 000000000..1e811c13b --- /dev/null +++ b/client/src/app/queries/archetypes.ts @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { AxiosError } from "axios"; +import { Archetype } from "@app/api/models"; +import { + createArchetype, + deleteArchetype, + getArchetypeById, + getArchetypes, + updateArchetype, +} from "@app/api/rest"; + +export const ARCHETYPES_QUERY_KEY = "archetypes"; +export const ARCHETYPE_QUERY_KEY = "archetype"; + +export const useFetchArchetypes = () => { + const { isLoading, error, refetch, data } = useQuery({ + initialData: [], + queryKey: [ARCHETYPES_QUERY_KEY], + queryFn: getArchetypes, + refetchInterval: 5000, + onError: (error: AxiosError) => console.log(error), + }); + + return { + archetypes: data || [], + isFetching: isLoading, + error, + refetch, + }; +}; + +export const useFetchArchetypeById = (id: number) => { + const { data, isLoading, error } = useQuery({ + queryKey: [ARCHETYPE_QUERY_KEY, id], + queryFn: () => getArchetypeById(id), + onError: (error: AxiosError) => console.log("error, ", error), + }); + + return { + archetype: data, + isFetching: isLoading, + fetchError: error, + }; +}; + +export const useCreateArchetypeMutation = ( + onSuccess: (archetype: Archetype) => void, + onError: (err: AxiosError) => void +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createArchetype, + onSuccess: (archetype) => { + onSuccess(archetype); + queryClient.invalidateQueries([ARCHETYPES_QUERY_KEY]); + }, + onError: onError, + }); +}; + +export const useUpdateArchetypeMutation = ( + onSuccess: (id: number) => void, + onError: (err: AxiosError) => void +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateArchetype, + onSuccess: (_, { id }) => { + onSuccess(id); + queryClient.invalidateQueries([ARCHETYPES_QUERY_KEY]); + queryClient.invalidateQueries([ARCHETYPE_QUERY_KEY, id]); + }, + onError: onError, + }); +}; + +export const useDeleteArchetypeMutation = ( + onSuccess: (id: number) => void, + onError: (err: AxiosError) => void +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteArchetype, + onSuccess: (_, id) => { + onSuccess(id); + queryClient.invalidateQueries([ARCHETYPES_QUERY_KEY]); + queryClient.invalidateQueries([ARCHETYPE_QUERY_KEY, id]); + }, + onError: onError, + }); +}; diff --git a/client/src/mocks/stub-new-work/archetypes.ts b/client/src/mocks/stub-new-work/archetypes.ts new file mode 100644 index 000000000..e93b6dbcb --- /dev/null +++ b/client/src/mocks/stub-new-work/archetypes.ts @@ -0,0 +1,175 @@ +import { type RestHandler, rest } from "msw"; + +import * as AppRest from "@app/api/rest"; +import type { Archetype, Tag, TagCategory } from "@app/api/models"; + +/** + * Simple stub handlers as place holders until hub API is ready. + * + * Handler structure modeled after hub api handler: + * https://github.com/konveyor/tackle2-hub/blob/main/api/tag.go + */ +const handlers: RestHandler[] = [ + rest.get(AppRest.ARCHETYPES, (req, res, ctx) => { + console.log( + "%c stub %c → %s", + "font-weight: bold; color: blue; background-color: white;", + "font-weight: normal; color: auto; background-color: auto;", + `get the list of archetypes` + ); + + const dataAsList = Array.from(Object.values(data)); + return res(ctx.json(dataAsList)); + }), + + rest.get(`${AppRest.ARCHETYPES}/:id`, async (req, res, ctx) => { + const { id } = req.params; + + console.log( + "%c stub %c → %s", + "font-weight: bold; color: blue; background-color: white;", + "font-weight: normal; color: auto; background-color: auto;", + `get archetype ${id}` + ); + + const id_ = Array.isArray(id) ? id[0] : id; + if (id_ in data) { + return res(ctx.json(data[id_])); + } + return res(ctx.status(404)); + }), + + rest.post(AppRest.ARCHETYPES, async (req, res, ctx) => { + const create = await req.json(); + + console.log( + "%c stub %c → %s", + "font-weight: bold; color: blue; background-color: white;", + "font-weight: normal; color: auto; background-color: auto;", + `create archetype →`, + create + ); + + const lastId = Math.max(...Object.keys(data).map((k) => +k)); + create.id = lastId + 1; + data[create.id] = create; + return res(ctx.status(201), ctx.json(create)); + }), + + rest.put(`${AppRest.ARCHETYPES}/:id`, async (req, res, ctx) => { + const { id } = req.params; + const update = await req.json(); + + console.log( + "%c stub %c → %s", + "font-weight: bold; color: blue; background-color: white;", + "font-weight: normal; color: auto; background-color: auto;", + `update archetype ${id} →`, + update + ); + + const id_ = Array.isArray(id) ? id[0] : id; + if (id_ in data) { + data[id_] = update; + return res(ctx.status(204)); + } + return res(ctx.status(404)); + }), + + rest.delete(`${AppRest.QUESTIONNAIRES}/:id`, (req, res, ctx) => { + const { id } = req.params; + console.log( + "%c✏️ archetype stub%c 🤖 %s", + "font-weight: bold; color: blue; background-color: white;", + "font-weight: normal; color: auto; background-color: auto;", + `delete archetype ${id}` + ); + + const id_ = Array.isArray(id) ? id[0] : id; + if (id_ in data) { + delete data[id_]; + return res(ctx.status(204)); + } + return res(ctx.status(404)); + }), +]; + +const tagCategoryData: Record> = { + A: { + id: 1, + name: "Category Alpha", + rank: 5, + colour: "#112233", + }, + 2: { + id: 2, + name: "Category Bravo", + rank: 7, + colour: "#113322", + }, + 3: { + id: 3, + name: "Category Charlie", + rank: 9, + colour: "#331122", + }, + 4: { + id: 4, + name: "Category Delta", + rank: 12, + colour: "#332211", + }, +}; + +const tagData: Record = { + 1: { id: 1, name: "Alpha 1", category: tagCategoryData["1"] }, + 2: { id: 2, name: "Alpha 2", category: tagCategoryData["1"] }, + 3: { id: 3, name: "Bravo 1", category: tagCategoryData["2"] }, + + 81: { id: 81, name: "Charlie 1", category: tagCategoryData["3"] }, + 82: { id: 82, name: "Delta 1", category: tagCategoryData["4"] }, + 83: { id: 83, name: "Delta 2", category: tagCategoryData["4"] }, +}; + +/** + * The archetype stub/mock data. + */ +const data: Record = { + 1: { + id: 1, + name: "Wayne", + description: "Wayne does the bare minimum", + comments: "This one needs coffee", + criteriaTags: [tagData["1"]], + archetypeTags: [tagData["81"]], + assessmentTags: [], + stakeholders: [], + stakeholderGroups: [], + }, + + 2: { + id: 2, + name: "Garth", + description: "Garth has some extra tags", + comments: "This one needs tea", + criteriaTags: [tagData["2"]], + archetypeTags: [tagData["81"], tagData["82"]], + assessmentTags: [], + stakeholders: [], + stakeholderGroups: [], + }, + + 3: { + id: 3, + name: "Cassandra", + description: "Cassandra is the most complex", + comments: "This one needs cakes", + criteriaTags: [tagData["3"]], + archetypeTags: [tagData["81"], tagData["82"], tagData["83"]], + assessmentTags: [], + stakeholders: [], + stakeholderGroups: [], + }, +}; + +export default handlers; diff --git a/client/src/mocks/stub-new-work/index.ts b/client/src/mocks/stub-new-work/index.ts index 9e814f1af..dfc72c1de 100644 --- a/client/src/mocks/stub-new-work/index.ts +++ b/client/src/mocks/stub-new-work/index.ts @@ -3,9 +3,11 @@ import { type RestHandler } from "msw"; import questionnaires from "./questionnaires"; import assessments from "./assessments"; import applications from "./applications"; +import archetypes from "./archetypes"; export default [ ...questionnaires, ...assessments, ...applications, + ...archetypes, ] as RestHandler[];