diff --git a/packages/console/src/components/Executions/ExecutionFilters.tsx b/packages/console/src/components/Executions/ExecutionFilters.tsx index 43b494117..2b22adc82 100644 --- a/packages/console/src/components/Executions/ExecutionFilters.tsx +++ b/packages/console/src/components/Executions/ExecutionFilters.tsx @@ -5,12 +5,14 @@ import { MultiSelectForm } from 'components/common/MultiSelectForm'; import { SearchInputForm } from 'components/common/SearchInputForm'; import { SingleSelectForm } from 'components/common/SingleSelectForm'; import { FilterPopoverButton } from 'components/Tables/filters/FilterPopoverButton'; +import { TagsInputForm } from 'components/common/TagsInputForm'; import { FilterState, MultiFilterState, SearchFilterState, SingleFilterState, BooleanFilterState, + TagsFilterState, } from './filters/types'; const useStyles = makeStyles((theme: Theme) => ({ @@ -49,6 +51,7 @@ export interface ExecutionFiltersProps { const RenderFilter: React.FC<{ filter: FilterState }> = ({ filter }) => { const searchFilterState = filter as SearchFilterState; + const tagsFilterState = filter as TagsFilterState; switch (filter.type) { case 'single': return )} />; @@ -61,6 +64,13 @@ const RenderFilter: React.FC<{ filter: FilterState }> = ({ filter }) => { defaultValue={searchFilterState.value} /> ); + case 'tags': + return ( + + ); default: return null; } diff --git a/packages/console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx b/packages/console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx index 3309296ba..29a124127 100644 --- a/packages/console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx +++ b/packages/console/src/components/Executions/Tables/WorkflowExecutionTable/cells.tsx @@ -22,6 +22,7 @@ import { Execution } from 'models/Execution/types'; import { ExecutionState, WorkflowExecutionPhase } from 'models/Execution/enums'; import classnames from 'classnames'; import { LaunchPlanLink } from 'components/LaunchPlan/LaunchPlanLink'; +import { getColorFromString } from 'components/utils'; import { WorkflowExecutionsTableState } from '../types'; import { WorkflowExecutionLink } from '../WorkflowExecutionLink'; import { getWorkflowExecutionTimingMS, isExecutionArchived } from '../../utils'; @@ -115,7 +116,10 @@ export function getExecutionTagsCell( key={tag} label={tag} size="small" - color={isArchived ? 'default' : 'primary'} + color="default" + style={{ + backgroundColor: isArchived ? undefined : getColorFromString(tag), + }} /> ); })} diff --git a/packages/console/src/components/Executions/filters/constants.ts b/packages/console/src/components/Executions/filters/constants.ts index 3de575243..e4c325156 100644 --- a/packages/console/src/components/Executions/filters/constants.ts +++ b/packages/console/src/components/Executions/filters/constants.ts @@ -3,4 +3,5 @@ export const filterLabels = { startTime: 'Start Time', status: 'Status', version: 'Version', + tags: 'Tags', }; diff --git a/packages/console/src/components/Executions/filters/types.ts b/packages/console/src/components/Executions/filters/types.ts index 9242f478a..894a71716 100644 --- a/packages/console/src/components/Executions/filters/types.ts +++ b/packages/console/src/components/Executions/filters/types.ts @@ -20,7 +20,12 @@ export interface FilterButtonState { onClick: () => void; } -export type FilterStateType = 'single' | 'multi' | 'search' | 'boolean'; +export type FilterStateType = + | 'single' + | 'multi' + | 'search' + | 'boolean' + | 'tags'; export interface FilterState { active: boolean; @@ -60,3 +65,10 @@ export interface BooleanFilterState extends FilterState { setActive: (active: boolean) => void; type: 'boolean'; } + +export interface TagsFilterState extends FilterState { + onChange: (newTags: string[]) => void; + placeholder: string; + type: 'tags'; + tags: string[]; +} diff --git a/packages/console/src/components/Executions/filters/useExecutionFiltersState.ts b/packages/console/src/components/Executions/filters/useExecutionFiltersState.ts index 1b1cbbd17..336b7bf89 100644 --- a/packages/console/src/components/Executions/filters/useExecutionFiltersState.ts +++ b/packages/console/src/components/Executions/filters/useExecutionFiltersState.ts @@ -14,6 +14,7 @@ import { FilterState } from './types'; import { useMultiFilterState } from './useMultiFilterState'; import { useSearchFilterState } from './useSearchFilterState'; import { useSingleFilterState } from './useSingleFilterState'; +import { useTagsFilterState } from './useTagsFilterState'; export interface ExecutionFiltersState { appliedFilters: FilterOperation[]; @@ -45,6 +46,12 @@ export function useWorkflowExecutionFiltersState() { listHeader: 'Filter By', queryStateKey: 'status', }), + useTagsFilterState({ + filterKey: 'admin_tag.name', + label: filterLabels.tags, + placeholder: 'Enter Tags String', + queryStateKey: 'tags', + }), useSearchFilterState({ filterKey: 'workflow.version', label: filterLabels.version, diff --git a/packages/console/src/components/Executions/filters/useTagsFilterState.ts b/packages/console/src/components/Executions/filters/useTagsFilterState.ts new file mode 100644 index 000000000..4637e4541 --- /dev/null +++ b/packages/console/src/components/Executions/filters/useTagsFilterState.ts @@ -0,0 +1,87 @@ +import { useQueryState } from 'components/hooks/useQueryState'; +import { FilterOperationName } from 'models/AdminEntity/types'; +import { useEffect, useState } from 'react'; +import { TagsFilterState } from './types'; +import { useFilterButtonState } from './useFilterButtonState'; + +function serializeForQueryState(values: any[]) { + return values.join(';'); +} +function deserializeFromQueryState(stateValue = '') { + return stateValue.split(';'); +} + +interface TagsFilterStateStateArgs { + defaultValue?: string[]; + filterKey: string; + filterOperation?: FilterOperationName; + label: string; + placeholder: string; + queryStateKey: string; +} + +/** Maintains the state for a `TagsInputForm` filter. + * The generated `FilterOperation` will use the provided `key` and `operation` + * (defaults to `VALUE_IN`) + * The current search value will be synced to the query string using the + * provided `queryStateKey` value. + */ +export function useTagsFilterState({ + defaultValue = [], + filterKey, + filterOperation = FilterOperationName.VALUE_IN, + label, + placeholder, + queryStateKey, +}: TagsFilterStateStateArgs): TagsFilterState { + const { params, setQueryStateValue } = + useQueryState>(); + const queryStateValue = params[queryStateKey]; + + const [tags, setTags] = useState(defaultValue); + const active = tags.length !== 0; + + const button = useFilterButtonState(); + const onChange = (newValue: string[]) => { + setTags(newValue); + }; + + const onReset = () => { + setTags(defaultValue); + button.setOpen(false); + }; + + useEffect(() => { + const queryValue = tags.length ? serializeForQueryState(tags) : undefined; + setQueryStateValue(queryStateKey, queryValue); + }, [tags.join(), queryStateKey]); + + useEffect(() => { + if (queryStateValue) { + setTags(deserializeFromQueryState(queryStateValue)); + } + }, [queryStateValue]); + + const getFilter = () => + tags.length + ? [ + { + value: tags, + key: filterKey, + operation: filterOperation, + }, + ] + : []; + + return { + active, + button, + getFilter, + onChange, + onReset, + label, + placeholder, + tags, + type: 'tags', + }; +} diff --git a/packages/console/src/components/common/TagsInputForm.tsx b/packages/console/src/components/common/TagsInputForm.tsx new file mode 100644 index 000000000..3d1fa0dc4 --- /dev/null +++ b/packages/console/src/components/common/TagsInputForm.tsx @@ -0,0 +1,154 @@ +import { + Chip, + FormControl, + FormLabel, + IconButton, + InputAdornment, + makeStyles, + OutlinedInput, + Theme, + Link, +} from '@material-ui/core'; +import { Add } from '@material-ui/icons'; +import { getColorFromString } from 'components/utils'; +import * as React from 'react'; + +const useStyles = makeStyles((theme: Theme) => ({ + input: { + margin: `${theme.spacing(1)}px 0`, + }, + listHeader: { + color: theme.palette.text.secondary, + lineHeight: 1.5, + textTransform: 'uppercase', + }, + resetLink: { + marginLeft: theme.spacing(4), + width: theme.spacing(5), + }, + title: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + margin: 0, + textTransform: 'uppercase', + color: theme.palette.text.secondary, + }, + tagStack: { + display: 'flex', + gap: theme.spacing(0.5), + flexWrap: 'wrap', + width: '240px', + margin: `${theme.spacing(1)}px 0`, + }, +})); + +export interface TagsInputFormProps { + label: string; + placeholder?: string; + onChange: (tags: string[]) => void; + defaultValue: string[]; +} + +/** Form content for rendering a header and search input. The value is applied + * on submission of the form. + */ +export const TagsInputForm: React.FC = ({ + label, + placeholder, + onChange, + defaultValue, +}) => { + const [tags, setTags] = React.useState(defaultValue); + const [value, setValue] = React.useState(''); + const composition = React.useRef(false); + + const styles = useStyles(); + const onInputChange: React.ChangeEventHandler = ({ + target: { value }, + }) => setValue(value); + + const addTag = () => { + const newTag = value.trim(); + setValue(''); + if (!tags.includes(newTag) && newTag !== '') { + const newTags = [...tags, newTag]; + setTags(newTags); + onChange(newTags); + } + }; + + const removeTag = (tag: string) => { + const newTags = tags.filter(t => t !== tag); + setTags(newTags); + onChange(newTags); + }; + + const handleClickReset = () => { + setTags([]); + onChange([]); + }; + + const resetControl = tags.length ? ( + + Reset + + ) : ( +
+ ); + + return ( +
+
+ {label} + {resetControl} +
+ + (composition.current = true)} + onCompositionEnd={() => (composition.current = false)} + placeholder={placeholder} + type="text" + value={value} + onKeyDown={e => { + if (e.key === 'Enter' && !composition.current) { + addTag(); + } + }} + endAdornment={ + + + + + + } + /> +
+ {tags.map(tag => { + return ( + removeTag(tag)} + style={{ + backgroundColor: getColorFromString(tag), + }} + /> + ); + })} +
+
+
+ ); +}; diff --git a/packages/console/src/components/utils/index.ts b/packages/console/src/components/utils/index.ts index 794f1f5f9..8e223fb10 100644 --- a/packages/console/src/components/utils/index.ts +++ b/packages/console/src/components/utils/index.ts @@ -1,3 +1,22 @@ export const removeLeadingSlash = (pathName: string | null): string => { return pathName?.replace(/^\//, '') || ''; }; + +export const getColorFromString = (str: string) => { + const strToDec = (string: string) => { + return ( + Array.from(string) + .map(c => c.codePointAt(0) || 0) + .reduce((sum, char, i) => sum + ((i + 1) * char) / 256, 0) % 1 + ); + }; + return ( + 'hsl(' + + 360 * strToDec(str) + + ',' + + (40 + 60 * strToDec(str)) + + '%,' + + (75 + 10 * strToDec(str)) + + '%)' + ); +};