From 77997674e5e37f4492a52c588564b931ace99cc2 Mon Sep 17 00:00:00 2001 From: Ayush Agrawal <54364088+AyushAgrawal-A2@users.noreply.github.com> Date: Tue, 3 Oct 2023 20:25:31 +0530 Subject: [PATCH] Feat: Add "All assignees" in Task team member dropdown (#1763) * implemented all select option FilterDropdownEntitySearchSelect and enabled it for tasks page filter * created new filter operand IsNotNull for make a select all qraphql query, added internal state for tracking isAllEntitySelected * used filterCurrentlyEdited to track if isAllEntitySelected is selected * fixed filter button icon SelectAll Icon --- front/src/generated/graphql.tsx | 1 + .../modules/ui/chip/components/EntityChip.tsx | 24 ++- .../components/SingleEntitySelectBase.tsx | 21 ++- .../FilterDropdownEntitySearchSelect.tsx | 58 +++++- .../components/GenericEntityFilterChip.tsx | 5 +- .../SingleEntityFilterDropdownButton.tsx | 10 +- .../ui/view-bar/types/FilterDefinition.ts | 2 + .../ui/view-bar/utils/getOperandLabel.ts | 4 + .../utils/turnFilterIntoWhereClause.ts | 173 +++++++++--------- front/src/pages/tasks/tasks-filters.tsx | 4 +- server/src/database/schema.prisma | 1 + 11 files changed, 209 insertions(+), 94 deletions(-) diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 2719cf673..af1656214 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -3043,6 +3043,7 @@ export enum ViewFilterOperand { GreaterThan = 'GreaterThan', Is = 'Is', IsNot = 'IsNot', + IsNotNull = 'IsNotNull', LessThan = 'LessThan' } diff --git a/front/src/modules/ui/chip/components/EntityChip.tsx b/front/src/modules/ui/chip/components/EntityChip.tsx index 25d7c49a2..44228df4a 100644 --- a/front/src/modules/ui/chip/components/EntityChip.tsx +++ b/front/src/modules/ui/chip/components/EntityChip.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTheme } from '@emotion/react'; +import { IconComponent } from '@/ui/icon/types/IconComponent'; import { Avatar, AvatarType } from '@/users/components/Avatar'; import { isNonEmptyString } from '~/utils/isNonEmptyString'; @@ -13,6 +15,7 @@ type OwnProps = { pictureUrl?: string; avatarType?: AvatarType; variant?: EntityChipVariant; + LeftIcon?: IconComponent; }; export enum EntityChipVariant { @@ -27,9 +30,12 @@ export const EntityChip = ({ pictureUrl, avatarType = 'rounded', variant = EntityChipVariant.Regular, + LeftIcon, }: OwnProps) => { const navigate = useNavigate(); + const theme = useTheme(); + const handleLinkClick = (event: React.MouseEvent) => { if (linkToEntity) { event.preventDefault(); @@ -50,13 +56,17 @@ export const EntityChip = ({ : ChipVariant.Transparent } leftComponent={ - + LeftIcon ? ( + + ) : ( + + ) } /> diff --git a/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx b/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx index bb5d6cc48..48f2783d3 100644 --- a/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx +++ b/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx @@ -33,6 +33,11 @@ export type SingleEntitySelectBaseProps< selectedEntity?: CustomEntityForSelect; onCreate?: () => void; showCreateButton?: boolean; + SelectAllIcon?: IconComponent; + selectAllLabel?: string; + isAllEntitySelected?: boolean; + isAllEntitySelectShown?: boolean; + onAllEntitySelected?: () => void; }; export const SingleEntitySelectBase = < @@ -47,6 +52,11 @@ export const SingleEntitySelectBase = < selectedEntity, onCreate, showCreateButton, + SelectAllIcon, + selectAllLabel, + isAllEntitySelected, + isAllEntitySelectShown, + onAllEntitySelected, }: SingleEntitySelectBaseProps) => { const containerRef = useRef(null); @@ -94,6 +104,15 @@ export const SingleEntitySelectBase = < return ( <> + {isAllEntitySelectShown && selectAllLabel && onAllEntitySelected && ( + onAllEntitySelected()} + LeftIcon={SelectAllIcon} + text={selectAllLabel} + hovered={preselectedOptionId === EmptyButtonId} + selected={!!isAllEntitySelected} + /> + )} {emptyLabel && ( onEntitySelected()} @@ -105,7 +124,7 @@ export const SingleEntitySelectBase = < )} {loading ? ( - ) : entitiesInDropdown.length === 0 ? ( + ) : entitiesInDropdown.length === 0 && !isAllEntitySelectShown ? ( ) : ( entitiesInDropdown?.map((entity) => ( diff --git a/front/src/modules/ui/view-bar/components/FilterDropdownEntitySearchSelect.tsx b/front/src/modules/ui/view-bar/components/FilterDropdownEntitySearchSelect.tsx index 1dce00cf6..a7b3e364e 100644 --- a/front/src/modules/ui/view-bar/components/FilterDropdownEntitySearchSelect.tsx +++ b/front/src/modules/ui/view-bar/components/FilterDropdownEntitySearchSelect.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { EntitiesForMultipleEntitySelect } from '@/ui/input/relation-picker/components/MultipleEntitySelect'; import { SingleEntitySelectBase } from '@/ui/input/relation-picker/components/SingleEntitySelectBase'; @@ -12,6 +12,8 @@ import { filterDropdownSelectedEntityIdScopedState } from '@/ui/view-bar/states/ import { selectedOperandInDropdownScopedState } from '@/ui/view-bar/states/selectedOperandInDropdownScopedState'; import { useViewBarContext } from '../hooks/useViewBarContext'; +import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState'; +import { FilterOperand } from '../types/FilterOperand'; export const FilterDropdownEntitySearchSelect = ({ entitiesForSelect, @@ -20,6 +22,8 @@ export const FilterDropdownEntitySearchSelect = ({ }) => { const { ViewBarRecoilScopeContext } = useViewBarContext(); + const [isAllEntitySelected, setIsAllEntitySelected] = useState(false); + const [filterDropdownSelectedEntityId, setFilterDropdownSelectedEntityId] = useRecoilScopedState( filterDropdownSelectedEntityIdScopedState, @@ -52,6 +56,10 @@ export const FilterDropdownEntitySearchSelect = ({ return; } + if (isAllEntitySelected) { + setIsAllEntitySelected(false); + } + const clickedOnAlreadySelectedEntity = selectedEntity.id === filterDropdownSelectedEntityId; @@ -72,11 +80,54 @@ export const FilterDropdownEntitySearchSelect = ({ } }; + const [filterDropdownSearchInput] = useRecoilScopedState( + filterDropdownSearchInputScopedState, + ViewBarRecoilScopeContext, + ); + + const isAllEntitySelectShown = + !!filterDefinitionUsedInDropdown?.selectAllLabel && + !!filterDefinitionUsedInDropdown?.SelectAllIcon && + (isAllEntitySelected || + filterDefinitionUsedInDropdown?.selectAllLabel + .toLocaleLowerCase() + .includes(filterDropdownSearchInput.toLocaleLowerCase())); + + const handleAllEntitySelectClick = () => { + if ( + !filterDefinitionUsedInDropdown || + !selectedOperandInDropdown || + !filterDefinitionUsedInDropdown.selectAllLabel + ) { + return; + } + if (isAllEntitySelected) { + setIsAllEntitySelected(false); + + removeFilter(filterDefinitionUsedInDropdown.key); + } else { + setIsAllEntitySelected(true); + + setFilterDropdownSelectedEntityId(null); + + upsertFilter({ + displayValue: filterDefinitionUsedInDropdown.selectAllLabel, + key: filterDefinitionUsedInDropdown.key, + operand: FilterOperand.IsNotNull, + type: filterDefinitionUsedInDropdown.type, + value: '', + }); + } + }; + useEffect(() => { if (!filterCurrentlyEdited) { setFilterDropdownSelectedEntityId(null); } else { setFilterDropdownSelectedEntityId(filterCurrentlyEdited.value); + setIsAllEntitySelected( + filterCurrentlyEdited.operand === FilterOperand.IsNotNull, + ); } }, [ filterCurrentlyEdited, @@ -91,6 +142,11 @@ export const FilterDropdownEntitySearchSelect = ({ selectedEntity={entitiesForSelect.selectedEntities[0]} loading={entitiesForSelect.loading} onEntitySelected={handleUserSelected} + SelectAllIcon={filterDefinitionUsedInDropdown?.SelectAllIcon} + selectAllLabel={filterDefinitionUsedInDropdown?.selectAllLabel} + isAllEntitySelected={isAllEntitySelected} + isAllEntitySelectShown={isAllEntitySelectShown} + onAllEntitySelected={handleAllEntitySelectClick} /> ); diff --git a/front/src/modules/ui/view-bar/components/GenericEntityFilterChip.tsx b/front/src/modules/ui/view-bar/components/GenericEntityFilterChip.tsx index d4998e6f8..fa483fe2b 100644 --- a/front/src/modules/ui/view-bar/components/GenericEntityFilterChip.tsx +++ b/front/src/modules/ui/view-bar/components/GenericEntityFilterChip.tsx @@ -1,16 +1,19 @@ import { EntityChip } from '@/ui/chip/components/EntityChip'; +import { IconComponent } from '@/ui/icon/types/IconComponent'; import { Filter } from '../types/Filter'; type OwnProps = { filter: Filter; + Icon?: IconComponent; }; -export const GenericEntityFilterChip = ({ filter }: OwnProps) => ( +export const GenericEntityFilterChip = ({ filter, Icon }: OwnProps) => ( ); diff --git a/front/src/modules/ui/view-bar/components/SingleEntityFilterDropdownButton.tsx b/front/src/modules/ui/view-bar/components/SingleEntityFilterDropdownButton.tsx index 185ce624d..decb80a7f 100644 --- a/front/src/modules/ui/view-bar/components/SingleEntityFilterDropdownButton.tsx +++ b/front/src/modules/ui/view-bar/components/SingleEntityFilterDropdownButton.tsx @@ -17,6 +17,7 @@ import { useViewBarContext } from '../hooks/useViewBarContext'; import { availableFiltersScopedState } from '../states/availableFiltersScopedState'; import { filtersScopedState } from '../states/filtersScopedState'; import { isFilterDropdownUnfoldedScopedState } from '../states/isFilterDropdownUnfoldedScopedState'; +import { FilterOperand } from '../types/FilterOperand'; import { getOperandsForFilterType } from '../utils/getOperandsForFilterType'; import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput'; @@ -100,7 +101,14 @@ export const SingleEntityFilterDropdownButton = ({ onClick={() => handleIsUnfoldedChange(!isFilterDropdownUnfolded)} > {filters[0] ? ( - + ) : ( 'Filter' )} diff --git a/front/src/modules/ui/view-bar/types/FilterDefinition.ts b/front/src/modules/ui/view-bar/types/FilterDefinition.ts index b08ff8bda..db1a0c062 100644 --- a/front/src/modules/ui/view-bar/types/FilterDefinition.ts +++ b/front/src/modules/ui/view-bar/types/FilterDefinition.ts @@ -8,4 +8,6 @@ export type FilterDefinition = { Icon: IconComponent; type: FilterType; entitySelectComponent?: JSX.Element; + selectAllLabel?: string; + SelectAllIcon?: IconComponent; }; diff --git a/front/src/modules/ui/view-bar/utils/getOperandLabel.ts b/front/src/modules/ui/view-bar/utils/getOperandLabel.ts index 03d12ef1f..6e318451e 100644 --- a/front/src/modules/ui/view-bar/utils/getOperandLabel.ts +++ b/front/src/modules/ui/view-bar/utils/getOperandLabel.ts @@ -14,6 +14,8 @@ export const getOperandLabel = (operand: FilterOperand | null | undefined) => { return 'Is'; case FilterOperand.IsNot: return 'Is not'; + case FilterOperand.IsNotNull: + return 'Is not null'; default: return ''; } @@ -29,6 +31,8 @@ export const getOperandLabelShort = ( case FilterOperand.IsNot: case FilterOperand.DoesNotContain: return ': Not'; + case FilterOperand.IsNotNull: + return ': NotNull'; case FilterOperand.GreaterThan: return '\u00A0> '; case FilterOperand.LessThan: diff --git a/front/src/modules/ui/view-bar/utils/turnFilterIntoWhereClause.ts b/front/src/modules/ui/view-bar/utils/turnFilterIntoWhereClause.ts index 55431e2a8..af90d765f 100644 --- a/front/src/modules/ui/view-bar/utils/turnFilterIntoWhereClause.ts +++ b/front/src/modules/ui/view-bar/utils/turnFilterIntoWhereClause.ts @@ -4,88 +4,97 @@ import { Filter } from '../types/Filter'; import { FilterOperand } from '../types/FilterOperand'; export const turnFilterIntoWhereClause = (filter: Filter) => { - switch (filter.type) { - case 'text': - switch (filter.operand) { - case FilterOperand.Contains: - return { - [filter.key]: { - contains: filter.value, - mode: QueryMode.Insensitive, - }, - }; - case FilterOperand.DoesNotContain: - return { - [filter.key]: { - not: { - contains: filter.value, - mode: QueryMode.Insensitive, - }, - }, - }; - default: - throw new Error( - `Unknown operand ${filter.operand} for ${filter.type} filter`, - ); - } - case 'number': - switch (filter.operand) { - case FilterOperand.GreaterThan: - return { - [filter.key]: { - gte: parseFloat(filter.value), - }, - }; - case FilterOperand.LessThan: - return { - [filter.key]: { - lte: parseFloat(filter.value), - }, - }; - default: - throw new Error( - `Unknown operand ${filter.operand} for ${filter.type} filter`, - ); - } - case 'date': - switch (filter.operand) { - case FilterOperand.GreaterThan: - return { - [filter.key]: { - gte: filter.value, - }, - }; - case FilterOperand.LessThan: - return { - [filter.key]: { - lte: filter.value, - }, - }; - default: - throw new Error( - `Unknown operand ${filter.operand} for ${filter.type} filter`, - ); - } - case 'entity': - switch (filter.operand) { - case FilterOperand.Is: - return { - [filter.key]: { - equals: filter.value, - }, - }; - case FilterOperand.IsNot: - return { - [filter.key]: { - not: { equals: filter.value }, - }, - }; - default: - throw new Error( - `Unknown operand ${filter.operand} for ${filter.type} filter`, - ); - } + switch (filter.operand) { + case FilterOperand.IsNotNull: + return { + [filter.key]: { + not: null, + }, + }; default: - throw new Error('Unknown filter type'); + switch (filter.type) { + case 'text': + switch (filter.operand) { + case FilterOperand.Contains: + return { + [filter.key]: { + contains: filter.value, + mode: QueryMode.Insensitive, + }, + }; + case FilterOperand.DoesNotContain: + return { + [filter.key]: { + not: { + contains: filter.value, + mode: QueryMode.Insensitive, + }, + }, + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.type} filter`, + ); + } + case 'number': + switch (filter.operand) { + case FilterOperand.GreaterThan: + return { + [filter.key]: { + gte: parseFloat(filter.value), + }, + }; + case FilterOperand.LessThan: + return { + [filter.key]: { + lte: parseFloat(filter.value), + }, + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.type} filter`, + ); + } + case 'date': + switch (filter.operand) { + case FilterOperand.GreaterThan: + return { + [filter.key]: { + gte: filter.value, + }, + }; + case FilterOperand.LessThan: + return { + [filter.key]: { + lte: filter.value, + }, + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.type} filter`, + ); + } + case 'entity': + switch (filter.operand) { + case FilterOperand.Is: + return { + [filter.key]: { + equals: filter.value, + }, + }; + case FilterOperand.IsNot: + return { + [filter.key]: { + not: { equals: filter.value }, + }, + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.type} filter`, + ); + } + default: + throw new Error('Unknown filter type'); + } } }; diff --git a/front/src/pages/tasks/tasks-filters.tsx b/front/src/pages/tasks/tasks-filters.tsx index e7c4fa697..a7864aaf6 100644 --- a/front/src/pages/tasks/tasks-filters.tsx +++ b/front/src/pages/tasks/tasks-filters.tsx @@ -1,5 +1,5 @@ import { TasksRecoilScopeContext } from '@/activities/states/recoil-scope-contexts/TasksRecoilScopeContext'; -import { IconUser } from '@/ui/icon'; +import { IconUser, IconUserCircle } from '@/ui/icon'; import { FilterDefinitionByEntity } from '@/ui/view-bar/types/FilterDefinitionByEntity'; import { FilterDropdownUserSearchSelect } from '@/users/components/FilterDropdownUserSearchSelect'; import { Activity } from '~/generated/graphql'; @@ -13,5 +13,7 @@ export const tasksFilters: FilterDefinitionByEntity[] = [ entitySelectComponent: ( ), + selectAllLabel: 'All assignees', + SelectAllIcon: IconUserCircle, }, ]; diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index aacd03681..73d3281c3 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -823,6 +823,7 @@ enum ViewFilterOperand { LessThan Is IsNot + IsNotNull } model ViewFilter {