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 <charlesBochet@users.noreply.github.com>
This commit is contained in:
Emilien Chauvet 2023-08-02 21:36:16 +02:00 committed by GitHub
parent 2128d44212
commit 4252a0a2c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 601 additions and 189 deletions

View File

@ -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;

View File

@ -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],
}}
/>
);

View File

@ -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<typeof TaskList> = {
title: 'Modules/Activity/TaskList',
component: TaskList,
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
ComponentDecorator,
],
args: {
title: 'Tasks',
tasks: mockedActivities,
},
parameters: {
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof TaskList>;
export const Default: Story = {
args: {
title: 'Tasks',
tasks: mockedActivities,
},
};
export const Empty: Story = {
args: {
title: 'No tasks',
tasks: [],
},
};

View File

@ -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]);
}

View File

@ -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,
};
}

View File

@ -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();

View File

@ -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,
};
}

View File

@ -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<string | null>;
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 (
<DropdownButton
label="Filter"
isActive={isFilterSelected}
isUnfolded={isUnfolded}
onIsUnfoldedChange={handleIsUnfoldedChange}
return availableFilters.length === 1 &&
availableFilters[0].type === 'entity' ? (
<SingleEntityFilterDropdownButton
context={context}
HotkeyScope={HotkeyScope}
>
{!filterDefinitionUsedInDropdown ? (
<FilterDropdownFilterSelect context={context} />
) : isFilterDropdownOperandSelectUnfolded ? (
<FilterDropdownOperandSelect context={context} />
) : (
selectedOperandInDropdown && (
<>
<FilterDropdownOperandButton context={context} />
<DropdownMenuSeparator />
{filterDefinitionUsedInDropdown.type === 'text' && (
<FilterDropdownTextSearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'number' && (
<FilterDropdownNumberSearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'date' && (
<FilterDropdownDateSearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySelect context={context} />
)}
</>
)
)}
</DropdownButton>
/>
) : (
<MultipleFiltersDropdownButton
context={context}
HotkeyScope={HotkeyScope}
/>
);
}

View File

@ -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 (
<EntityChip
entityId={filter.value}
name={filter.displayValue}
avatarType="rounded"
pictureUrl={filter.displayAvatarUrl}
/>
);
}

View File

@ -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<string | null>;
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 (
<DropdownButton
label="Filter"
isActive={isFilterSelected}
isUnfolded={isUnfolded}
onIsUnfoldedChange={handleIsUnfoldedChange}
HotkeyScope={HotkeyScope}
>
{!filterDefinitionUsedInDropdown ? (
<FilterDropdownFilterSelect context={context} />
) : isFilterDropdownOperandSelectUnfolded ? (
<FilterDropdownOperandSelect context={context} />
) : (
selectedOperandInDropdown && (
<>
<FilterDropdownOperandButton context={context} />
<DropdownMenuSeparator />
{filterDefinitionUsedInDropdown.type === 'text' && (
<FilterDropdownTextSearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'number' && (
<FilterDropdownNumberSearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'date' && (
<FilterDropdownDateSearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySearchInput context={context} />
)}
{filterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySelect context={context} />
)}
</>
)
)}
</DropdownButton>
);
}

View File

@ -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<StyledDropdownButtonProps>`
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<string | null>;
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 (
<StyledDropdownButtonContainer>
<StyledDropdownButton
isUnfolded={isUnfolded}
onClick={() => handleIsUnfoldedChange(!isUnfolded)}
>
{filters[0] ? (
<GenericEntityFilterChip filter={filters[0]} />
) : (
'Filter'
)}
<IconChevronDown size={theme.icon.size.md} />
</StyledDropdownButton>
{isUnfolded && (
<DropdownMenuContainer onClose={() => handleIsUnfoldedChange(false)}>
<FilterDropdownEntitySearchInput context={context} />
<FilterDropdownEntitySelect context={context} />
</DropdownMenuContainer>
)}
</StyledDropdownButtonContainer>
);
}

View File

@ -6,5 +6,6 @@ export type Filter = {
type: FilterType;
value: string;
displayValue: string;
displayAvatarUrl?: string;
operand: FilterOperand;
};

View File

@ -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 (

View File

@ -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<string | null>;
}) {
const filterDropdownSearchInput = useRecoilScopedValue(
filterDropdownSearchInputScopedState,
TableContext,
context,
);
const [filterDropdownSelectedEntityId] = useRecoilScopedState(
filterDropdownSelectedEntityIdScopedState,
TableContext,
context,
);
const usersForSelect = useFilteredSearchEntityQuery({
@ -38,7 +43,7 @@ export function FilterDropdownUserSearchSelect() {
return (
<FilterDropdownEntitySearchSelect
entitiesForSelect={usersForSelect}
context={TableContext}
context={context}
/>
);
}

View File

@ -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<Company>[] = [
label: 'Account owner',
icon: <IconUser size={icon.size.md} stroke={icon.stroke.sm} />,
type: 'entity',
entitySelectComponent: <FilterDropdownUserSearchSelect />,
entitySelectComponent: (
<FilterDropdownUserSearchSelect context={TableContext} />
),
},
];

View File

@ -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() {
<TabList context={TasksContext} tabs={TASK_TABS} />
</StyledTabListContainer>
}
rightComponents={[
<FilterDropdownButton
key="tasks-filter-dropdown-button"
context={TasksContext}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
/>,
]}
/>
<TaskGroups />
</RecoilScope>

View File

@ -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<PageDecoratorArgs> = {
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<typeof Tasks>;
export const Default: Story = {};

View File

@ -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<Activity>[] = [
{
field: 'assigneeId',
label: 'Assignee',
icon: <IconUser />,
type: 'entity',
entitySelectComponent: (
<FilterDropdownUserSearchSelect context={TasksContext} />
),
},
];

View File

@ -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: {

View File

@ -33,7 +33,7 @@ type MockedActivity = Pick<
lastName: string;
displayName: string;
};
comments: Array<Pick<Comment, 'body'>>;
comments: Array<Pick<Comment, 'body' | 'id'>>;
activityTargets: Array<
Pick<
ActivityTarget,
@ -56,7 +56,7 @@ export const mockedActivities: Array<MockedActivity> = [
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<MockedActivity> = [
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',

View File

@ -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);
});

View File

@ -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,
) {