From 91c867162391c3158f019320a03badb8206581ff Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Fri, 7 Jun 2024 15:53:44 +0200 Subject: [PATCH] View files task attachments Resolves: https://github.com/konveyor/tackle2-ui/issues/1929 Functional changes: 1. document to be viewed is selected from the list (first action above the editor) or via URL 2. display language toggle based on languages supported by the document. For attachments the list consists of plain text and optionally a second language discovered based on file extension (YAML or JSON). 3. replace "merged" checkbox with an option in the select Related refactorings: 1. split getTaskById() query into specialized queries: a) getTaskById() returning Promise for working with the object b) getTaskByIdAndFormat() returning Promise for displaying the task as a formatted text 2. configure AnalysisDetails component to respond to 2 routes: existing details route and newly added /applications/:applicationId/analysis-details/:taskId/attachments/:attachmentId Signed-off-by: Radoslaw Szwajkowski --- client/public/locales/en/translation.json | 1 + client/src/app/Paths.ts | 8 + client/src/app/Routes.tsx | 5 + client/src/app/api/models.ts | 5 + client/src/app/api/rest.ts | 29 ++- .../AttachmentToggle.tsx | 56 ++++++ .../simple-document-viewer/LanguageToggle.tsx | 86 +++++---- .../SimpleDocumentViewer.css | 8 +- .../SimpleDocumentViewer.tsx | 169 +++++++++++++----- .../SimpleDocumentViewerModal.tsx | 4 +- .../analysis-details/AnalysisDetails.tsx | 65 +++++-- client/src/app/queries/tasks.ts | 64 ++++++- 12 files changed, 388 insertions(+), 112 deletions(-) create mode 100644 client/src/app/components/simple-document-viewer/AttachmentToggle.tsx 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, });