diff --git a/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx b/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx index f30d99d49..70ca1735f 100644 --- a/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx +++ b/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx @@ -113,11 +113,7 @@ export function getActionsCell( )} {!!onArchiveClick && ( - + {getArchiveIcon(isArchived)} )} diff --git a/src/components/Project/ProjectWorkflows.tsx b/src/components/Project/ProjectWorkflows.tsx index 5d0d151ed..aaa431079 100644 --- a/src/components/Project/ProjectWorkflows.tsx +++ b/src/components/Project/ProjectWorkflows.tsx @@ -1,8 +1,8 @@ import { WaitForData } from 'components/common/WaitForData'; +import { useWorkflowShowArchivedState } from 'components/Workflow/filters/useWorkflowShowArchivedState'; import { SearchableWorkflowNameList } from 'components/Workflow/SearchableWorkflowNameList'; -import { Admin } from 'flyteidl'; import { limits } from 'models/AdminEntity/constants'; -import { FilterOperationName, SortDirection } from 'models/AdminEntity/types'; +import { SortDirection } from 'models/AdminEntity/types'; import { workflowSortFields } from 'models/Workflow/constants'; import * as React from 'react'; import { useWorkflowInfoList } from '../Workflow/useWorkflowInfoList'; @@ -12,33 +12,33 @@ export interface ProjectWorkflowsProps { domainId: string; } +const DEFAULT_SORT = { + direction: SortDirection.ASCENDING, + key: workflowSortFields.name, +}; + /** A listing of the Workflows registered for a project */ export const ProjectWorkflows: React.FC = ({ domainId: domain, projectId: project, }) => { + const archivedFilter = useWorkflowShowArchivedState(); const workflows = useWorkflowInfoList( { domain, project }, { limit: limits.NONE, - sort: { - direction: SortDirection.ASCENDING, - key: workflowSortFields.name, - }, - // Hide archived workflows from the list - filter: [ - { - key: 'state', - operation: FilterOperationName.EQ, - value: Admin.NamedEntityState.NAMED_ENTITY_ACTIVE, - }, - ], + sort: DEFAULT_SORT, + filter: [archivedFilter.getFilter()], }, ); return ( - + ); }; diff --git a/src/components/Project/test/ProjectWorkflows.test.tsx b/src/components/Project/test/ProjectWorkflows.test.tsx index 8a4d785c5..f47090c81 100644 --- a/src/components/Project/test/ProjectWorkflows.test.tsx +++ b/src/components/Project/test/ProjectWorkflows.test.tsx @@ -1,22 +1,36 @@ -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { APIContext } from 'components/data/apiContext'; import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; import { Admin } from 'flyteidl'; import { FilterOperationName } from 'models/AdminEntity/types'; -import { listNamedEntities } from 'models/Common/api'; -import { NamedEntity } from 'models/Common/types'; +import { getUserProfile, listNamedEntities } from 'models/Common/api'; +import { NamedEntity, UserProfile } from 'models/Common/types'; import * as React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router'; import { createWorkflowName } from 'test/modelUtils'; +import { createTestQueryClient } from 'test/utils'; import { ProjectWorkflows } from '../ProjectWorkflows'; +const sampleUserProfile: UserProfile = { + subject: 'subject', +} as UserProfile; + +jest.mock('notistack', () => ({ + useSnackbar: () => ({ enqueueSnackbar: jest.fn() }), +})); + describe('ProjectWorkflows', () => { const project = 'TestProject'; const domain = 'TestDomain'; let workflowNames: NamedEntity[]; + let queryClient: QueryClient; let mockListNamedEntities: jest.Mock>; + let mockGetUserProfile: jest.Mock>; beforeEach(() => { + mockGetUserProfile = jest.fn().mockResolvedValue(null); + queryClient = createTestQueryClient(); workflowNames = ['MyWorkflow', 'MyOtherWorkflow'].map((name) => createWorkflowName({ domain, name, project }), ); @@ -25,13 +39,16 @@ describe('ProjectWorkflows', () => { const renderComponent = () => render( - - - , + + + + + , { wrapper: MemoryRouter }, ); @@ -52,4 +69,28 @@ describe('ProjectWorkflows', () => { }), ); }); + + it('should display checkbox if user login', async () => { + mockGetUserProfile.mockResolvedValue(sampleUserProfile); + const { getAllByRole } = renderComponent(); + await waitFor(() => {}); + const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; + expect(checkboxes).toHaveLength(1); + expect(checkboxes[0]).toBeTruthy(); + expect(checkboxes[0]?.checked).toEqual(false); + }); + + /** user doesn't have its own workflow */ + it('clicking show archived should hide active workflows', async () => { + mockGetUserProfile.mockResolvedValue(sampleUserProfile); + const { getByText, queryByText, getAllByRole } = renderComponent(); + await waitFor(() => {}); + const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; + expect(checkboxes[0]).toBeTruthy(); + expect(checkboxes[0]?.checked).toEqual(false); + await waitFor(() => expect(getByText('MyWorkflow'))); + fireEvent.click(checkboxes[0]); + // when user selects checkbox, table should have no workflows to display + await waitFor(() => expect(queryByText('MyWorkflow')).not.toBeInTheDocument()); + }); }); diff --git a/src/components/Workflow/SearchableWorkflowNameList.tsx b/src/components/Workflow/SearchableWorkflowNameList.tsx index 4d4e6bc34..14cd92f9d 100644 --- a/src/components/Workflow/SearchableWorkflowNameList.tsx +++ b/src/components/Workflow/SearchableWorkflowNameList.tsx @@ -1,4 +1,4 @@ -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles, Theme } from '@material-ui/core/styles'; import DeviceHub from '@material-ui/icons/DeviceHub'; import classNames from 'classnames'; import { useNamedEntityListStyles } from 'components/common/SearchableNamedEntityList'; @@ -11,27 +11,82 @@ import { WorkflowExecutionPhase } from 'models/Execution/enums'; import { Shimmer } from 'components/common/Shimmer'; import { WorkflowExecutionIdentifier } from 'models/Execution/types'; import { debounce } from 'lodash'; -import { Typography } from '@material-ui/core'; +import { + IconButton, + Typography, + FormControlLabel, + Checkbox, + FormGroup, + Button, + CircularProgress, +} from '@material-ui/core'; +import UnarchiveOutline from '@material-ui/icons/UnarchiveOutlined'; +import ArchiveOutlined from '@material-ui/icons/ArchiveOutlined'; +import { useMutation } from 'react-query'; +import { WorkflowExecutionState } from 'models/Workflow/enums'; +import { updateWorkflowState } from 'models/Workflow/api'; +import { useState } from 'react'; +import { useSnackbar } from 'notistack'; import { WorkflowListStructureItem } from './types'; import ProjectStatusBar from '../Project/ProjectStatusBar'; import { workflowNoInputsString } from '../Launch/LaunchForm/constants'; import { SearchableInput } from '../common/SearchableList'; import { useSearchableListState } from '../common/useSearchableListState'; import { useWorkflowInfoItem } from './useWorkflowInfoItem'; +import t from '../Executions/Tables/WorkflowExecutionTable/strings'; +import { isWorkflowArchived } from './utils'; interface SearchableWorkflowNameItemProps { item: WorkflowListStructureItem; } +interface SearchableWorkflowNameItemActionsProps { + item: WorkflowListStructureItem; + setHideItem: (hide: boolean) => void; +} + interface SearchableWorkflowNameListProps { workflows: WorkflowListStructureItem[]; + onArchiveFilterChange: (showArchievedItems: boolean) => void; + showArchived: boolean; } -const useStyles = makeStyles(() => ({ +export const showOnHoverClass = 'showOnHover'; + +const useStyles = makeStyles((theme: Theme) => ({ + actionContainer: { + display: 'flex', + right: 0, + top: 0, + position: 'absolute', + height: '100%', + }, + archiveCheckbox: { + whiteSpace: 'nowrap', + }, + centeredChild: { + alignItems: 'center', + marginRight: 24, + }, + confirmationButton: { + borderRadius: 0, + minWidth: '100px', + minHeight: '53px', + '&:last-child': { + borderRadius: '0px 16px 16px 0px', // to ensure that cancel button will have rounded corners on the right side + }, + }, container: { - padding: 13, - paddingRight: 71, + padding: theme.spacing(2), + paddingRight: theme.spacing(5), }, + filterGroup: { + display: 'flex', + flexWrap: 'nowrap', + flexDirection: 'row', + margin: theme.spacing(4, 5, 2, 2), + }, + itemContainer: { marginBottom: 15, borderRadius: 16, @@ -40,6 +95,15 @@ const useStyles = makeStyles(() => ({ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', + position: 'relative', + // All children using the showOnHover class will be hidden until + // the mouse enters the container + [`& .${showOnHoverClass}`]: { + opacity: 0, + }, + [`&:hover .${showOnHoverClass}`]: { + opacity: 1, + }, }, itemName: { display: 'flex', @@ -71,8 +135,7 @@ const useStyles = makeStyles(() => ({ color: workflowLabelColor, }, searchInputContainer: { - padding: '0 13px', - margin: '32px 0 23px', + paddingLeft: 0, }, w100: { flex: 1, @@ -98,6 +161,103 @@ const padExecutionPaths = (items: WorkflowExecutionIdentifier[]) => { return [...items.map((id) => Routes.ExecutionDetails.makeUrl(id)), ...emptyExecutions].reverse(); }; +const getArchiveIcon = (isArchived: boolean) => + isArchived ? : ; + +const SearchableWorkflowNameItemActions: React.FC = ({ + item, + setHideItem, +}) => { + const styles = useStyles(); + const { enqueueSnackbar } = useSnackbar(); + const { id } = item; + const isArchived = isWorkflowArchived(item); + const [isUpdating, setIsUpdating] = useState(false); + const [showConfirmation, setShowConfirmation] = useState(false); + + const mutation = useMutation( + (newState: WorkflowExecutionState) => updateWorkflowState(id, newState), + { + onMutate: () => setIsUpdating(true), + onSuccess: () => { + enqueueSnackbar(t('archiveSuccess', !isArchived), { + variant: 'success', + }); + setHideItem(true); + }, + onError: () => { + enqueueSnackbar(`${mutation.error ?? t('archiveError', !isArchived)}`, { + variant: 'error', + }); + }, + onSettled: () => { + setShowConfirmation(false); + setIsUpdating(false); + }, + }, + ); + + const onArchiveClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + setShowConfirmation(true); + }; + + const onConfirmArchiveClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + mutation.mutate( + isWorkflowArchived(item) + ? WorkflowExecutionState.NAMED_ENTITY_ACTIVE + : WorkflowExecutionState.NAMED_ENTITY_ARCHIVED, + ); + }; + + const onCancelClick = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + setShowConfirmation(false); + }; + + const singleItemStyle = isUpdating || !showConfirmation ? styles.centeredChild : ''; + return ( +
+ {isUpdating ? ( + + + + ) : showConfirmation ? ( + <> + + + + ) : ( + + {getArchiveIcon(isArchived)} + + )} +
+ ); +}; + /** * Renders individual searchable workflow item * @param item @@ -111,6 +271,12 @@ const SearchableWorkflowNameItem: React.FC = Re const { id, description } = item; const { data: workflow, isLoading } = useWorkflowInfoItem(id); + const [hideItem, setHideItem] = useState(false); + + if (hideItem) { + return null; + } + return ( = Re {isLoading ? : workflow?.outputs ?? No output data found.} + ); @@ -174,6 +341,8 @@ const SearchableWorkflowNameItem: React.FC = Re */ export const SearchableWorkflowNameList: React.FC = ({ workflows, + onArchiveFilterChange, + showArchived, }) => { const styles = useStyles(); const [search, setSearch] = React.useState(''); @@ -192,14 +361,26 @@ export const SearchableWorkflowNameList: React.FC - + + + onArchiveFilterChange(checked)} + /> + } + label="Show Only Archived Workflows" + /> +
{results.map(({ value }) => ( { + return ( + + {props.children} + + ); +}; + const stories = storiesOf('Workflow/SearchableWorkflowNameList', module); stories.addDecorator((story) =>
{story()}
); -stories.add('basic', () => ); +stories.add('basic', () => ( + + + +)); diff --git a/src/components/Workflow/filters/useWorkflowShowArchivedState.ts b/src/components/Workflow/filters/useWorkflowShowArchivedState.ts new file mode 100644 index 000000000..9f9f4a017 --- /dev/null +++ b/src/components/Workflow/filters/useWorkflowShowArchivedState.ts @@ -0,0 +1,33 @@ +import { useState } from 'react'; +import { FilterOperation, FilterOperationName } from 'models/AdminEntity/types'; +import { WorkflowExecutionState } from 'models/Workflow/enums'; + +interface ArchiveFilterState { + showArchived: boolean; + setShowArchived: (newValue: boolean) => void; + getFilter: () => FilterOperation; +} + +/** + * Allows to filter by Archive state + */ +export function useWorkflowShowArchivedState(): ArchiveFilterState { + const [showArchived, setShowArchived] = useState(false); + + // By default all values are returned with NAMED_ENTITY_ACTIVE state + const getFilter = (): FilterOperation => { + return { + key: 'state', + operation: FilterOperationName.EQ, + value: showArchived + ? WorkflowExecutionState.NAMED_ENTITY_ARCHIVED + : WorkflowExecutionState.NAMED_ENTITY_ACTIVE, + }; + }; + + return { + showArchived, + setShowArchived, + getFilter, + }; +} diff --git a/src/components/Workflow/types.ts b/src/components/Workflow/types.ts index 4bbc0d87c..7fa3b787e 100644 --- a/src/components/Workflow/types.ts +++ b/src/components/Workflow/types.ts @@ -2,6 +2,7 @@ import { WorkflowId } from 'models/Workflow/types'; import { WorkflowExecutionPhase } from 'models/Execution/enums'; import { WorkflowExecutionIdentifier } from 'models/Execution/types'; import { NamedEntityIdentifier } from 'models/Common/types'; +import { WorkflowExecutionState } from 'models/Workflow/enums'; export type WorkflowListItem = { id: WorkflowId; @@ -11,9 +12,11 @@ export type WorkflowListItem = { executionStatus?: WorkflowExecutionPhase[]; executionIds?: WorkflowExecutionIdentifier[]; description?: string; + state: WorkflowExecutionState; }; export type WorkflowListStructureItem = { id: NamedEntityIdentifier; description: string; + state: WorkflowExecutionState; }; diff --git a/src/components/Workflow/useWorkflowInfoList.ts b/src/components/Workflow/useWorkflowInfoList.ts index d46eeb548..5560bb3e7 100644 --- a/src/components/Workflow/useWorkflowInfoList.ts +++ b/src/components/Workflow/useWorkflowInfoList.ts @@ -16,9 +16,10 @@ export const useWorkflowInfoList = (scope: DomainIdentifierScope, config?: Reque ); return { - entities: entities.map(({ id, metadata: { description } }) => ({ + entities: entities.map(({ id, metadata: { description, state } }) => ({ id, description, + state, })), ...rest, }; diff --git a/src/components/Workflow/utils.ts b/src/components/Workflow/utils.ts new file mode 100644 index 000000000..5486b08bc --- /dev/null +++ b/src/components/Workflow/utils.ts @@ -0,0 +1,11 @@ +import { WorkflowExecutionState } from 'models/Workflow/enums'; +import { WorkflowListStructureItem } from './types'; + +function isWorkflowStateArchive(workflow: WorkflowListStructureItem): boolean { + const state = workflow?.state ?? null; + return !!state && state === WorkflowExecutionState.NAMED_ENTITY_ARCHIVED; +} + +export function isWorkflowArchived(workflow: WorkflowListStructureItem): boolean { + return isWorkflowStateArchive(workflow); +} diff --git a/src/models/Workflow/api.ts b/src/models/Workflow/api.ts index 162e22349..72094e2d9 100644 --- a/src/models/Workflow/api.ts +++ b/src/models/Workflow/api.ts @@ -1,8 +1,10 @@ -import { Admin } from 'flyteidl'; -import { getAdminEntity } from 'models/AdminEntity/AdminEntity'; +import { Admin, Core } from 'flyteidl'; +import { getAdminEntity, postAdminEntity } from 'models/AdminEntity/AdminEntity'; import { defaultPaginationConfig } from 'models/AdminEntity/constants'; import { RequestConfig } from 'models/AdminEntity/types'; import { Identifier, IdentifierScope } from 'models/Common/types'; +import { makeNamedEntityPath } from 'models/Common/utils'; +import { WorkflowExecutionState } from './enums'; import { Workflow } from './types'; import { makeWorkflowPath, workflowListTransformer } from './utils'; @@ -26,3 +28,28 @@ export const getWorkflow = (id: Identifier, config?: RequestConfig) => }, config, ); + +/** Updates `Workflow` archive state */ +export const updateWorkflowState = ( + id: Admin.NamedEntityIdentifier, + newState: WorkflowExecutionState, + config?: RequestConfig, +) => { + const path = makeNamedEntityPath({ resourceType: Core.ResourceType.WORKFLOW, ...id }); + return postAdminEntity( + { + data: { + resourceType: Core.ResourceType.WORKFLOW, + id, + metadata: { + state: newState, + }, + }, + path, + requestMessageType: Admin.NamedEntityUpdateRequest, + responseMessageType: Admin.NamedEntityUpdateResponse, + method: 'put', + }, + config, + ); +}; diff --git a/src/models/Workflow/enums.ts b/src/models/Workflow/enums.ts new file mode 100644 index 000000000..4b453637f --- /dev/null +++ b/src/models/Workflow/enums.ts @@ -0,0 +1,11 @@ +import { Admin } from 'flyteidl'; + +/** These enums are only aliased and exported from this file. They should + * be imported directly from here to avoid runtime errors when TS processes + * modules individually (such as when running with ts-jest) + */ + +/* It's an ENUM exports, and as such need to be exported as both type and const value */ +/* eslint-disable @typescript-eslint/no-redeclare */ +export type WorkflowExecutionState = Admin.NamedEntityState; +export const WorkflowExecutionState = Admin.NamedEntityState; diff --git a/src/models/__mocks__/sampleWorkflowNames.ts b/src/models/__mocks__/sampleWorkflowNames.ts index b1b95aef6..f80342771 100644 --- a/src/models/__mocks__/sampleWorkflowNames.ts +++ b/src/models/__mocks__/sampleWorkflowNames.ts @@ -1,4 +1,5 @@ import { WorkflowListStructureItem } from 'components/Workflow/types'; +import { WorkflowExecutionState } from 'models/Workflow/enums'; import { WorkflowId } from 'models/Workflow/types'; export const sampleWorkflowIds: WorkflowId[] = [ @@ -37,4 +38,5 @@ export const sampleWorkflowIds: WorkflowId[] = [ export const sampleWorkflowNames: WorkflowListStructureItem[] = sampleWorkflowIds.map((id) => ({ id, description: '', + state: WorkflowExecutionState.NAMED_ENTITY_ACTIVE, }));