This is an automated email from the ASF dual-hosted git repository. porcelli pushed a commit to branch KOGITO-8015-feature-preview in repository https://gitbox.apache.org/repos/asf/incubator-kie-tools-temporary-rnd-do-not-use.git
commit 1abdf8072b48b8ad310cb86003e0b30ad4cfd8d0 Author: Fabrizio Antonangeli <[email protected]> AuthorDate: Mon Mar 27 15:38:30 2023 +0200 KOGITO-8151: Refactor file listing according to UX redesign (#1463) Co-authored-by: Guilherme Caponetto <[email protected]> --- packages/serverless-logic-web-tools/package.json | 2 +- .../src/editor/EditorToolbar.tsx | 2 +- .../src/homepage/pageTemplate/OnlineEditorPage.tsx | 8 +- .../homepage/recentModels/ConfirmDeleteModal.tsx | 154 ++++++ .../src/homepage/recentModels/RecentModels.tsx | 209 ++++++++ .../src/homepage/recentModels/WorkspacesTable.tsx | 232 +++++++++ .../homepage/recentModels/WorkspacesTableRow.tsx | 209 ++++++++ .../src/homepage/routes/HomePageRoutes.tsx | 10 +- .../homepage/serverlessModels/ServerlessModels.tsx | 552 --------------------- .../src/homepage/uiNav/HomePageNav.tsx | 25 +- .../src/navigation/Routes.ts | 3 + .../serverless-logic-web-tools/src/table/Table.css | 21 + .../src/table/TablePagination.tsx | 54 ++ .../src/table/TableToolbar.tsx | 161 ++++++ pnpm-lock.yaml | 6 +- 15 files changed, 1072 insertions(+), 576 deletions(-) diff --git a/packages/serverless-logic-web-tools/package.json b/packages/serverless-logic-web-tools/package.json index 5a80c7f3da..b4dc7778a3 100644 --- a/packages/serverless-logic-web-tools/package.json +++ b/packages/serverless-logic-web-tools/package.json @@ -55,12 +55,12 @@ "@patternfly/quickstarts": "^2.3.2", "@patternfly/react-core": "^4.276.6", "@patternfly/react-icons": "^4.93.6", + "@patternfly/react-table": "^4.112.39", "@patternfly/react-tokens": "^4.94.6", "@rhoas/registry-instance-sdk": "^0.34.1", "axios": "^0.27.2", "bowser": "^2.10.0", "buffer": "^6.0.3", - "dexie": "^3.2.2", "history": "^4.9.0", "jszip": "^3.7.1", "moment": "^2.29.4", diff --git a/packages/serverless-logic-web-tools/src/editor/EditorToolbar.tsx b/packages/serverless-logic-web-tools/src/editor/EditorToolbar.tsx index 20348a49c8..23eca2485c 100644 --- a/packages/serverless-logic-web-tools/src/editor/EditorToolbar.tsx +++ b/packages/serverless-logic-web-tools/src/editor/EditorToolbar.tsx @@ -1313,7 +1313,7 @@ If you are, it means that creating this Gist failed and it can safely be deleted <Button className={"kie-tools--masthead-hoverable"} variant={ButtonVariant.plain} - onClick={() => history.push({ pathname: routes.home.path({}) })} + onClick={() => history.push({ pathname: routes.recentModels.path({}) })} > <AngleLeftIcon /> </Button> diff --git a/packages/serverless-logic-web-tools/src/homepage/pageTemplate/OnlineEditorPage.tsx b/packages/serverless-logic-web-tools/src/homepage/pageTemplate/OnlineEditorPage.tsx index e9c22f59b6..7d481676b1 100644 --- a/packages/serverless-logic-web-tools/src/homepage/pageTemplate/OnlineEditorPage.tsx +++ b/packages/serverless-logic-web-tools/src/homepage/pageTemplate/OnlineEditorPage.tsx @@ -166,7 +166,13 @@ export function OnlineEditorPage(props: OnlineEditorPageProps) { return ( <QuickStartContainer {...drawerProps}> <div id="page-container" ref={props.pageContainerRef}> - <Page header={masthead} sidebar={sidebar} skipToContent={pageSkipToContent} mainContainerId={mainContainerId}> + <Page + header={masthead} + sidebar={sidebar} + skipToContent={pageSkipToContent} + mainContainerId={mainContainerId} + isManagedSidebar + > {props.children} </Page> </div> diff --git a/packages/serverless-logic-web-tools/src/homepage/recentModels/ConfirmDeleteModal.tsx b/packages/serverless-logic-web-tools/src/homepage/recentModels/ConfirmDeleteModal.tsx new file mode 100644 index 0000000000..4d260c06af --- /dev/null +++ b/packages/serverless-logic-web-tools/src/homepage/recentModels/ConfirmDeleteModal.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from "react"; +import { WorkspaceDescriptor } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/WorkspaceDescriptor"; +import { Button, Checkbox, Modal, ModalProps, Skeleton } from "@patternfly/react-core/dist/js"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useWorkspaces } from "@kie-tools-core/workspaces-git-fs/dist/context/WorkspacesContext"; +import { splitFiles } from "../../extension"; + +export function ConfirmDeleteModal( + props: { + onDelete: () => void; + selectedWorkspaceIds: WorkspaceDescriptor["workspaceId"][]; + } & Pick<ModalProps, "isOpen" | "onClose"> +) { + const { selectedWorkspaceIds, isOpen, onClose, onDelete } = props; + const [isDeleteCheck, setIsDeleteCheck] = useState(false); + const [firstSelectedWorkspaceName, setFirstSelectedWorkspaceName] = useState(""); + const [selectedFoldersCount, setSelectedFoldersCount] = useState(0); + const [elementsTypeName, setElementsTypeName] = useState("models"); + const [dataLoaded, setDataLoaded] = useState(false); + const [fetchError, setFetchError] = useState(false); + const workspaces = useWorkspaces(); + + const isPlural = useMemo(() => selectedWorkspaceIds.length > 1, [selectedWorkspaceIds]); + + const isWsFolder = useCallback( + async (workspaceId: WorkspaceDescriptor["workspaceId"]) => { + const { editableFiles, readonlyFiles } = splitFiles(await workspaces.getFiles({ workspaceId })); + return editableFiles.length > 1 || readonlyFiles.length > 0; + }, + [workspaces] + ); + + const getWorkspaceName = useCallback( + async (workspaceId: WorkspaceDescriptor["workspaceId"]) => { + if (selectedWorkspaceIds.length !== 1) { + return ""; + } + const workspaceData = await workspaces.getWorkspace({ workspaceId }); + return (await isWsFolder(workspaceId)) + ? workspaceData.name + : (await workspaces.getFiles({ workspaceId }))[0].nameWithoutExtension; + }, + [isWsFolder, selectedWorkspaceIds, workspaces] + ); + + const onDeleteCheckChange = useCallback((checked: boolean) => { + setIsDeleteCheck(checked); + }, []); + + useEffect(() => { + if (!isOpen) { + return; + } + + const allPromises: Promise<void>[] = []; + + setIsDeleteCheck(false); + setDataLoaded(false); + setFetchError(false); + + if (selectedWorkspaceIds.length === 1) { + allPromises.push(getWorkspaceName(selectedWorkspaceIds[0]).then(setFirstSelectedWorkspaceName)); + } + + allPromises.push( + Promise.all(selectedWorkspaceIds.map(isWsFolder)).then((results) => { + const foldersCount = results.filter((r) => r).length; + setSelectedFoldersCount(foldersCount); + if (isPlural) { + setElementsTypeName(foldersCount ? "workspaces" : "models"); + } else { + setElementsTypeName(foldersCount ? "workspace" : "model"); + } + }) + ); + + Promise.all(allPromises) + .then(() => setDataLoaded(true)) + .catch((error) => { + console.error("Error retrieving workspace data:", error); + setFetchError(true); + }); + }, [selectedWorkspaceIds, isWsFolder, isOpen, getWorkspaceName, isPlural]); + + return ( + <> + <Modal + title={`Delete ${elementsTypeName}`} + titleIconVariant={"warning"} + isOpen={isOpen && !fetchError} + onClose={onClose} + aria-describedby="modal-custom-icon-description" + actions={[ + dataLoaded ? ( + <Button key="confirm" variant="danger" onClick={onDelete} isDisabled={!isDeleteCheck} aria-label="Delete"> + Delete {elementsTypeName} + </Button> + ) : ( + <Skeleton width="100px" key="confirm-skeleton" /> + ), + <Button key="cancel" variant="link" onClick={onClose} aria-label="Cancel"> + Cancel + </Button>, + ]} + variant="small" + > + {dataLoaded ? ( + <span id="modal-custom-icon-description"> + Deleting {isPlural ? "these" : "this"}{" "} + <b>{isPlural ? selectedWorkspaceIds.length : firstSelectedWorkspaceName}</b> {elementsTypeName} + {selectedFoldersCount ? ` removes the ${elementsTypeName} and all the models inside.` : "."} + </span> + ) : ( + <Skeleton width="80%" /> + )} + <br /> + <br /> + <Checkbox + label="I understand that this action cannot be undone." + id="delete-model-check" + isChecked={isDeleteCheck} + onChange={onDeleteCheckChange} + aria-label="Confirm checkbox delete model" + /> + </Modal> + + <Modal + title={`Error retrieving data`} + titleIconVariant={"danger"} + isOpen={isOpen && fetchError} + onClose={onClose} + aria-describedby="modal-custom-icon-description" + variant="small" + > + <span id="modal-custom-icon-description">An error occurred while loading the data!</span> + </Modal> + </> + ); +} diff --git a/packages/serverless-logic-web-tools/src/homepage/recentModels/RecentModels.tsx b/packages/serverless-logic-web-tools/src/homepage/recentModels/RecentModels.tsx new file mode 100644 index 0000000000..964fce3616 --- /dev/null +++ b/packages/serverless-logic-web-tools/src/homepage/recentModels/RecentModels.tsx @@ -0,0 +1,209 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PromiseStateWrapper } from "@kie-tools-core/react-hooks/dist/PromiseState"; +import { useWorkspaces } from "@kie-tools-core/workspaces-git-fs/dist/context/WorkspacesContext"; +import { useWorkspaceDescriptorsPromise } from "@kie-tools-core/workspaces-git-fs/dist/hooks/WorkspacesHooks"; +import { WorkspaceDescriptor } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/WorkspaceDescriptor"; +import { PerPageOptions } from "@patternfly/react-core/dist/js/components/Pagination"; +import { Alert, AlertActionCloseButton, AlertProps } from "@patternfly/react-core/dist/js/components/Alert"; +import { AlertGroup } from "@patternfly/react-core/dist/js/components/AlertGroup"; +import { EmptyState, EmptyStateBody, EmptyStateIcon } from "@patternfly/react-core/dist/js/components/EmptyState"; +import { Page, PageSection } from "@patternfly/react-core/dist/js/components/Page"; +import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/js/components/Text"; +import { Title } from "@patternfly/react-core/dist/js/components/Title"; +import { Bullseye } from "@patternfly/react-core/dist/js/layouts/Bullseye"; +import { CubesIcon } from "@patternfly/react-icons/dist/js/icons/cubes-icon"; +import * as React from "react"; +import { useCallback, useState } from "react"; +import { ConfirmDeleteModal } from "./ConfirmDeleteModal"; +import { TableToolbar } from "../../table/TableToolbar"; +import { WorkspacesTable } from "./WorkspacesTable"; +import { TablePagination } from "../../table/TablePagination"; + +const perPageOptions: PerPageOptions[] = [5, 10, 20, 50, 100].map((n) => ({ + title: n.toString(), + value: n, +})); + +export function RecentModels() { + const workspaceDescriptorsPromise = useWorkspaceDescriptorsPromise(); + const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<WorkspaceDescriptor["workspaceId"][]>([]); + const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = useState(false); + const [alerts, setAlerts] = useState<Partial<AlertProps>[]>([]); + const [searchValue, setSearchValue] = React.useState(""); + const [page, setPage] = React.useState(1); + const [perPage, setPerPage] = React.useState(5); + const workspaces = useWorkspaces(); + + const onConfirmDeleteModalClose = useCallback(() => setIsConfirmDeleteModalOpen(false), []); + + const addAlert = useCallback( + (title: string, variant: AlertProps["variant"], key: React.Key = new Date().getTime()) => { + setAlerts((prevAlerts) => [...prevAlerts, { title, variant, key }]); + }, + [] + ); + + const removeAlert = useCallback((key: React.Key) => { + setAlerts((prevAlerts) => [...prevAlerts.filter((alert) => alert.key !== key)]); + }, []); + + const onConfirmDeleteModalDelete = useCallback( + async (workspaceDescriptors: WorkspaceDescriptor[]) => { + const modelsWord = selectedWorkspaceIds.length > 1 ? "Models" : "Model"; + setIsConfirmDeleteModalOpen(false); + + Promise.all( + workspaceDescriptors + .filter((w) => selectedWorkspaceIds.includes(w.workspaceId)) + .map((w) => workspaces.deleteWorkspace(w)) + ) + .then(() => { + addAlert(`${modelsWord} deleted successfully`, "success"); + }) + .catch((e) => { + console.error(e); + addAlert( + `Oops, something went wrong while trying to delete the selected ${modelsWord}. Please refresh the page and try again. If the problem persists, you can try deleting site data for this application in your browser's settings.`, + "danger" + ); + }) + .finally(() => { + setSelectedWorkspaceIds([]); + }); + }, + [selectedWorkspaceIds, addAlert, workspaces] + ); + + const onWsToggle = useCallback((workspaceId: WorkspaceDescriptor["workspaceId"], checked: boolean) => { + setSelectedWorkspaceIds((prevSelected) => { + const otherSelectedIds = prevSelected.filter((r) => r !== workspaceId); + return checked ? [...otherSelectedIds, workspaceId] : otherSelectedIds; + }); + }, []); + + const onToggleAllElements = useCallback((checked: boolean, workspaceDescriptors: WorkspaceDescriptor[]) => { + setSelectedWorkspaceIds(checked ? workspaceDescriptors.map((e) => e.workspaceId) : []); + }, []); + + const onClearFilters = useCallback(() => { + setSearchValue(""); + }, []); + + return ( + <PromiseStateWrapper + promise={workspaceDescriptorsPromise} + rejected={(e) => <>Error fetching workspaces: {e + ""}</>} + resolved={(workspaceDescriptors: WorkspaceDescriptor[]) => { + const itemCount = workspaceDescriptors.length; + + return ( + <> + <AlertGroup isToast isLiveRegion> + {alerts.map( + ({ key, variant, title }) => + key && ( + <Alert + variant={variant} + title={title} + timeout + onTimeout={() => removeAlert(key)} + actionClose={ + <AlertActionCloseButton + title={title as string} + variantLabel={`${variant} alert`} + onClose={() => removeAlert(key)} + /> + } + key={key} + /> + ) + )} + </AlertGroup> + <Page> + <PageSection variant={"light"}> + <TextContent> + <Text component={TextVariants.h1}>Recent models</Text> + <Text component={TextVariants.p}> + Use your recent models from GitHub Repository, a GitHub Gist or saved in your browser. + </Text> + </TextContent> + </PageSection> + + <PageSection isFilled aria-label="workspaces-table-section"> + <PageSection variant={"light"} padding={{ default: "noPadding" }}> + {itemCount > 0 && ( + <> + <TableToolbar + itemCount={itemCount} + onDeleteActionButtonClick={() => setIsConfirmDeleteModalOpen(true)} + onToggleAllElements={(checked) => onToggleAllElements(checked, workspaceDescriptors)} + searchValue={searchValue} + selectedElementsCount={selectedWorkspaceIds.length} + setSearchValue={setSearchValue} + page={page} + perPage={perPage} + perPageOptions={perPageOptions} + setPage={setPage} + setPerPage={setPerPage} + /> + <WorkspacesTable + page={page} + perPage={perPage} + onClearFilters={onClearFilters} + onWsToggle={onWsToggle} + searchValue={searchValue} + selectedWorkspaceIds={selectedWorkspaceIds} + workspaceDescriptors={workspaceDescriptors} + /> + <TablePagination + itemCount={itemCount} + page={page} + perPage={perPage} + perPageOptions={perPageOptions} + setPage={setPage} + setPerPage={setPerPage} + variant="bottom" + /> + </> + )} + {workspaceDescriptors.length === 0 && ( + <Bullseye> + <EmptyState> + <EmptyStateIcon icon={CubesIcon} /> + <Title headingLevel="h4" size="lg"> + {`Nothing here`} + </Title> + <EmptyStateBody>{`Start by adding a new model`}</EmptyStateBody> + </EmptyState> + </Bullseye> + )} + </PageSection> + </PageSection> + </Page> + <ConfirmDeleteModal + selectedWorkspaceIds={selectedWorkspaceIds} + isOpen={isConfirmDeleteModalOpen} + onClose={onConfirmDeleteModalClose} + onDelete={() => onConfirmDeleteModalDelete(workspaceDescriptors)} + /> + </> + ); + }} + /> + ); +} diff --git a/packages/serverless-logic-web-tools/src/homepage/recentModels/WorkspacesTable.tsx b/packages/serverless-logic-web-tools/src/homepage/recentModels/WorkspacesTable.tsx new file mode 100644 index 0000000000..9ae35c5f1a --- /dev/null +++ b/packages/serverless-logic-web-tools/src/homepage/recentModels/WorkspacesTable.tsx @@ -0,0 +1,232 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + PromiseStateStatus, + PromiseStateWrapper, + useLivePromiseState, +} from "@kie-tools-core/react-hooks/dist/PromiseState"; +import { useWorkspaces, WorkspaceFile } from "@kie-tools-core/workspaces-git-fs/dist/context/WorkspacesContext"; +import { WorkspaceDescriptor } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/WorkspaceDescriptor"; +import { WorkspaceKind } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/WorkspaceOrigin"; +import "@patternfly/react-core/dist/styles/base.css"; +import { + TableComposable, + Tbody, + Th, + Thead, + Tr, + ThProps, +} from "@patternfly/react-table/dist/js/components/TableComposable"; +import * as React from "react"; +import { useCallback, useMemo, useState } from "react"; +import { splitFiles } from "../../extension"; +import { ErrorBoundary } from "../../reactExt/ErrorBoundary"; +import { TablePaginationProps } from "../../table/TablePagination"; +import { + WorkspacesTableRow, + WorkspacesTableRowEmptyState, + WorkspacesTableRowError, + workspacesTableRowErrorContent, + WorkspacesTableRowLoading, +} from "./WorkspacesTableRow"; + +export const columnNames = { + name: "Name", + type: "Type", + created: "Created", + lastUpdated: "Last updated", + editableFiles: "Editable files", + totalFiles: "Total files", +}; + +export type WorkspacesTableProps = Pick<TablePaginationProps, "page" | "perPage"> & { + onClearFilters: () => void; + onWsToggle: (workspaceId: WorkspaceDescriptor["workspaceId"], checked: boolean) => void; + searchValue: string; + selectedWorkspaceIds: WorkspaceDescriptor["workspaceId"][]; + workspaceDescriptors: WorkspaceDescriptor[]; +}; + +export type WorkspacesTableRowData = Pick< + WorkspaceDescriptor, + "workspaceId" | "origin" | "createdDateISO" | "lastUpdatedDateISO" +> & { + descriptor: WorkspaceDescriptor; + editableFiles: WorkspaceFile[]; + hasErrors: boolean; + isWsFolder: boolean; + name: string; + totalFiles: number; +}; + +export function WorkspacesTable(props: WorkspacesTableProps) { + const { workspaceDescriptors, selectedWorkspaceIds, onClearFilters, searchValue, page, perPage } = props; + const [activeSortIndex, setActiveSortIndex] = useState<number>(3); + const [activeSortDirection, setActiveSortDirection] = useState<"asc" | "desc">("desc"); + const workspaces = useWorkspaces(); + + const [allWorkspacePromises] = useLivePromiseState<WorkspaceFile[][]>( + useMemo( + () => () => Promise.all(workspaceDescriptors.map((w) => workspaces.getFiles(w))), + [workspaceDescriptors, workspaces] + ) + ); + + const tableData = useMemo<WorkspacesTableRowData[]>( + () => + allWorkspacePromises.status !== PromiseStateStatus.RESOLVED + ? [] + : workspaceDescriptors.map((workspace, index) => { + const { editableFiles, readonlyFiles } = splitFiles(allWorkspacePromises.data[index] ?? []); + const isWsFolder = + editableFiles.length > 1 || readonlyFiles.length > 0 || workspace.origin.kind !== WorkspaceKind.LOCAL; + const hasErrors = !editableFiles || !editableFiles[0]; + const name = getWorkspaceName(workspace, isWsFolder, hasErrors, editableFiles); + + return { + createdDateISO: workspace.createdDateISO, + descriptor: workspace, + editableFiles: editableFiles, + hasErrors, + isWsFolder, + lastUpdatedDateISO: workspace.lastUpdatedDateISO, + name, + origin: workspace.origin, + totalFiles: editableFiles.length + readonlyFiles.length, + workspaceId: workspace.workspaceId, + }; + }), + [workspaceDescriptors, allWorkspacePromises.data, allWorkspacePromises.status] + ); + + const filteredTableData = useMemo<WorkspacesTableRowData[]>(() => { + const searchRegex = new RegExp(searchValue, "i"); + return searchValue ? tableData.filter((e) => e.name.search(searchRegex) >= 0) : tableData; + }, [searchValue, tableData]); + + const sortedTableData = useMemo<WorkspacesTableRowData[]>( + () => + // slice() here is needed to create a copy of filteredTableData and sort the data + filteredTableData.slice().sort((a, b) => { + const aValue = getSortableRowValues(a)[activeSortIndex]; + const bValue = getSortableRowValues(b)[activeSortIndex]; + // put items with errors at the top + if (a.hasErrors) { + return -1; + } + if (typeof aValue === "number") { + return activeSortDirection === "asc" + ? (aValue as number) - (bValue as number) + : (bValue as number) - (aValue as number); + } else { + return activeSortDirection === "asc" + ? (aValue as string).localeCompare(bValue as string) + : (bValue as string).localeCompare(aValue as string); + } + }), + [filteredTableData, activeSortIndex, activeSortDirection] + ); + + const visibleTableData = useMemo<WorkspacesTableRowData[]>( + () => sortedTableData.slice((page - 1) * perPage, page * perPage), + [sortedTableData, page, perPage] + ); + + const getSortParams = useCallback( + (columnIndex: number): ThProps["sort"] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: "asc", + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }), + [activeSortIndex, activeSortDirection] + ); + + const isWsCheckboxChecked = useCallback( + (workspaceId: WorkspaceDescriptor["workspaceId"]) => selectedWorkspaceIds.includes(workspaceId), + [selectedWorkspaceIds] + ); + + return ( + <> + <TableComposable aria-label="Selectable table"> + <Thead> + <Tr> + <Th> </Th> + <Th sort={getSortParams(0)}>{columnNames.name}</Th> + <Th sort={getSortParams(1)}>{columnNames.type}</Th> + <Th sort={getSortParams(2)}>{columnNames.created}</Th> + <Th sort={getSortParams(3)}>{columnNames.lastUpdated}</Th> + <Th sort={getSortParams(4)}>{columnNames.editableFiles}</Th> + <Th sort={getSortParams(5)}>{columnNames.totalFiles}</Th> + <Th></Th> + </Tr> + </Thead> + <Tbody> + <PromiseStateWrapper + promise={allWorkspacePromises} + pending={<WorkspacesTableRowLoading />} + rejected={() => <>ERROR</>} + resolved={() => + !visibleTableData.length ? ( + <WorkspacesTableRowEmptyState onClearFilters={onClearFilters} /> + ) : ( + visibleTableData.map((rowData, rowIndex) => ( + <ErrorBoundary key={rowData.workspaceId} error={<WorkspacesTableRowError rowData={rowData} />}> + <WorkspacesTableRow + rowData={rowData} + rowIndex={rowIndex} + isSelected={isWsCheckboxChecked(rowData.workspaceId)} + onToggle={(checked) => props.onWsToggle(rowData.workspaceId, checked)} + /> + </ErrorBoundary> + )) + ) + } + /> + </Tbody> + </TableComposable> + </> + ); +} + +function getSortableRowValues(tableData: WorkspacesTableRowData): (string | number | boolean)[] { + const { name, isWsFolder, createdDateISO, lastUpdatedDateISO, editableFiles, totalFiles, descriptor } = tableData; + const workspaceType = !editableFiles.length + ? "" + : isWsFolder + ? "d_" + descriptor.origin.toString() + : "f_" + editableFiles[0].extension; + return [name, workspaceType, createdDateISO, lastUpdatedDateISO, editableFiles.length, totalFiles]; +} + +function getWorkspaceName( + workspace: WorkspaceDescriptor, + isWsFolder: boolean, + hasErrors: boolean, + editableFiles: WorkspaceFile[] +) { + if (hasErrors) { + return workspacesTableRowErrorContent; + } + return !isWsFolder && editableFiles.length ? editableFiles[0].nameWithoutExtension : workspace.name; +} diff --git a/packages/serverless-logic-web-tools/src/homepage/recentModels/WorkspacesTableRow.tsx b/packages/serverless-logic-web-tools/src/homepage/recentModels/WorkspacesTableRow.tsx new file mode 100644 index 0000000000..68eb97634b --- /dev/null +++ b/packages/serverless-logic-web-tools/src/homepage/recentModels/WorkspacesTableRow.tsx @@ -0,0 +1,209 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from "react"; +import { useMemo } from "react"; +import { useWorkspaces } from "@kie-tools-core/workspaces-git-fs/dist/context/WorkspacesContext"; +import { Bullseye } from "@patternfly/react-core/dist/js/layouts/Bullseye"; +import { Button } from "@patternfly/react-core/dist/js/components/Button"; +import { EmptyState, EmptyStateBody, EmptyStateIcon } from "@patternfly/react-core/dist/js/components/EmptyState"; +import { Popover } from "@patternfly/react-core/dist/js/components/Popover"; +import { Title } from "@patternfly/react-core/dist/js/components/Title"; +import { Skeleton } from "@patternfly/react-core/dist/js/components/Skeleton"; +import "@patternfly/react-core/dist/styles/base.css"; +import { ExclamationTriangleIcon, OutlinedQuestionCircleIcon, SearchIcon } from "@patternfly/react-icons/dist/js/icons"; +import { FolderIcon } from "@patternfly/react-icons/dist/js/icons/folder-icon"; +import { TaskIcon } from "@patternfly/react-icons/dist/js/icons/task-icon"; +import { ActionsColumn, Td, Tr } from "@patternfly/react-table/dist/esm"; +import { TdSelectType } from "@patternfly/react-table/dist/esm/components/Table/base"; +import { Link } from "react-router-dom"; +import { RelativeDate } from "../../dates/RelativeDate"; +import { routes } from "../../navigation/Routes"; +import { FileLabel } from "../../workspace/components/FileLabel"; +import { WorkspaceLabel } from "../../workspace/components/WorkspaceLabel"; +import { columnNames, WorkspacesTableRowData } from "./WorkspacesTable"; +import "../../table/Table.css"; + +export const workspacesTableRowErrorContent = "Error obtaining workspace information"; + +export type WorkspacesTableRowProps = { + rowIndex: TdSelectType["rowIndex"]; + rowData: WorkspacesTableRowData; + isSelected: boolean; + /** + * event fired when the Checkbox is toggled + */ + onToggle: (selected: boolean) => void; +}; + +export function WorkspacesTableRow(props: WorkspacesTableRowProps) { + const { isSelected, rowIndex } = props; + const { descriptor, editableFiles, totalFiles, name, isWsFolder, workspaceId, createdDateISO, lastUpdatedDateISO } = + props.rowData; + const workspaces = useWorkspaces(); + + const linkTo = useMemo( + () => + routes.workspaceWithFilePath.path({ + workspaceId: editableFiles[0].workspaceId, + fileRelativePath: editableFiles[0].relativePathWithoutExtension, + extension: editableFiles[0].extension, + }), + [editableFiles] + ); + + return ( + <Tr key={name}> + <Td + select={{ + rowIndex, + onSelect: (_event, checked) => props.onToggle(checked), + isSelected, + }} + /> + <Td dataLabel={columnNames.name}> + {isWsFolder ? ( + <> + <FolderIcon /> + {name} + </> + ) : ( + <> + <TaskIcon /> + <Link to={linkTo}>{name}</Link> + </> + )} + </Td> + <Td dataLabel={columnNames.type}> + {isWsFolder ? <WorkspaceLabel descriptor={descriptor} /> : <FileLabel extension={editableFiles[0].extension} />} + </Td> + <Td dataLabel={columnNames.created}> + <RelativeDate date={new Date(createdDateISO ?? "")} /> + </Td> + <Td dataLabel={columnNames.lastUpdated}> + <RelativeDate date={new Date(lastUpdatedDateISO ?? "")} /> + </Td> + <Td dataLabel={columnNames.editableFiles}>{editableFiles.length}</Td> + <Td dataLabel={columnNames.totalFiles}>{totalFiles}</Td> + <Td isActionCell> + <ActionsColumn + items={[ + { + title: "Delete", + onClick: () => workspaces.deleteWorkspace({ workspaceId }), + }, + ]} + /> + </Td> + </Tr> + ); +} + +export function WorkspacesTableRowError(props: { rowData: WorkspacesTableRowData }) { + const { rowData } = props; + const workspaces = useWorkspaces(); + + return ( + <> + <Tr> + <Td> </Td> + <Td colSpan={Object.keys(columnNames).length}> + <ExclamationTriangleIcon /> + + {workspacesTableRowErrorContent} + <Popover + maxWidth="30%" + bodyContent={ + <> + Error obtaining information for the following element: + <br /> + workspace name: <b>{rowData.descriptor.name}</b> + <br /> + workspace id: <b>{rowData.workspaceId}</b> + <br /> + <br /> + To solve the issue, try deleting the workspace and creating it again. + </> + } + > + <OutlinedQuestionCircleIcon className="pf-c-question-circle-icon" /> + </Popover> + </Td> + <Td isActionCell> + <ActionsColumn + items={[ + { + title: "Delete", + onClick: () => workspaces.deleteWorkspace({ workspaceId: rowData.workspaceId }), + }, + ]} + /> + </Td> + </Tr> + </> + ); +} + +export function WorkspacesTableRowEmptyState(props: { onClearFilters: () => void }) { + return ( + <Tr> + <Td colSpan={Object.keys(columnNames).length + 2}> + <Bullseye> + <EmptyState variant="small"> + <EmptyStateIcon icon={SearchIcon} /> + <Title headingLevel="h2" size="lg"> + No matching modules found + </Title> + <EmptyStateBody>This filter criteria matches no groups. Try changing your filter settings.</EmptyStateBody> + <Button variant="link" onClick={props.onClearFilters}> + Clear all filters + </Button> + </EmptyState> + </Bullseye> + </Td> + </Tr> + ); +} + +export function WorkspacesTableRowLoading() { + return ( + <tr> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + <Td> + <Skeleton /> + </Td> + </tr> + ); +} diff --git a/packages/serverless-logic-web-tools/src/homepage/routes/HomePageRoutes.tsx b/packages/serverless-logic-web-tools/src/homepage/routes/HomePageRoutes.tsx index 2a2f17cf6e..5885cc544d 100644 --- a/packages/serverless-logic-web-tools/src/homepage/routes/HomePageRoutes.tsx +++ b/packages/serverless-logic-web-tools/src/homepage/routes/HomePageRoutes.tsx @@ -17,7 +17,7 @@ import React, { useMemo } from "react"; import { Switch } from "react-router"; import { Overview } from "../overView/Overview"; -import { ServerlessModels } from "../serverlessModels/ServerlessModels"; +import { RecentModels } from "../recentModels/RecentModels"; import { Route } from "react-router-dom"; import { SampleCatalog } from "../sampleCatalog/SampleCatalog"; import { useRoutes } from "../../navigation/Hooks"; @@ -54,13 +54,13 @@ export function HomePageRoutes(props: { isNavOpen: boolean }) { /> )} </Route> - <Route path="/" exact> + <Route path={routes.home.path({})} exact> <Overview isNavOpen={props.isNavOpen} /> </Route> - <Route path="/ServerlessModels"> - <ServerlessModels /> + <Route path={routes.recentModels.path({})}> + <RecentModels /> </Route> - <Route path="/SampleCatalog"> + <Route path={routes.sampleCatalog.path({})}> <SampleCatalog /> </Route> <Route component={NoMatchPage} /> diff --git a/packages/serverless-logic-web-tools/src/homepage/serverlessModels/ServerlessModels.tsx b/packages/serverless-logic-web-tools/src/homepage/serverlessModels/ServerlessModels.tsx deleted file mode 100644 index a2289c29c9..0000000000 --- a/packages/serverless-logic-web-tools/src/homepage/serverlessModels/ServerlessModels.tsx +++ /dev/null @@ -1,552 +0,0 @@ -/* - * Copyright 2022 Red Hat, Inc. and/or its affiliates. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from "react"; -import { PromiseStateWrapper } from "@kie-tools-core/react-hooks/dist/PromiseState"; -import { useController } from "@kie-tools-core/react-hooks/dist/useController"; -import { useWorkspaces, WorkspaceFile } from "@kie-tools-core/workspaces-git-fs/dist/context/WorkspacesContext"; -import { useWorkspacePromise } from "@kie-tools-core/workspaces-git-fs/dist/hooks/WorkspaceHooks"; -import { useWorkspaceDescriptorsPromise } from "@kie-tools-core/workspaces-git-fs/dist/hooks/WorkspacesHooks"; -import { WorkspaceDescriptor } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/WorkspaceDescriptor"; -import { WorkspaceKind } from "@kie-tools-core/workspaces-git-fs/dist/worker/api/WorkspaceOrigin"; -import { - Card, - CardActions, - CardBody, - CardHeader, - CardHeaderMain, - CardTitle, -} from "@patternfly/react-core/dist/js/components/Card"; -import { - DataList, - DataListCell, - DataListItem, - DataListItemCells, - DataListItemRow, -} from "@patternfly/react-core/dist/js/components/DataList"; -import { - Drawer, - DrawerActions, - DrawerCloseButton, - DrawerContent, - DrawerContentBody, - DrawerHead, - DrawerPanelBody, - DrawerPanelContent, - DrawerSection, -} from "@patternfly/react-core/dist/js/components/Drawer"; -import { Dropdown, DropdownToggle } from "@patternfly/react-core/dist/js/components/Dropdown"; -import { EmptyState, EmptyStateBody, EmptyStateIcon } from "@patternfly/react-core/dist/js/components/EmptyState"; -import { ExpandableSection } from "@patternfly/react-core/dist/js/components/ExpandableSection"; -import { Skeleton } from "@patternfly/react-core/dist/js/components/Skeleton"; -import { Spinner } from "@patternfly/react-core/dist/js/components/Spinner"; -import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/js/components/Text"; -import { Title } from "@patternfly/react-core/dist/js/components/Title"; -import { Bullseye } from "@patternfly/react-core/dist/js/layouts/Bullseye"; -import { Flex, FlexItem } from "@patternfly/react-core/dist/js/layouts/Flex"; -import { Stack, StackItem } from "@patternfly/react-core/dist/js/layouts/Stack"; -import { CubesIcon } from "@patternfly/react-icons/dist/js/icons/cubes-icon"; -import { ExclamationTriangleIcon } from "@patternfly/react-icons/dist/js/icons/exclamation-triangle-icon"; -import { FolderIcon } from "@patternfly/react-icons/dist/js/icons/folder-icon"; -import { PlusIcon } from "@patternfly/react-icons/dist/js/icons/plus-icon"; -import { TaskIcon } from "@patternfly/react-icons/dist/js/icons/task-icon"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useHistory } from "react-router"; -import { Link } from "react-router-dom"; -import { Alerts, AlertsController } from "../../alerts/Alerts"; -import { RelativeDate } from "../../dates/RelativeDate"; -import { DeleteDropdownWithConfirmation } from "../../editor/DeleteDropdownWithConfirmation"; -import { NewFileDropdownMenu } from "../../editor/NewFileDropdownMenu"; -import { isEditable, splitFiles } from "../../extension"; -import { useRoutes } from "../../navigation/Hooks"; -import { QueryParams } from "../../navigation/Routes"; -import { useQueryParam, useQueryParams } from "../../queryParams/QueryParamsContext"; -import { ErrorBoundary } from "../../reactExt/ErrorBoundary"; -import { FileLabel } from "../../workspace/components/FileLabel"; -import { WorkspaceLabel } from "../../workspace/components/WorkspaceLabel"; - -export function ServerlessModels() { - const routes = useRoutes(); - const history = useHistory(); - const workspaceDescriptorsPromise = useWorkspaceDescriptorsPromise(); - const expandedWorkspaceId = useQueryParam(QueryParams.EXPAND); - const queryParams = useQueryParams(); - - const closeExpandedWorkspace = useCallback(() => { - history.replace({ - pathname: "/ServerlessModels", - search: queryParams.without(QueryParams.EXPAND).toString(), - }); - }, [history, queryParams]); - - const expandWorkspace = useCallback( - (workspaceId: string) => { - const expand = workspaceId !== expandedWorkspaceId ? workspaceId : undefined; - if (!expand) { - closeExpandedWorkspace(); - return; - } - - history.replace({ - pathname: "/ServerlessModels", - search: routes.home.queryString({ expand }), - }); - }, - [closeExpandedWorkspace, history, routes, expandedWorkspaceId] - ); - - useEffect(() => { - if ( - workspaceDescriptorsPromise.data && - !workspaceDescriptorsPromise.data.map((f) => f.workspaceId).includes(expandedWorkspaceId!) - ) { - closeExpandedWorkspace(); - } - }, [workspaceDescriptorsPromise, closeExpandedWorkspace, expandedWorkspaceId]); - - return ( - <PromiseStateWrapper - promise={workspaceDescriptorsPromise} - rejected={(e) => <>Error fetching workspaces: {e + ""}</>} - resolved={(workspaceDescriptors) => { - return ( - <Drawer isExpanded={!!expandedWorkspaceId} isInline={true}> - <DrawerSection> - <TextContent> - <Text component={TextVariants.h1}>Recent models</Text> - </TextContent> - <br /> - </DrawerSection> - <DrawerContent - panelContent={ - <WorkspacesListDrawerPanelContent workspaceId={expandedWorkspaceId} onClose={closeExpandedWorkspace} /> - } - > - <DrawerContentBody> - {workspaceDescriptors.length > 0 && ( - <Stack hasGutter={true} style={{ padding: "10px" }}> - {workspaceDescriptors - .sort((a, b) => (new Date(a.lastUpdatedDateISO) < new Date(b.lastUpdatedDateISO) ? 1 : -1)) - .map((workspace) => ( - <StackItem key={workspace.workspaceId}> - <ErrorBoundary error={<WorkspaceCardError workspace={workspace} />}> - <WorkspaceCard - workspaceId={workspace.workspaceId} - onSelect={() => expandWorkspace(workspace.workspaceId)} - isSelected={workspace.workspaceId === expandedWorkspaceId} - /> - </ErrorBoundary> - </StackItem> - ))} - </Stack> - )} - {workspaceDescriptors.length === 0 && ( - <Bullseye> - <EmptyState> - <EmptyStateIcon icon={CubesIcon} /> - <Title headingLevel="h4" size="lg"> - {`Nothing here`} - </Title> - <EmptyStateBody>{`Start by adding a new model`}</EmptyStateBody> - </EmptyState> - </Bullseye> - )} - </DrawerContentBody> - </DrawerContent> - </Drawer> - ); - }} - /> - ); -} - -export function WorkspacesListDrawerPanelContent(props: { workspaceId: string | undefined; onClose: () => void }) { - const routes = useRoutes(); - const workspacePromise = useWorkspacePromise(props.workspaceId); - - const readonlyFiles = useMemo( - () => - (workspacePromise.data?.files ?? []) - .sort((a, b) => a.relativePath.localeCompare(b.relativePath)) - .filter((file) => !isEditable(file.relativePath)), - [workspacePromise.data?.files] - ); - - const editableFiles = useMemo( - () => - (workspacePromise.data?.files ?? []) - .sort((a, b) => a.relativePath.localeCompare(b.relativePath)) - .filter((file) => isEditable(file.relativePath)), - [workspacePromise.data?.files] - ); - - const [isNewFileDropdownMenuOpen, setNewFileDropdownMenuOpen] = useState(false); - const [alerts, alertsRef] = useController<AlertsController>(); - - return ( - <DrawerPanelContent isResizable={true} minSize={"40%"} maxSize={"80%"}> - <PromiseStateWrapper - promise={workspacePromise} - pending={ - <DrawerPanelBody> - <Bullseye> - <Spinner /> - </Bullseye> - </DrawerPanelBody> - } - resolved={(workspace) => ( - <> - <Alerts width={"100%"} ref={alertsRef} /> - <DrawerHead> - <Flex> - <FlexItem> - <TextContent> - <Text - component={TextVariants.h3} - >{`Editable files in '${workspacePromise.data?.descriptor.name}'`}</Text> - </TextContent> - </FlexItem> - <FlexItem> - <Dropdown - isPlain={true} - position={"left"} - isOpen={isNewFileDropdownMenuOpen} - toggle={ - <DropdownToggle - className={"kie-tools--masthead-hoverable"} - toggleIndicator={null} - onToggle={setNewFileDropdownMenuOpen} - > - <PlusIcon /> - </DropdownToggle> - } - > - <NewFileDropdownMenu - alerts={alerts} - workspaceId={workspace.descriptor.workspaceId} - destinationDirPath={""} - onAddFile={async () => setNewFileDropdownMenuOpen(false)} - /> - </Dropdown> - </FlexItem> - </Flex> - {(workspace.descriptor.origin.kind === WorkspaceKind.GITHUB_GIST || - workspace.descriptor.origin.kind === WorkspaceKind.GIT) && ( - <TextContent> - <Text component={TextVariants.small}> - <i>{workspace.descriptor.origin.url.toString()}</i> - </Text> - </TextContent> - )} - <DrawerActions> - <DrawerCloseButton onClick={props.onClose} /> - </DrawerActions> - </DrawerHead> - <DrawerPanelBody> - <DataList aria-label="models-data-list"> - {editableFiles.map((file) => ( - <Link - key={file.relativePath} - to={routes.workspaceWithFilePath.path({ - workspaceId: workspace.descriptor.workspaceId ?? "", - fileRelativePath: file.relativePathWithoutExtension, - extension: file.extension, - })} - > - <FileDataListItem file={file} /> - </Link> - ))} - </DataList> - <br /> - {readonlyFiles.length > 0 && ( - <ExpandableSection - toggleTextCollapsed="View readonly files" - toggleTextExpanded="Hide readonly files" - className={"plain"} - > - <DataList aria-label="readonly-files-data-list"> - {readonlyFiles.map((file) => ( - <Link - key={file.relativePath} - to={routes.workspaceWithFilePath.path({ - workspaceId: workspace.descriptor.workspaceId ?? "", - fileRelativePath: file.relativePathWithoutExtension, - extension: file.extension, - })} - > - <FileDataListItem key={file.relativePath} file={file} /> - </Link> - ))} - </DataList> - </ExpandableSection> - )} - </DrawerPanelBody> - </> - )} - /> - </DrawerPanelContent> - ); -} - -export function WorkspaceLoadingCard() { - return ( - <Card> - <CardBody> - <Skeleton fontSize={"sm"} width={"40%"} /> - <br /> - <Skeleton fontSize={"sm"} width={"70%"} /> - </CardBody> - </Card> - ); -} - -export function WorkspaceCardError(props: { workspace: WorkspaceDescriptor }) { - const workspaces = useWorkspaces(); - return ( - <Card isSelected={false} isSelectable={true} isHoverable={true} isCompact={true}> - <CardHeader> - <CardHeaderMain> - <Flex> - <FlexItem> - <CardTitle> - <TextContent> - <Text component={TextVariants.h3}> - <ExclamationTriangleIcon /> - - {`There was an error obtaining information for '${props.workspace.workspaceId}'`} - </Text> - </TextContent> - </CardTitle> - </FlexItem> - </Flex> - </CardHeaderMain> - <CardActions> - <DeleteDropdownWithConfirmation - onDelete={() => { - workspaces.deleteWorkspace({ workspaceId: props.workspace.workspaceId }); - }} - item={ - <> - Delete <b>{`"${props.workspace.name}"`}</b> - </> - } - /> - </CardActions> - </CardHeader> - </Card> - ); -} - -export function WorkspaceCard(props: { workspaceId: string; isSelected: boolean; onSelect: () => void }) { - const routes = useRoutes(); - const history = useHistory(); - const workspaces = useWorkspaces(); - const [isHovered, setHovered] = useState(false); - const workspacePromise = useWorkspacePromise(props.workspaceId); - - const { editableFiles, readonlyFiles } = useMemo( - () => splitFiles(workspacePromise.data?.files ?? []), - [workspacePromise.data?.files] - ); - - const workspaceName = useMemo(() => { - return workspacePromise.data ? workspacePromise.data.descriptor.name : null; - }, [workspacePromise.data]); - - return ( - <PromiseStateWrapper - promise={workspacePromise} - pending={<WorkspaceLoadingCard />} - rejected={() => <>ERROR</>} - resolved={(workspace) => ( - <> - {(editableFiles.length === 1 && - readonlyFiles.length === 0 && - workspace.descriptor.origin.kind === WorkspaceKind.LOCAL && ( - <Card - isSelected={props.isSelected} - isSelectable={true} - onMouseOver={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - isHoverable={true} - isCompact={true} - style={{ cursor: "pointer" }} - onClick={() => { - history.push({ - pathname: routes.workspaceWithFilePath.path({ - workspaceId: editableFiles[0].workspaceId, - fileRelativePath: editableFiles[0].relativePathWithoutExtension, - extension: editableFiles[0].extension, - }), - }); - }} - > - <CardHeader> - <Link - to={routes.workspaceWithFilePath.path({ - workspaceId: editableFiles[0].workspaceId, - fileRelativePath: editableFiles[0].relativePathWithoutExtension, - extension: editableFiles[0].extension, - })} - > - <CardHeaderMain style={{ width: "100%" }}> - <Flex> - <FlexItem> - <CardTitle> - <TextContent> - <Text - component={TextVariants.h3} - style={{ textOverflow: "ellipsis", overflow: "hidden" }} - > - <TaskIcon /> - - {editableFiles[0].nameWithoutExtension} - </Text> - </TextContent> - </CardTitle> - </FlexItem> - <FlexItem> - <b> - <FileLabel extension={editableFiles[0].extension} /> - </b> - </FlexItem> - </Flex> - </CardHeaderMain> - </Link> - <CardActions> - {isHovered && ( - <DeleteDropdownWithConfirmation - onDelete={() => { - workspaces.deleteWorkspace({ workspaceId: props.workspaceId }); - }} - item={ - <Flex flexWrap={{ default: "nowrap" }}> - <FlexItem> - Delete <b>{`"${editableFiles[0].nameWithoutExtension}"`}</b> - </FlexItem> - <FlexItem> - <b> - <FileLabel extension={editableFiles[0].extension} /> - </b> - </FlexItem> - </Flex> - } - /> - )} - </CardActions> - </CardHeader> - <CardBody> - <TextContent> - <Text component={TextVariants.p}> - <b>{`Created: `}</b> - <RelativeDate date={new Date(workspacePromise.data?.descriptor.createdDateISO ?? "")} /> - <b>{`, Last updated: `}</b> - <RelativeDate date={new Date(workspacePromise.data?.descriptor.lastUpdatedDateISO ?? "")} /> - </Text> - </TextContent> - </CardBody> - </Card> - )) || ( - <Card - isSelected={props.isSelected} - isSelectable={true} - onMouseOver={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - isHoverable={true} - isCompact={true} - style={{ cursor: "pointer" }} - onClick={props.onSelect} - > - <CardHeader> - <CardHeaderMain style={{ width: "100%" }}> - <Flex> - <FlexItem> - <CardTitle> - <TextContent> - <Text component={TextVariants.h3} style={{ textOverflow: "ellipsis", overflow: "hidden" }}> - <FolderIcon /> - - {workspaceName} - - <WorkspaceLabel descriptor={workspacePromise.data?.descriptor} /> - </Text> - </TextContent> - </CardTitle> - </FlexItem> - <FlexItem> - <Text component={TextVariants.p}> - {`${editableFiles?.length} editable files(s) in ${workspace.files.length} file(s)`} - </Text> - </FlexItem> - </Flex> - </CardHeaderMain> - - <CardActions> - {isHovered && ( - <DeleteDropdownWithConfirmation - onDelete={() => { - workspaces.deleteWorkspace({ workspaceId: props.workspaceId }); - }} - item={ - <> - Delete <b>{`"${workspacePromise.data?.descriptor.name}"`}</b> - </> - } - /> - )} - </CardActions> - </CardHeader> - <CardBody> - <TextContent> - <Text component={TextVariants.p}> - <b>{`Created: `}</b> - <RelativeDate date={new Date(workspacePromise.data?.descriptor.createdDateISO ?? "")} /> - <b>{`, Last updated: `}</b> - <RelativeDate date={new Date(workspacePromise.data?.descriptor.lastUpdatedDateISO ?? "")} /> - </Text> - </TextContent> - </CardBody> - </Card> - )} - </> - )} - /> - ); -} - -export function FileDataListItem(props: { file: WorkspaceFile }) { - return ( - <DataListItem> - <DataListItemRow> - <DataListItemCells - dataListCells={[ - <DataListCell key="link" isFilled={false}> - <Flex flexWrap={{ default: "nowrap" }}> - <FlexItem>{props.file.nameWithoutExtension}</FlexItem> - <FlexItem> - <FileLabel extension={props.file.extension} /> - </FlexItem> - </Flex> - <TextContent> - <Text component={TextVariants.small}>{props.file.relativeDirPath.split("/").join(" > ")}</Text> - </TextContent> - </DataListCell>, - ]} - /> - </DataListItemRow> - </DataListItem> - ); -} diff --git a/packages/serverless-logic-web-tools/src/homepage/uiNav/HomePageNav.tsx b/packages/serverless-logic-web-tools/src/homepage/uiNav/HomePageNav.tsx index 6e3bdb8945..209a55f0d7 100644 --- a/packages/serverless-logic-web-tools/src/homepage/uiNav/HomePageNav.tsx +++ b/packages/serverless-logic-web-tools/src/homepage/uiNav/HomePageNav.tsx @@ -18,6 +18,7 @@ import * as React from "react"; import { Nav, NavItem, NavList } from "@patternfly/react-core/dist/js/components/Nav"; import { Link } from "react-router-dom"; import { ExternalLinkAltIcon } from "@patternfly/react-icons/dist/js/icons"; +import { routes } from "../../navigation/Routes"; export function HomePageNav(props: { pathname: string }) { return ( @@ -25,25 +26,23 @@ export function HomePageNav(props: { pathname: string }) { <div className="chr-c-app-title">Serverless Logic Web Tools</div> <Nav aria-label="Global NAV" theme="dark"> <NavList> - <NavItem itemId={0} key={"Overview-nav"} isActive={props.pathname === "/"}> - <Link to="/">Overview</Link> + <NavItem itemId={0} key={"Overview-nav"} isActive={props.pathname === routes.home.path({})}> + <Link to={routes.home.path({})}>Overview</Link> </NavItem> - <NavItem itemId={1} key={"Serverless-models-nav"} isActive={props.pathname === "/ServerlessModels"}> - <Link to="/ServerlessModels">Serverless Models</Link> + <NavItem itemId={1} key={"Recent-models-nav"} isActive={props.pathname === routes.recentModels.path({})}> + <Link to={routes.recentModels.path({})}>Recent Models</Link> </NavItem> - <NavItem itemId={2} key={"SampleCatalog-nav"} isActive={props.pathname === "/SampleCatalog"}> - <Link to="/SampleCatalog">Sample Catalog</Link> + <NavItem itemId={2} key={"SampleCatalog-nav"} isActive={props.pathname === routes.sampleCatalog.path({})}> + <Link to={routes.sampleCatalog.path({})}>Sample Catalog</Link> </NavItem> - <NavItem - itemId={3} - key={"Documentation-nav"} - className="chr-c-navigation__additional-links" - isActive={props.pathname === "/Documentation"} - > - <a href="https://kiegroup.github.io/kogito-docs/serverlessworkflow/latest/index.html" target="_blank"> + <NavItem itemId={3} key={"Documentation-nav"} className="chr-c-navigation__additional-links"> + <a + href="https://kiegroup.github.io/kogito-docs/serverlessworkflow/latest/tooling/serverless-logic-web-tools/serverless-logic-web-tools-overview.html" + target="_blank" + > Documentation <ExternalLinkAltIcon /> </a> diff --git a/packages/serverless-logic-web-tools/src/navigation/Routes.ts b/packages/serverless-logic-web-tools/src/navigation/Routes.ts index b62375e791..b821ee0a80 100644 --- a/packages/serverless-logic-web-tools/src/navigation/Routes.ts +++ b/packages/serverless-logic-web-tools/src/navigation/Routes.ts @@ -129,6 +129,9 @@ export const routes = { `/${workspaceId}/file/${fileRelativePath}${extension ? "." + extension : ""}` ), + recentModels: new Route<{}>(() => `/RecentModels`), + sampleCatalog: new Route<{}>(() => `/SampleCatalog`), + settings: { home: new Route<{}>(() => SETTINGS_ROUTE), github: new Route<{}>(() => `${SETTINGS_ROUTE}/github`), diff --git a/packages/serverless-logic-web-tools/src/table/Table.css b/packages/serverless-logic-web-tools/src/table/Table.css new file mode 100644 index 0000000000..bf887a88c0 --- /dev/null +++ b/packages/serverless-logic-web-tools/src/table/Table.css @@ -0,0 +1,21 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.pf-c-question-circle-icon { + color: var(--pf-global--palette--black-600); + margin-left: var(--pf-global--spacer--sm); + cursor: pointer; +} diff --git a/packages/serverless-logic-web-tools/src/table/TablePagination.tsx b/packages/serverless-logic-web-tools/src/table/TablePagination.tsx new file mode 100644 index 0000000000..d438703838 --- /dev/null +++ b/packages/serverless-logic-web-tools/src/table/TablePagination.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from "react"; +import { OnPerPageSelect, Pagination, PaginationProps } from "@patternfly/react-core/dist/js/components/Pagination"; +import "@patternfly/react-core/dist/styles/base.css"; +import { useCallback } from "react"; + +export type TablePaginationProps = Pick<PaginationProps, "variant" | "isCompact" | "perPageOptions"> & { + itemCount: number; + page: number; + perPage: number; + setPage: (newPage: number) => void; + setPerPage: (newPerPage: number) => void; +}; + +export function TablePagination(props: TablePaginationProps) { + const { isCompact, itemCount, page, perPage, perPageOptions, setPage, setPerPage, variant } = props; + + const onPerPageSelect: OnPerPageSelect = useCallback( + (_e, v) => { + // When changing the number of results per page, keep the start row approximately the same + const firstRow = (page - 1) * perPage; + setPage(Math.floor(firstRow / v) + 1); + setPerPage(v); + }, + [page, perPage, setPage, setPerPage] + ); + + return ( + <Pagination + isCompact={isCompact} + itemCount={itemCount} + onPerPageSelect={onPerPageSelect} + onSetPage={(_e, v) => setPage(v)} + page={page} + perPage={perPage} + perPageOptions={perPageOptions} + variant={variant} + /> + ); +} diff --git a/packages/serverless-logic-web-tools/src/table/TableToolbar.tsx b/packages/serverless-logic-web-tools/src/table/TableToolbar.tsx new file mode 100644 index 0000000000..84286b5f64 --- /dev/null +++ b/packages/serverless-logic-web-tools/src/table/TableToolbar.tsx @@ -0,0 +1,161 @@ +/* + * Copyright 2023 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Dropdown, + DropdownItem, + DropdownToggle, + DropdownToggleCheckbox, + KebabToggle, +} from "@patternfly/react-core/dist/js/components/Dropdown"; +import { SearchInput } from "@patternfly/react-core/dist/js/components/SearchInput"; +import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core/dist/js/components/Toolbar"; +import "@patternfly/react-core/dist/styles/base.css"; +import * as React from "react"; +import { useCallback, useMemo, useState } from "react"; +import { TablePagination, TablePaginationProps } from "./TablePagination"; + +export type TableToolbarProps = TablePaginationProps & { + onDeleteActionButtonClick: () => void; + onToggleAllElements: (checked: boolean) => void; + searchValue: string; + selectedElementsCount: number; + setSearchValue: React.Dispatch<React.SetStateAction<string>>; +}; + +export function TableToolbar(props: TableToolbarProps) { + const { + itemCount: itemCount, + onDeleteActionButtonClick: onDeleteActionButtonClick, + selectedElementsCount, + searchValue, + setSearchValue, + onToggleAllElements, + page, + perPage, + perPageOptions, + setPage, + setPerPage, + } = props; + const [isBulkDropDownOpen, setIsBulkDropDownOpen] = useState(false); + const [isActionDropdownOpen, setIsActionDropdownOpen] = React.useState(false); + + const isBulkCheckBoxChecked = useMemo( + () => (itemCount === selectedElementsCount ? true : selectedElementsCount === 0 ? false : null), + [itemCount, selectedElementsCount] + ); + + const onBulkDropDownSelect = useCallback(() => setIsBulkDropDownOpen(false), []); + + const onBulkDropDownToggle = useCallback((isOpen: boolean) => setIsBulkDropDownOpen(isOpen), []); + + const onActionDropdownToggle = useCallback(() => { + setIsActionDropdownOpen(!isActionDropdownOpen); + }, [isActionDropdownOpen]); + + const actionDropdownItems = useMemo(() => { + return [ + <DropdownItem + key={"delete-dropdown-item"} + isDisabled={!selectedElementsCount} + onClick={onDeleteActionButtonClick} + ouiaId={"delete-action-button"} + aria-label="Open confirm delete modal" + > + Delete + </DropdownItem>, + ]; + }, [selectedElementsCount, onDeleteActionButtonClick]); + + const bulkDropDownItems = useMemo( + () => [ + <DropdownItem onClick={() => onToggleAllElements(false)} key="none" aria-label="Select none"> + Select none (0) + </DropdownItem>, + <DropdownItem onClick={() => onToggleAllElements(true)} key="all" aria-label="Select All"> + Select all({itemCount}) + </DropdownItem>, + ], + [itemCount, onToggleAllElements] + ); + + const onSearchChange = useCallback( + (value: string) => { + setSearchValue(value); + }, + [setSearchValue] + ); + + return ( + <Toolbar> + <ToolbarContent style={{ paddingLeft: "10px", paddingRight: "10px" }}> + <ToolbarItem alignment={{ default: "alignLeft" }}> + <Dropdown + onSelect={onBulkDropDownSelect} + toggle={ + <DropdownToggle + splitButtonItems={[ + <DropdownToggleCheckbox + onChange={(checked) => onToggleAllElements(checked)} + isChecked={isBulkCheckBoxChecked} + id="split-button-text-checkbox" + key="bulk-check-box" + aria-label="Select all" + > + {selectedElementsCount ? `${selectedElementsCount} selected` : ""} + </DropdownToggleCheckbox>, + ]} + onToggle={onBulkDropDownToggle} + id="toggle-split-button-text" + /> + } + isOpen={isBulkDropDownOpen} + dropdownItems={bulkDropDownItems} + aria-label="Bulk selection dropdown" + /> + </ToolbarItem> + <ToolbarItem variant="search-filter"> + <SearchInput + placeholder="Filter by name" + value={searchValue} + onChange={(_event, value) => onSearchChange(value)} + onClear={() => onSearchChange("")} + /> + </ToolbarItem> + <ToolbarItem> + <Dropdown + onSelect={onActionDropdownToggle} + toggle={<KebabToggle id="toggle-kebab" onToggle={onActionDropdownToggle} />} + isOpen={isActionDropdownOpen} + isPlain + dropdownItems={actionDropdownItems} + /> + </ToolbarItem> + <ToolbarItem variant="pagination"> + <TablePagination + itemCount={itemCount} + page={page} + perPage={perPage} + perPageOptions={perPageOptions} + setPage={setPage} + setPerPage={setPerPage} + variant="top" + isCompact + /> + </ToolbarItem> + </ToolbarContent> + </Toolbar> + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45c346fa5c..f2678e932e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5442,6 +5442,9 @@ importers: "@patternfly/react-icons": specifier: ^4.93.6 version: 4.93.6([email protected])([email protected]) + "@patternfly/react-table": + specifier: ^4.112.39 + version: 4.112.39([email protected])([email protected]) "@patternfly/react-tokens": specifier: ^4.94.6 version: 4.94.6 @@ -5457,9 +5460,6 @@ importers: buffer: specifier: ^6.0.3 version: 6.0.3 - dexie: - specifier: ^3.2.2 - version: 3.2.2 history: specifier: ^4.9.0 version: 4.10.1 --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
