diff --git a/.github/workflows/ci-actions.yml b/.github/workflows/ci-actions.yml index cec3dd8a9f..eeed592731 100644 --- a/.github/workflows/ci-actions.yml +++ b/.github/workflows/ci-actions.yml @@ -4,12 +4,10 @@ on: push: branches: - main - - "v[0-9]+.[0-9]+.[0-9]+" - "release-*" pull_request: branches: - main - - "v[0-9]+.[0-9]+.[0-9]+" - "release-*" jobs: @@ -17,13 +15,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: + # Note: This should match the node version(s) used in the base Dockerfile node-version: [18.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} @@ -43,13 +42,37 @@ jobs: run: npm run test -- --coverage --watchAll=false - name: Upload to codecov (client) - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: flags: client directory: ./*/coverage - name: Upload to codecov (server) - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: flags: server directory: ./*/coverage + + build-and-upload-for-global-ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: save tackle2-ui image + run: | + docker build . -t quay.io/konveyor/tackle2-ui:latest + docker save -o /tmp/tackle2-ui.tar quay.io/konveyor/tackle2-ui:latest + + - name: Upload tackle2-ui image as artifact + uses: actions/upload-artifact@v3 + with: + name: tackle2-ui + path: /tmp/tackle2-ui.tar + retention-days: 1 + + test-integration: + needs: build-and-upload-for-global-ci + uses: konveyor/ci/.github/workflows/global-ci.yml@main + with: + component_name: tackle2-ui + run_api_tests: false diff --git a/.github/workflows/image-build.yaml b/.github/workflows/image-build.yaml new file mode 100644 index 0000000000..261d01674a --- /dev/null +++ b/.github/workflows/image-build.yaml @@ -0,0 +1,26 @@ +name: Multiple Architecture Image Build + +on: + workflow_dispatch: + push: + branches: + - 'main' + - 'release-*' + tags: + - 'v*' + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + image-build: + uses: konveyor/release-tools/.github/workflows/build-push-images.yaml@main + with: + registry: "quay.io/konveyor" + image_name: "tackle2-ui" + containerfile: "./Dockerfile" + architectures: '[ "amd64", "arm64", "ppc64le", "s390x" ]' + secrets: + registry_username: ${{ secrets.QUAY_PUBLISH_ROBOT }} + registry_password: ${{ secrets.QUAY_PUBLISH_TOKEN }} diff --git a/.github/workflows/march-image-build-push.yml b/.github/workflows/march-image-build-push.yml deleted file mode 100644 index f93448352c..0000000000 --- a/.github/workflows/march-image-build-push.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: 'Build and Push Multi-Arch Image' - -on: - workflow_dispatch: - push: - branches: - - 'main' - - 'release-*' - tags: - - 'v*' - -concurrency: - group: march-build-${{ github.ref }} - cancel-in-progress: true - -jobs: - push-quay: - name: Build and Push Manifest - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - steps: - - name: Checkout Push to Registry action - uses: konveyor/release-tools/build-push-quay@main - with: - architectures: "amd64, arm64, ppc64le, s390x" - containerfile: "./Dockerfile" - image_name: "tackle2-ui" - image_namespace: "konveyor" - image_registry: "quay.io" - quay_publish_robot: ${{ secrets.QUAY_PUBLISH_ROBOT }} - quay_publish_token: ${{ secrets.QUAY_PUBLISH_TOKEN }} - ref: ${{ github.ref }} diff --git a/.github/workflows/pr-closed.yaml b/.github/workflows/pr-closed.yaml new file mode 100644 index 0000000000..41afa19e6c --- /dev/null +++ b/.github/workflows/pr-closed.yaml @@ -0,0 +1,16 @@ +name: PR Closed + +on: + pull_request_target: + branches: + - main + types: + - closed + +jobs: + cherry_pick_job: + permissions: + pull-requests: write + contents: write + if: github.event.pull_request.merged == true + uses: konveyor/release-tools/.github/workflows/cherry-pick.yml@main diff --git a/client/config/webpack.common.ts b/client/config/webpack.common.ts index 98f0dade73..ffaa95c5ca 100644 --- a/client/config/webpack.common.ts +++ b/client/config/webpack.common.ts @@ -1,5 +1,5 @@ import path from "path"; -import { Configuration, WatchIgnorePlugin } from "webpack"; +import { Configuration } from "webpack"; // import CaseSensitivePathsWebpackPlugin from "case-sensitive-paths-webpack-plugin"; import CopyPlugin from "copy-webpack-plugin"; import Dotenv from "dotenv-webpack"; @@ -181,8 +181,8 @@ const config: Configuration = { to: pathTo("../dist/manifest.json"), }, { - from: pathTo("../public/template_application_import.csv"), - to: pathTo("../dist/template_application_import.csv"), + from: pathTo("../public/templates"), + to: pathTo("../dist/templates"), }, ], }), diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index cfa6b25d21..f97c91fc29 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -91,6 +91,8 @@ "noDataStateBody": "Create a new {{what}} to start seeing data here.", "noDataStateTitle": "No {{what}} available", "Nquestions": "{{n}} questions", + "ofTotalApplications": "Of {{count}} application", + "ofTotalApplications_plural": "Of {{count}} applications", "ofTotalAssessments": "Of {{count}} assessment", "ofTotalAssessments_plural": "Of {{count}} assessments", "selectMany": "Select {{what}}", @@ -113,7 +115,7 @@ "maxfileSize": "Max file size of 1MB exceeded. Upload a smaller file.", "dragAndDropFile": "Drag and drop your file here or upload one.", "uploadYamlFile": "Upload your YAML file", - "deleteQuestionnire": "Deleting a questionnaire will cascade into the deletion of all answered questionnaires associated to applications and/or archetypes.", + "deleteQuestionnaire": "Deleting a questionnaire will cascade into the deletion of all answered questionnaires associated to applications and/or archetypes.", "confirmDeletion": "Confirm deletion by typing <1>{{nameToDelete}} below:" }, "title": { @@ -163,8 +165,11 @@ "archetypeNoApplications": "No applications currently match the criteria tags.", "archetypeAlreadyAssessed": "An assessment for one or more of the archetypes this application is associated with exists.", "archetypeAlreadyReviewed": "A review for one or more of the archetypes this application is associated with exists. Open the details drawer to view the review(s).", + "autoSelectTagsLabel": "Automatically select tags based on the answers to the questionnaire. ", "appNotAssesedTitle": "Assessment has not been completed", "appNotAssessedBody": "In order to review an application it must be assessed first. Assess the application and try again.", + "autoSelectTooltip": "Automatically select this answer based on tags associated with the application(s) or archetype.", + "autoTagTooltip": "Automatically tag this application or archetype with these tags based on this answer to the questionnaire.", "assessmentStakeholderHeader": "Select the stakeholder(s) or stakeholder group(s) associated with this assessment.", "binaryPackaging": "Packaging will default to JAR if left empty.", "blockedDeleteTracker": "Cannot delete {{what}} because it is associated with a tracker.", @@ -182,6 +187,11 @@ "duplicateWave": "The migration wave could not be created due to a conflict with an existing wave. Make sure the name and start/end dates are unique and try again.", "importErrorCheckDocumentation": "For status Error imports, check the documentation to ensure your file is structured correctly.", "insecureTracker": "Insecure mode deactivates certificate verification. Use insecure mode for instances that have self-signed certificates.", + "inheritedReviewTooltip": "This application is inheriting a review from an archetype.", + "inheritedReviewTooltip_plural": "This application is inheriting reviews from {{count}} archetypes.", + "inheritedAssessmentTooltip": "This application is inheriting an assessment from an archetype.", + "inheritedAssessmentTooltip_plural": "This application is inheriting assessments from {{count}} archetypes.", + "dependentQuestionTooltip": "This question is conditionally included or excluded based on tags:", "jiraInstanceNotConnected": "Jira instance {{name}} is not connected.", "manageDependenciesInstructions": "Add northbound and southbound dependencies for the selected application here. Note that any selections made will be saved automatically. To undo any changes, you must manually delete the applications from the dropdowns.", "noDataAvailableBody": "No data available to be shown here.", @@ -192,6 +202,7 @@ "overrideAssessmentConfirmation": "Do you want to create a dedicated assessment for this application and override the inherited archetype assessment(s)?", "overrideArchetypeReviewDescription": "The application {{name}} already is associated with archetypes: {{what}}.", "overrideArchetypeReviewConfirmation": "Do you want to create a dedicated review for this application and override the inherited archetype review?", + "editApplicationReviewConfirmation": "This application has already been reviewed. Do you want to continue?", "editArchetypeReviewConfirmation": "This archetype has already been reviewed. Do you want to continue?", "reasonForError": "The reported reason for the error:", "reviewInstructions": "Use this section to provide your assessment of the possible migration/modernization plan and effort estimation.", @@ -201,7 +212,10 @@ "selectOwnerFromStakeholdersList": "Select owner from list of stakeholders", "suggestedAdoptionPlanHelpText": "The suggested approach to migration based on effort, priority, and dependencies.", "taskInProgressForTags": "A new analysis is in-progress. Tags may be updated upon completion.", - "toTagApplication": "Either no tags exist yet or you may not have permission to view any. If you have permission, try creating a new custom tag." + "toTagApplication": "Either no tags exist yet or you may not have permission to view any. If you have permission, try creating a new custom tag.", + "unsavedChanges": "Are you sure you want to close the assessment? Any unsaved changes will be lost.", + "noAnswers": "Are you sure you want to close the assessment? There are no answers to save.", + "unlinkTicket": "Unlink from Jira" }, "proposedActions": { "refactor": "Refactor", @@ -234,6 +248,7 @@ "associatedApplications": "Associated applications", "associatedArchetypes": "Associated archetypes", "archetypesReviewed": "Archetypes reviewed", + "archetypesAssessed": "Archetypes assessed", "add": "Add", "additionalNotesOrComments": "Additional notes or comments", "adoptionCandidateDistribution": "Application confidence and risk", @@ -294,6 +309,7 @@ "date": "Date", "decision": "Decision", "dependencies": "Dependencies", + "dependentQuestion": "Conditional question", "description": "Description", "details": "Details", "displayName": "Display name", @@ -302,6 +318,7 @@ "email": "Email", "error": "Error", "errorReport": "Error report", + "exclude": "Exclude", "exportToIssue": "Export to Issue Manager", "facts": "Facts", "failed": "Failed", @@ -319,11 +336,14 @@ "impactfulButNotAdvisableToMove": "Impactful but not advisable to move", "image": "Image", "imports": "Imports", + "include": "Include", + "importSummary": "Import summary", "importSummaryDeleted": "Import summary deleted", "inadvisable": "Inadvisable", "inProgress": "In-progress", "instanceType": "Instance type", "instance": "Instance", + "inherited": "Inherited", "issueType": "Issue type", "jiraConfig": "Jira configuration", "issue": "Issue", @@ -371,7 +391,6 @@ "repositoryType": "Repository type", "review": "Review", "reviewedArchetype": "Archetype reviewed", - "reviews": "Reviews", "reviewComments": "Review comments", "risk": "Risk", @@ -380,6 +399,7 @@ "rootPath": "Root path", "scheduled": "Scheduled", "select": "Select", + "section": "Section", "settingsAllowApps": "Allow reviewing applications without running an assessment first", "showLess": "Show less", "showMore": "Show more", @@ -401,6 +421,7 @@ "suggestedAdoptionPlan": "Suggested adoption plan", "svnConfig": "Subversion configuration", "tableView": "Table view", + "tag": "Tag", "tag(s)": "Tag(s)", "tagCount": "Tag count", "tagDeleted": "Tag deleted", @@ -416,6 +437,7 @@ "teamMember": "team member", "ticket": "Ticket", "trivialButMigratable": "Trivial but migratable", + "unassessedOrUnknown": "Unassessed or unknown", "unassessed": "Unassessed", "unassigned": "Not yet assigned", "unknown": "Unknown", @@ -425,7 +447,6 @@ "user": "User", "version": "Version", "workPriority": "Work priority", - "tag": "Tag", "YAMLTemplate": "YAML template" }, "titles": { @@ -458,6 +479,8 @@ "max": "This field must be less than {{value}}.", "maxLength": "This field must contain fewer than {{length}} characters.", "min": "This field must be greater than {{value}}.", + "minCount": "At least one {{type}} must be selected.", + "minCount_plural": "At least {{count}} {{types}} must be selected.", "minLength": "This field must contain at least {{length}} characters.", "minOneStakeholderOrGroupRequired": "At least one stakeholder or stakeholder groups is required.", "required": "This field is required." @@ -491,6 +514,7 @@ }, "terms": { "advanced": "Advanced", + "advancedAnalysisDetails": "Enhanced analysis details", "analysisMode": "Analysis mode", "configureAnalysis": "Configure analysis", "customRules": "Custom rules", @@ -502,7 +526,7 @@ "packages": "Packages", "review": "Review", "rules": "Rules", - "rulesTags": "rules tags", + "rulesTags": "rules labels", "scope": "Scope", "setTargets": "Set targets", "source": "Source", @@ -515,6 +539,9 @@ "title": { "advancedOptions": "Advanced options", "review": "Review analysis details" + }, + "tooltip": { + "advancedAnalysisDetails": "Enable enhanced analysis details to get more information about the analysis." } } } diff --git a/client/public/templates/questionnaire-template.yaml b/client/public/templates/questionnaire-template.yaml new file mode 100644 index 0000000000..4163e6edc5 --- /dev/null +++ b/client/public/templates/questionnaire-template.yaml @@ -0,0 +1,92 @@ +name: Uploadable Cloud Readiness Questionnaire Template +description: This questionnaire is an example template for assessing cloud readiness. It serves as a guide for users to create and customize their own questionnaire templates. +required: true +sections: + - order: 1 + name: Application Technologies + questions: + - order: 1 + text: What is the main technology in your application? + explanation: Identify the main framework or technology used in your application. + includeFor: + - category: Language + tag: Java + answers: + - order: 1 + text: Quarkus + risk: green + rationale: Quarkus is a modern, container-friendly framework. + mitigation: No mitigation needed. + applyTags: + - category: Runtime + tag: Quarkus + autoAnswerFor: + - category: Runtime + tag: Quarkus + - order: 2 + text: Spring Boot + risk: green + rationale: Spring Boot is versatile and widely used. + mitigation: Ensure container compatibility. + applyTags: + - category: Runtime + tag: Spring Boot + autoAnswerFor: + - category: Runtime + tag: Spring Boot + - order: 3 + text: Legacy Monolithic Application + risk: red + rationale: Legacy monoliths are challenging for cloud adaptation. + mitigation: Consider refactoring into microservices. + - order: 2 + text: Does your application use a microservices architecture? + explanation: Assess if the application is built using a microservices architecture. + answers: + - order: 1 + text: Yes + risk: green + rationale: Microservices are well-suited for cloud environments. + mitigation: Continue monitoring service dependencies. + - order: 2 + text: No + risk: yellow + rationale: Non-microservices architectures may face scalability issues. + mitigation: Assess the feasibility of transitioning to microservices. + - order: 3 + text: Unknown + risk: unknown + rationale: Lack of clarity on architecture can lead to unplanned issues. + mitigation: Conduct an architectural review. + + - order: 3 + text: Is your application's data storage cloud-optimized? + explanation: Evaluate if the data storage solution is optimized for cloud usage. + includeFor: + - category: Language + tag: Java + answers: + - order: 1 + text: Cloud-Native Storage Solution + risk: green + rationale: Cloud-native solutions offer scalability and resilience. + mitigation: Ensure regular backups and disaster recovery plans. + - order: 2 + text: Traditional On-Premises Storage + risk: red + rationale: Traditional storage might not scale well in the cloud. + mitigation: Explore cloud-based storage solutions. + - order: 3 + text: Hybrid Storage Approach + risk: yellow + rationale: Hybrid solutions may have integration complexities. + mitigation: Evaluate and optimize cloud integration points. +thresholds: + red: 1 + yellow: 30 + unknown: 15 +riskMessages: + red: Requires deep changes in architecture or lifecycle + yellow: Cloud friendly but needs minor changes + green: Cloud Native + unknown: More information needed diff --git a/client/public/template_application_import.csv b/client/public/templates/template_application_import.csv similarity index 100% rename from client/public/template_application_import.csv rename to client/public/templates/template_application_import.csv diff --git a/client/src/app/Constants.ts b/client/src/app/Constants.ts index 59d8974062..81954b6c0f 100644 --- a/client/src/app/Constants.ts +++ b/client/src/app/Constants.ts @@ -24,6 +24,12 @@ export const isRWXSupported = ENV.RWX_SUPPORTED === "true"; export const DEFAULT_SELECT_MAX_HEIGHT = 200; +/** + * The name of the client generated id field inserted in a object marked with mixin type + * `WithUiId`. + */ +export const UI_UNIQUE_ID = "_ui_unique_id"; + // Colors // t('colors.red') diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 2f2c071027..e3107eff72 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -72,6 +72,9 @@ export interface Ref { id: number; name: string; } +export interface IdRef { + id: number; +} export interface JobFunction { id: number; @@ -321,11 +324,11 @@ export interface TaskData { tagger: { enabled: boolean; }; + verbosity: number; mode: { binary: boolean; withDeps: boolean; artifact: string; - diva: boolean; csv?: boolean; }; targets?: string[]; @@ -575,8 +578,14 @@ export interface BaseAnalysisIssueReport extends AnalysisIssuesCommonFields { files: number; } -// After fetching from the hub, we inject a unique id composed of ruleset+rule for convenience +/** + * Mark an object as having a unique client generated id field. Use this type if + * an objects from hub does not have a single field with a unique key AND the object + * is to be used in a table. Our table handlers assume a single field with a unique + * value across all objects in a set to properly handle row selections. + */ export type WithUiId = T & { _ui_unique_id: string }; + export type AnalysisRuleReport = WithUiId; export type AnalysisIssueReport = WithUiId; @@ -643,6 +652,10 @@ export type HubFile = { path: string; }; +export interface LooseQuestionnaire { + [key: string]: any; +} + export interface Questionnaire { id: number; name: string; @@ -650,12 +663,15 @@ export interface Questionnaire { revision: number; questions: number; rating: string; - createTime: string; required: boolean; - builtin?: boolean; sections: Section[]; thresholds: Thresholds; riskMessages: RiskMessages; + builtin?: boolean; + createTime?: string; + createUser?: string; + updateTime?: string; + updateUser?: string; } export interface RiskMessages { @@ -773,3 +789,8 @@ export interface SectionWithQuestionOrder extends Section { export interface AssessmentWithSectionOrder extends Assessment { sections: SectionWithQuestionOrder[]; } + +export interface AssessmentWithArchetypeApplications + extends AssessmentWithSectionOrder { + archetypeApplications: Ref[]; +} diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index d1c6edccbc..dc525e6bb6 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -322,21 +322,28 @@ export const getApplicationImports = ( .get(`${APP_IMPORT}?importSummary.id=${importSummaryID}&isValid=${isValid}`) .then((response) => response.data); -export function getTaskById(id: number, format: "json"): Promise; -export function getTaskById(id: number, format: "yaml"): Promise; export function getTaskById( id: number, - format: "json" | "yaml" + format: string, + merged: boolean = false ): Promise { - if (format === "yaml") { - return axios - .get(`${TASKS}/${id}`, yamlHeaders) - .then((response) => response.data); - } else { - return axios - .get(`${TASKS}/${id}`, jsonHeaders) - .then((response) => response.data); + const headers = + format === "yaml" ? { ...yamlHeaders.headers } : { ...jsonHeaders.headers }; + const responseType = format === "yaml" ? "text" : "json"; + + let url = `${TASKS}/${id}`; + if (merged) { + url += "?merged=1"; } + + return axios + .get(url, { + headers: headers, + responseType: responseType, + }) + .then((response) => { + return response.data; + }); } export const getTasks = () => diff --git a/client/src/app/axios-config/apiInit.ts b/client/src/app/axios-config/apiInit.ts index 06e3c97e4e..913ab563df 100644 --- a/client/src/app/axios-config/apiInit.ts +++ b/client/src/app/axios-config/apiInit.ts @@ -1,14 +1,43 @@ import axios from "axios"; +import keycloak from "@app/keycloak"; -export const initInterceptors = (getToken: () => Promise) => { +export const initInterceptors = () => { axios.interceptors.request.use( - async (config) => { - const token = await getToken(); - if (token) config.headers["Authorization"] = "Bearer " + token; + (config) => { + const token = keycloak.token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } return config; }, (error) => { - Promise.reject(error); + return Promise.reject(error); + } + ); + + axios.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + if (error.response && error.response.status === 401) { + try { + const refreshed = await keycloak.updateToken(5); + if (refreshed) { + const retryConfig = { + ...error.config, + headers: { + ...error.config.headers, + Authorization: `Bearer ${keycloak.token}`, + }, + }; + return axios(retryConfig); + } + } catch (refreshError) { + keycloak.login(); + } + } + return Promise.reject(error); } ); }; diff --git a/client/src/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx b/client/src/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx index 5e64af7409..a37059b20d 100644 --- a/client/src/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx +++ b/client/src/app/components/ConfirmDeleteDialog/ConfirmDeleteDialog.tsx @@ -9,6 +9,7 @@ import { Text, TextInput, } from "@patternfly/react-core"; +import { collapseSpacesAndCompare } from "@app/utils/utils"; import "./ConfirmDeleteDialog.css"; @@ -39,7 +40,12 @@ const ConfirmDeleteDialog: FC = ({ const [nameToDeleteInput, setNameToDeleteInput] = useState(""); - const isDisabled = nameToDeleteInput !== nameToDelete; + /* + Enable the delete button once the input name matches the `nameToDelete`, BUT + collapse spaces since that is the way the name is rendered + */ + const isDisabled = + collapseSpacesAndCompare(nameToDeleteInput, nameToDelete) !== 0; const handleClose = () => { setNameToDeleteInput(""); diff --git a/client/src/app/components/ExternalLink.tsx b/client/src/app/components/ExternalLink.tsx index 3566361648..3357db7079 100644 --- a/client/src/app/components/ExternalLink.tsx +++ b/client/src/app/components/ExternalLink.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Flex, FlexItem, Icon, Text } from "@patternfly/react-core"; +import { Button, Icon } from "@patternfly/react-core"; import ExternalLinkAltIcon from "@patternfly/react-icons/dist/esm/icons/external-link-alt-icon"; /** @@ -7,20 +7,25 @@ import ExternalLinkAltIcon from "@patternfly/react-icons/dist/esm/icons/external */ export const ExternalLink: React.FC<{ href: string; + isInline?: boolean; children: React.ReactNode; -}> = ({ href, children }) => ( - - - - {children} - - - +}> = ({ href, isInline = false, children }) => ( + ); export default ExternalLink; diff --git a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx index f79e677df7..627faa55fc 100644 --- a/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx +++ b/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx @@ -138,8 +138,6 @@ export const MultiselectFilterControl = ({ const input = textInput?.toLowerCase(); return renderSelectOptions((optionProps, groupName) => { - if (!input) return false; - // TODO: Checking for a filter match against the key or the value may not be desirable. return ( groupName?.toLowerCase().includes(input) || diff --git a/client/src/app/components/IconedStatus.tsx b/client/src/app/components/IconedStatus.tsx index 665d4b0581..6dbf102e9c 100644 --- a/client/src/app/components/IconedStatus.tsx +++ b/client/src/app/components/IconedStatus.tsx @@ -1,13 +1,18 @@ import React from "react"; -import { Flex, FlexItem, Icon } from "@patternfly/react-core"; +import { Flex, FlexItem, Icon, Tooltip } from "@patternfly/react-core"; import { useTranslation } from "react-i18next"; import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; import TimesCircleIcon from "@patternfly/react-icons/dist/esm/icons/times-circle-icon"; import InProgressIcon from "@patternfly/react-icons/dist/esm/icons/in-progress-icon"; import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; import UnknownIcon from "@patternfly/react-icons/dist/esm/icons/unknown-icon"; +import TopologyIcon from "@patternfly/react-icons/dist/esm/icons/topology-icon"; export type IconedStatusPreset = + | "InheritedReviews" + | "InProgressInheritedReviews" + | "InProgressInheritedAssessments" + | "InheritedAssessments" | "Canceled" | "Completed" | "Error" @@ -35,6 +40,8 @@ export interface IIconedStatusProps { icon?: React.ReactNode; className?: string; label?: React.ReactNode | string; + tooltipMessage?: string; + tooltipCount?: number; } export const IconedStatus: React.FC = ({ @@ -43,9 +50,42 @@ export const IconedStatus: React.FC = ({ icon, className = "", label, + tooltipCount = 0, }: IIconedStatusProps) => { const { t } = useTranslation(); const presets: IconedStatusPresetType = { + InProgressInheritedReviews: { + icon: , + status: "info", + label: t("terms.inProgress"), + tooltipMessage: t("message.inheritedReviewTooltip", { + count: tooltipCount, + }), + }, + InProgressInheritedAssessments: { + icon: , + status: "info", + label: t("terms.inProgress"), + tooltipMessage: t("message.inheritedAssessmentTooltip", { + count: tooltipCount, + }), + }, + InheritedReviews: { + icon: , + status: "success", + label: t("terms.completed"), + tooltipMessage: t("message.inheritedReviewTooltip", { + count: tooltipCount, + }), + }, + InheritedAssessments: { + icon: , + status: "success", + label: t("terms.completed"), + tooltipMessage: t("message.inheritedAssessmentTooltip", { + count: tooltipCount, + }), + }, Canceled: { icon: , status: "info", @@ -89,6 +129,39 @@ export const IconedStatus: React.FC = ({ }, }; const presetProps = preset && presets[preset]; + const IconWithOptionalTooltip: React.FC<{ children: React.ReactElement }> = ({ + children, + }) => + presetProps?.tooltipMessage ? ( + {children} + ) : ( + <>{children} + ); + + const getTooltipContent = () => { + switch (preset) { + case "InheritedReviews": + return t("message.inheritedReviewTooltip", { + count: tooltipCount, + }); + + case "InheritedAssessments": + return t("message.inheritedAssessmentTooltip", { + count: tooltipCount, + }); + case "InProgressInheritedReviews": + return t("message.inheritedReviewTooltip", { + count: tooltipCount, + }); + case "InProgressInheritedAssessments": + return t("message.inheritedAssessmentTooltip", { + count: tooltipCount, + }); + + default: + return ""; + } + }; return ( = ({ spaceItems={{ default: "spaceItemsSm" }} > - - {icon || presetProps?.icon || } - + + + {icon || presetProps?.icon || } + + {label || presetProps?.label} + {(preset === "InheritedReviews" || + preset === "InheritedAssessments" || + preset === "InProgressInheritedAssessments" || + preset === "InProgressInheritedReviews") && ( + + + + + + )} ); }; diff --git a/client/src/app/components/KeycloakProvider.tsx b/client/src/app/components/KeycloakProvider.tsx index daf2ed50f5..4ba2211b6b 100644 --- a/client/src/app/components/KeycloakProvider.tsx +++ b/client/src/app/components/KeycloakProvider.tsx @@ -1,12 +1,9 @@ -import { initInterceptors } from "@app/axios-config"; -import { isAuthRequired } from "@app/Constants"; -import i18n from "@app/i18n"; +import React, { Suspense } from "react"; +import { ReactKeycloakProvider } from "@react-keycloak/web"; import keycloak from "@app/keycloak"; -import { deleteCookie, getCookie, setCookie } from "@app/queries/cookies"; import { AppPlaceholder } from "./AppPlaceholder"; -import { Flex, FlexItem, Spinner } from "@patternfly/react-core"; -import { ReactKeycloakProvider } from "@react-keycloak/web"; -import React, { Suspense } from "react"; +import { initInterceptors } from "@app/axios-config"; +import ENV from "@app/env"; interface IKeycloakProviderProps { children: React.ReactNode; @@ -15,81 +12,27 @@ interface IKeycloakProviderProps { export const KeycloakProvider: React.FC = ({ children, }) => { - const checkAuthCookie = () => { - if (!getCookie("keycloak_cookie") && keycloak?.token) { - setCookie("keycloak_cookie", keycloak.token, 365); - } - }; - if (isAuthRequired) { - return ( - <> - - - Loading... - - - } - isLoadingCheck={(keycloak) => { - if (keycloak.authenticated) { - initInterceptors( - () => - new Promise((resolve, reject) => { - if (keycloak.token) { - if (keycloak.refreshToken) { - keycloak - .updateToken(60) - .then(() => { - deleteCookie("keycloak_cookie"); - checkAuthCookie(); - return resolve(keycloak.token!); - }) - .catch((err) => { - console.log("err", err); - return reject("Failed to refresh token"); - }); - } else return resolve(keycloak.token!); - } else { - keycloak.login(); - reject("Not logged in"); - } - }) - ); + return ENV.AUTH_REQUIRED !== "true" ? ( + <>{children} + ) : ( + {children} + ); +}; - const kcLocale = (keycloak.tokenParsed as any)["locale"]; - if (kcLocale) { - i18n.changeLanguage(kcLocale); - } - } +const AuthEnabledKeycloakProvider: React.FC = ({ + children, +}) => { + React.useEffect(() => { + initInterceptors(); + }, []); - return !keycloak.authenticated; - }} - > - {children} - - - ); - } else { - return ( - <> - }>{children} - - ); - } + return ( + } + > + }>{children} + + ); }; diff --git a/client/src/app/components/SimpleDocumentViewer.css b/client/src/app/components/SimpleDocumentViewer.css index 5e19d7bcc3..a566ed5be9 100644 --- a/client/src/app/components/SimpleDocumentViewer.css +++ b/client/src/app/components/SimpleDocumentViewer.css @@ -74,3 +74,7 @@ .simple-task-viewer .language-toggle-group { --pf-v5-c-toggle-group__button--FontSize: var(--pf-v5-global--FontSize--md); } + +.merged-checkbox { + margin: auto 0.5rem; +} diff --git a/client/src/app/components/SimpleDocumentViewer.tsx b/client/src/app/components/SimpleDocumentViewer.tsx index 8583221587..91dc6c4a32 100644 --- a/client/src/app/components/SimpleDocumentViewer.tsx +++ b/client/src/app/components/SimpleDocumentViewer.tsx @@ -6,6 +6,7 @@ import { } from "@patternfly/react-code-editor"; import { Button, + Checkbox, EmptyState, EmptyStateIcon, EmptyStateVariant, @@ -22,24 +23,17 @@ import CodeIcon from "@patternfly/react-icons/dist/esm/icons/code-icon"; import UndoIcon from "@patternfly/react-icons/dist/esm/icons/undo-icon"; import "./SimpleDocumentViewer.css"; +import { useFetchTaskByID } from "@app/queries/tasks"; export { Language } from "@patternfly/react-code-editor"; -interface FetchFunction { - /** Fetch a yaml document for the given document */ - (documentId: number, format: Language.yaml): Promise; - - /** Fetch a JSON document as a `FetchType` object for the given document */ - (documentId: number, format: Language.json): Promise; -} - /** The subset of MonacoEditor component functions we want to use. */ type ControlledEditor = { focus: () => void; setPosition: (position: object) => void; }; -export interface ISimpleDocumentViewerProps { +export interface ISimpleDocumentViewerProps { /** The id of the document to display, or `undefined` to display the empty state. */ documentId: number | undefined; @@ -57,45 +51,45 @@ export interface ISimpleDocumentViewerProps { * vertical space. Defaults to "450px". */ height?: string | "full"; - - /** Function that will fetch the document to display. */ - fetch: FetchFunction; } /** * Fetch and then use the `@patternfly/react-code-editor` to display a document in * read-only mode with language highlighting applied. */ -export const SimpleDocumentViewer = ({ +export const SimpleDocumentViewer = ({ documentId, downloadFilename, language = Language.yaml, height = "450px", - fetch, -}: ISimpleDocumentViewerProps) => { +}: ISimpleDocumentViewerProps) => { const editorRef = React.useRef(); - - const [code, setCode] = React.useState(undefined); const [currentLanguage, setCurrentLanguage] = React.useState(language); + const [code, setCode] = React.useState(); + const [merged, setMerged] = React.useState(false); + + const { task, isFetching, fetchError, refetch } = useFetchTaskByID( + documentId, + currentLanguage === Language.yaml ? "yaml" : "json", + merged + ); + + const onMergedChange = (checked: boolean) => { + setMerged(checked); + refetch(); + }; React.useEffect(() => { - setCode(undefined); - documentId && fetchDocument(documentId); - }, [documentId, currentLanguage]); + if (task) { + const formattedCode = + currentLanguage === Language.yaml + ? task.toString() + : JSON.stringify(task, undefined, 2); - const fetchDocument = (documentId: number) => { - if (currentLanguage === Language.yaml) { - fetch(documentId, currentLanguage).then((yaml) => { - setCode(yaml.toString()); - focusAndHomePosition(); - }); - } else { - fetch(documentId, currentLanguage).then((json) => { - setCode(JSON.stringify(json, undefined, 2)); - focusAndHomePosition(); - }); + setCode(formattedCode); + focusAndHomePosition(); } - }; + }, [task, currentLanguage]); const focusAndHomePosition = () => { if (editorRef.current) { @@ -103,13 +97,14 @@ export const SimpleDocumentViewer = ({ editorRef.current.setPosition({ column: 0, lineNumber: 1 }); } }; + const refreshControl = ( } aria-label="refresh-task" tooltipProps={{ content: "Refresh" }} onClick={() => { - documentId && fetchDocument(documentId); + refetch(); }} isVisible={code !== ""} /> @@ -147,6 +142,15 @@ export const SimpleDocumentViewer = ({ } customControls={[ refreshControl, + onMergedChange(checked)} + aria-label="Merged Checkbox" + />,
({ ); }; -export interface ISimpleDocumentViewerModalProps - extends ISimpleDocumentViewerProps { +export interface ISimpleDocumentViewerModalProps + extends ISimpleDocumentViewerProps { /** Simple text content of the modal header. */ title?: string; @@ -220,14 +224,14 @@ export interface ISimpleDocumentViewerModalProps * displayed if the `documentId` is set. If `documentId` is `undefined`, the modal is * closed. */ -export const SimpleDocumentViewerModal = ({ +export const SimpleDocumentViewerModal = ({ title, documentId, onClose, position = "top", isFullHeight = true, ...rest -}: ISimpleDocumentViewerModalProps) => { +}: ISimpleDocumentViewerModalProps) => { const isOpen = documentId !== undefined; return ( @@ -248,7 +252,7 @@ export const SimpleDocumentViewerModal = ({ , ]} > - + = ({ propHelpers: { tableProps, getThProps, getTrProps, getTdProps }, } = tableControls; - const getIconByRisk = (risk: string): React.ReactElement => { - switch (risk) { - case "green": - return ; - case "red": - return } status="danger" />; - case "yellow": - return } status="warning" />; - default: - return ; - } - }; - return ( <> @@ -113,65 +102,74 @@ const AnswerTable: React.FC = ({ } > - {currentPageItems?.map((answer, rowIndex) => { - return ( - <> - - - - - - - - {!!answer?.autoAnswerFor?.length && ( - <> -
- - Auto answer if the following tags are present: - - {answer?.autoAnswerFor?.map((tag, index) => { - return ( -
- -
- ); - })} -
- - )} - - - {!!answer?.applyTags?.length && ( - <> -
- - Apply Tags for this answer choice: - - {answer?.applyTags?.map((tag, index) => { - return ( -
- + {currentPageItems?.map((answer, rowIndex) => ( + +
+ + - - ); - })} + } + > + + + + + )} + + + + + + + {/* ... other rows ... */} + + ))}
- {answer.text} - - {getIconByRisk(answer.risk)} -
+
+ {answer.text} + {(!!answer?.autoAnswerFor?.length || + !!answer?.applyTags?.length) && ( + + {!!answer?.autoAnswerFor?.length && ( + <> + + {t("message.autoSelectTooltip")} + + + {answer.autoAnswerFor.map( + (tag, index) => ( + + + + ) + )} + + + )} + {!!answer?.applyTags?.length && ( + <> + {t("message.autoTagTooltip")} + + {answer.applyTags.map((tag, index) => ( + + + + ))} + + + )}
- ); - })} - - - )} -
+ +
diff --git a/client/src/app/components/application-assessment-donut-chart/application-assessment-donut-chart.tsx b/client/src/app/components/application-assessment-donut-chart/application-assessment-donut-chart.tsx new file mode 100644 index 0000000000..efd11a4467 --- /dev/null +++ b/client/src/app/components/application-assessment-donut-chart/application-assessment-donut-chart.tsx @@ -0,0 +1,179 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { ChartDonut, ChartLegend } from "@patternfly/react-charts"; +import { RISK_LIST } from "@app/Constants"; +import { + Assessment, + Section, + IdRef, + AssessmentWithArchetypeApplications, +} from "@app/api/models"; + +import { global_palette_blue_300 as defaultColor } from "@patternfly/react-tokens"; +import { useFetchAssessmentsWithArchetypeApplications } from "@app/queries/assessments"; + +export interface ChartData { + red: number; + amber: number; + green: number; + unknown: number; +} + +export const getChartDataFromCategories = ( + categories: Section[] +): ChartData => { + let green = 0; + let amber = 0; + let red = 0; + let unknown = 0; + + categories + .flatMap((f) => f.questions) + .flatMap((f) => f.answers) + .filter((f) => f.selected === true) + .forEach((f) => { + switch (f.risk) { + case "GREEN": + green++; + break; + case "yellow": + amber++; + break; + case "RED": + red++; + break; + default: + unknown++; + } + }); + + return { + red, + amber, + green, + unknown, + } as ChartData; +}; + +export const getChartDataFromMultipleAssessments = ( + assessments: Assessment[] +): ChartData => { + let green = 0, + amber = 0, + red = 0, + unknown = 0; + + assessments.forEach((assessment) => { + assessment.sections + .flatMap((section) => section.questions) + .flatMap((question) => question.answers) + .filter((answer) => answer.selected) + .forEach((answer) => { + switch (answer.risk) { + case "green": + green++; + break; + case "yellow": + amber++; + break; + case "red": + red++; + break; + default: + unknown++; + } + }); + }); + + return { red, amber, green, unknown }; +}; + +export interface IApplicationAssessmentDonutChartProps { + assessmentRefs?: IdRef[]; +} + +export const ApplicationAssessmentDonutChart: React.FC< + IApplicationAssessmentDonutChartProps +> = ({ assessmentRefs }) => { + const { t } = useTranslation(); + const { assessmentsWithArchetypeApplications } = + useFetchAssessmentsWithArchetypeApplications(); + + const filterAssessmentsByRefs = ( + assessments: AssessmentWithArchetypeApplications[], + refs: IdRef[] + ) => { + if (refs && refs.length > 0) { + return assessments.filter((assessment) => + refs.some((ref) => ref.id === assessment.id) + ); + } + return assessments; + }; + + const filteredAssessments = filterAssessmentsByRefs( + assessmentsWithArchetypeApplications, + assessmentRefs || [] + ); + + const charData: ChartData = useMemo(() => { + return getChartDataFromMultipleAssessments(filteredAssessments); + }, [filteredAssessments]); + + const chartDefinition = [ + { + x: t(RISK_LIST["green"].i18Key), + y: charData.green, + color: RISK_LIST["green"].hexColor, + }, + { + x: t(RISK_LIST["yellow"].i18Key), + y: charData.amber, + color: RISK_LIST["yellow"].hexColor, + }, + { + x: t(RISK_LIST["red"].i18Key), + y: charData.red, + color: RISK_LIST["red"].hexColor, + }, + { + x: t(RISK_LIST["unknown"].i18Key), + y: charData.unknown, + color: RISK_LIST["unknown"].hexColor, + }, + ].filter((f) => f.y > 0); + + return ( +
+ ({ x: elem.x, y: elem.y }))} + labels={({ datum }) => `${datum.x}: ${datum.y}`} + colorScale={chartDefinition.map( + (elem) => elem.color || defaultColor.value + )} + legendComponent={ + ({ + name: `${elem.x}: ${elem.y}`, + }))} + colorScale={chartDefinition.map( + (elem) => elem.color || defaultColor.value + )} + /> + } + legendOrientation="vertical" + legendPosition="right" + padding={{ + bottom: 20, + left: 20, + right: 140, + top: 20, + }} + innerRadius={50} + width={380} + /> +
+ ); +}; diff --git a/client/src/app/components/questionnaire-summary/questionnaire-summary.tsx b/client/src/app/components/questionnaire-summary/questionnaire-summary.tsx index 26d2907788..d9b44830a0 100644 --- a/client/src/app/components/questionnaire-summary/questionnaire-summary.tsx +++ b/client/src/app/components/questionnaire-summary/questionnaire-summary.tsx @@ -82,10 +82,6 @@ const QuestionnaireSummary: React.FC = ({ filteredSummaryData?.sections?.flatMap((section) => section.questions) || []; - if (!summaryData) { - return
No data available.
; - } - const dynamicPath = isArchetype ? formatPath(Paths.archetypeAssessmentActions, { archetypeId: (summaryData as Assessment)?.archetype?.id, diff --git a/client/src/app/components/questions-table/questions-table.tsx b/client/src/app/components/questions-table/questions-table.tsx index d1b5495fe1..4d417dec68 100644 --- a/client/src/app/components/questions-table/questions-table.tsx +++ b/client/src/app/components/questions-table/questions-table.tsx @@ -17,7 +17,7 @@ import { useTranslation } from "react-i18next"; import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { Assessment, Question, Questionnaire } from "@app/api/models"; import { useLocalTableControls } from "@app/hooks/table-controls"; -import { Label } from "@patternfly/react-core"; +import { Label, List, ListItem, Tooltip } from "@patternfly/react-core"; import { NoDataEmptyState } from "@app/components/NoDataEmptyState"; import AnswerTable from "@app/components/answer-table/answer-table"; import { AxiosError } from "axios"; @@ -45,6 +45,7 @@ const QuestionsTable: React.FC<{ section: "Section", }, isExpansionEnabled: true, + isPaginationEnabled: false, expandableVariant: "single", forceNumRenderedColumns: isAllQuestionsTab ? 3 : 2, // columns+1 for expand control }); @@ -98,8 +99,48 @@ const QuestionsTable: React.FC<{ data?.sections.find((section) => section.questions.includes(question) )?.name || ""; + + const getConditionalTooltipContent = (question: Question) => { + return ( +
+
{t("message.dependentQuestionTooltip")}
+ {!!question.includeFor?.length && ( + <> +
{t("terms.include")}:
+ + {question.includeFor.map((tag, index) => ( + + + + ))} + + + )} + {!!question.excludeFor?.length && ( + <> +
{t("terms.exclude")}:
+ + {question.excludeFor.map((tag, index) => ( + + + + ))} + + + )} +
+ ); + }; + return ( - <> + {(!!question?.includeFor?.length || !!question?.excludeFor?.length) && ( - + + + )} {question.text} @@ -140,7 +187,7 @@ const QuestionsTable: React.FC<{ ) : null} - + ); })} diff --git a/client/src/app/components/risk-icon/risk-icon.tsx b/client/src/app/components/risk-icon/risk-icon.tsx new file mode 100644 index 0000000000..d4a7ddf52d --- /dev/null +++ b/client/src/app/components/risk-icon/risk-icon.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { TimesCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons"; +import { IconedStatus } from "@app/components/IconedStatus"; + +interface RiskIconProps { + risk: string; +} + +const RiskIcon: React.FC = ({ risk }) => { + switch (risk) { + case "green": + return ; + case "red": + return } status="danger" />; + case "yellow": + return } status="warning" />; + default: + return ; + } +}; + +export default RiskIcon; diff --git a/client/src/app/hooks/table-controls/DOCS.md b/client/src/app/hooks/table-controls/DOCS.md index 1b409e1b12..ab19bef9fe 100644 --- a/client/src/app/hooks/table-controls/DOCS.md +++ b/client/src/app/hooks/table-controls/DOCS.md @@ -514,7 +514,9 @@ Table columns are identified by unique keys which are statically inferred from t #### Item IDs -Item objects must contain some unique identifier which is either a string or number. The property key of this identifier is a required config argument called `idProperty`, which will usually be `"id"`. If no unique identifier is present in the API data, an artificial one can be injected before passing the data into these hooks, which can be done in the useQuery `select` callback (see instances where we have used `"_ui_unique_id"`). Any state which keeps track of something by item (i.e. by row) makes use of `item[idProperty]` as an identifier. Examples of this include selected rows, expanded rows and active rows. Valid `idProperty` values are also enforced by TypeScript generics; if an `idProperty` is provided that is not a property on the `TItem` type, you should get a type error. +Item objects must contain some unique identifier which is either a string or number. The property key of this identifier is a required config argument called `idProperty`, which will usually be `"id"`. If no unique identifier is present in the API data, an artificial one can be injected before passing the data into these hooks. This can be done in the useQuery `select` callback (see instances where we have used `"_ui_unique_id"`). Another option is to use the query hook `useWithUiId()` on the react-query fetched data. Since `select` modified data is not part of the query cache, it does not matter if transforms are done in react-query, `useWithUiId` hook, or other means. + +Any state which keeps track of something by item (i.e. by row) makes use of `item[idProperty]` as an identifier. Examples of this include selected rows, expanded rows and active rows. Valid `idProperty` values are also enforced by TypeScript generics. If an `idProperty` is provided that is not a property on the `TItem` type, you should get a type error. > ⚠️ TECH DEBT NOTE: Things specific to `useQuery` and `_ui_unique_id` here are Konveyor-specific notes that should be removed after moving this to table-batteries. diff --git a/client/src/app/hooks/table-controls/filtering/useFilterState.ts b/client/src/app/hooks/table-controls/filtering/useFilterState.ts index 1b2b267d68..890f9dd686 100644 --- a/client/src/app/hooks/table-controls/filtering/useFilterState.ts +++ b/client/src/app/hooks/table-controls/filtering/useFilterState.ts @@ -44,6 +44,7 @@ export type IFilterStateArgs< * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) */ filterCategories: FilterCategory[]; + initialFilterValues?: IFilterValues; } >; @@ -63,6 +64,10 @@ export const useFilterState = < ): IFilterState => { const { isFilterEnabled, persistTo = "state", persistenceKeyPrefix } = args; + const initialFilterValues: IFilterValues = isFilterEnabled + ? args?.initialFilterValues ?? {} + : {}; + // We won't need to pass the latter two type params here if TS adds support for partial inference. // See https://github.com/konveyor/tackle2-ui/issues/1456 const [filterValues, setFilterValues] = usePersistentState< @@ -71,7 +76,7 @@ export const useFilterState = < "filters" >({ isEnabled: !!isFilterEnabled, - defaultValue: {}, + defaultValue: initialFilterValues, persistenceKeyPrefix, // Note: For the discriminated union here to work without TypeScript getting confused // (e.g. require the urlParams-specific options when persistTo === "urlParams"), diff --git a/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts b/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts index 9128b1d598..ec98ee9359 100644 --- a/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts +++ b/client/src/app/hooks/table-controls/getLocalTableControlDerivedState.ts @@ -48,7 +48,7 @@ export const getLocalTableControlDerivedState = < items: sortedItems, }); return { - totalItemCount: items.length, + totalItemCount: filteredItems.length, currentPageItems: isPaginationEnabled ? currentPageItems : sortedItems, }; }; diff --git a/client/src/app/hooks/table-controls/types.ts b/client/src/app/hooks/table-controls/types.ts index 256166ac2e..a96829c6a1 100644 --- a/client/src/app/hooks/table-controls/types.ts +++ b/client/src/app/hooks/table-controls/types.ts @@ -231,7 +231,7 @@ export type ITableControlDerivedState = { */ currentPageItems: TItem[]; /** - * The total number of items in the entire un-filtered, un-paginated table (the size of the entire API collection being tabulated). + * The total number of items after filtering but before pagination. */ totalItemCount: number; }; diff --git a/client/src/app/layout/HeaderApp/SSOMenu.tsx b/client/src/app/layout/HeaderApp/SSOMenu.tsx index d57f07ac6d..1279947f9b 100644 --- a/client/src/app/layout/HeaderApp/SSOMenu.tsx +++ b/client/src/app/layout/HeaderApp/SSOMenu.tsx @@ -45,7 +45,8 @@ export const SSOMenu: React.FC = () => { id="sso-actions-toggle" onClick={() => onDropdownToggle(!isDropdownOpen)} > - {(keycloak?.idTokenParsed as any)["preferred_username"]} + {(keycloak?.idTokenParsed as any)?.["preferred_username"] ?? + "DefaultUsername"} )} > @@ -63,8 +64,6 @@ export const SSOMenu: React.FC = () => { id="logout" key="sso_logout" onClick={() => { - // Clears selected persona from storage without updating it in React state so we don't re-render the persona selector while logging out. - // We have to clear it before logout because the redirect can happen before the logout promise resolves. window.localStorage.removeItem( LocalStorageKey.selectedPersona ); diff --git a/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx b/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx index 02399a22b9..65db4a7afe 100644 --- a/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx +++ b/client/src/app/pages/applications/analysis-wizard/analysis-wizard.tsx @@ -53,11 +53,11 @@ const defaultTaskData: TaskData = { tagger: { enabled: true, }, + verbosity: 0, mode: { binary: false, withDeps: false, artifact: "", - diva: false, }, targets: [], sources: [], @@ -171,7 +171,6 @@ export const AnalysisWizard: React.FC = ({ excludedPackages: [], customRulesFiles: [], excludedRulesTags: [], - diva: false, hasExcludedPackages: false, associatedCredentials: "", rulesKind: "manual", @@ -180,6 +179,7 @@ export const AnalysisWizard: React.FC = ({ branch: "", rootPath: "", autoTaggingEnabled: true, + advancedAnalysisEnabled: false, }, resolver: yupResolver(allFieldsSchema), mode: "all", @@ -216,7 +216,6 @@ export const AnalysisWizard: React.FC = ({ const { mode, withKnownLibs, hasExcludedPackages } = values; const hasIncludedPackages = withKnownLibs.includes("select"); - const setupTaskgroup = ( currentTaskgroup: Taskgroup, fieldValues: AnalysisWizardFormValues @@ -229,6 +228,7 @@ export const AnalysisWizard: React.FC = ({ tasks: analyzableApplications.map((app: Application) => initTask(app)), data: { ...defaultTaskData, + verbosity: fieldValues.advancedAnalysisEnabled ? 1 : 0, tagger: { enabled: fieldValues.autoTaggingEnabled, }, @@ -238,7 +238,6 @@ export const AnalysisWizard: React.FC = ({ artifact: fieldValues.artifact?.name ? `/binary/${fieldValues.artifact.name}` : "", - diva: fieldValues.diva, }, scope: { withKnownLibs: fieldValues.withKnownLibs.includes("oss") diff --git a/client/src/app/pages/applications/analysis-wizard/custom-rules.tsx b/client/src/app/pages/applications/analysis-wizard/custom-rules.tsx index 6f268ed1bf..24508ac368 100644 --- a/client/src/app/pages/applications/analysis-wizard/custom-rules.tsx +++ b/client/src/app/pages/applications/analysis-wizard/custom-rules.tsx @@ -222,7 +222,7 @@ export const CustomRules: React.FC = () => { {t("wizard.label.customRules")} - {values.formLabels.length === 0 && + {values.selectedTargets.length === 0 && values.customRulesFiles.length === 0 && !values.sourceRepository && ( = ({ applications, mode }) => { excludedPackages, customRulesFiles, excludedRulesTags, - diva, autoTaggingEnabled, + advancedAnalysisEnabled, } = watch(); const hasIncludedPackages = withKnownLibs.includes("select"); @@ -195,18 +195,20 @@ export const Review: React.FC = ({ applications, mode }) => { - {t("wizard.terms.transactionReport")} + {t("wizard.terms.autoTagging")} - - {diva ? t("wizard.terms.enabled") : t("wizard.terms.disabled")} + + {autoTaggingEnabled + ? t("wizard.terms.enabled") + : t("wizard.terms.disabled")} - {" "} + - {t("wizard.terms.autoTagging")} + {t("wizard.terms.advancedAnalysisDetails")} - - {autoTaggingEnabled + + {advancedAnalysisEnabled ? t("wizard.terms.enabled") : t("wizard.terms.disabled")} diff --git a/client/src/app/pages/applications/analysis-wizard/schema.ts b/client/src/app/pages/applications/analysis-wizard/schema.ts index 8ce5a54094..9b6682f8f6 100644 --- a/client/src/app/pages/applications/analysis-wizard/schema.ts +++ b/client/src/app/pages/applications/analysis-wizard/schema.ts @@ -125,10 +125,14 @@ const useCustomRulesStepSchema = (): yup.SchemaOf => { then: yup.array().of(customRulesFilesSchema), otherwise: (schema) => schema, }) - .when(["formLabels", "rulesKind"], { - is: (labels: TargetLabel[], rulesKind: string) => - labels.length === 0 && rulesKind === "manual", - then: (schema) => schema.min(1, "At least 1 Rule File is required"), // TODO translation here + .when(["formLabels", "rulesKind", "selectedTargets"], { + is: ( + labels: TargetLabel[], + rulesKind: string, + selectedTargets: number + ) => + labels.length === 0 && rulesKind === "manual" && selectedTargets <= 0, + then: (schema) => schema.min(1, "At least 1 Rule File is required"), }), repositoryType: yup.mixed().when("rulesKind", { is: "repository", @@ -155,18 +159,18 @@ const useCustomRulesStepSchema = (): yup.SchemaOf => { }; export interface OptionsStepValues { - diva: boolean; excludedRulesTags: string[]; autoTaggingEnabled: boolean; + advancedAnalysisEnabled: boolean; selectedSourceLabels: TargetLabel[]; } const useOptionsStepSchema = (): yup.SchemaOf => { const { t } = useTranslation(); return yup.object({ - diva: yup.bool().defined(), excludedRulesTags: yup.array().of(yup.string().defined()), autoTaggingEnabled: yup.bool().defined(), + advancedAnalysisEnabled: yup.bool().defined(), selectedSourceLabels: yup.array().of( yup.object().shape({ name: yup.string().defined(), diff --git a/client/src/app/pages/applications/analysis-wizard/set-options.tsx b/client/src/app/pages/applications/analysis-wizard/set-options.tsx index c0a216570f..a3c895f7b9 100644 --- a/client/src/app/pages/applications/analysis-wizard/set-options.tsx +++ b/client/src/app/pages/applications/analysis-wizard/set-options.tsx @@ -1,10 +1,13 @@ import * as React from "react"; import { Checkbox, + Flex, + FlexItem, Form, Text, TextContent, Title, + Tooltip, } from "@patternfly/react-core"; import { SelectVariant, @@ -25,6 +28,7 @@ import { getParsedLabel } from "@app/utils/rules-utils"; import { DEFAULT_SELECT_MAX_HEIGHT } from "@app/Constants"; import { useFetchTargets } from "@app/queries/targets"; import defaultSources from "./sources"; +import { QuestionCircleIcon } from "@patternfly/react-icons"; export const SetOptions: React.FC = () => { const { t } = useTranslation(); @@ -32,7 +36,12 @@ export const SetOptions: React.FC = () => { const { watch, control, setValue } = useFormContext(); - const { formLabels, diva, excludedRulesTags, autoTaggingEnabled } = watch(); + const { + formLabels, + excludedRulesTags, + autoTaggingEnabled, + advancedAnalysisEnabled, + } = watch(); const [isSelectTargetsOpen, setSelectTargetsOpen] = React.useState(false); const [isSelectSourcesOpen, setSelectSourcesOpen] = React.useState(false); @@ -244,16 +253,6 @@ export const SetOptions: React.FC = () => { removeItemButtonId={(tag) => `remove-${tag}-from-excluded-rules-tags`} className={spacing.mtMd} /> - setValue("diva", !diva)} - id="enable-transaction-report-checkbox" - name="enableTransactionReport" - /> { id="enable-auto-tagging-checkbox" name="autoTaggingEnabled" /> + + + + setValue("advancedAnalysisEnabled", !advancedAnalysisEnabled) + } + id="enable-advanced-analysis-details-checkbox" + name="advancedAnalysisDetailsEnabled" + /> + + + + + + + ); }; diff --git a/client/src/app/pages/applications/applications-table/applications-table.tsx b/client/src/app/pages/applications/applications-table/applications-table.tsx index 7afc874fbd..13d6b52ffe 100644 --- a/client/src/app/pages/applications/applications-table/applications-table.tsx +++ b/client/src/app/pages/applications/applications-table/applications-table.tsx @@ -17,8 +17,6 @@ import { MenuToggle, MenuToggleElement, Modal, - Flex, - FlexItem, } from "@patternfly/react-core"; import { PencilAltIcon, TagIcon, EllipsisVIcon } from "@patternfly/react-icons"; import { @@ -30,7 +28,6 @@ import { ActionsColumn, Tbody, } from "@patternfly/react-table"; -import { QuestionCircleIcon } from "@patternfly/react-icons/dist/esm/icons/question-circle-icon"; // @app components and utilities import { AppPlaceholder } from "@app/components/AppPlaceholder"; @@ -44,7 +41,6 @@ import { ConditionalTableBody, TableRowContentWithControls, } from "@app/components/TableControls"; -import { IconedStatus } from "@app/components/IconedStatus"; import { ToolbarBulkSelector } from "@app/components/ToolbarBulkSelector"; import { ConfirmDialog } from "@app/components/ConfirmDialog"; import { NotificationsContext } from "@app/components/NotificationsContext"; @@ -58,8 +54,13 @@ import keycloak from "@app/keycloak"; import { RBAC, RBAC_TYPE, + analysesReadScopes, applicationsWriteScopes, + assessmentWriteScopes, + credentialsReadScopes, + dependenciesWriteScopes, importsWriteScopes, + reviewsWriteScopes, tasksReadScopes, tasksWriteScopes, } from "@app/rbac"; @@ -68,7 +69,10 @@ import WarningTriangleIcon from "@patternfly/react-icons/dist/esm/icons/warning- // Hooks import { useQueryClient } from "@tanstack/react-query"; -import { useLocalTableControls } from "@app/hooks/table-controls"; +import { + deserializeFilterUrlParams, + useLocalTableControls, +} from "@app/hooks/table-controls"; // Queries import { Application, Assessment, Ref, Task } from "@app/api/models"; @@ -91,13 +95,8 @@ import { ImportApplicationsForm } from "../components/import-applications-form"; import { ConditionalRender } from "@app/components/ConditionalRender"; import { NoDataEmptyState } from "@app/components/NoDataEmptyState"; import { ConditionalTooltip } from "@app/components/ConditionalTooltip"; -import { - getArchetypeById, - getAssessmentsByItemId, - getTaskById, -} from "@app/api/rest"; +import { getArchetypeById, getAssessmentsByItemId } from "@app/api/rest"; import { ApplicationDependenciesForm } from "@app/components/ApplicationDependenciesFormContainer/ApplicationDependenciesForm"; -import { useFetchArchetypes } from "@app/queries/archetypes"; import { useState } from "react"; import { ApplicationAnalysisStatus } from "../components/application-analysis-status"; import { ApplicationDetailDrawer } from "../components/application-detail-drawer/application-detail-drawer"; @@ -105,6 +104,7 @@ import { SimpleDocumentViewerModal } from "@app/components/SimpleDocumentViewer" import { AnalysisWizard } from "../analysis-wizard/analysis-wizard"; import { TaskGroupProvider } from "../analysis-wizard/components/TaskGroupContext"; import { ApplicationIdentityForm } from "../components/application-identity-form/application-identity-form"; +import { ApplicationReviewStatus } from "../components/application-review-status/application-review-status"; export const ApplicationsTable: React.FC = () => { const { t } = useTranslation(); @@ -210,8 +210,6 @@ export const ApplicationsTable: React.FC = () => { refetch: fetchApplications, } = useFetchApplications(); - const { archetypes } = useFetchArchetypes(); - const onDeleteApplicationSuccess = (appIDCount: number) => { pushNotification({ title: t("toastr.success.applicationDeleted", { @@ -246,32 +244,11 @@ export const ApplicationsTable: React.FC = () => { queryClient.invalidateQueries([ApplicationsQueryKey]); }; - const onDeleteAssessmentSuccess = (name: string) => { - pushNotification({ - title: t("toastr.success.assessmentDiscarded", { - application: name, - }), - variant: "success", - }); - queryClient.invalidateQueries([ApplicationsQueryKey]); - }; - - const onDeleteError = (error: AxiosError) => { - pushNotification({ - title: getAxiosErrorMessage(error), - variant: "danger", - }); - }; - const { mutate: deleteReview } = useDeleteReviewMutation( - onDeleteReviewSuccess, - onDeleteError + onDeleteReviewSuccess ); - const { mutate: deleteAssessment } = useDeleteAssessmentMutation( - onDeleteAssessmentSuccess, - onDeleteError - ); + const { mutate: deleteAssessment } = useDeleteAssessmentMutation(); const discardAssessment = async (application: Application) => { try { @@ -283,10 +260,22 @@ export const ApplicationsTable: React.FC = () => { applicationName: application.name, }); }) - ); + ).then(() => { + pushNotification({ + title: t("toastr.success.assessmentDiscarded", { + application: application.name, + }), + variant: "success", + }); + queryClient.invalidateQueries([ApplicationsQueryKey]); + }); } } catch (error) { console.error("Error while deleting assessments:", error); + pushNotification({ + title: getAxiosErrorMessage(error as AxiosError), + variant: "danger", + }); } }; @@ -300,9 +289,18 @@ export const ApplicationsTable: React.FC = () => { } } catch (error) { console.error("Error while deleting review:", error); + pushNotification({ + title: getAxiosErrorMessage(error as AxiosError), + variant: "danger", + }); } }; + const urlParams = new URLSearchParams(window.location.search); + const filters = urlParams.get("filters"); + + const deserializedFilterValues = deserializeFilterUrlParams({ filters }); + const tableControls = useLocalTableControls({ idProperty: "id", items: applications || [], @@ -321,6 +319,7 @@ export const ApplicationsTable: React.FC = () => { isActiveItemEnabled: true, sortableColumns: ["name", "businessService", "tags", "effort"], initialSort: { columnKey: "name", direction: "asc" }, + initialFilterValues: deserializedFilterValues, getSortValues: (app) => ({ name: app.name, businessService: app.businessService?.name || "", @@ -331,12 +330,17 @@ export const ApplicationsTable: React.FC = () => { { key: "name", title: t("terms.name"), - type: FilterType.search, + type: FilterType.multiselect, placeholderText: t("actions.filterBy", { what: t("terms.name").toLowerCase(), }) + "...", getItemValue: (item) => item?.name || "", + selectOptions: [ + ...new Set( + applications.map((application) => application.name).filter(Boolean) + ), + ].map((name) => ({ key: name, value: name })), }, { key: "archetypes", @@ -468,6 +472,22 @@ export const ApplicationsTable: React.FC = () => { return matchString; }, }, + { + key: "risk", + title: t("terms.risk"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.risk").toLowerCase(), + }) + "...", + selectOptions: [ + { key: "green", value: "Low" }, + { key: "yellow", value: "Medium" }, + { key: "red", value: "High" }, + { key: "unknown", value: "Unknown" }, + ], + getItemValue: (item) => item.risk || "", + }, ], initialItemsPerPage: 10, hasActionsColumn: true, @@ -517,8 +537,13 @@ export const ApplicationsTable: React.FC = () => { const userScopes: string[] = token?.scope.split(" ") || [], importWriteAccess = checkAccess(userScopes, importsWriteScopes), applicationWriteAccess = checkAccess(userScopes, applicationsWriteScopes), + assessmentWriteAccess = checkAccess(userScopes, assessmentWriteScopes), + credentialsReadAccess = checkAccess(userScopes, credentialsReadScopes), + dependenciesWriteAccess = checkAccess(userScopes, dependenciesWriteScopes), + analysesReadAccess = checkAccess(userScopes, analysesReadScopes), tasksReadAccess = checkAccess(userScopes, tasksReadScopes), - tasksWriteAccess = checkAccess(userScopes, tasksWriteScopes); + tasksWriteAccess = checkAccess(userScopes, tasksWriteScopes), + reviewsWriteAccess = checkAccess(userScopes, reviewsWriteScopes); const areAppsInWaves = selectedRows.some( (application) => application.migrationWave !== null @@ -543,6 +568,7 @@ export const ApplicationsTable: React.FC = () => { , ] : []; + const applicationDropdownItems = applicationWriteAccess ? [ { {t("actions.delete")} , - { - setSaveApplicationsCredentialsModalState(selectedRows); - }} - > - {t("actions.manageCredentials")} - , + ...(credentialsReadAccess + ? [ + { + setSaveApplicationsCredentialsModalState(selectedRows); + }} + > + {t("actions.manageCredentials")} + , + ] + : []), ] : []; + const dropdownItems = [...importDropdownItems, ...applicationDropdownItems]; const isAnalyzingAllowed = () => { @@ -588,6 +619,7 @@ export const ApplicationsTable: React.FC = () => { if (candidateTasks.length === selectedRows.length) return true; return false; }; + const hasExistingAnalysis = selectedRows.some((app) => tasks.some((task) => task.application?.id === app.id) ); @@ -690,7 +722,9 @@ export const ApplicationsTable: React.FC = () => { return ( } >
{ > {currentPageItems?.map((application, rowIndex) => { - const isAppReviewed = !!application.review; - const applicationArchetypes = application.archetypes?.map( - (archetypeRef) => { - return archetypes.find( - (archetype) => archetype.id === archetypeRef.id - ); - } + const hasExistingAnalysis = tasks.some( + (task) => task.application?.id === application.id ); - const hasReviewedArchetype = applicationArchetypes?.some( - (archetype) => !!archetype?.review - ); - - const hasAssessedArchetype = applicationArchetypes?.some( - (archetype) => !!archetype?.assessments?.length - ); - console.log("hasassessed", hasAssessedArchetype); return ( { modifier="truncate" {...getTdProps({ columnKey: "assessment" })} > - - - - - - - - - - + - - - - - - - - - - + { > {application?.effort ?? "-"} + - ) : ( - + Loading... )} @@ -247,7 +257,7 @@ const DynamicAssessmentActionsRow: FunctionComponent<
{assessment ? ( - + + {link.title} ))} diff --git a/client/src/app/pages/issues/helpers.ts b/client/src/app/pages/issues/helpers.ts index 489296aa1a..a78958e5e8 100644 --- a/client/src/app/pages/issues/helpers.ts +++ b/client/src/app/pages/issues/helpers.ts @@ -3,6 +3,7 @@ import { AnalysisIssue, AnalysisIssueReport, AnalysisRuleReport, + Archetype, } from "@app/api/models"; import { FilterCategory, @@ -62,16 +63,30 @@ export const useSharedAffectedApplicationFilterCategories = < t("actions.filterBy", { what: t("terms.archetype").toLowerCase(), }) + "...", - selectOptions: archetypes.map(({ name }) => ({ key: name, value: name })), - getServerFilterValue: (selectedOptions) => - selectedOptions - ?.map((option) => archetypes.find((item) => item.name === option)) + selectOptions: archetypes.map(({ name }) => ({ + key: name, + value: name, + })), + + getServerFilterValue: (selectedOptions) => { + const findArchetypeByName = (name: string) => { + return archetypes.find((item) => item.name === name); + }; + + const getApplicationIds = (archetype: Archetype) => { + return archetype.applications?.map((app) => String(app.id)); + }; + + if (!selectedOptions) return ["-1"]; + + const archetypeIds = selectedOptions + .map((option) => findArchetypeByName(option)) .filter(Boolean) - .flatMap( - ({ applications }) => - applications?.map(({ id }) => String(id)) ?? [] - ) - .filter(Boolean), + .flatMap((archetype) => getApplicationIds(archetype)) + .filter(Boolean); + + return archetypeIds.length === 0 ? ["-1"] : archetypeIds; + }, }, { key: "businessService.name", @@ -232,6 +247,4 @@ export const parseReportLabels = ( export const getIssueTitle = ( issueReport: AnalysisRuleReport | AnalysisIssue | AnalysisIssueReport ) => - issueReport?.description?.split("\n")[0] || - issueReport?.name?.split("\n")[0] || - "*Unnamed*"; + issueReport?.description || issueReport?.name?.split("\n")[0] || "*Unnamed*"; diff --git a/client/src/app/pages/issues/issues-table.tsx b/client/src/app/pages/issues/issues-table.tsx index 7229f9015c..401e724eba 100644 --- a/client/src/app/pages/issues/issues-table.tsx +++ b/client/src/app/pages/issues/issues-table.tsx @@ -33,7 +33,7 @@ import { useSelectionState } from "@migtools/lib-ui"; import { AppPlaceholder } from "@app/components/AppPlaceholder"; import { OptionWithValue, SimpleSelect } from "@app/components/SimpleSelect"; -import { TablePersistenceKeyPrefix } from "@app/Constants"; +import { TablePersistenceKeyPrefix, UI_UNIQUE_ID } from "@app/Constants"; import { useFetchIssueReports, useFetchRuleReports } from "@app/queries/issues"; import { FilterType, @@ -138,8 +138,18 @@ export const IssuesTable: React.FC = ({ mode }) => { what: t("terms.source").toLowerCase(), }) + "...", serverFilterField: "labels", - getServerFilterValue: (value) => - value?.length === 1 ? [`konveyor.io/source=*${value}*`] : undefined, + getServerFilterValue: (value) => { + if ( + (value && value[0] === "None") || + (value && value[0] === "none") + ) { + return [`konveyor.io/source`]; + } else if (value && value.length > 0) { + return [`konveyor.io/source=*${value}*`]; + } else { + return undefined; + } + }, }, { key: "target", @@ -216,7 +226,7 @@ export const IssuesTable: React.FC = ({ mode }) => { const tableControls = useTableControlProps({ ...tableControlState, // Includes filterState, sortState and paginationState - idProperty: "_ui_unique_id", + idProperty: UI_UNIQUE_ID, currentPageItems: currentPageReports, totalItemCount: totalReportCount, isLoading, diff --git a/client/src/app/pages/migration-targets/components/custom-target-form.tsx b/client/src/app/pages/migration-targets/components/custom-target-form.tsx index 12bbf8ba7f..9e7e5edab5 100644 --- a/client/src/app/pages/migration-targets/components/custom-target-form.tsx +++ b/client/src/app/pages/migration-targets/components/custom-target-form.tsx @@ -285,7 +285,7 @@ export const CustomTargetForm: React.FC = ({ description: formValues?.description?.trim() || "", ...(formValues.imageID && { image: { id: formValues.imageID } }), custom: true, - labels: labels.length ? labels : [{ name: "custom", label: "custom" }], + labels: labels.length ? labels : [], ruleset: { id: target && target.custom ? target.ruleset.id : undefined, name: formValues.name.trim(), diff --git a/client/src/app/pages/migration-waves/components/migration-wave-form.tsx b/client/src/app/pages/migration-waves/components/migration-wave-form.tsx index 3788539a75..2b098417c8 100644 --- a/client/src/app/pages/migration-waves/components/migration-wave-form.tsx +++ b/client/src/app/pages/migration-waves/components/migration-wave-form.tsx @@ -20,6 +20,7 @@ import { useUpdateMigrationWaveMutation, } from "@app/queries/migration-waves"; import dayjs from "dayjs"; + import { Stakeholder, StakeholderGroup, @@ -34,6 +35,7 @@ import { OptionWithValue, SimpleSelect } from "@app/components/SimpleSelect"; import { NotificationsContext } from "@app/components/NotificationsContext"; import { DEFAULT_SELECT_MAX_HEIGHT } from "@app/Constants"; import { matchItemsToRefs } from "@app/utils/model-utils"; +import { useRef } from "react"; const stakeholderGroupToOption = ( value: StakeholderGroup @@ -204,7 +206,7 @@ export const WaveForm: React.FC = ({ watch, trigger, } = useForm({ - mode: "all", + mode: "onChange", defaultValues: { name: migrationWave?.name || "", startDateStr: migrationWave?.startDate @@ -220,9 +222,13 @@ export const WaveForm: React.FC = ({ }); const startDateStr = watch("startDateStr"); + const endDateStr = watch("endDateStr"); const startDate = dateStrFormatValidator(startDateStr) ? dayjs(startDateStr).toDate() : null; + const endDate = dateStrFormatValidator(endDateStr) + ? dayjs(endDateStr).toDate() + : null; const onSubmit = (formValues: WaveFormValues) => { const stakeholders = @@ -256,17 +262,27 @@ export const WaveForm: React.FC = ({ }; const startDateRangeValidator = (date: Date) => { - if (date < dayjs().toDate()) { - return "Date is before allowable range."; + const selectedDate = dayjs(date); + const currentDate = dayjs(); + + if (selectedDate.isBefore(currentDate, "day")) { + return "Start date cannot be in the past."; } + return ""; }; const endDateRangeValidator = (date: Date) => { - const sDate = startDate || new Date(); - if (sDate >= date) { - return "Date is before allowable range."; + const selectedEndDate = dayjs(date); + const selectedStartDate = startDate ? dayjs(startDate) : null; + + if ( + !selectedStartDate || + selectedEndDate.isSameOrBefore(selectedStartDate, "day") + ) { + return "End date must be at least one day after the start date."; } + return ""; }; @@ -276,6 +292,9 @@ export const WaveForm: React.FC = ({ const stakeholderGroupsToRefs = (names: string[] | undefined | null) => matchItemsToRefs(stakeholderGroups, (i) => i.name, names); + const startDateRef = useRef(null); + const endDateRef = useRef(null); + return (
@@ -288,28 +307,30 @@ export const WaveForm: React.FC = ({ /> - ( - { - onChange(val); - trigger("endDateStr"); // Validation of endDateStr depends on startDateStr - }} - placeholder="MM/DD/YYYY" - value={value} - dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")} - dateParse={(val) => dayjs(val).toDate()} - validators={[startDateRangeValidator]} - appendTo={() => document.body} - /> - )} - /> +
+ ( + { + onChange(val); + if (endDate) trigger("endDateStr"); + }} + placeholder="MM/DD/YYYY" + value={value} + dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")} + dateParse={(val) => dayjs(val).toDate()} + validators={[startDateRangeValidator]} + appendTo={() => startDateRef.current || document.body} + /> + )} + /> +
= ({ > to - - ( - { - onChange(val); - }} - placeholder="MM/DD/YYYY" - value={value} - dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")} - dateParse={(val) => dayjs(val).toDate()} - validators={[endDateRangeValidator]} - appendTo={() => document.body} - isDisabled={!!formErrors.startDateStr} - /> - )} - /> +
+ ( + onChange(val)} + placeholder="MM/DD/YYYY" + value={value && startDate ? value : ""} + dateFormat={(val) => dayjs(val).format("MM/DD/YYYY")} + dateParse={(val) => dayjs(val).toDate()} + validators={[endDateRangeValidator]} + rangeStart={startDate ? startDate : undefined} + appendTo={() => endDateRef.current || document.body} + /> + )} + /> +
+ = ({ diff --git a/client/src/app/pages/migration-waves/components/ticket-issue.tsx b/client/src/app/pages/migration-waves/components/ticket-issue.tsx index 3f70f0a551..75e80c4fff 100644 --- a/client/src/app/pages/migration-waves/components/ticket-issue.tsx +++ b/client/src/app/pages/migration-waves/components/ticket-issue.tsx @@ -1,7 +1,9 @@ import React from "react"; -import { Text } from "@patternfly/react-core"; +import { Text, TextVariants } from "@patternfly/react-core"; import { Ticket } from "@app/api/models"; +import { useTranslation } from "react-i18next"; +import ExternalLink from "@app/components/ExternalLink"; import { useTrackerTypesByProjectId } from "@app/queries/trackers"; export interface ITicketIssueProps { @@ -9,17 +11,28 @@ export interface ITicketIssueProps { } export const TicketIssue: React.FC = ({ ticket }) => { - const useTicketIssue = () => { - const types = useTrackerTypesByProjectId( - ticket?.tracker?.name, - ticket?.parent - ); - const type = types.find((kind) => kind.id === ticket?.kind); - if (type) return type.name; - return ""; - }; + const { t } = useTranslation(); + const ticketIssue = useTicketIssue(ticket); - const ticketIssue = useTicketIssue(); + return ( + + {ticket?.link ? ( + + {ticketIssue} + + ) : ( + t("terms.unassigned") + )} + + ); +}; + +const useTicketIssue = (ticket?: Ticket) => { + const types = useTrackerTypesByProjectId( + ticket?.tracker?.name, + ticket?.parent + ); + const type = types.find((kind) => kind.id === ticket?.kind); - return {ticketIssue}; + return type ? type.name : ""; }; diff --git a/client/src/app/pages/migration-waves/components/wave-status-table.tsx b/client/src/app/pages/migration-waves/components/wave-status-table.tsx index b59d981837..c2e9c2f08b 100644 --- a/client/src/app/pages/migration-waves/components/wave-status-table.tsx +++ b/client/src/app/pages/migration-waves/components/wave-status-table.tsx @@ -3,13 +3,13 @@ import { MigrationWave, Ticket, WaveWithStatus } from "@app/api/models"; import { useTranslation } from "react-i18next"; import { Button, - ButtonVariant, CodeBlock, CodeBlockCode, Modal, Toolbar, ToolbarContent, ToolbarItem, + Tooltip, } from "@patternfly/react-core"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import alignment from "@patternfly/react-styles/css/utilities/Alignment/alignment"; @@ -22,20 +22,34 @@ import { TableRowContentWithControls, } from "@app/components/TableControls"; import { SimplePagination } from "@app/components/SimplePagination"; -import { NoDataEmptyState } from "@app/components/NoDataEmptyState"; import { useHistory } from "react-router-dom"; import { useFetchTickets } from "@app/queries/tickets"; -import { Paths } from "@app/Paths"; import { TicketIssue } from "./ticket-issue"; +import { useDeleteTicketMutation } from "@app/queries/migration-waves"; +import UnlinkIcon from "@patternfly/react-icons/dist/esm/icons/unlink-icon"; + +type SetCellExpandedArgs = { + item: WaveWithStatus; + isExpanding?: boolean; + columnKey?: + | "stakeholders" + | "applications" + | "name" + | "startDate" + | "endDate" + | "status"; +}; export interface IWaveStatusTableProps { migrationWave: WaveWithStatus; removeApplication: (migrationWave: MigrationWave, id: number) => void; + setCellExpanded: (args: SetCellExpandedArgs) => void; } export const WaveStatusTable: React.FC = ({ migrationWave, removeApplication, + setCellExpanded, }) => { const { t } = useTranslation(); const [codeModalState, setCodeModalState] = useState< @@ -44,6 +58,7 @@ export const WaveStatusTable: React.FC = ({ const history = useHistory(); const { tickets } = useFetchTickets(); + const { mutate: deleteTicket } = useDeleteTicketMutation(); const tableControls = useLocalTableControls({ idProperty: "name", @@ -110,72 +125,91 @@ export const WaveStatusTable: React.FC = ({ - -
- -
-
- } > - {currentPageItems?.map((app, rowIndex) => ( - - - - {app.name} - - - {getTicketByApplication(tickets, app.id)?.error ? ( + {currentPageItems?.map((app, rowIndex) => { + const ticket = getTicketByApplication(tickets, app.id); + return ( + + + + {app.name} + + + {getTicketByApplication(tickets, app.id)?.error ? ( + + ) : ( + getTicketByApplication(tickets, app?.id)?.status || "" + )} + + + + + + {ticket?.id && ( + + - ) : ( - getTicketByApplication(tickets, app?.id)?.status || "" - )} - - - - - - - - - - ))} + + + + ); + })} diff --git a/client/src/app/pages/migration-waves/migration-waves.tsx b/client/src/app/pages/migration-waves/migration-waves.tsx index 586f526b7e..19117b6f38 100644 --- a/client/src/app/pages/migration-waves/migration-waves.tsx +++ b/client/src/app/pages/migration-waves/migration-waves.tsx @@ -234,7 +234,7 @@ export const MigrationWaves: React.FC = () => { getTdProps, getExpandedContentTdProps, }, - expansionDerivedState: { isCellExpanded }, + expansionDerivedState: { isCellExpanded, setCellExpanded }, } = tableControls; // TODO: Check RBAC access @@ -437,7 +437,7 @@ export const MigrationWaves: React.FC = () => { > {migrationWave.applications.length ? migrationWave.status - : "N/A"} + : "--"} { ) )} diff --git a/client/src/app/pages/reports/components/application-landscape/application-landscape.tsx b/client/src/app/pages/reports/components/application-landscape/application-landscape.tsx new file mode 100644 index 0000000000..6637c350e8 --- /dev/null +++ b/client/src/app/pages/reports/components/application-landscape/application-landscape.tsx @@ -0,0 +1,217 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Flex, FlexItem, Skeleton } from "@patternfly/react-core"; + +import { RISK_LIST } from "@app/Constants"; +import { + Application, + AssessmentWithArchetypeApplications, + IdRef, + Questionnaire, + Ref, +} from "@app/api/models"; +import { ConditionalRender } from "@app/components/ConditionalRender"; +import { useFetchAssessmentsWithArchetypeApplications } from "@app/queries/assessments"; +import { useFetchApplications } from "@app/queries/applications"; +import { Donut } from "../donut/donut"; +import { serializeFilterUrlParams } from "@app/hooks/table-controls"; +import { Paths } from "@app/Paths"; +import { Link } from "react-router-dom"; + +interface IAggregateRiskData { + green: number; + yellow: number; + red: number; + unknown: number; + unassessed: number; + applicationsCount: number; +} + +const aggregateRiskData = ( + assessments: AssessmentWithArchetypeApplications[], + applications: Application[] +): IAggregateRiskData => { + let low = 0; + let medium = 0; + let high = 0; + let unknown = 0; + const processedAppIds = new Set(); // Set to track processed application IDs + + const findFullApplication = (ref: Ref) => { + return applications.find((app) => app.id === ref.id); + }; + + assessments?.forEach((assessment) => { + const combinedApplications = [ + ...(assessment.application ? [assessment.application] : []), + ...(assessment.archetypeApplications ?? []), + ]; + + const uniqueApplications = combinedApplications.reduce( + (acc: Ref[], current) => { + if (!acc.find((item) => item?.id === current.id)) { + acc.push(current); + } + return acc; + }, + [] + ); + + uniqueApplications.forEach((appRef) => { + const fullApp = findFullApplication(appRef); + if (fullApp && fullApp.risk && !processedAppIds.has(fullApp.id)) { + processedAppIds.add(fullApp.id); + + switch (fullApp.risk) { + case "green": + low++; + break; + case "yellow": + medium++; + break; + case "red": + high++; + break; + case "unknown": + unknown++; + break; + } + } + }); + }); + const unassessed = applications.length - processedAppIds.size; + + return { + green: low, + yellow: medium, + red: high, + unknown, + unassessed, + applicationsCount: applications.length, + }; +}; + +interface IApplicationLandscapeProps { + /** + * The selected questionnaire or `null` if _all questionnaires_ is selected. + */ + questionnaire: Questionnaire | null; + + /** + * The set of assessments for the selected questionnaire. Risk values will be + * aggregated from the individual assessment risks. + */ + assessmentRefs?: IdRef[]; +} + +export const ApplicationLandscape: React.FC = ({ + questionnaire, + assessmentRefs, +}) => { + const { t } = useTranslation(); + + const { assessmentsWithArchetypeApplications } = + useFetchAssessmentsWithArchetypeApplications(); + const { data: applications } = useFetchApplications(); + + const filteredAssessments = assessmentsWithArchetypeApplications.filter( + (assessment) => assessmentRefs?.some((ref) => ref.id === assessment.id) + ); + + const landscapeData = useMemo( + () => aggregateRiskData(filteredAssessments, applications), + [filteredAssessments, applications] + ); + + return ( + + + + } + > + {landscapeData && ( + + + {t("terms.highRisk")} + } + riskTitle={t("terms.highRisk")} + riskDescription={questionnaire?.riskMessages?.red ?? ""} + /> + + + + {t("terms.mediumRisk")} + + } + riskTitle={t("terms.mediumRisk")} + riskDescription={questionnaire?.riskMessages?.yellow ?? ""} + /> + + + {t("terms.lowRisk")} + } + riskTitle={t("terms.lowRisk")} + riskDescription={questionnaire?.riskMessages?.green ?? ""} + /> + + + + {`${t("terms.unassessed")}/${t("terms.unknown")}`} + + } + riskTitle={t("terms.unassessedOrUnknown")} + /> + + + )} + + ); +}; + +const getRisksUrl = (risks: string[]) => { + const filterValues = { + risk: risks, + }; + + const serializedParams = serializeFilterUrlParams(filterValues); + + const queryString = serializedParams.filters + ? `filters=${serializedParams.filters}` + : ""; + return `${Paths.applications}?${queryString}`; +}; diff --git a/client/src/app/pages/reports/components/application-landscape/index.ts b/client/src/app/pages/reports/components/application-landscape/index.ts new file mode 100644 index 0000000000..c942552221 --- /dev/null +++ b/client/src/app/pages/reports/components/application-landscape/index.ts @@ -0,0 +1 @@ +export { ApplicationLandscape } from "./application-landscape"; diff --git a/client/src/app/pages/reports/components/landscape/donut.tsx b/client/src/app/pages/reports/components/donut/donut.tsx similarity index 66% rename from client/src/app/pages/reports/components/landscape/donut.tsx rename to client/src/app/pages/reports/components/donut/donut.tsx index 6d40bada99..6d5c72eb29 100644 --- a/client/src/app/pages/reports/components/landscape/donut.tsx +++ b/client/src/app/pages/reports/components/donut/donut.tsx @@ -10,6 +10,7 @@ import { StackItem, Text, TextContent, + TextVariants, } from "@patternfly/react-core"; export interface IDonutProps { @@ -17,8 +18,10 @@ export interface IDonutProps { value: number; total: number; color: string; - riskLabel: string; + riskLabel: string | React.ReactElement; riskDescription?: string; + riskTitle: string; + isAssessment: boolean; } export const Donut: React.FC = ({ @@ -27,6 +30,8 @@ export const Donut: React.FC = ({ total, color, riskLabel, + isAssessment, + riskTitle, riskDescription, }) => { const { t } = useTranslation(); @@ -38,12 +43,18 @@ export const Donut: React.FC = ({ `${datum.x}: ${datum.y}`} @@ -54,7 +65,12 @@ export const Donut: React.FC = ({ {riskLabel} - {riskDescription} + + {riskDescription} + diff --git a/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx b/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx index 74507382f5..cc97ee7cd9 100644 --- a/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx +++ b/client/src/app/pages/reports/components/identified-risks-table/identified-risks-table.tsx @@ -1,46 +1,109 @@ import React from "react"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; -import { useFetchAssessments } from "@app/queries/assessments"; +import { useFetchAssessmentsWithArchetypeApplications } from "@app/queries/assessments"; import { useTranslation } from "react-i18next"; -import { Ref } from "@app/api/models"; +import { + Answer, + AssessmentWithArchetypeApplications, + IdRef, + Question, + Ref, +} from "@app/api/models"; import { NoDataEmptyState } from "@app/components/NoDataEmptyState"; import { TableHeaderContentWithControls, ConditionalTableBody, TableRowContentWithControls, } from "@app/components/TableControls"; -import { useLocalTableControls } from "@app/hooks/table-controls"; +import { + serializeFilterUrlParams, + useLocalTableControls, +} from "@app/hooks/table-controls"; import { SimplePagination } from "@app/components/SimplePagination"; -import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { + TextContent, + Toolbar, + ToolbarContent, + ToolbarItem, + Text, + Divider, +} from "@patternfly/react-core"; +import { Link } from "react-router-dom"; +import { Paths } from "@app/Paths"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; +import RiskIcon from "@app/components/risk-icon/risk-icon"; +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; -export interface IIdentifiedRisksTableProps {} +export interface IIdentifiedRisksTableProps { + assessmentRefs?: IdRef[]; + isReviewPage?: boolean; +} +const riskLevelMapping = { + red: 3, + yellow: 2, + green: 1, + unknown: 0, +}; -export const IdentifiedRisksTable: React.FC< - IIdentifiedRisksTableProps -> = () => { +export const IdentifiedRisksTable: React.FC = ({ + assessmentRefs, + isReviewPage = false, +}) => { const { t } = useTranslation(); - const { assessments } = useFetchAssessments(); + const { assessmentsWithArchetypeApplications } = + useFetchAssessmentsWithArchetypeApplications(); interface ITableRowData { - assessmentName: string; + questionnaireName: string; questionId: string; section: string; - question: string; - answer: string; + question: Question; + answer: Answer; applications: Ref[]; } const tableData: ITableRowData[] = []; - // ... - assessments.forEach((assessment) => { + const filterAssessmentsByRefs = ( + assessments: AssessmentWithArchetypeApplications[], + refs: IdRef[] + ) => { + if (refs && refs.length > 0) { + return assessments.filter((assessment) => + refs.some((ref) => ref.id === assessment.id) + ); + } + return assessments; + }; + + const filteredAssessments = filterAssessmentsByRefs( + assessmentsWithArchetypeApplications, + assessmentRefs || [] + ); + + filteredAssessments.forEach((assessment) => { + const combinedApplications = [ + ...(assessment.application ? [assessment.application] : []), + ...(assessment.archetypeApplications ?? []), + ]; + + const uniqueApplications = combinedApplications.reduce( + (acc: Ref[], current) => { + if (!acc.find((item) => item?.id === current.id)) { + acc.push(current); + } + return acc; + }, + [] + ); + assessment.sections.forEach((section) => { section.questions.forEach((question) => { question.answers.forEach((answer) => { if (answer.selected) { const itemId = [ - assessment.id, + assessment.questionnaire.id, section.order, question.order, answer.order, @@ -52,23 +115,22 @@ export const IdentifiedRisksTable: React.FC< if (existingItemIndex !== -1) { const existingItem = tableData[existingItemIndex]; - if ( - assessment.application && - !existingItem.applications - .map((app) => app.name) - .includes(assessment.application.name) - ) { - existingItem.applications.push(assessment.application); - } + uniqueApplications.forEach((application) => { + if ( + !existingItem.applications.some( + (app) => app.id === application.id + ) + ) { + existingItem.applications.push(application); + } + }); } else { tableData.push({ section: section.name, - question: question.text, - answer: answer.text, - applications: assessment.application - ? [assessment.application] - : [], - assessmentName: assessment.questionnaire.name, + question: question, + answer: answer, + applications: uniqueApplications ? uniqueApplications : [], + questionnaireName: assessment.questionnaire.name, questionId: itemId, }); } @@ -82,24 +144,117 @@ export const IdentifiedRisksTable: React.FC< idProperty: "questionId", items: tableData || [], columnNames: { - assessmentName: "Assessment Name", + questionnaireName: "Questionnaire Name", section: "Section", question: "Question", answer: "Answer", + risk: "Risk", applications: "Applications", }, variant: "compact", isPaginationEnabled: true, + isFilterEnabled: true, isSortEnabled: true, hasActionsColumn: false, getSortValues: (item) => ({ - assessmentName: item.assessmentName, + questionnaireName: item.questionnaireName, section: item.section, - question: item.question, - answer: item.answer, + question: item.question.text, + answer: item.answer.text, applications: item.applications.length, + risk: + riskLevelMapping[item.answer.risk as keyof typeof riskLevelMapping] || + riskLevelMapping["unknown"], }), - sortableColumns: ["assessmentName", "section", "question", "answer"], + sortableColumns: [ + "questionnaireName", + "section", + "question", + "answer", + "risk", + ], + isExpansionEnabled: true, + expandableVariant: "single", + filterCategories: [ + { + key: "questionnaireName", + title: t("terms.questionnaire"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.questionnaire").toLowerCase(), + }) + "...", + getItemValue: (item) => item.questionnaireName || "", + selectOptions: [ + ...new Set( + tableData.map((item) => item.questionnaireName).filter(Boolean) + ), + ].map((name) => ({ key: name, value: name })), + }, + { + key: "section", + title: t("terms.section"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.section").toLowerCase(), + }) + "...", + getItemValue: (item) => item.section || "", + selectOptions: [ + ...new Set(tableData.map((item) => item.section).filter(Boolean)), + ].map((name) => ({ key: name, value: name })), + }, + { + key: "question", + title: t("terms.question"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.question").toLowerCase(), + }) + "...", + getItemValue: (item) => item.question.text || "", + selectOptions: [ + ...new Set( + tableData.map((item) => item.question.text).filter(Boolean) + ), + ].map((name) => ({ key: name, value: name })), + }, + { + key: "answer", + title: t("terms.answer"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { + what: t("terms.answer").toLowerCase(), + }) + "...", + getItemValue: (item) => item.answer.text || "", + selectOptions: [ + ...new Set(tableData.map((item) => item.answer.text).filter(Boolean)), + ].map((name) => ({ key: name, value: name })), + }, + { + key: "risk", + title: t("terms.risk"), + type: FilterType.multiselect, + placeholderText: + t("actions.filterBy", { what: t("terms.risk").toLowerCase() }) + + "...", + getItemValue: (item: ITableRowData) => { + const riskKey = item.answer.risk; + const riskValue = + riskLevelMapping[riskKey as keyof typeof riskLevelMapping]; + return riskValue.toString(); + }, + selectOptions: [ + { key: "3", value: "High" }, + { key: "2", value: "Medium" }, + { key: "1", value: "Low" }, + { key: "0", value: "Unknown" }, + ], + }, + ], + initialItemsPerPage: 10, + isSelectionEnabled: false, }); const { @@ -108,18 +263,24 @@ export const IdentifiedRisksTable: React.FC< propHelpers: { toolbarProps, paginationToolbarItemProps, + filterToolbarProps, paginationProps, tableProps, getThProps, getTrProps, getTdProps, + getExpandedContentTdProps, }, + sortState, + expansionDerivedState: { isCellExpanded }, } = tableControls; return ( <> + {...filterToolbarProps} /> + - - Assessment name + + Questionnaire name Section Question Answer - - Application - + Risk + { + /* Only show the applications column on the reports page */ + !isReviewPage && ( + + Application + + ) + } @@ -161,34 +328,90 @@ export const IdentifiedRisksTable: React.FC< {currentPageItems?.map((item, rowIndex) => { return ( - - - - {item.assessmentName} - - - {item?.section ?? "N/A"} - - - {item?.question ?? "N/A"} - - - {item.answer ?? "N/A"} - - - {item?.applications.length ?? "N/A"} - - - + <> + + + + {item.questionnaireName} + + + {item?.section ?? "N/A"} + + + {item?.question.text ?? "N/A"} + + + {item.answer.text ?? "N/A"} + + + + + {!isReviewPage && ( + + {item?.applications.length ? ( + + {t("composed.totalApplications", { + count: item.applications.length, + })} + + ) : ( + "N/A" + )} + + )} + + + {isCellExpanded(item) ? ( + + + + + Rationale + + {item?.answer?.rationale + ? item.answer.rationale + : "N/A"} + + + + Mitigation + + {item?.answer?.mitigation + ? item.answer.mitigation + : "N/A"} + + + + + ) : null} + ); })} + ); }; + +const getApplicationsUrl = (applications: Ref[]) => { + const filterValues = { + name: applications.map((app) => app.name), + }; + + const serializedParams = serializeFilterUrlParams(filterValues); + + const queryString = serializedParams.filters + ? `filters=${serializedParams.filters}` + : ""; + return `${Paths.applications}?${queryString}`; +}; diff --git a/client/src/app/pages/reports/components/landscape/index.ts b/client/src/app/pages/reports/components/landscape/index.ts deleted file mode 100644 index b3d59d110b..0000000000 --- a/client/src/app/pages/reports/components/landscape/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Landscape } from "./landscape"; diff --git a/client/src/app/pages/reports/components/landscape/landscape.tsx b/client/src/app/pages/reports/components/landscape/landscape.tsx deleted file mode 100644 index 6a9fec9b30..0000000000 --- a/client/src/app/pages/reports/components/landscape/landscape.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { Flex, FlexItem, Skeleton } from "@patternfly/react-core"; - -import { RISK_LIST } from "@app/Constants"; -import { Assessment, Questionnaire } from "@app/api/models"; -import { ConditionalRender } from "@app/components/ConditionalRender"; -import { Donut } from "./donut"; - -interface IAggregateRiskData { - green: number; - yellow: number; - red: number; - unknown: number; - unassessed: number; - assessmentCount: number; -} - -const aggregateRiskData = (assessments: Assessment[]): IAggregateRiskData => { - let low = 0; - let medium = 0; - let high = 0; - let unknown = 0; - - assessments?.forEach((assessment) => { - switch (assessment.risk) { - case "green": - low++; - break; - case "yellow": - medium++; - break; - case "red": - high++; - break; - case "unknown": - unknown++; - break; - } - }); - - return { - green: low, - yellow: medium, - red: high, - unknown, - unassessed: assessments.length - low - medium - high, - assessmentCount: assessments.length, - }; -}; - -interface ILandscapeProps { - /** - * The selected questionnaire or `null` if _all questionnaires_ is selected. - */ - questionnaire: Questionnaire | null; - - /** - * The set of assessments for the selected questionnaire. Risk values will be - * aggregated from the individual assessment risks. - */ - assessments: Assessment[]; -} - -export const Landscape: React.FC = ({ - questionnaire, - assessments, -}) => { - const { t } = useTranslation(); - - const landscapeData = useMemo( - () => aggregateRiskData(assessments), - [assessments] - ); - - return ( - - - - } - > - {landscapeData && ( - - - - - - - - - - - - - - - )} - - ); -}; diff --git a/client/src/app/pages/reports/reports.tsx b/client/src/app/pages/reports/reports.tsx index 201b3bcadb..e2941d79be 100644 --- a/client/src/app/pages/reports/reports.tsx +++ b/client/src/app/pages/reports/reports.tsx @@ -1,28 +1,21 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { - Bullseye, - Button, - ButtonVariant, Card, CardBody, - CardExpandableContent, CardHeader, - CardTitle, + Flex, + FlexItem, MenuToggle, PageSection, PageSectionVariants, - Popover, Select, SelectOption, - Split, - SplitItem, Stack, StackItem, Text, TextContent, } from "@patternfly/react-core"; -import { HelpIcon } from "@patternfly/react-icons"; import { Questionnaire } from "@app/api/models"; import { useFetchApplications } from "@app/queries/applications"; @@ -34,10 +27,9 @@ import { ConditionalRender } from "@app/components/ConditionalRender"; import { StateError } from "@app/components/StateError"; import { ApplicationSelectionContextProvider } from "./application-selection-context"; -import { Landscape } from "./components/landscape"; -import AdoptionCandidateTable from "./components/adoption-candidate-table/adoption-candidate-table"; -import { AdoptionPlan } from "./components/adoption-plan"; import { IdentifiedRisksTable } from "./components/identified-risks-table"; +import { toIdRef } from "@app/utils/model-utils"; +import { ApplicationLandscape } from "./components/application-landscape"; const ALL_QUESTIONNAIRES = -1; @@ -77,10 +69,6 @@ export const Reports: React.FC = () => { const [selectedQuestionnaireId, setSelectedQuestionnaireId] = React.useState(ALL_QUESTIONNAIRES); - const [isAdoptionPlanOpen, setAdoptionPlanOpen] = useState(false); - - const [isRiskCardOpen, setIsRiskCardOpen] = useState(false); - const pageHeaderSection = ( @@ -115,12 +103,32 @@ export const Reports: React.FC = () => { const answeredQuestionnaires: Questionnaire[] = isAssessmentsFetching || isQuestionnairesFetching ? [] - : assessments - .map((assessment) => assessment?.questionnaire?.id) - .filter((id) => id > 0) + : Array.from( + new Set( + assessments + .map((assessment) => assessment?.questionnaire?.id) + .filter((id) => id > 0) + ) + ) .map((id) => questionnairesById[id]) - .sort((a, b) => a.name.localeCompare(b.name)) - .filter((questionnaire) => questionnaire !== undefined); + .filter((questionnaire) => questionnaire !== undefined) + .sort((a, b) => a.name.localeCompare(b.name)); + + const isAllQuestionnairesSelected = + selectedQuestionnaireId === ALL_QUESTIONNAIRES; + + const questionnaire = isAllQuestionnairesSelected + ? null + : questionnairesById[selectedQuestionnaireId]; + + const assessmentRefs = assessments + .filter( + (assessment) => + isAllQuestionnairesSelected || + assessment.questionnaire.id === selectedQuestionnaireId + ) + .map((assessment) => toIdRef(assessment)) + .filter(Boolean); return ( <> @@ -140,160 +148,83 @@ export const Reports: React.FC = () => { - - setIsQuestionnaireSelectOpen(false) - } - toggle={(toggleRef) => ( - { - setIsQuestionnaireSelectOpen( - !isQuestionnaireSelectOpen - ); - }} - isExpanded={isQuestionnaireSelectOpen} - > - {selectedQuestionnaireId === ALL_QUESTIONNAIRES - ? "All questionnaires" - : questionnairesById[selectedQuestionnaireId] - ?.name} - - )} - shouldFocusToggleOnSelect - > - - All questionnaires - - {...answeredQuestionnaires.map( - (answeredQuestionnaire) => ( - - {answeredQuestionnaire.name} - - ) - )} - - ), - }} - > + - {t("terms.currentLandscape")} + + + + {t("terms.currentLandscape")} + + + + + + - - questionnaire.id === selectedQuestionnaireId - ) - } + - + - - - - {t("terms.adoptionCandidateDistribution")} - - - + + {t("terms.identifiedRisks")} + - + - - - setAdoptionPlanOpen((current) => !current)} - > - - - - {t("terms.suggestedAdoptionPlan")} - - {t("message.suggestedAdoptionPlanHelpText")} - - } - position="right" - > - - - - - - - - - {isAdoptionPlanOpen && } - - - - - - - setIsRiskCardOpen((current) => !current)} - > - - - - - - - {t("terms.identifiedRisks")} - - - - - - - - - - {isRiskCardOpen && } - - - - diff --git a/client/src/app/pages/review/components/application-details/application-details.tsx b/client/src/app/pages/review/components/application-details/application-details.tsx deleted file mode 100644 index bab21fcfbf..0000000000 --- a/client/src/app/pages/review/components/application-details/application-details.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; - -import { - DescriptionList, - DescriptionListDescription, - DescriptionListGroup, - DescriptionListTerm, - List, -} from "@patternfly/react-core"; - -import { Application, Assessment } from "@app/api/models"; -import { useFetchQuestionnaires } from "@app/queries/questionnaires"; - -export interface IApplicationDetailsProps { - application?: Application; - assessment?: Assessment; -} - -export const ApplicationDetails: React.FC = ({ - application, - assessment, -}) => { - const { questionnaires } = useFetchQuestionnaires(); - - const matchingQuestionnaire = questionnaires.find( - (questionnaire) => questionnaire.id === assessment?.questionnaire?.id - ); - const { t } = useTranslation(); - if (!matchingQuestionnaire || !application) { - return null; - } - - return ( - - - {t("terms.applicationName")} - - {application.name} - - - - {t("terms.description")} - - {application.description} - - - - {t("terms.assessmentNotes")} - - - {/* {matchingQuestionnaire.sections - .filter((f) => f.comment && f.comment.trim().length > 0) - .map((category, i) => ( - - {category.title}: {category.comment} - - ))} */} - - - - - ); -}; diff --git a/client/src/app/pages/review/components/application-details/index.ts b/client/src/app/pages/review/components/application-details/index.ts deleted file mode 100644 index 7ecfc28a67..0000000000 --- a/client/src/app/pages/review/components/application-details/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ApplicationDetails } from "./application-details"; diff --git a/client/src/app/pages/review/components/application-details/tests/__snapshots__/application-details.test.tsx.snap b/client/src/app/pages/review/components/application-details/tests/__snapshots__/application-details.test.tsx.snap deleted file mode 100644 index 0c82534d49..0000000000 --- a/client/src/app/pages/review/components/application-details/tests/__snapshots__/application-details.test.tsx.snap +++ /dev/null @@ -1,252 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AppTable Renders without crashing 1`] = ` -{ - "asFragment": [Function], - "baseElement": -
-
-
-
- - terms.applicationName - -
-
-
- myApp -
-
-
-
-
- - terms.description - -
-
-
- myDescription -
-
-
-
-
- - terms.assessmentNotes - -
-
-
-
    -
  • - title1 - : - comments1 -
  • -
  • - title2 - : - comments2 -
  • -
  • - title3 - : - comments3 -
  • -
-
-
-
-
-
- , - "container":
-
-
-
- - terms.applicationName - -
-
-
- myApp -
-
-
-
-
- - terms.description - -
-
-
- myDescription -
-
-
-
-
- - terms.assessmentNotes - -
-
-
-
    -
  • - title1 - : - comments1 -
  • -
  • - title2 - : - comments2 -
  • -
  • - title3 - : - comments3 -
  • -
-
-
-
-
-
, - "debug": [Function], - "findAllByAltText": [Function], - "findAllByDisplayValue": [Function], - "findAllByLabelText": [Function], - "findAllByPlaceholderText": [Function], - "findAllByRole": [Function], - "findAllByTestId": [Function], - "findAllByText": [Function], - "findAllByTitle": [Function], - "findByAltText": [Function], - "findByDisplayValue": [Function], - "findByLabelText": [Function], - "findByPlaceholderText": [Function], - "findByRole": [Function], - "findByTestId": [Function], - "findByText": [Function], - "findByTitle": [Function], - "getAllByAltText": [Function], - "getAllByDisplayValue": [Function], - "getAllByLabelText": [Function], - "getAllByPlaceholderText": [Function], - "getAllByRole": [Function], - "getAllByTestId": [Function], - "getAllByText": [Function], - "getAllByTitle": [Function], - "getByAltText": [Function], - "getByDisplayValue": [Function], - "getByLabelText": [Function], - "getByPlaceholderText": [Function], - "getByRole": [Function], - "getByTestId": [Function], - "getByText": [Function], - "getByTitle": [Function], - "queryAllByAltText": [Function], - "queryAllByDisplayValue": [Function], - "queryAllByLabelText": [Function], - "queryAllByPlaceholderText": [Function], - "queryAllByRole": [Function], - "queryAllByTestId": [Function], - "queryAllByText": [Function], - "queryAllByTitle": [Function], - "queryByAltText": [Function], - "queryByDisplayValue": [Function], - "queryByLabelText": [Function], - "queryByPlaceholderText": [Function], - "queryByRole": [Function], - "queryByTestId": [Function], - "queryByText": [Function], - "queryByTitle": [Function], - "rerender": [Function], - "unmount": [Function], -} -`; diff --git a/client/src/app/pages/review/components/application-details/tests/application-details.test.tsx b/client/src/app/pages/review/components/application-details/tests/application-details.test.tsx deleted file mode 100644 index 31443d7f1a..0000000000 --- a/client/src/app/pages/review/components/application-details/tests/application-details.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Application } from "@app/api/models"; - -describe("AppTable", () => { - it.skip("Renders without crashing", () => { - const application: Application = { - id: 1, - name: "myApp", - description: "myDescription", - migrationWave: null, - }; - - // const assessment: Assessment = { - // applicationId: 1, - // status: "COMPLETE", - // questionnaire: { - // categories: [ - // { - // id: 1, - // order: 1, - // questions: [], - // title: "title1", - // comment: "comments1", - // }, - // { - // id: 2, - // order: 2, - // questions: [], - // title: "title2", - // comment: "comments2", - // }, - // { - // id: 3, - // order: 3, - // questions: [], - // title: "title3", - // comment: "comments3", - // }, - // ], - // }, - // }; - // const wrapper = render( - // - // ); - // expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/client/src/app/pages/review/components/review-form/review-form.tsx b/client/src/app/pages/review/components/review-form/review-form.tsx index d7cb37c527..7b6100d4e2 100644 --- a/client/src/app/pages/review/components/review-form/review-form.tsx +++ b/client/src/app/pages/review/components/review-form/review-form.tsx @@ -135,7 +135,6 @@ export const ReviewForm: React.FC = ({ try { if (review) { - // This is an update action await updateReviewMutation.mutateAsync({ ...review, ...payload, @@ -145,10 +144,9 @@ export const ReviewForm: React.FC = ({ variant: "info", }); } else { - // This is a save action await createReviewMutation.mutateAsync(payload); pushNotification({ - title: "Review has been updated.", + title: "Review has been created.", variant: "info", }); } diff --git a/client/src/app/pages/review/review-page.tsx b/client/src/app/pages/review/review-page.tsx index 9a08148015..81ef11d83e 100644 --- a/client/src/app/pages/review/review-page.tsx +++ b/client/src/app/pages/review/review-page.tsx @@ -3,11 +3,15 @@ import { useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Bullseye, + Card, + CardBody, + CardHeader, FormSection, Grid, GridItem, PageSection, Text, + TextContent, } from "@patternfly/react-core"; import BanIcon from "@patternfly/react-icons/dist/esm/icons/ban-icon"; @@ -16,15 +20,13 @@ import { ReviewForm } from "./components/review-form"; import { SimpleEmptyState } from "@app/components/SimpleEmptyState"; import { ConditionalRender } from "@app/components/ConditionalRender"; import { AppPlaceholder } from "@app/components/AppPlaceholder"; -import { ApplicationAssessmentDonutChart } from "./components/application-assessment-donut-chart/application-assessment-donut-chart"; -import QuestionnaireSummary, { - SummaryType, -} from "@app/components/questionnaire-summary/questionnaire-summary"; import { PageHeader } from "@app/components/PageHeader"; import { useFetchReviewById } from "@app/queries/reviews"; import useIsArchetype from "@app/hooks/useIsArchetype"; import { useFetchApplicationById } from "@app/queries/applications"; import { useFetchArchetypeById } from "@app/queries/archetypes"; +import { IdentifiedRisksTable } from "../reports/components/identified-risks-table"; +import { ApplicationAssessmentDonutChart } from "../../components/application-assessment-donut-chart/application-assessment-donut-chart"; const ReviewPage: React.FC = () => { const { t } = useTranslation(); @@ -38,7 +40,6 @@ const ReviewPage: React.FC = () => { const { review, fetchError, isFetching } = useFetchReviewById( isArchetype ? archetype?.review?.id : application?.review?.id ); - const assessment = undefined; const breadcrumbs = [ ...(isArchetype ? [ @@ -53,10 +54,10 @@ const ReviewPage: React.FC = () => { path: Paths.applications, }, ]), - // { - // title: t("terms.review"), - // path: Paths.applicationsReview, - // }, + { + title: t("terms.review"), + path: Paths.applicationsReview, + }, ]; if (fetchError) { @@ -94,40 +95,57 @@ const ReviewPage: React.FC = () => { breadcrumbs={breadcrumbs} />
- - }> - - -
- {/* - - */} - - - -
-
- {assessment && ( - - - - )} -
-
- {assessment && ( - - )} + + + + }> + + +
+ + + + +
+
+ {application?.assessments?.length || + archetype?.assessments?.length ? ( + + + + ) : null} +
+
+
+
+ {application?.assessments?.length || archetype?.assessments?.length ? ( + + + + + {t("terms.assessmentSummary")} + + + + + + + + ) : null} ); }; diff --git a/client/src/app/queries/assessments.ts b/client/src/app/queries/assessments.ts index d805b47ca2..8128ba75b4 100644 --- a/client/src/app/queries/assessments.ts +++ b/client/src/app/queries/assessments.ts @@ -1,9 +1,15 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; +import { + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { createAssessment, deleteAssessment, + getArchetypeById, getAssessmentById, getAssessments, getAssessmentsByItemId, @@ -12,6 +18,7 @@ import { import { AxiosError } from "axios"; import { Assessment, + AssessmentWithArchetypeApplications, AssessmentWithSectionOrder, InitialAssessment, } from "@app/api/models"; @@ -100,7 +107,7 @@ export const useUpdateAssessmentMutation = ( }; export const useDeleteAssessmentMutation = ( - onSuccess?: (applicationName: string) => void, + onSuccess?: (name: string) => void, onError?: (err: AxiosError) => void ) => { const queryClient = useQueryClient(); @@ -110,6 +117,7 @@ export const useDeleteAssessmentMutation = ( assessmentId: number; applicationName?: string; applicationId?: number; + archetypeName?: string; archetypeId?: number; }) => { const deletedAssessment = deleteAssessment(args.assessmentId); @@ -131,7 +139,8 @@ export const useDeleteAssessmentMutation = ( return deletedAssessment; }, onSuccess: (_, args) => { - onSuccess && onSuccess(args?.applicationName || "Unknown"); + onSuccess && + onSuccess(args?.applicationName || args?.archetypeName || "Unknown"); }, onError: onError, }); @@ -175,7 +184,6 @@ export const useFetchAssessmentsByItemId = ( }; const assessmentsWithOrder: AssessmentWithSectionOrder[] = data?.map(addSectionOrderToQuestions) || []; - return { assessments: assessmentsWithOrder, isFetching: isLoading, @@ -210,3 +218,47 @@ const removeSectionOrderFromQuestions = ( })), }; }; + +export const useFetchAssessmentsWithArchetypeApplications = () => { + const { assessments, isFetching: assessmentsLoading } = useFetchAssessments(); + + const archetypesUsedInAnAssessmentQueries = useQueries({ + queries: + [ + ...new Set( + assessments + .map((assessment) => assessment?.archetype?.id) + .filter(Boolean) + ), + ].map((archetypeId) => ({ + queryKey: ["archetype", archetypeId], + queryFn: () => getArchetypeById(archetypeId), + })) || [], + }); + + const isArchetypesLoading = archetypesUsedInAnAssessmentQueries.some( + (query) => query.isLoading + ); + + const archetypeApplicationsMap = new Map(); + archetypesUsedInAnAssessmentQueries.forEach((query, index) => { + if (query.data && assessments[index].archetype?.id) { + archetypeApplicationsMap.set( + assessments[index]?.archetype?.id, + query.data.applications + ); + } + }); + + const assessmentsWithArchetypeApplications: AssessmentWithArchetypeApplications[] = + assessments.map((assessment) => ({ + ...assessment, + archetypeApplications: + archetypeApplicationsMap.get(assessment?.archetype?.id) ?? [], + })); + + return { + assessmentsWithArchetypeApplications, + isLoading: assessmentsLoading || isArchetypesLoading, + }; +}; diff --git a/client/src/app/queries/dependencies.ts b/client/src/app/queries/dependencies.ts index 6fecde893f..24b155f672 100644 --- a/client/src/app/queries/dependencies.ts +++ b/client/src/app/queries/dependencies.ts @@ -1,70 +1,46 @@ -import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { - AnalysisAppDependency, AnalysisDependency, HubPaginatedResult, HubRequestParams, WithUiId, } from "@app/api/models"; import { getAppDependencies, getDependencies } from "@app/api/rest"; - -export interface IDependenciesFetchState { - result: HubPaginatedResult>; - isFetching: boolean; - fetchError: unknown; - refetch: () => void; -} -export interface IAppDependenciesFetchState { - result: HubPaginatedResult; - isFetching: boolean; - fetchError: unknown; - refetch: () => void; -} +import { useWithUiId } from "@app/utils/query-utils"; export const DependenciesQueryKey = "dependencies"; export const AppDependenciesQueryKey = "appDependencies"; -export const useFetchDependencies = ( - params: HubRequestParams = {} -): IDependenciesFetchState => { - const { data, isLoading, error, refetch } = useQuery({ +export const useFetchDependencies = (params: HubRequestParams = {}) => { + const { + data: dependencies, + isLoading, + error, + refetch, + } = useQuery({ queryKey: [DependenciesQueryKey, params], queryFn: async () => await getDependencies(params), onError: (error) => console.log("error, ", error), keepPreviousData: true, }); - const result = useMemo(() => { - if (!data) { - return { data: [], total: 0, params }; - } - - const syntheticData: WithUiId[] = data.data.map( - (dep) => ({ - ...dep, - _ui_unique_id: `${dep.name}/${dep.provider}`, - }) - ); - - return { - data: syntheticData, - total: data.total, - params: data.params, - }; - }, [data, params]); - + const withUiId = useWithUiId( + dependencies?.data, + (d) => `${d.name}/${d.provider}` + ); return { - result, + result: { + data: withUiId, + total: dependencies?.total ?? 0, + params: dependencies?.params ?? params, + } as HubPaginatedResult>, isFetching: isLoading, fetchError: error, refetch, }; }; -export const useFetchAppDependencies = ( - params: HubRequestParams = {} -): IAppDependenciesFetchState => { +export const useFetchAppDependencies = (params: HubRequestParams = {}) => { const { data, isLoading, error, refetch } = useQuery({ queryKey: [AppDependenciesQueryKey, params], queryFn: async () => await getAppDependencies(params), diff --git a/client/src/app/queries/issues.ts b/client/src/app/queries/issues.ts index ac66630ee5..4a0cf53755 100644 --- a/client/src/app/queries/issues.ts +++ b/client/src/app/queries/issues.ts @@ -2,8 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { AnalysisIssueReport, AnalysisRuleReport, - BaseAnalysisIssueReport, - BaseAnalysisRuleReport, HubPaginatedResult, HubRequestParams, WithUiId, @@ -17,6 +15,7 @@ import { getIssueReports, getIssue, } from "@app/api/rest"; +import { useWithUiId } from "@app/utils/query-utils"; export const RuleReportsQueryKey = "rulereports"; export const AppReportsQueryKey = "appreports"; @@ -26,37 +25,33 @@ export const IssuesQueryKey = "issues"; export const IssueQueryKey = "issue"; export const IncidentsQueryKey = "incidents"; -const injectUiUniqueIds = < - T extends BaseAnalysisRuleReport | BaseAnalysisIssueReport, ->( - result: HubPaginatedResult -): HubPaginatedResult> => { - // There is no single unique id property on some of the hub's composite report objects. - // We need to create one for table hooks to work. - const processedData = result.data.map( - (baseReport): WithUiId => ({ - ...baseReport, - _ui_unique_id: `${baseReport.ruleset}/${baseReport.rule}`, - }) - ); - return { ...result, data: processedData }; -}; - export const useFetchRuleReports = ( enabled: boolean, params: HubRequestParams = {} ) => { - const { data, isLoading, error, refetch } = useQuery({ + const { + data: ruleReport, + isLoading, + error, + refetch, + } = useQuery({ queryKey: [RuleReportsQueryKey, params], queryFn: () => getRuleReports(params), onError: (error) => console.log("error, ", error), keepPreviousData: true, - select: (result): HubPaginatedResult => - injectUiUniqueIds(result), enabled, }); + + const withUiId = useWithUiId( + ruleReport?.data, + (r) => `${r.ruleset}/${r.rule}` + ); return { - result: data || { data: [], total: 0, params }, + result: { + data: withUiId, + total: ruleReport?.total ?? 0, + params: ruleReport?.params ?? params, + } as HubPaginatedResult>, isFetching: isLoading, fetchError: error, refetch, @@ -82,17 +77,29 @@ export const useFetchIssueReports = ( applicationId?: number, params: HubRequestParams = {} ) => { - const { data, isLoading, error, refetch } = useQuery({ + const { + data: issueReport, + isLoading, + error, + refetch, + } = useQuery({ enabled: applicationId !== undefined, queryKey: [IssueReportsQueryKey, applicationId, params], queryFn: () => getIssueReports(applicationId, params), onError: (error) => console.log("error, ", error), keepPreviousData: true, - select: (result): HubPaginatedResult => - injectUiUniqueIds(result), }); + + const withUiId = useWithUiId( + issueReport?.data, + (r) => `${r.ruleset}/${r.rule}` + ); return { - result: data || { data: [], total: 0, params }, + result: { + data: withUiId, + total: issueReport?.total ?? 0, + params: issueReport?.params ?? params, + } as HubPaginatedResult, isFetching: isLoading, fetchError: error, refetch, diff --git a/client/src/app/queries/questionnaires.ts b/client/src/app/queries/questionnaires.ts index 9dd379020e..668e5c12aa 100644 --- a/client/src/app/queries/questionnaires.ts +++ b/client/src/app/queries/questionnaires.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; +import yaml from "js-yaml"; import { QUESTIONNAIRES, @@ -9,17 +10,38 @@ import { getQuestionnaires, updateQuestionnaire, } from "@app/api/rest"; -import { Questionnaire } from "@app/api/models"; +import { LooseQuestionnaire, Questionnaire } from "@app/api/models"; import saveAs from "file-saver"; export const QuestionnairesQueryKey = "questionnaires"; export const QuestionnaireByIdQueryKey = "questionnaireById"; +/** + * For a Questionnaire, walk the structure and sort lists by order if the items + * in that list have an order. Hub stores things in the document order not logical + * order. UI needs to have things in logical order. + */ +//TODO: this is not working, need to figure out why https://issues.redhat.com/browse/MTA-1907 +// function inPlaceSortByOrder(q: Questionnaire) { +// q.sections.sort((a, b) => a.order - b.order); +// q.sections.forEach((s) => { +// s.questions.sort((a, b) => a.order - b.order); +// s.questions.forEach((q) => { +// q.answers.sort((a, b) => a.order - b.order); +// }); +// }); +// return q; +// } + export const useFetchQuestionnaires = () => { const { isLoading, data, error } = useQuery({ queryKey: [QuestionnairesQueryKey], queryFn: getQuestionnaires, onError: (error: AxiosError) => console.log("error, ", error), + // select: (questionnaires) => { + // questionnaires.forEach((q) => inPlaceSortByOrder(q)); + // return questionnaires; + // }, }); return { questionnaires: data || [], @@ -71,7 +93,9 @@ export const useFetchQuestionnaireById = (id: number | string) => { queryKey: [QuestionnaireByIdQueryKey, id], queryFn: () => getQuestionnaireById(id), onError: (error: AxiosError) => console.log("error, ", error), + // select: (q) => inPlaceSortByOrder(q), }); + return { questionnaire: data, isFetching: isLoading, @@ -121,7 +145,7 @@ export const downloadQuestionnaire = async ( try { const response = await axios.get(url, { - responseType: "blob", + responseType: "text", headers: { Accept: "application/x-yaml", }, @@ -131,13 +155,25 @@ export const downloadQuestionnaire = async ( throw new Error("Network response was not ok when downloading file."); } - const blob = new Blob([response.data]); + const yamlData = yaml.load(response.data) as LooseQuestionnaire; + + delete yamlData.createUser; + delete yamlData.updateUser; + delete yamlData.createTime; + delete yamlData.id; + delete yamlData.required; + + const newYamlData = yaml.dump(yamlData); + + const blob = new Blob([newYamlData], { type: "application/x-yaml" }); + saveAs(blob, `questionnaire-${id}.yaml`); } catch (error) { console.error("There was an error downloading the file:", error); throw error; } }; + export const useDownloadQuestionnaire = () => { return useMutation({ mutationFn: downloadQuestionnaire }); }; diff --git a/client/src/app/queries/reviews.ts b/client/src/app/queries/reviews.ts index c841da5880..1bde4905a2 100644 --- a/client/src/app/queries/reviews.ts +++ b/client/src/app/queries/reviews.ts @@ -75,18 +75,18 @@ export interface IReviewMutation { } export const useDeleteReviewMutation = ( - onSuccess: (name: string) => void, - onError: (err: AxiosError) => void + onSuccess?: (name: string) => void, + onError?: (err: AxiosError) => void ) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (args: IReviewMutation) => deleteReview(args.id), onSuccess: (_, args) => { - onSuccess(args.name); + onSuccess && onSuccess(args.name); queryClient.invalidateQueries([reviewsQueryKey]); }, - onError: onError, + onError: onError && onError, }); }; diff --git a/client/src/app/queries/risks.ts b/client/src/app/queries/risks.ts deleted file mode 100644 index cc5b8bc9e3..0000000000 --- a/client/src/app/queries/risks.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { AssessmentRisk } from "@app/api/models"; - -export const RisksQueryKey = "risks"; - -/** @deprecated Risk is attached to assessments now. */ -export const useFetchRisks = (applicationIDs: number[]) => { - const { data, refetch, isFetching, error } = useQuery({ - queryKey: ["assessmentrisks", applicationIDs], - queryFn: async () => { - if (applicationIDs.length > 0) - // return (await getAssessmentLandscape(applicationIDs)).data; - //TODO see if we still need this - return []; - else return []; - }, - onError: (error) => console.log("error, ", error), - }); - - return { - risks: data || [], - isFetching, - error, - refetch, - }; -}; diff --git a/client/src/app/queries/tasks.ts b/client/src/app/queries/tasks.ts index b0e43699f2..23d107a911 100644 --- a/client/src/app/queries/tasks.ts +++ b/client/src/app/queries/tasks.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { cancelTask, deleteTask, getTasks } from "@app/api/rest"; +import { cancelTask, deleteTask, getTaskById, getTasks } from "@app/api/rest"; interface FetchTasksFilters { addon?: string; @@ -80,3 +80,25 @@ export const useCancelTaskMutation = ( }, }); }; + +export const TaskByIDQueryKey = "taskByID"; + +export const useFetchTaskByID = ( + taskId?: number, + format = "json", + merged = false +) => { + console.log("useFetchTaskByID", taskId, format, merged); + const { isLoading, error, data, refetch } = useQuery({ + queryKey: [TaskByIDQueryKey, taskId, format, merged], + queryFn: () => (taskId ? getTaskById(taskId, format, merged) : null), + enabled: !!taskId, + }); + + return { + task: data, + isFetching: isLoading, + fetchError: error, + refetch, + }; +}; diff --git a/client/src/app/rbac.ts b/client/src/app/rbac.ts index ecb7ec144c..5f1aa09b3e 100644 --- a/client/src/app/rbac.ts +++ b/client/src/app/rbac.ts @@ -20,7 +20,7 @@ export const RBAC = ({ if (isAuthRequired) { const token = keycloak.tokenParsed || undefined; if (rbacType === RBAC_TYPE.Role) { - let userRoles = token?.realm_access?.roles || [], + const userRoles = token?.realm_access?.roles || [], access = checkAccess(userRoles, allowedPermissions); return access && children; } else if (rbacType === RBAC_TYPE.Scope) { @@ -104,6 +104,27 @@ export const applicationsWriteScopes = [ "applications:delete", ]; +export const archetypesWriteScopes = [ + "archetypes:put", + "archetypes:post", + "archetypes:delete", +]; + +export const analysesReadScopes = ["applications.analyses:get"]; + +export const assessmentWriteScopes = [ + "applications.assessments:put", + "applications.assessments:post", + "applications.assessments:delete", + "archetypes.assessments:put", + "archetypes.assessments:post", + "archetypes.assessments:delete", +]; +export const assessmentReadScopes = [ + "applications.assessments:get", + "archetypes.assessments:get", +]; + export const modifiedPathfinderWriteScopes = [ "assessments:put", "assessments:patch", @@ -126,3 +147,17 @@ export const tasksWriteScopes = [ "taskgroups:put", "taskgroups:delete", ]; + +export const credentialsWriteScopes = [ + "identities:put", + "identities:post", + "identities:delete", +]; +export const credentialsReadScopes = ["identities:get"]; + +export const reviewsWriteScopes = [ + "reviews:put", + "reviews:post", + "reviews:delete", +]; +export const reviewsReadScopes = ["reviews:get"]; diff --git a/client/src/app/utils/model-utils.tsx b/client/src/app/utils/model-utils.tsx index 017e5878c7..1c89324b7c 100644 --- a/client/src/app/utils/model-utils.tsx +++ b/client/src/app/utils/model-utils.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Application, BusinessService, + IdRef, Identity, IdentityKind, IssueManagerKind, @@ -213,6 +214,14 @@ export const IssueManagerOptions: OptionWithValue[] = [ }, ]; +export const toIdRef = ( + source: RefLike | undefined +): IdRef | undefined => { + if (!source || !source.id) return undefined; + + return { id: source.id }; +}; + /** * Convert any object that looks like a `Ref` into a `Ref`. If the source object * is `undefined`, or doesn't look like a `Ref`, return `undefined`. diff --git a/client/src/app/utils/query-utils.ts b/client/src/app/utils/query-utils.ts new file mode 100644 index 0000000000..84c1590aba --- /dev/null +++ b/client/src/app/utils/query-utils.ts @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import { UI_UNIQUE_ID } from "@app/Constants"; +import { WithUiId } from "@app/api/models"; + +/** + * Make a shallow copy of `data` and insert a new `UI_UNIQUE_ID` field in each element + * with the output of the `generator` function. This hook allows generating the needed + * UI id field for any object that does not already have a unique id field so the object + * can be used with our table selection handlers. + * + * @returns A shallow copy of `T` with an added `UI_UNIQUE_ID` field. + */ +export const useWithUiId = ( + /** Source data to modify. */ + data: T[] | undefined, + /** Generate the unique id for a specific `T`. */ + generator: (item: T) => string +): WithUiId[] => { + const result = useMemo(() => { + if (!data || data.length === 0) { + return []; + } + + const dataWithUiId: WithUiId[] = data.map((item) => ({ + ...item, + [UI_UNIQUE_ID]: generator(item), + })); + + return dataWithUiId; + }, [data, generator]); + + return result; +}; diff --git a/client/src/app/utils/utils.test.ts b/client/src/app/utils/utils.test.ts index 016eab1841..99c634afa3 100644 --- a/client/src/app/utils/utils.test.ts +++ b/client/src/app/utils/utils.test.ts @@ -8,6 +8,7 @@ import { standardURLRegex, formatPath, extractFirstSha, + collapseSpacesAndCompare, } from "./utils"; import { Paths } from "@app/Paths"; @@ -97,49 +98,67 @@ describe("utils", () => { expect(result).toBe("myKey"); }); - //URL Regex tests - it("Regex should validate git URLs", () => { - const testGitURLs: string[] = [ - "git@github.com:konveyor/tackle2-ui", - "http://git@github.com:konveyor/tackle2-ui", - ]; - - for (const url of testGitURLs) { - const gitTestResult = gitUrlRegex.test(url); - expect(gitTestResult).toBe(true); - } - }); - - it("Regex should validate standard URLs", () => { - const testStandardURLs: string[] = [ - "http://www.foo.bar", - "www.foo.bar", - "https://www.github.com/ibolton336/tackle-testapp.git", - ]; - - for (const url of testStandardURLs) { - const standardTestResult = standardURLRegex.test(url); - expect(standardTestResult).toBe(true); - } - }); - - it("Regex should fail when validating broken standard URLs", () => { - const testBrokenURLs: string[] = [ - "", - " http://www.foo.bar ", - " http://www.foo", - " http://wrong", - "wwwfoo.bar", - "foo.bar", - "www.foo.b", - "foo.ba", - "git@github.com:konveyor/tackle2-ui", - ]; - - for (const url of testBrokenURLs) { - const testResult = standardURLRegex.test(url); - expect(testResult).toBe(false); - } + describe("URL Regex tests", () => { + // Define your regex patterns here + + it("Regex should validate git URLs", () => { + const testGitURLs = [ + "git@github.com:konveyor/tackle2-ui.git", + "http://github.com/konveyor/tackle2-ui.git", + ]; + + for (const url of testGitURLs) { + expect(gitUrlRegex.test(url)).toBe(true); + } + }); + + it("Regex should fail when validating incorrect git URLs", () => { + const testIncorrectGitURLs = [ + "https://", + "git@", + "http://github.com/konveyor", + ]; + + for (const url of testIncorrectGitURLs) { + const result = gitUrlRegex.test(url); + console.log(`Testing URL: ${url}, Result: ${result}`); + + expect(result).toBe(false); + } + }); + + it("Regex should validate standard URLs", () => { + const testStandardURLs = [ + "http://www.foo.bar", + "www.foo.bar", + "https://www.github.com/ibolton336/tackle-testapp.git", + ]; + + for (const url of testStandardURLs) { + expect(standardURLRegex.test(url)).toBe(true); + } + }); + + it("Regex should fail when validating broken standard URLs", () => { + const testBrokenURLs = [ + "", + "http://", + "https://", + "http:", + "http://www.foo", + "http://wrong", + "wwwfoo.bar", + "foo.bar", + "www.foo.b", + ]; + + for (const url of testBrokenURLs) { + const result = standardURLRegex.test(url); + console.log(`Testing URL: ${url}, Result: ${result}`); + + expect(result).toBe(false); + } + }); }); it("URL should match the same multiple times in a row", () => { @@ -219,3 +238,36 @@ describe("SHA extraction", () => { expect(first).toBe("9c04cd6372077e9b11f70ca111c9807dc7137e4b"); }); }); + +describe("space collapse string compare (using en-US compares)", () => { + it("both undefined matches", () => { + const result = collapseSpacesAndCompare(undefined, undefined, "en-US"); + expect(result).toBe(0); + }); + + it("left undefined goes before right defined", () => { + const result = collapseSpacesAndCompare(undefined, "anything", "en-US"); + expect(result).toBe(-1); + }); + + it("left defined goes after right undefined", () => { + const result = collapseSpacesAndCompare("anything", undefined, "en-US"); + expect(result).toBe(1); + }); + + it.each([ + ["alpha", "alpha", 0], + ["alpha", "bravo", -1], + ["bravo", "alpha", 1], + [" alpha", "alpha ", 0], + ["alpha bravo", "alpha bravo", 0], + ["bravo alpha", "bravo bravo", -1], + ["The quick brown fox ", "The quick brown fox", 0], + ])( + "mismatching spaces work as if spaces are collapsed (%s) to (%s) = %i", + (a, b, expected) => { + const result = collapseSpacesAndCompare(a, b, "en-US"); + expect(result).toBe(expected); + } + ); +}); diff --git a/client/src/app/utils/utils.ts b/client/src/app/utils/utils.ts index 29c3a8840a..7e7a9a0e35 100644 --- a/client/src/app/utils/utils.ts +++ b/client/src/app/utils/utils.ts @@ -119,7 +119,7 @@ export const standardURLRegex = /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/; export const gitUrlRegex = - /^(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\/?|#[-\d\w._]+?)$/; + /^(https?:\/\/[-\w.]+\/[-\w._]+\/[-\w._]+|git@[-\w.]+:[-\w._]+\/[-\w._]+)(\.git)?(\/?|#[-\d\w._]+)?$/; export const standardStrictURLRegex = /https:\/\/(www\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)/; @@ -168,3 +168,24 @@ export const extractFirstSha = (str: string): string | undefined => { const match = str.match(SHA_REGEX); return match && match[0] ? match[0] : undefined; }; + +export const collapseSpacesAndCompare = ( + str1: string | undefined, + str2: string | undefined, + locale?: string +): number => { + if (!str1 && !str2) { + return 0; + } + if (str1 && !str2) { + return 1; + } + if (!str1 && str2) { + return -1; + } + + const a = str1?.trim().replace(/\s+/g, " ") ?? ""; + const b = str2?.trim().replace(/\s+/g, " ") ?? ""; + + return a.localeCompare(b, locale); +}; diff --git a/client/src/index.tsx b/client/src/index.tsx index 5bc18346d0..29774a8cb8 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -8,6 +8,7 @@ import App from "@app/App"; import reportWebVitals from "@app/reportWebVitals"; import { KeycloakProvider } from "@app/components/KeycloakProvider"; import dayjs from "dayjs"; +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import customParseFormat from "dayjs/plugin/customParseFormat"; @@ -15,6 +16,7 @@ import customParseFormat from "dayjs/plugin/customParseFormat"; dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(customParseFormat); +dayjs.extend(isSameOrBefore); const queryClient = new QueryClient();