From 30b8478fdd72f889b7e6f183f15b5b6f9c6c1b62 Mon Sep 17 00:00:00 2001 From: Vadzim Antonau Date: Mon, 11 Sep 2023 13:27:44 +0300 Subject: [PATCH] feat(schedule): add status column filter (#2284) * feat(schedule): add filter to status column at All tab * feat(schedule): add filtering by status * feat(schedule): clear status column filter when tab switched * feat(schedule): refactor FilteredTags to handle multiple filter types, add handling to close status column filter * refactor(mentor-registry): update FilteredTags * Revert "refactor(mentor-registry): update FilteredTags" This reverts commit 8359fc71d506942e2616990a557c691a17859809. * Revert "feat(schedule): refactor FilteredTags to handle multiple filter types, add handling to close status column filter" This reverts commit 2bf62bffed6b1770838f9dbf7bcd7ae56cc283e6. * feat(scheduler): implement combined filter functionality for status and type columns * feat: replace deprecated onFilterDropdownVisibleChange * refactor(schedule): rename property at getColumns * refactor(schedule): rename variable at TableView * feat(schedule): move tagFilters state to combinedFilter as filterTags * refactor(schedule): delete unused LocalStorageKeys * test(schedule): update tests for new useLocalStorage structure * refactor(schedule): fix formatting * fix(schedule): filterTags filtering condition when tab is switched * feat(schedule): add FilterTag type for tags and update regarding functionality * refactor(schedule): make CombinedFilter interface as type * refactor(schedule): fix formatting * tests(schedule): fix TableView tests --- client/src/components/Table/columns.tsx | 2 +- .../components/TableView/TableView.test.tsx | 35 +++-- .../components/TableView/TableView.tsx | 131 +++++++++++++++--- .../components/TableView/renderers.tsx | 10 +- client/src/modules/Schedule/constants.ts | 2 +- client/src/modules/Schedule/utils.ts | 10 ++ 6 files changed, 153 insertions(+), 37 deletions(-) diff --git a/client/src/components/Table/columns.tsx b/client/src/components/Table/columns.tsx index 13b76b3707..58caf17041 100644 --- a/client/src/components/Table/columns.tsx +++ b/client/src/components/Table/columns.tsx @@ -51,7 +51,7 @@ export function getColumnSearchProps(dataIndex: string | string[], label?: strin ); return val; }, - onFilterDropdownVisibleChange: (visible: boolean) => { + onFilterDropdownOpenChange: (visible: boolean) => { if (visible && searchInput) { setTimeout(() => searchInput.select()); } diff --git a/client/src/modules/Schedule/components/TableView/TableView.test.tsx b/client/src/modules/Schedule/components/TableView/TableView.test.tsx index 5b80d16a8c..cd77ed849c 100644 --- a/client/src/modules/Schedule/components/TableView/TableView.test.tsx +++ b/client/src/modules/Schedule/components/TableView/TableView.test.tsx @@ -71,8 +71,8 @@ describe('TableView', () => { it('by selected tag', () => { jest .spyOn(ReactUse, 'useLocalStorage') - // Mock useLocalStorage for tagFilter - .mockReturnValueOnce([[TagsEnum.Test], jest.fn(), jest.fn()]); + // Mock useLocalStorage for combinedFilter + .mockReturnValueOnce([{ types: [TagsEnum.Test], statuses: [], tags: [] }, jest.fn(), jest.fn()]); const data = generateCourseData(); render(); @@ -170,11 +170,15 @@ describe('TableView', () => { `('should check filters in dropdown when tag "$tag" was selected', async ({ tag }: { tag: string }) => { jest .spyOn(ReactUse, 'useLocalStorage') - // Mock useLocalStorage for tagFilter - .mockReturnValue([[TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview], jest.fn(), jest.fn()]); + // Mock useLocalStorage for combinedFilter + .mockReturnValueOnce([ + { types: [TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview], statuses: [], tags: [] }, + jest.fn(), + jest.fn(), + ]); render(); - const tagFilterBtn = screen.getByRole('button', { name: /filter/i }); + const [, tagFilterBtn] = screen.getAllByRole('button', { name: /filter/i }); fireEvent.click(tagFilterBtn); const filtersDropdown = await screen.findByRole('menu'); @@ -183,11 +187,11 @@ describe('TableView', () => { expect(checkbox).toBeChecked(); }); - it('should not render filtered tags when tagFilter is null', () => { + it('should not render filtered tags when tags is empty', () => { jest .spyOn(ReactUse, 'useLocalStorage') - // Mock useLocalStorage for tagFilter - .mockReturnValue([null, jest.fn(), jest.fn()]); + // Mock useLocalStorage for combinedFilter + .mockReturnValueOnce([{ tags: [] }, jest.fn(), jest.fn()]); render(); const tag = screen.queryByText(/Type: /); @@ -197,16 +201,25 @@ describe('TableView', () => { it('should remove tags when "Clear all" button was clicked', async () => { const setFilterMock = jest.fn(); + const types = [TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview]; jest .spyOn(ReactUse, 'useLocalStorage') - // Mock useLocalStorage for tagFilter - .mockReturnValue([[TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview], setFilterMock, jest.fn()]); + // Mock useLocalStorage for combinedFilter + .mockReturnValueOnce([ + { + types, + statuses: [], + tags: types.map(t => ({ label: `${ColumnName.Type}: ${t}`, value: t, tagType: ColumnName.Type })), + }, + setFilterMock, + jest.fn(), + ]); render(); const clearAllBtn = screen.getByText(/Clear all/); fireEvent.click(clearAllBtn); - expect(setFilterMock).toHaveBeenCalledWith([]); + expect(setFilterMock).toHaveBeenCalledWith({ types: [], statuses: [], tags: [] }); }); }); diff --git a/client/src/modules/Schedule/components/TableView/TableView.tsx b/client/src/modules/Schedule/components/TableView/TableView.tsx index fd9e99acec..aa32e21a65 100644 --- a/client/src/modules/Schedule/components/TableView/TableView.tsx +++ b/client/src/modules/Schedule/components/TableView/TableView.tsx @@ -1,4 +1,4 @@ -import { Col, Form, Row, Table } from 'antd'; +import { Col, Form, Row, Table, message } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { CourseScheduleItemDto } from 'api'; import { GithubUserLink } from 'components/GithubUserLink'; @@ -17,16 +17,19 @@ import { ColumnName, CONFIGURABLE_COLUMNS, LocalStorageKeys, + SCHEDULE_STATUSES, + TAG_NAME_MAP, TAGS, } from 'modules/Schedule/constants'; import { ScheduleSettings } from 'modules/Schedule/hooks/useScheduleSettings'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { useLocalStorage } from 'react-use'; import dayjs from 'dayjs'; -import { statusRenderer, renderTagWithStyle } from './renderers'; +import { statusRenderer, renderTagWithStyle, renderStatusWithStyle } from './renderers'; import { FilterValue } from 'antd/lib/table/interface'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import { capitalize } from 'lodash'; dayjs.extend(utc); dayjs.extend(timezone); @@ -34,21 +37,30 @@ dayjs.extend(timezone); const getColumns = ({ timezone, tagColors, - tagFilter, + combinedFilter, filteredInfo, + currentTabKey, }: { - tagFilter: string[]; + combinedFilter: CombinedFilter; timezone: string; tagColors: Record; filteredInfo: Record; + currentTabKey: string; }): ColumnsType => { const timezoneOffset = `(UTC ${dayjs().tz(timezone).format('Z')})`; + const { types, statuses } = combinedFilter; return [ { key: ColumnKey.Status, title: ColumnName.Status, dataIndex: 'status', render: statusRenderer, + ...(currentTabKey === ALL_TAB_KEY && { + filters: SCHEDULE_STATUSES.map(({ value }) => ({ text: renderStatusWithStyle(value), value })), + defaultFilteredValue: statuses, + filtered: statuses?.length > 0, + filteredValue: statuses || null, + }), }, { key: ColumnKey.Name, @@ -64,9 +76,9 @@ const getColumns = ({ dataIndex: 'tag', render: (tag: CourseScheduleItemDto['tag']) => renderTagWithStyle(tag, tagColors), filters: TAGS.map(status => ({ text: renderTagWithStyle(status.value, tagColors), value: status.value })), - defaultFilteredValue: tagFilter, - filtered: tagFilter?.length > 0, - filteredValue: tagFilter || null, + defaultFilteredValue: types, + filtered: types?.length > 0, + filteredValue: types || null, }, { key: ColumnKey.StartDate, @@ -122,39 +134,116 @@ export interface TableViewProps { statusFilter?: string; } +export type CombinedFilter = { + types: string[]; + statuses: string[]; + + tags?: FilterTag[]; +}; + +export type FilterTag = { + label: string; + value: string; + tagType: ColumnName.Type | ColumnName.Status; +}; + const hasStatusFilter = (statusFilter?: string, itemStatus?: string) => Array.isArray(statusFilter) || statusFilter === ALL_TAB_KEY || itemStatus === statusFilter; export function TableView({ data, settings, statusFilter = ALL_TAB_KEY }: TableViewProps) { const [form] = Form.useForm(); - const [tagFilter = [], setTagFilter] = useLocalStorage(LocalStorageKeys.TagFilter); const [filteredInfo, setFilteredInfo] = useState>({}); + const [combinedFilter = { types: [], statuses: [], tags: [] }, setCombinedFilter] = useLocalStorage( + LocalStorageKeys.Filters, + ); + + useEffect(() => { + if (statusFilter !== ALL_TAB_KEY && combinedFilter.statuses.length) { + const tags = combinedFilter.tags?.filter(({ tagType }) => tagType !== ColumnName.Status); + setCombinedFilter({ ...combinedFilter, statuses: [], tags }); + } + }, [statusFilter]); - const filteredData = data - .filter(item => (hasStatusFilter(statusFilter, item.status) ? item : null)) - .filter(event => (tagFilter?.length > 0 ? tagFilter.includes(event.tag) : event)); + const filteredData = useMemo(() => { + return data + .filter(item => (hasStatusFilter(statusFilter, item.status) ? item : null)) + .filter( + item => + (combinedFilter?.types?.length ? combinedFilter.types.includes(item.tag) : true) && + (combinedFilter?.statuses?.length ? combinedFilter.statuses.includes(item.status) : true), + ); + }, [combinedFilter, data, statusFilter]); const filteredColumns = useMemo( () => getColumns({ tagColors: settings.tagColors, timezone: settings.timezone, - tagFilter, + combinedFilter, filteredInfo, + currentTabKey: statusFilter, }).filter(column => { const key = (column.key as ColumnKey) ?? ColumnKey.Name; return CONFIGURABLE_COLUMNS.includes(key) ? !settings.columnsHidden.includes(key) : true; }), - [settings.columnsHidden, settings.timezone, settings.tagColors, statusFilter, tagFilter], + [settings.columnsHidden, settings.timezone, settings.tagColors, statusFilter, combinedFilter], ); const columns = filteredColumns as ColumnsType; - const handleTagClose = (removedTag: string) => { - setTagFilter(tagFilter.filter(t => t !== removedTag)); + const handleTagClose = (removedTagLabel: string) => { + const tags = combinedFilter.tags?.filter(({ label }) => label !== removedTagLabel); + const removedTag = combinedFilter.tags?.find(({ label }) => label === removedTagLabel); + + switch (removedTag?.tagType) { + case ColumnName.Type: + setCombinedFilter({ + ...combinedFilter, + types: combinedFilter.types.filter(tag => tag !== removedTag.value), + tags, + }); + break; + case ColumnName.Status: + setCombinedFilter({ + ...combinedFilter, + statuses: combinedFilter.statuses.filter(status => status !== removedTag.value), + tags, + }); + break; + default: + message.error('Unknown tag'); + break; + } }; const handleClearAllButtonClick = () => { - setTagFilter([]); + setCombinedFilter({ types: [], statuses: [], tags: [] }); + }; + + const handleTableChange = (_: unknown, filters: Record) => { + const combinedFilter: CombinedFilter = { + types: filters.type?.map(tag => tag.toString()) ?? [], + statuses: filters.status?.map(status => status.toString()) ?? [], + }; + + combinedFilter.tags = [ + ...combinedFilter.types.map( + (tag: string): FilterTag => ({ + label: `${ColumnName.Type}: ${TAG_NAME_MAP[tag as CourseScheduleItemDto['tag']]}`, + value: tag, + tagType: ColumnName.Type, + }), + ), + ...combinedFilter.statuses.map( + (status: string): FilterTag => ({ + label: `${ColumnName.Status}: ${capitalize(status)}`, + value: status, + tagType: ColumnName.Status, + }), + ), + ]; + + setCombinedFilter(combinedFilter); + setFilteredInfo(filters); }; const generateUniqueRowKey = ({ id, name, tag }: CourseScheduleItemDto) => [id, name, tag].join('|'); @@ -164,8 +253,7 @@ export function TableView({ data, settings, statusFilter = ALL_TAB_KEY }: TableV
label) ?? []} onTagClose={handleTagClose} onClearAllButtonClick={handleClearAllButtonClick} /> @@ -176,10 +264,7 @@ export function TableView({ data, settings, statusFilter = ALL_TAB_KEY }: TableV triggerAsc: undefined, cancelSort: undefined, }} - onChange={(_, filters: Record) => { - setTagFilter(filters?.type as string[]); - setFilteredInfo(filters); - }} + onChange={handleTableChange} pagination={false} dataSource={filteredData} rowKey={generateUniqueRowKey} diff --git a/client/src/modules/Schedule/components/TableView/renderers.tsx b/client/src/modules/Schedule/components/TableView/renderers.tsx index f212b469f6..4f7fbad8a5 100644 --- a/client/src/modules/Schedule/components/TableView/renderers.tsx +++ b/client/src/modules/Schedule/components/TableView/renderers.tsx @@ -2,7 +2,7 @@ import { Badge, Tag } from 'antd'; import { CourseScheduleItemDto, CourseScheduleItemDtoStatusEnum } from 'api'; import capitalize from 'lodash/capitalize'; import { DEFAULT_TAG_COLOR_MAP, TAG_NAME_MAP } from 'modules/Schedule/constants'; -import { getTagStyle, getTaskStatusColor } from 'modules/Schedule/utils'; +import { getStatusStyle, getTagStyle, getTaskStatusColor } from 'modules/Schedule/utils'; export function statusRenderer(value: CourseScheduleItemDtoStatusEnum) { const label = capitalize(value); @@ -11,6 +11,14 @@ export function statusRenderer(value: CourseScheduleItemDtoStatusEnum) { return ; } +export function renderStatusWithStyle(statusName: CourseScheduleItemDtoStatusEnum) { + return ( + + {capitalize(statusName)} + + ); +} + export function renderTagWithStyle(tagName: CourseScheduleItemDto['tag'], tagColors = DEFAULT_TAG_COLOR_MAP) { return ( diff --git a/client/src/modules/Schedule/constants.ts b/client/src/modules/Schedule/constants.ts index 59904a3147..5416a109c0 100644 --- a/client/src/modules/Schedule/constants.ts +++ b/client/src/modules/Schedule/constants.ts @@ -42,7 +42,7 @@ export enum LocalStorageKeys { ColumnsHidden = 'scheduleColumnsHidden', EventTypesHidden = 'scheduleEventTypesHidden', StatusFilter = 'scheduleStatusFilter', - TagFilter = 'scheduleTagFilter', + Filters = 'scheduleFilters', } export const TAG_NAME_MAP: Record = { diff --git a/client/src/modules/Schedule/utils.ts b/client/src/modules/Schedule/utils.ts index d1667cdff5..986ccafc81 100644 --- a/client/src/modules/Schedule/utils.ts +++ b/client/src/modules/Schedule/utils.ts @@ -30,3 +30,13 @@ export function getTaskStatusColor(value: CourseScheduleItemDtoStatusEnum) { return '#d9d9d9'; } } + +export const getStatusStyle = (statusName: CourseScheduleItemDtoStatusEnum, styles?: CSSProperties) => { + const statusColor = getTaskStatusColor(statusName); + return { + ...styles, + borderColor: statusColor, + color: statusColor, + backgroundColor: `${statusColor}10`, + }; +};