From 4252a0a2c34e68ed828e600eb94c95a7f37c6850 Mon Sep 17 00:00:00 2001 From: Emilien Chauvet Date: Wed, 2 Aug 2023 21:36:16 +0200 Subject: [PATCH] Feat/filter activity inbox (#1032) * Move files * Add filtering for tasks inbox * Add filter dropdown for single entity * Minor * Fill empty button * Refine logic for filter dropdown * remove log * Fix unwanted change * Set current user as default filter * Add avatar on filter * Improve initialization of assignee filter * Add story for Tasks page * Add more stories * Add sotry with no tasks * Improve dates * Enh tests --------- Co-authored-by: Charles Bochet --- .../activities/comment/CommentHeader.tsx | 4 +- .../components/ActivityAssigneePicker.tsx | 8 +- .../components/TaskGroups.tsx | 0 .../components/TaskList.tsx | 0 .../components/TaskRow.tsx | 0 .../__stories__/TaskList.stories.tsx | 45 ++++++ .../hooks/useCompleteTask.ts | 0 .../hooks/useInitializeTasksFilters.ts | 22 +++ .../src/modules/activities/hooks/useTasks.ts | 103 ++++++++++++++ .../states/TasksContext.ts | 0 .../timeline/components/TimelineActivity.tsx | 6 +- .../types/TaskForList.ts | 0 front/src/modules/tasks/hooks/useTasks.ts | 59 -------- .../components/FilterDropdownButton.tsx | 118 +++------------- .../components/GenericEntityFilterChip.tsx | 18 +++ .../MultipleFiltersDropdownButton.tsx | 120 ++++++++++++++++ .../SingleEntityFilterDropdownButton.tsx | 131 ++++++++++++++++++ .../modules/ui/filter-n-sort/types/Filter.ts | 1 + .../components/ShowPageSummaryCard.tsx | 4 +- .../FilterDropdownUserSearchSelect.tsx | 15 +- .../src/pages/companies/companies-filters.tsx | 5 +- front/src/pages/tasks/Tasks.tsx | 13 +- .../pages/tasks/__stories__/Tasks.stories.tsx | 26 ++++ front/src/pages/tasks/tasks-filters.tsx | 18 +++ front/src/testing/graphqlMocks.ts | 8 ++ front/src/testing/mock-data/activities.ts | 6 +- front/src/utils/__tests__/date-utils.test.ts | 37 ++++- front/src/utils/date-utils.ts | 23 ++- 28 files changed, 601 insertions(+), 189 deletions(-) rename front/src/modules/{tasks => activities}/components/TaskGroups.tsx (100%) rename front/src/modules/{tasks => activities}/components/TaskList.tsx (100%) rename front/src/modules/{tasks => activities}/components/TaskRow.tsx (100%) create mode 100644 front/src/modules/activities/components/__stories__/TaskList.stories.tsx rename front/src/modules/{tasks => activities}/hooks/useCompleteTask.ts (100%) create mode 100644 front/src/modules/activities/hooks/useInitializeTasksFilters.ts create mode 100644 front/src/modules/activities/hooks/useTasks.ts rename front/src/modules/{tasks => activities}/states/TasksContext.ts (100%) rename front/src/modules/{tasks => activities}/types/TaskForList.ts (100%) delete mode 100644 front/src/modules/tasks/hooks/useTasks.ts create mode 100644 front/src/modules/ui/filter-n-sort/components/GenericEntityFilterChip.tsx create mode 100644 front/src/modules/ui/filter-n-sort/components/MultipleFiltersDropdownButton.tsx create mode 100644 front/src/modules/ui/filter-n-sort/components/SingleEntityFilterDropdownButton.tsx create mode 100644 front/src/pages/tasks/__stories__/Tasks.stories.tsx create mode 100644 front/src/pages/tasks/tasks-filters.tsx diff --git a/front/src/modules/activities/comment/CommentHeader.tsx b/front/src/modules/activities/comment/CommentHeader.tsx index 1f0a9b8b74d3..b7071ffdb2b3 100644 --- a/front/src/modules/activities/comment/CommentHeader.tsx +++ b/front/src/modules/activities/comment/CommentHeader.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { Avatar } from '@/users/components/Avatar'; import { - beautifyExactDate, + beautifyExactDateTime, beautifyPastDateRelativeToNow, } from '~/utils/date-utils'; @@ -64,7 +64,7 @@ const StyledTooltip = styled(Tooltip)` export function CommentHeader({ comment, actionBar }: OwnProps) { const beautifiedCreatedAt = beautifyPastDateRelativeToNow(comment.createdAt); - const exactCreatedAt = beautifyExactDate(comment.createdAt); + const exactCreatedAt = beautifyExactDateTime(comment.createdAt); const showDate = beautifiedCreatedAt !== ''; const author = comment.author; diff --git a/front/src/modules/activities/components/ActivityAssigneePicker.tsx b/front/src/modules/activities/components/ActivityAssigneePicker.tsx index 5f872a82ceca..17772b26ee7d 100644 --- a/front/src/modules/activities/components/ActivityAssigneePicker.tsx +++ b/front/src/modules/activities/components/ActivityAssigneePicker.tsx @@ -33,7 +33,7 @@ export function ActivityAssigneePicker({ ); const [updateActivity] = useUpdateActivityMutation(); - const companies = useFilteredSearchEntityQuery({ + const users = useFilteredSearchEntityQuery({ queryHook: useSearchUserQuery, selectedIds: activity?.accountOwner?.id ? [activity?.accountOwner?.id] : [], searchFilter: searchFilter, @@ -70,9 +70,9 @@ export function ActivityAssigneePicker({ onEntitySelected={handleEntitySelected} onCancel={onCancel} entities={{ - loading: companies.loading, - entitiesToSelect: companies.entitiesToSelect, - selectedEntity: companies.selectedEntities[0], + loading: users.loading, + entitiesToSelect: users.entitiesToSelect, + selectedEntity: users.selectedEntities[0], }} /> ); diff --git a/front/src/modules/tasks/components/TaskGroups.tsx b/front/src/modules/activities/components/TaskGroups.tsx similarity index 100% rename from front/src/modules/tasks/components/TaskGroups.tsx rename to front/src/modules/activities/components/TaskGroups.tsx diff --git a/front/src/modules/tasks/components/TaskList.tsx b/front/src/modules/activities/components/TaskList.tsx similarity index 100% rename from front/src/modules/tasks/components/TaskList.tsx rename to front/src/modules/activities/components/TaskList.tsx diff --git a/front/src/modules/tasks/components/TaskRow.tsx b/front/src/modules/activities/components/TaskRow.tsx similarity index 100% rename from front/src/modules/tasks/components/TaskRow.tsx rename to front/src/modules/activities/components/TaskRow.tsx diff --git a/front/src/modules/activities/components/__stories__/TaskList.stories.tsx b/front/src/modules/activities/components/__stories__/TaskList.stories.tsx new file mode 100644 index 000000000000..b9e8c6f59f0d --- /dev/null +++ b/front/src/modules/activities/components/__stories__/TaskList.stories.tsx @@ -0,0 +1,45 @@ +import { MemoryRouter } from 'react-router-dom'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { mockedActivities } from '~/testing/mock-data/activities'; + +import { TaskList } from '../TaskList'; + +const meta: Meta = { + title: 'Modules/Activity/TaskList', + component: TaskList, + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], + args: { + title: 'Tasks', + tasks: mockedActivities, + }, + parameters: { + msw: graphqlMocks, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Tasks', + tasks: mockedActivities, + }, +}; + +export const Empty: Story = { + args: { + title: 'No tasks', + tasks: [], + }, +}; diff --git a/front/src/modules/tasks/hooks/useCompleteTask.ts b/front/src/modules/activities/hooks/useCompleteTask.ts similarity index 100% rename from front/src/modules/tasks/hooks/useCompleteTask.ts rename to front/src/modules/activities/hooks/useCompleteTask.ts diff --git a/front/src/modules/activities/hooks/useInitializeTasksFilters.ts b/front/src/modules/activities/hooks/useInitializeTasksFilters.ts new file mode 100644 index 000000000000..cc810d63ba43 --- /dev/null +++ b/front/src/modules/activities/hooks/useInitializeTasksFilters.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +import { availableFiltersScopedState } from '@/ui/filter-n-sort/states/availableFiltersScopedState'; +import { FilterDefinition } from '@/ui/filter-n-sort/types/FilterDefinition'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; + +import { TasksContext } from '../states/TasksContext'; + +export function useInitializeTasksFilters({ + availableFilters, +}: { + availableFilters: FilterDefinition[]; +}) { + const [, setAvailableFilters] = useRecoilScopedState( + availableFiltersScopedState, + TasksContext, + ); + + useEffect(() => { + setAvailableFilters(availableFilters); + }, [setAvailableFilters, availableFilters]); +} diff --git a/front/src/modules/activities/hooks/useTasks.ts b/front/src/modules/activities/hooks/useTasks.ts new file mode 100644 index 000000000000..3b5f60371d9c --- /dev/null +++ b/front/src/modules/activities/hooks/useTasks.ts @@ -0,0 +1,103 @@ +import { useEffect } from 'react'; +import { DateTime } from 'luxon'; +import { useRecoilState } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState'; +import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause'; +import { activeTabIdScopedState } from '@/ui/tab/states/activeTabIdScopedState'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; +import { ActivityType, useGetActivitiesQuery } from '~/generated/graphql'; +import { tasksFilters } from '~/pages/tasks/tasks-filters'; +import { parseDate } from '~/utils/date-utils'; + +import { TasksContext } from '../states/TasksContext'; + +import { useInitializeTasksFilters } from './useInitializeTasksFilters'; + +export function useTasks() { + useInitializeTasksFilters({ + availableFilters: tasksFilters, + }); + + const [activeTabId] = useRecoilScopedState( + activeTabIdScopedState, + TasksContext, + ); + + const [filters, setFilters] = useRecoilScopedState( + filtersScopedState, + TasksContext, + ); + + // If there is no filter, we set the default filter to the current user + const [currentUser] = useRecoilState(currentUserState); + + useEffect(() => { + if (currentUser && !filters.length) { + setFilters([ + { + field: 'assigneeId', + type: 'entity', + value: currentUser.id, + operand: 'is', + displayValue: currentUser.displayName, + displayAvatarUrl: currentUser.avatarUrl ?? undefined, + }, + ]); + } + }, [currentUser, filters, setFilters]); + + const whereFilters = Object.assign( + {}, + ...filters.map((filter) => { + return turnFilterIntoWhereClause(filter); + }), + ); + + const { data: completeTasksData } = useGetActivitiesQuery({ + variables: { + where: { + type: { equals: ActivityType.Task }, + completedAt: { not: { equals: null } }, + ...whereFilters, + }, + }, + }); + + const { data: incompleteTaskData } = useGetActivitiesQuery({ + variables: { + where: { + type: { equals: ActivityType.Task }, + completedAt: { equals: null }, + ...whereFilters, + }, + }, + }); + + const tasksData = + activeTabId === 'done' ? completeTasksData : incompleteTaskData; + + const todayOrPreviousTasks = tasksData?.findManyActivities.filter((task) => { + if (!task.dueAt) { + return false; + } + const dueDate = parseDate(task.dueAt).toJSDate(); + const today = DateTime.now().endOf('day').toJSDate(); + return dueDate <= today; + }); + + const upcomingTasks = tasksData?.findManyActivities.filter((task) => { + if (!task.dueAt) { + return false; + } + const dueDate = parseDate(task.dueAt).toJSDate(); + const today = DateTime.now().endOf('day').toJSDate(); + return dueDate > today; + }); + + return { + todayOrPreviousTasks, + upcomingTasks, + }; +} diff --git a/front/src/modules/tasks/states/TasksContext.ts b/front/src/modules/activities/states/TasksContext.ts similarity index 100% rename from front/src/modules/tasks/states/TasksContext.ts rename to front/src/modules/activities/states/TasksContext.ts diff --git a/front/src/modules/activities/timeline/components/TimelineActivity.tsx b/front/src/modules/activities/timeline/components/TimelineActivity.tsx index 9970b81be8d3..a92637347e3c 100644 --- a/front/src/modules/activities/timeline/components/TimelineActivity.tsx +++ b/front/src/modules/activities/timeline/components/TimelineActivity.tsx @@ -1,13 +1,13 @@ import { Tooltip } from 'react-tooltip'; import styled from '@emotion/styled'; +import { useCompleteTask } from '@/activities/hooks/useCompleteTask'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; -import { useCompleteTask } from '@/tasks/hooks/useCompleteTask'; import { IconNotes } from '@/ui/icon'; import { OverflowingTextWithTooltip } from '@/ui/tooltip/OverflowingTextWithTooltip'; import { Activity, User } from '~/generated/graphql'; import { - beautifyExactDate, + beautifyExactDateTime, beautifyPastDateRelativeToNow, } from '~/utils/date-utils'; @@ -126,7 +126,7 @@ type OwnProps = { export function TimelineActivity({ activity }: OwnProps) { const beautifiedCreatedAt = beautifyPastDateRelativeToNow(activity.createdAt); - const exactCreatedAt = beautifyExactDate(activity.createdAt); + const exactCreatedAt = beautifyExactDateTime(activity.createdAt); const body = JSON.parse(activity.body ?? '{}')[0]?.content[0]?.text; const openActivityRightDrawer = useOpenActivityRightDrawer(); diff --git a/front/src/modules/tasks/types/TaskForList.ts b/front/src/modules/activities/types/TaskForList.ts similarity index 100% rename from front/src/modules/tasks/types/TaskForList.ts rename to front/src/modules/activities/types/TaskForList.ts diff --git a/front/src/modules/tasks/hooks/useTasks.ts b/front/src/modules/tasks/hooks/useTasks.ts deleted file mode 100644 index ebcc8b4f46ae..000000000000 --- a/front/src/modules/tasks/hooks/useTasks.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { DateTime } from 'luxon'; - -import { activeTabIdScopedState } from '@/ui/tab/states/activeTabIdScopedState'; -import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; -import { ActivityType, useGetActivitiesQuery } from '~/generated/graphql'; -import { parseDate } from '~/utils/date-utils'; - -import { TasksContext } from '../states/TasksContext'; - -export function useTasks() { - const [activeTabId] = useRecoilScopedState( - activeTabIdScopedState, - TasksContext, - ); - - const { data: completeTasksData } = useGetActivitiesQuery({ - variables: { - where: { - type: { equals: ActivityType.Task }, - completedAt: { not: { equals: null } }, - }, - }, - }); - - const { data: incompleteTaskData } = useGetActivitiesQuery({ - variables: { - where: { - type: { equals: ActivityType.Task }, - completedAt: - activeTabId === 'done' ? { not: { equals: null } } : { equals: null }, - }, - }, - }); - - const data = activeTabId === 'done' ? completeTasksData : incompleteTaskData; - - const todayOrPreviousTasks = data?.findManyActivities.filter((task) => { - if (!task.dueAt) { - return false; - } - const dueDate = parseDate(task.dueAt).toJSDate(); - const today = DateTime.now().endOf('day').toJSDate(); - return dueDate <= today; - }); - - const upcomingTasks = data?.findManyActivities.filter((task) => { - if (!task.dueAt) { - return false; - } - const dueDate = parseDate(task.dueAt).toJSDate(); - const today = DateTime.now().endOf('day').toJSDate(); - return dueDate > today; - }); - - return { - todayOrPreviousTasks, - upcomingTasks, - }; -} diff --git a/front/src/modules/ui/filter-n-sort/components/FilterDropdownButton.tsx b/front/src/modules/ui/filter-n-sort/components/FilterDropdownButton.tsx index 781eb06db461..ddb731743842 100644 --- a/front/src/modules/ui/filter-n-sort/components/FilterDropdownButton.tsx +++ b/front/src/modules/ui/filter-n-sort/components/FilterDropdownButton.tsx @@ -1,25 +1,12 @@ -import { Context, useCallback, useState } from 'react'; +import { Context } from 'react'; -import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator'; -import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState'; -import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState'; -import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState'; -import { isFilterDropdownOperandSelectUnfoldedScopedState } from '@/ui/filter-n-sort/states/isFilterDropdownOperandSelectUnfoldedScopedState'; -import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState'; -import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; +import { availableFiltersScopedState } from '../states/availableFiltersScopedState'; import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope'; -import DropdownButton from './DropdownButton'; -import { FilterDropdownDateSearchInput } from './FilterDropdownDateSearchInput'; -import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput'; -import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect'; -import { FilterDropdownFilterSelect } from './FilterDropdownFilterSelect'; -import { FilterDropdownNumberSearchInput } from './FilterDropdownNumberSearchInput'; -import { FilterDropdownOperandButton } from './FilterDropdownOperandButton'; -import { FilterDropdownOperandSelect } from './FilterDropdownOperandSelect'; -import { FilterDropdownTextSearchInput } from './FilterDropdownTextSearchInput'; +import { MultipleFiltersDropdownButton } from './MultipleFiltersDropdownButton'; +import { SingleEntityFilterDropdownButton } from './SingleEntityFilterDropdownButton'; export function FilterDropdownButton({ context, @@ -28,93 +15,20 @@ export function FilterDropdownButton({ context: Context; HotkeyScope: FiltersHotkeyScope; }) { - const [isUnfolded, setIsUnfolded] = useState(false); - - const [ - isFilterDropdownOperandSelectUnfolded, - setIsFilterDropdownOperandSelectUnfolded, - ] = useRecoilScopedState( - isFilterDropdownOperandSelectUnfoldedScopedState, + const [availableFilters] = useRecoilScopedState( + availableFiltersScopedState, context, ); - - const [filterDefinitionUsedInDropdown, setFilterDefinitionUsedInDropdown] = - useRecoilScopedState(filterDefinitionUsedInDropdownScopedState, context); - - const [, setFilterDropdownSearchInput] = useRecoilScopedState( - filterDropdownSearchInputScopedState, - context, - ); - - const [filters] = useRecoilScopedState(filtersScopedState, context); - - const [selectedOperandInDropdown, setSelectedOperandInDropdown] = - useRecoilScopedState(selectedOperandInDropdownScopedState, context); - - const resetState = useCallback(() => { - setIsFilterDropdownOperandSelectUnfolded(false); - setFilterDefinitionUsedInDropdown(null); - setSelectedOperandInDropdown(null); - setFilterDropdownSearchInput(''); - }, [ - setFilterDefinitionUsedInDropdown, - setSelectedOperandInDropdown, - setFilterDropdownSearchInput, - setIsFilterDropdownOperandSelectUnfolded, - ]); - - const isFilterSelected = (filters?.length ?? 0) > 0; - - const setHotkeyScope = useSetHotkeyScope(); - - function handleIsUnfoldedChange(newIsUnfolded: boolean) { - if (newIsUnfolded) { - setHotkeyScope(HotkeyScope); - setIsUnfolded(true); - } else { - if (filterDefinitionUsedInDropdown?.type === 'entity') { - setHotkeyScope(HotkeyScope); - } - setIsUnfolded(false); - resetState(); - } - } - - return ( - + ) : ( + - {!filterDefinitionUsedInDropdown ? ( - - ) : isFilterDropdownOperandSelectUnfolded ? ( - - ) : ( - selectedOperandInDropdown && ( - <> - - - {filterDefinitionUsedInDropdown.type === 'text' && ( - - )} - {filterDefinitionUsedInDropdown.type === 'number' && ( - - )} - {filterDefinitionUsedInDropdown.type === 'date' && ( - - )} - {filterDefinitionUsedInDropdown.type === 'entity' && ( - - )} - {filterDefinitionUsedInDropdown.type === 'entity' && ( - - )} - - ) - )} - + /> ); } diff --git a/front/src/modules/ui/filter-n-sort/components/GenericEntityFilterChip.tsx b/front/src/modules/ui/filter-n-sort/components/GenericEntityFilterChip.tsx new file mode 100644 index 000000000000..a7aa25c8edda --- /dev/null +++ b/front/src/modules/ui/filter-n-sort/components/GenericEntityFilterChip.tsx @@ -0,0 +1,18 @@ +import { EntityChip } from '@/ui/chip/components/EntityChip'; + +import { Filter } from '../types/Filter'; + +type OwnProps = { + filter: Filter; +}; + +export function GenericEntityFilterChip({ filter }: OwnProps) { + return ( + + ); +} diff --git a/front/src/modules/ui/filter-n-sort/components/MultipleFiltersDropdownButton.tsx b/front/src/modules/ui/filter-n-sort/components/MultipleFiltersDropdownButton.tsx new file mode 100644 index 000000000000..0fe9a3fa6d64 --- /dev/null +++ b/front/src/modules/ui/filter-n-sort/components/MultipleFiltersDropdownButton.tsx @@ -0,0 +1,120 @@ +import { Context, useCallback, useState } from 'react'; + +import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator'; +import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState'; +import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState'; +import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState'; +import { isFilterDropdownOperandSelectUnfoldedScopedState } from '@/ui/filter-n-sort/states/isFilterDropdownOperandSelectUnfoldedScopedState'; +import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; + +import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope'; + +import DropdownButton from './DropdownButton'; +import { FilterDropdownDateSearchInput } from './FilterDropdownDateSearchInput'; +import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput'; +import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect'; +import { FilterDropdownFilterSelect } from './FilterDropdownFilterSelect'; +import { FilterDropdownNumberSearchInput } from './FilterDropdownNumberSearchInput'; +import { FilterDropdownOperandButton } from './FilterDropdownOperandButton'; +import { FilterDropdownOperandSelect } from './FilterDropdownOperandSelect'; +import { FilterDropdownTextSearchInput } from './FilterDropdownTextSearchInput'; + +export function MultipleFiltersDropdownButton({ + context, + HotkeyScope, +}: { + context: Context; + HotkeyScope: FiltersHotkeyScope; +}) { + const [isUnfolded, setIsUnfolded] = useState(false); + + const [ + isFilterDropdownOperandSelectUnfolded, + setIsFilterDropdownOperandSelectUnfolded, + ] = useRecoilScopedState( + isFilterDropdownOperandSelectUnfoldedScopedState, + context, + ); + + const [filterDefinitionUsedInDropdown, setFilterDefinitionUsedInDropdown] = + useRecoilScopedState(filterDefinitionUsedInDropdownScopedState, context); + + const [, setFilterDropdownSearchInput] = useRecoilScopedState( + filterDropdownSearchInputScopedState, + context, + ); + + const [filters] = useRecoilScopedState(filtersScopedState, context); + + const [selectedOperandInDropdown, setSelectedOperandInDropdown] = + useRecoilScopedState(selectedOperandInDropdownScopedState, context); + + const resetState = useCallback(() => { + setIsFilterDropdownOperandSelectUnfolded(false); + setFilterDefinitionUsedInDropdown(null); + setSelectedOperandInDropdown(null); + setFilterDropdownSearchInput(''); + }, [ + setFilterDefinitionUsedInDropdown, + setSelectedOperandInDropdown, + setFilterDropdownSearchInput, + setIsFilterDropdownOperandSelectUnfolded, + ]); + + const isFilterSelected = (filters?.length ?? 0) > 0; + + const setHotkeyScope = useSetHotkeyScope(); + + function handleIsUnfoldedChange(newIsUnfolded: boolean) { + if (newIsUnfolded) { + setHotkeyScope(HotkeyScope); + setIsUnfolded(true); + } else { + if (filterDefinitionUsedInDropdown?.type === 'entity') { + setHotkeyScope(HotkeyScope); + } + setIsUnfolded(false); + resetState(); + } + } + + return ( + + {!filterDefinitionUsedInDropdown ? ( + + ) : isFilterDropdownOperandSelectUnfolded ? ( + + ) : ( + selectedOperandInDropdown && ( + <> + + + {filterDefinitionUsedInDropdown.type === 'text' && ( + + )} + {filterDefinitionUsedInDropdown.type === 'number' && ( + + )} + {filterDefinitionUsedInDropdown.type === 'date' && ( + + )} + {filterDefinitionUsedInDropdown.type === 'entity' && ( + + )} + {filterDefinitionUsedInDropdown.type === 'entity' && ( + + )} + + ) + )} + + ); +} diff --git a/front/src/modules/ui/filter-n-sort/components/SingleEntityFilterDropdownButton.tsx b/front/src/modules/ui/filter-n-sort/components/SingleEntityFilterDropdownButton.tsx new file mode 100644 index 000000000000..a31dbf994cf7 --- /dev/null +++ b/front/src/modules/ui/filter-n-sort/components/SingleEntityFilterDropdownButton.tsx @@ -0,0 +1,131 @@ +import { Context, useState } from 'react'; +import React from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconChevronDown } from '@tabler/icons-react'; + +import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState'; +import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState'; +import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; + +import { availableFiltersScopedState } from '../states/availableFiltersScopedState'; +import { filtersScopedState } from '../states/filtersScopedState'; +import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope'; +import { getOperandsForFilterType } from '../utils/getOperandsForFilterType'; + +import { DropdownMenuContainer } from './DropdownMenuContainer'; +import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput'; +import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect'; +import { GenericEntityFilterChip } from './GenericEntityFilterChip'; + +const StyledDropdownButtonContainer = styled.div` + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +`; + +type StyledDropdownButtonProps = { + isUnfolded: boolean; +}; + +const StyledDropdownButton = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.primary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + cursor: pointer; + display: flex; + + filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')}; + padding: ${({ theme }) => theme.spacing(1)}; + + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + + &:hover { + filter: brightness(0.95); + } + user-select: none; +`; + +export function SingleEntityFilterDropdownButton({ + context, + HotkeyScope, +}: { + context: Context; + HotkeyScope: FiltersHotkeyScope; +}) { + const theme = useTheme(); + + const [availableFilters] = useRecoilScopedState( + availableFiltersScopedState, + context, + ); + const availableFilter = availableFilters[0]; + + const [isUnfolded, setIsUnfolded] = useState(false); + + const [filters] = useRecoilScopedState(filtersScopedState, context); + + const [, setFilterDefinitionUsedInDropdown] = useRecoilScopedState( + filterDefinitionUsedInDropdownScopedState, + context, + ); + + const [, setFilterDropdownSearchInput] = useRecoilScopedState( + filterDropdownSearchInputScopedState, + context, + ); + + const [, setSelectedOperandInDropdown] = useRecoilScopedState( + selectedOperandInDropdownScopedState, + context, + ); + + React.useEffect(() => { + setFilterDefinitionUsedInDropdown(availableFilter); + const defaultOperand = getOperandsForFilterType(availableFilter?.type)[0]; + setSelectedOperandInDropdown(defaultOperand); + }, [ + availableFilter, + setFilterDefinitionUsedInDropdown, + setSelectedOperandInDropdown, + ]); + + const setHotkeyScope = useSetHotkeyScope(); + + function handleIsUnfoldedChange(newIsUnfolded: boolean) { + if (newIsUnfolded) { + setHotkeyScope(HotkeyScope); + setIsUnfolded(true); + } else { + setHotkeyScope(HotkeyScope); + setIsUnfolded(false); + setFilterDropdownSearchInput(''); + } + } + + return ( + + handleIsUnfoldedChange(!isUnfolded)} + > + {filters[0] ? ( + + ) : ( + 'Filter' + )} + + + {isUnfolded && ( + handleIsUnfoldedChange(false)}> + + + + )} + + ); +} diff --git a/front/src/modules/ui/filter-n-sort/types/Filter.ts b/front/src/modules/ui/filter-n-sort/types/Filter.ts index 125d3cd18442..a97353e2a757 100644 --- a/front/src/modules/ui/filter-n-sort/types/Filter.ts +++ b/front/src/modules/ui/filter-n-sort/types/Filter.ts @@ -6,5 +6,6 @@ export type Filter = { type: FilterType; value: string; displayValue: string; + displayAvatarUrl?: string; operand: FilterOperand; }; diff --git a/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx b/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx index 015e880cbab1..83f5cd58d81f 100644 --- a/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx +++ b/front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx @@ -4,7 +4,7 @@ import { v4 as uuidV4 } from 'uuid'; import { Avatar } from '@/users/components/Avatar'; import { - beautifyExactDate, + beautifyExactDateTime, beautifyPastDateRelativeToNow, } from '~/utils/date-utils'; @@ -71,7 +71,7 @@ export function ShowPageSummaryCard({ }: OwnProps) { const beautifiedCreatedAt = date !== '' ? beautifyPastDateRelativeToNow(date) : ''; - const exactCreatedAt = date !== '' ? beautifyExactDate(date) : ''; + const exactCreatedAt = date !== '' ? beautifyExactDateTime(date) : ''; const dateElementId = `date-id-${uuidV4()}`; return ( diff --git a/front/src/modules/users/components/FilterDropdownUserSearchSelect.tsx b/front/src/modules/users/components/FilterDropdownUserSearchSelect.tsx index 0a5ff223d77f..82bb86b38c14 100644 --- a/front/src/modules/users/components/FilterDropdownUserSearchSelect.tsx +++ b/front/src/modules/users/components/FilterDropdownUserSearchSelect.tsx @@ -1,22 +1,27 @@ +import { Context } from 'react'; + import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery'; import { FilterDropdownEntitySearchSelect } from '@/ui/filter-n-sort/components/FilterDropdownEntitySearchSelect'; import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState'; import { filterDropdownSelectedEntityIdScopedState } from '@/ui/filter-n-sort/states/filterDropdownSelectedEntityIdScopedState'; import { Entity } from '@/ui/input/relation-picker/types/EntityTypeForSelect'; -import { TableContext } from '@/ui/table/states/TableContext'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; import { useSearchUserQuery } from '~/generated/graphql'; -export function FilterDropdownUserSearchSelect() { +export function FilterDropdownUserSearchSelect({ + context, +}: { + context: Context; +}) { const filterDropdownSearchInput = useRecoilScopedValue( filterDropdownSearchInputScopedState, - TableContext, + context, ); const [filterDropdownSelectedEntityId] = useRecoilScopedState( filterDropdownSelectedEntityIdScopedState, - TableContext, + context, ); const usersForSelect = useFilteredSearchEntityQuery({ @@ -38,7 +43,7 @@ export function FilterDropdownUserSearchSelect() { return ( ); } diff --git a/front/src/pages/companies/companies-filters.tsx b/front/src/pages/companies/companies-filters.tsx index 9a981c0af38b..b2e93124f913 100644 --- a/front/src/pages/companies/companies-filters.tsx +++ b/front/src/pages/companies/companies-filters.tsx @@ -7,6 +7,7 @@ import { IconUser, IconUsers, } from '@/ui/icon/index'; +import { TableContext } from '@/ui/table/states/TableContext'; import { icon } from '@/ui/theme/constants/icon'; import { FilterDropdownUserSearchSelect } from '@/users/components/FilterDropdownUserSearchSelect'; import { Company } from '~/generated/graphql'; @@ -49,6 +50,8 @@ export const companiesFilters: FilterDefinitionByEntity[] = [ label: 'Account owner', icon: , type: 'entity', - entitySelectComponent: , + entitySelectComponent: ( + + ), }, ]; diff --git a/front/src/pages/tasks/Tasks.tsx b/front/src/pages/tasks/Tasks.tsx index 96abbaf87bd1..24b164c139b0 100644 --- a/front/src/pages/tasks/Tasks.tsx +++ b/front/src/pages/tasks/Tasks.tsx @@ -1,8 +1,10 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { TaskGroups } from '@/tasks/components/TaskGroups'; -import { TasksContext } from '@/tasks/states/TasksContext'; +import { TaskGroups } from '@/activities/components/TaskGroups'; +import { TasksContext } from '@/activities/states/TasksContext'; +import { FilterDropdownButton } from '@/ui/filter-n-sort/components/FilterDropdownButton'; +import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope'; import { IconArchive, IconCheck, IconCheckbox } from '@/ui/icon/index'; import { WithTopBarContainer } from '@/ui/layout/components/WithTopBarContainer'; import { TabList } from '@/ui/tab/components/TabList'; @@ -52,6 +54,13 @@ export function Tasks() { } + rightComponents={[ + , + ]} /> diff --git a/front/src/pages/tasks/__stories__/Tasks.stories.tsx b/front/src/pages/tasks/__stories__/Tasks.stories.tsx new file mode 100644 index 000000000000..d443ee77f2e8 --- /dev/null +++ b/front/src/pages/tasks/__stories__/Tasks.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { + PageDecorator, + type PageDecoratorArgs, +} from '~/testing/decorators/PageDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { Tasks } from '../Tasks'; + +const meta: Meta = { + title: 'Pages/Tasks/Default', + component: Tasks, + decorators: [PageDecorator], + args: { currentPath: '/tasks' }, + parameters: { + docs: { story: 'inline', iframeHeight: '500px' }, + msw: graphqlMocks, + }, +}; + +export default meta; + +export type Story = StoryObj; + +export const Default: Story = {}; diff --git a/front/src/pages/tasks/tasks-filters.tsx b/front/src/pages/tasks/tasks-filters.tsx new file mode 100644 index 000000000000..d3c27c048d9d --- /dev/null +++ b/front/src/pages/tasks/tasks-filters.tsx @@ -0,0 +1,18 @@ +import { IconUser } from '@tabler/icons-react'; + +import { TasksContext } from '@/activities/states/TasksContext'; +import { FilterDefinitionByEntity } from '@/ui/filter-n-sort/types/FilterDefinitionByEntity'; +import { FilterDropdownUserSearchSelect } from '@/users/components/FilterDropdownUserSearchSelect'; +import { Activity } from '~/generated/graphql'; + +export const tasksFilters: FilterDefinitionByEntity[] = [ + { + field: 'assigneeId', + label: 'Assignee', + icon: , + type: 'entity', + entitySelectComponent: ( + + ), + }, +]; diff --git a/front/src/testing/graphqlMocks.ts b/front/src/testing/graphqlMocks.ts index 39d8b9e8590a..3ac029237c64 100644 --- a/front/src/testing/graphqlMocks.ts +++ b/front/src/testing/graphqlMocks.ts @@ -1,6 +1,7 @@ import { getOperationName } from '@apollo/client/utilities'; import { graphql } from 'msw'; +import { GET_ACTIVITIES } from '@/activities/queries'; import { CREATE_EVENT } from '@/analytics/queries'; import { GET_CLIENT_CONFIG } from '@/client-config/queries'; import { GET_COMPANIES } from '@/companies/queries'; @@ -210,6 +211,13 @@ export const graphqlMocks = [ }), ); }), + graphql.query(getOperationName(GET_ACTIVITIES) ?? '', (req, res, ctx) => { + return res( + ctx.data({ + findManyActivities: mockedActivities, + }), + ); + }), graphql.query(getOperationName(GET_VIEW_FIELDS) ?? '', (req, res, ctx) => { const { where: { diff --git a/front/src/testing/mock-data/activities.ts b/front/src/testing/mock-data/activities.ts index dd2275fc6072..632827f92c23 100644 --- a/front/src/testing/mock-data/activities.ts +++ b/front/src/testing/mock-data/activities.ts @@ -33,7 +33,7 @@ type MockedActivity = Pick< lastName: string; displayName: string; }; - comments: Array>; + comments: Array>; activityTargets: Array< Pick< ActivityTarget, @@ -56,7 +56,7 @@ export const mockedActivities: Array = [ title: 'My very first note', type: ActivityType.Note, body: null, - dueAt: null, + dueAt: '2023-04-26T10:12:42.33625+00:00', completedAt: null, author: { id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e', @@ -112,7 +112,7 @@ export const mockedActivities: Array = [ body: null, type: ActivityType.Note, completedAt: null, - dueAt: null, + dueAt: '2029-08-26T10:12:42.33625+00:00', author: { id: '374fe3a5-df1e-4119-afe0-2a62a2ba481e', firstName: 'Charles', diff --git a/front/src/utils/__tests__/date-utils.test.ts b/front/src/utils/__tests__/date-utils.test.ts index abcc40d56900..952eb011b66c 100644 --- a/front/src/utils/__tests__/date-utils.test.ts +++ b/front/src/utils/__tests__/date-utils.test.ts @@ -3,6 +3,7 @@ import { DateTime } from 'luxon'; import { beautifyExactDate, + beautifyExactDateTime, beautifyPastDateAbsolute, beautifyPastDateRelativeToNow, DEFAULT_DATE_LOCALE, @@ -12,14 +13,46 @@ import { logError } from '../logError'; jest.mock('~/utils/logError'); -describe('beautifyExactDate', () => { - it('should return the correct relative date', () => { +describe('beautifyExactDateTime', () => { + it('should return the date in the correct format with time', () => { const mockDate = '2023-01-01T12:13:24'; const actualDate = new Date(mockDate); const expected = DateTime.fromJSDate(actualDate) .setLocale(DEFAULT_DATE_LOCALE) .toFormat('DD · T'); + const result = beautifyExactDateTime(mockDate); + expect(result).toEqual(expected); + }); + it('should return the time in the correct format for a datetime that is today', () => { + const todayString = DateTime.local().toISODate(); + const mockDate = `${todayString}T12:13:24`; + const actualDate = new Date(mockDate); + const expected = DateTime.fromJSDate(actualDate) + .setLocale(DEFAULT_DATE_LOCALE) + .toFormat('T'); + + const result = beautifyExactDateTime(mockDate); + expect(result).toEqual(expected); + }); +}); + +describe('beautifyExactDate', () => { + it('should return the past date in the correct format without time', () => { + const mockDate = '2023-01-01T12:13:24'; + const actualDate = new Date(mockDate); + const expected = DateTime.fromJSDate(actualDate) + .setLocale(DEFAULT_DATE_LOCALE) + .toFormat('DD'); + + const result = beautifyExactDate(mockDate); + expect(result).toEqual(expected); + }); + it('should return "Today" if the date is today', () => { + const todayString = DateTime.local().toISODate(); + const mockDate = `${todayString}T12:13:24`; + const expected = 'Today'; + const result = beautifyExactDate(mockDate); expect(result).toEqual(expected); }); diff --git a/front/src/utils/date-utils.ts b/front/src/utils/date-utils.ts index 27ad6ba1a8c3..d83664449bfb 100644 --- a/front/src/utils/date-utils.ts +++ b/front/src/utils/date-utils.ts @@ -29,17 +29,32 @@ export function parseDate(dateToParse: Date | string | number) { return formattedDate.setLocale(DEFAULT_DATE_LOCALE); } -export function beautifyExactDate(dateToBeautify: Date | string | number) { - try { - const parsedDate = parseDate(dateToBeautify); +function isSameDay(a: DateTime, b: DateTime): boolean { + return a.hasSame(b, 'day') && a.hasSame(b, 'month') && a.hasSame(b, 'year'); +} - return parsedDate.toFormat('DD · T'); +function formatDate(dateToFormat: Date | string | number, format: string) { + try { + const parsedDate = parseDate(dateToFormat); + return parsedDate.toFormat(format); } catch (error) { logError(error); return ''; } } +export function beautifyExactDateTime(dateToBeautify: Date | string | number) { + const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local()); + const dateFormat = isToday ? 'T' : 'DD · T'; + return formatDate(dateToBeautify, dateFormat); +} + +export function beautifyExactDate(dateToBeautify: Date | string | number) { + const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local()); + const dateFormat = isToday ? "'Today'" : 'DD'; + return formatDate(dateToBeautify, dateFormat); +} + export function beautifyPastDateRelativeToNow( pastDate: Date | string | number, ) {