diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index bbce9e047f..af9e7cc6fb 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -285,6 +285,7 @@ "assessmentQuestionnaires": "Assessment questionnaires", "assessmentNotes": "Assessment notes", "assessmentSummary": "Assessment summary", + "attachments": "Attachments", "autoTagging": "Automated Tagging", "binary": "Binary", "branch": "Branch", diff --git a/client/src/app/Paths.ts b/client/src/app/Paths.ts index 57708283ad..92b7a0faff 100644 --- a/client/src/app/Paths.ts +++ b/client/src/app/Paths.ts @@ -3,6 +3,8 @@ export const DevPaths = { applications: "/applications", applicationsAnalysisDetails: "/applications/:applicationId/analysis-details/:taskId", + applicationsAnalysisDetailsAttachment: + "/applications/:applicationId/analysis-details/:taskId/attachments/:attachmentId", applicationsAnalysisTab: "/applications/analysis-tab", applicationsAssessmentTab: "/applications/assessment-tab", applicationsImports: "/applications/application-imports", @@ -93,3 +95,9 @@ export interface AnalysisDetailsRoute { applicationId: string; taskId: string; } + +export interface AnalysisDetailsAttachmentRoute { + applicationId: string; + taskId: string; + attachmentId: string; +} diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx index 46cf40bd04..8a44ef9778 100644 --- a/client/src/app/Routes.tsx +++ b/client/src/app/Routes.tsx @@ -79,6 +79,11 @@ export const devRoutes: IRoute[] = [ { path: Paths.applicationsAnalysisDetails, comp: AnalysisDetails, + exact: true, + }, + { + path: Paths.applicationsAnalysisDetailsAttachment, + comp: AnalysisDetails, exact: false, }, { diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index c106243c3e..87c2401a16 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -319,6 +319,11 @@ export interface Task { state?: TaskState; job?: string; report?: TaskReport; + attached?: TaskAttachment[]; +} + +interface TaskAttachment extends Ref { + activity?: number; } export interface TaskData { diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index df92de2d33..7571f4d8c3 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -319,13 +319,25 @@ export const getApplicationImports = ( .get(`${APP_IMPORT}?importSummary.id=${importSummaryID}&isValid=${isValid}`) .then((response) => response.data); -export function getTaskById( +export function getTaskById(id: number): Promise { + return axios + .get(`${TASKS}/${id}`, { + headers: { ...jsonHeaders }, + responseType: "json", + }) + .then((response) => { + return response.data; + }); +} + +export function getTaskByIdAndFormat( id: number, format: string, merged: boolean = false -): Promise { - const headers = format === "yaml" ? { ...yamlHeaders } : { ...jsonHeaders }; - const responseType = format === "yaml" ? "text" : "json"; +): Promise { + const isYaml = format === "yaml"; + const headers = isYaml ? { ...yamlHeaders } : { ...jsonHeaders }; + const responseType = isYaml ? "text" : "json"; let url = `${TASKS}/${id}`; if (merged) { @@ -338,7 +350,9 @@ export function getTaskById( responseType: responseType, }) .then((response) => { - return response.data; + return isYaml + ? String(response.data ?? "") + : JSON.stringify(response.data, undefined, 2); }); } @@ -436,6 +450,11 @@ export const createFile = ({ return response.data; }); +export const getTextFile = (id: number): Promise => + axios + .get(`${FILES}/${id}`, { headers: { Accept: "text/plain" } }) + .then((response) => response.data); + export const getSettingById = ( id: K ): Promise => diff --git a/client/src/app/components/simple-document-viewer/AttachmentToggle.tsx b/client/src/app/components/simple-document-viewer/AttachmentToggle.tsx new file mode 100644 index 0000000000..77060e7eb9 --- /dev/null +++ b/client/src/app/components/simple-document-viewer/AttachmentToggle.tsx @@ -0,0 +1,56 @@ +import React, { FC, useState } from "react"; + +import { + Select, + SelectOption, + SelectList, + MenuToggleElement, + MenuToggle, +} from "@patternfly/react-core"; +import { Document } from "./SimpleDocumentViewer"; +import "./SimpleDocumentViewer.css"; + +export const AttachmentToggle: FC<{ + onSelect: (doc: Document) => void; + documents: Document[]; +}> = ({ onSelect, documents }) => { + const [isOpen, setIsOpen] = useState(false); + const onToggle = () => { + setIsOpen(!isOpen); + }; + + return ( +
+ +
+ ); +}; diff --git a/client/src/app/components/simple-document-viewer/LanguageToggle.tsx b/client/src/app/components/simple-document-viewer/LanguageToggle.tsx index f558149c97..6e7b3d71cc 100644 --- a/client/src/app/components/simple-document-viewer/LanguageToggle.tsx +++ b/client/src/app/components/simple-document-viewer/LanguageToggle.tsx @@ -7,49 +7,47 @@ import CodeIcon from "@patternfly/react-icons/dist/esm/icons/code-icon"; import "./SimpleDocumentViewer.css"; export const LanguageToggle: React.FC<{ - currentLanguage: Language.yaml | Language.json; + currentLanguage: Language; code?: string; - setCurrentLanguage: (lang: Language.yaml | Language.json) => void; -}> = ({ currentLanguage, code, setCurrentLanguage }) => ( -
- void; +}> = ({ currentLanguage, code, setCurrentLanguage, supportedLanguages }) => { + if (supportedLanguages.length <= 1) { + return <>; + } + + return ( +
- - - - - JSON - - } - buttonId="code-language-select-json" - isSelected={currentLanguage === "json"} - isDisabled={!code && currentLanguage !== "json"} - onChange={() => setCurrentLanguage(Language.json)} - /> - - - - - YAML - - } - buttonId="code-language-select-yaml" - isSelected={currentLanguage === "yaml"} - isDisabled={!code && currentLanguage !== "yaml"} - onChange={() => setCurrentLanguage(Language.yaml)} - /> - -
-); + + {supportedLanguages.map((lang) => ( + + + + + + {lang === Language.plaintext ? "Text" : lang.toUpperCase()} + + + } + buttonId={`code-language-select-${lang}`} + isSelected={currentLanguage === lang} + isDisabled={!code && currentLanguage !== lang} + onChange={() => setCurrentLanguage(lang)} + /> + ))} + +
+ ); +}; diff --git a/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.css b/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.css index e1b00de138..5c2511c228 100644 --- a/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.css +++ b/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.css @@ -53,6 +53,10 @@ flex-grow: 1; } +.simple-task-viewer-code .pf-v5-c-code-editor__controls > * { + display: flex; +} + .simple-task-viewer-code .pf-v5-c-code-editor__header-main { display: none; } @@ -67,6 +71,6 @@ --pf-v5-c-toggle-group__button--FontSize: var(--pf-v5-global--FontSize--md); } -.merged-checkbox { - margin: auto 0.5rem; +.simple-task-viewer-attachment-toggle { + order: -1; } diff --git a/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.tsx b/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.tsx index 8307a28a7b..81d7e330ba 100644 --- a/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.tsx +++ b/client/src/app/components/simple-document-viewer/SimpleDocumentViewer.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { CodeEditor, Language } from "@patternfly/react-code-editor"; import { - Checkbox, EmptyState, EmptyStateIcon, EmptyStateVariant, @@ -10,9 +9,14 @@ import { } from "@patternfly/react-core"; import "./SimpleDocumentViewer.css"; -import { useFetchTaskByID } from "@app/queries/tasks"; +import { + useFetchTaskAttachmentById, + useFetchTaskByIdAndFormat, +} from "@app/queries/tasks"; import { RefreshControl } from "./RefreshControl"; import { LanguageToggle } from "./LanguageToggle"; +import { AttachmentToggle } from "./AttachmentToggle"; +import { Ref } from "@app/api/models"; export { Language } from "@patternfly/react-code-editor"; @@ -22,63 +26,134 @@ type ControlledEditor = { setPosition: (position: object) => void; }; +export interface Document { + id: DocumentId; + name: string; + isSelected: boolean; + description?: string; + languages: Language[]; +} + export interface ISimpleDocumentViewerProps { - /** The id of the document to display, or `undefined` to display the empty state. */ - documentId: number | undefined; + /** The id of the task to display, or `undefined` to display the empty state. */ + taskId: number | undefined; + + /** The attachment ID, if present will be displayed instead of the task */ + attachmentId: number | undefined; + + /** Task attachments */ + attachments: Ref[]; /** Filename, without extension, to use with the download file action. */ downloadFilename?: string; - /** - * Initial language of the document. Also used for the file extensions with - * the download file action. Defaults to `Language.yaml`. - */ - language?: Language.yaml | Language.json; - /** * Height of the document viewer, or `"full"` to take up all of the available * vertical space. Defaults to "450px". */ height?: string | "full"; + + /** Callback triggered when user selects a new document to display */ + onDocumentChange?: (documentId: DocumentId) => void; } +export type DocumentId = number | "LOG_VIEW" | "MERGED_VIEW"; + +const useDocuments = ({ + taskId, + selectedId, + currentLanguage, +}: { + taskId?: number; + selectedId: DocumentId; + currentLanguage: Language; +}) => { + const { task, refetch: refetchTask } = useFetchTaskByIdAndFormat({ + taskId, + format: currentLanguage === Language.yaml ? "yaml" : "json", + enabled: + !!taskId && (selectedId === "LOG_VIEW" || selectedId === "MERGED_VIEW"), + merged: selectedId === "MERGED_VIEW", + }); + + const isAttachment = typeof selectedId === "number"; + const { attachment, refetch: refetchAttachment } = useFetchTaskAttachmentById( + { + attachmentId: isAttachment ? selectedId : undefined, + enabled: isAttachment, + } + ); + + return isAttachment + ? { code: attachment, refetch: refetchAttachment } + : { code: task, refetch: refetchTask }; +}; + /** * Fetch and then use the `@patternfly/react-code-editor` to display a document in * read-only mode with language highlighting applied. */ export const SimpleDocumentViewer = ({ - documentId, + taskId, + attachmentId, + attachments, downloadFilename, - language = Language.yaml, height = "450px", + onDocumentChange, }: ISimpleDocumentViewerProps) => { - const editorRef = React.useRef(); - 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 configuredDocuments: Document[] = [ + { + id: "LOG_VIEW", + name: "Log view", + isSelected: !attachmentId, + languages: [Language.yaml, Language.json], + }, + { + id: "MERGED_VIEW", + name: "Merged log view", + description: "with inlined commands output", + isSelected: false, + languages: [Language.yaml, Language.json], + }, + ...attachments.map(({ id, name }) => ({ + id, + name, + isSelected: id === attachmentId, + languages: [ + Language.plaintext, + name.endsWith(".yaml") && Language.yaml, + name.endsWith(".json") && Language.json, + // Language.json, + ].filter(Boolean), + })), + ]; + + const [documents, setDocuments] = React.useState([...configuredDocuments]); + const selectedId = + documents.find(({ isSelected }) => isSelected)?.id ?? "LOG_VIEW"; + const supportedLanguages = documents.find(({ isSelected }) => isSelected) + ?.languages ?? [Language.yaml, Language.json]; + + const [currentLanguage, setCurrentLanguage] = React.useState( + supportedLanguages[0] ?? Language.plaintext ); - const onMergedChange = (checked: boolean) => { - setMerged(checked); - refetch(); - }; + const editorRef = React.useRef(); - React.useEffect(() => { - if (task) { - const formattedCode = - currentLanguage === Language.yaml - ? task.toString() - : JSON.stringify(task, undefined, 2); + const { code, refetch } = useDocuments({ + taskId, + currentLanguage, + selectedId, + }); - setCode(formattedCode); + // move focus on first code change AFTER a new document was selected + const focusMovedOnSelectedDocumentChange = React.useRef(false); + React.useEffect(() => { + if (code && !focusMovedOnSelectedDocumentChange.current) { focusAndHomePosition(); + focusMovedOnSelectedDocumentChange.current = true; } - }, [task, currentLanguage]); + }, [code]); const focusAndHomePosition = () => { if (editorRef.current) { @@ -87,6 +162,19 @@ export const SimpleDocumentViewer = ({ } }; + const onSelect = (doc: Document) => { + if (!doc) { + return; + } + + setCurrentLanguage(doc.languages[0] ?? Language.plaintext); + setDocuments( + documents.map((it) => ({ ...it, isSelected: it.id === doc.id })) + ); + focusMovedOnSelectedDocumentChange.current = false; + onDocumentChange?.(doc.id); + }; + return ( } customControls={[ + , , - onMergedChange(checked)} - aria-label="Merged Checkbox" - />, , diff --git a/client/src/app/components/simple-document-viewer/SimpleDocumentViewerModal.tsx b/client/src/app/components/simple-document-viewer/SimpleDocumentViewerModal.tsx index 4a179ce6e6..d95b43fb1d 100644 --- a/client/src/app/components/simple-document-viewer/SimpleDocumentViewerModal.tsx +++ b/client/src/app/components/simple-document-viewer/SimpleDocumentViewerModal.tsx @@ -37,7 +37,7 @@ export interface ISimpleDocumentViewerModalProps export const SimpleDocumentViewerModal = ({ title, - documentId, + taskId: documentId, onClose, position = "top", isFullHeight = true, @@ -64,7 +64,7 @@ export const SimpleDocumentViewerModal = ({ ]} > diff --git a/client/src/app/pages/applications/analysis-details/AnalysisDetails.tsx b/client/src/app/pages/applications/analysis-details/AnalysisDetails.tsx index 82e8c7ebca..a42e80b0ff 100644 --- a/client/src/app/pages/applications/analysis-details/AnalysisDetails.tsx +++ b/client/src/app/pages/applications/analysis-details/AnalysisDetails.tsx @@ -1,28 +1,50 @@ import React from "react"; -import { useParams } from "react-router-dom"; +import { useHistory, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { PageSection } from "@patternfly/react-core"; -import { AnalysisDetailsRoute, Paths } from "@app/Paths"; +import { AnalysisDetailsAttachmentRoute, Paths } from "@app/Paths"; import { PageHeader } from "@app/components/PageHeader"; import { formatPath } from "@app/utils/utils"; -import { SimpleDocumentViewer } from "@app/components/simple-document-viewer"; +import { + DocumentId, + SimpleDocumentViewer, +} from "@app/components/simple-document-viewer"; import { useFetchApplicationById } from "@app/queries/applications"; import { useFetchTaskByID } from "@app/queries/tasks"; export const AnalysisDetails: React.FC = () => { - // i18 const { t } = useTranslation(); - // Router - const { applicationId, taskId } = useParams(); + const { applicationId, taskId, attachmentId } = + useParams(); + + const history = useHistory(); + const onDocumentChange = (documentId: DocumentId) => + typeof documentId === "number" + ? history.push( + formatPath(Paths.applicationsAnalysisDetailsAttachment, { + applicationId: applicationId, + taskId: taskId, + attachmentId: documentId, + }) + ) + : history.push( + formatPath(Paths.applicationsAnalysisDetails, { + applicationId: applicationId, + taskId: taskId, + }) + ); const { application } = useFetchApplicationById(applicationId); const { task } = useFetchTaskByID(Number(taskId)); - const taskName = - (typeof task != "string" ? task?.name : taskId) ?? t("terms.unknown"); - const appName = application?.name ?? t("terms.unknown") ?? ""; + + const taskName = task?.name ?? t("terms.unknown"); + const appName: string = application?.name ?? t("terms.unknown"); + const attachmentName = task?.attached?.find( + ({ id }) => String(id) === attachmentId + )?.name; return ( <> @@ -45,11 +67,34 @@ export const AnalysisDetails: React.FC = () => { taskId: taskId, }), }, + ...(attachmentName + ? [ + { + title: t("terms.attachments"), + }, + { + title: attachmentName, + path: formatPath(Paths.applicationsAnalysisDetails, { + applicationId: applicationId, + taskId: taskId, + attachment: attachmentId, + }), + }, + ] + : []), ]} /> - + ); diff --git a/client/src/app/queries/tasks.ts b/client/src/app/queries/tasks.ts index 9d07bd42c5..00613facbd 100644 --- a/client/src/app/queries/tasks.ts +++ b/client/src/app/queries/tasks.ts @@ -1,6 +1,13 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { cancelTask, deleteTask, getTaskById, getTasks } from "@app/api/rest"; +import { + cancelTask, + deleteTask, + getTaskById, + getTaskByIdAndFormat, + getTasks, + getTextFile, +} from "@app/api/rest"; import { universalComparator } from "@app/utils/utils"; interface FetchTasksFilters { @@ -81,16 +88,59 @@ export const useCancelTaskMutation = ( }; export const TaskByIDQueryKey = "taskByID"; +export const TaskAttachmentByIDQueryKey = "taskAttachmentByID"; -export const useFetchTaskByID = ( - taskId?: number, +export const useFetchTaskByIdAndFormat = ({ + taskId, format = "json", - merged = false -) => { - console.log("useFetchTaskByID", taskId, format, merged); + merged = false, + enabled = true, +}: { + taskId?: number; + format?: "json" | "yaml"; + merged?: boolean; + enabled?: boolean; +}) => { const { isLoading, error, data, refetch } = useQuery({ queryKey: [TaskByIDQueryKey, taskId, format, merged], - queryFn: () => (taskId ? getTaskById(taskId, format, merged) : null), + queryFn: () => + taskId ? getTaskByIdAndFormat(taskId, format, merged) : undefined, + enabled, + }); + + return { + task: data, + isFetching: isLoading, + fetchError: error, + refetch, + }; +}; + +export const useFetchTaskAttachmentById = ({ + attachmentId, + enabled = true, +}: { + attachmentId?: number; + enabled?: boolean; +}) => { + const { isLoading, error, data, refetch } = useQuery({ + queryKey: [TaskAttachmentByIDQueryKey, attachmentId], + queryFn: () => (attachmentId ? getTextFile(attachmentId) : undefined), + enabled, + }); + + return { + attachment: data, + isFetching: isLoading, + fetchError: error, + refetch, + }; +}; + +export const useFetchTaskByID = (taskId?: number) => { + const { isLoading, error, data, refetch } = useQuery({ + queryKey: [TaskByIDQueryKey, taskId], + queryFn: () => (taskId ? getTaskById(taskId) : null), enabled: !!taskId, });