Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(schedule): add status column filter #2284

Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2991adf
feat(schedule): add filter to status column at All tab
ThorsAngerVaNeT Sep 6, 2023
9f7011f
feat(schedule): add filtering by status
ThorsAngerVaNeT Sep 6, 2023
ef111b8
feat(schedule): clear status column filter when tab switched
ThorsAngerVaNeT Sep 6, 2023
2bf62bf
feat(schedule): refactor FilteredTags to handle multiple filter types…
ThorsAngerVaNeT Sep 6, 2023
8359fc7
refactor(mentor-registry): update FilteredTags
ThorsAngerVaNeT Sep 6, 2023
8692f1b
Revert "refactor(mentor-registry): update FilteredTags"
ThorsAngerVaNeT Sep 6, 2023
cccfaeb
Revert "feat(schedule): refactor FilteredTags to handle multiple filt…
ThorsAngerVaNeT Sep 6, 2023
7328cc7
feat(scheduler): implement combined filter functionality for status a…
ThorsAngerVaNeT Sep 6, 2023
e05cf3f
feat: replace deprecated onFilterDropdownVisibleChange
ThorsAngerVaNeT Sep 7, 2023
3ec6432
refactor(schedule): rename property at getColumns
ThorsAngerVaNeT Sep 7, 2023
0ae23e8
refactor(schedule): rename variable at TableView
ThorsAngerVaNeT Sep 7, 2023
9df0f62
feat(schedule): move tagFilters state to combinedFilter as filterTags
ThorsAngerVaNeT Sep 7, 2023
2e817d8
refactor(schedule): delete unused LocalStorageKeys
ThorsAngerVaNeT Sep 7, 2023
d2c228d
test(schedule): update tests for new useLocalStorage structure
ThorsAngerVaNeT Sep 7, 2023
de8d3e6
refactor(schedule): fix formatting
ThorsAngerVaNeT Sep 7, 2023
de0ad5c
Merge branch 'master' into feat/schedule-status-filter
ThorsAngerVaNeT Sep 7, 2023
f27f79d
fix(schedule): filterTags filtering condition when tab is switched
ThorsAngerVaNeT Sep 7, 2023
87dc8d0
feat(schedule): add FilterTag type for tags and update regarding func…
ThorsAngerVaNeT Sep 8, 2023
1316d20
refactor(schedule): make CombinedFilter interface as type
ThorsAngerVaNeT Sep 8, 2023
ed932d7
refactor(schedule): fix formatting
ThorsAngerVaNeT Sep 8, 2023
f845c8d
tests(schedule): fix TableView tests
ThorsAngerVaNeT Sep 8, 2023
6c7a5d9
Merge branch 'master' into feat/schedule-status-filter
ThorsAngerVaNeT Sep 8, 2023
306da87
Merge branch 'master' into feat/schedule-status-filter
ThorsAngerVaNeT Sep 8, 2023
c925c57
Merge branch 'master' into feat/schedule-status-filter
ThorsAngerVaNeT Sep 10, 2023
8bc59c2
Merge branch 'master' into feat/schedule-status-filter
ThorsAngerVaNeT Sep 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/components/Table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
31 changes: 20 additions & 11 deletions client/src/modules/Schedule/components/TableView/TableView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [], filterTags: [] }, jest.fn(), jest.fn()]);
const data = generateCourseData();

render(<TableView settings={PROPS_SETTINGS_MOCK} data={data} />);
Expand Down Expand Up @@ -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
.mockReturnValueOnce([[TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview], jest.fn(), jest.fn()]);
// Mock useLocalStorage for combinedFilter
.mockReturnValueOnce([
{ types: [TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview], statuses: [], filterTags: [] },
jest.fn(),
jest.fn(),
]);
render(<TableView settings={PROPS_SETTINGS_MOCK} data={generateCourseData()} />);

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');
Expand All @@ -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 filterTags is empty', () => {
jest
.spyOn(ReactUse, 'useLocalStorage')
// Mock useLocalStorage for tagFilter
.mockReturnValueOnce([null, jest.fn(), jest.fn()]);
// Mock useLocalStorage for combinedFilter
.mockReturnValueOnce([{ filterTags: [] }, jest.fn(), jest.fn()]);
render(<TableView settings={PROPS_SETTINGS_MOCK} data={generateCourseData()} />);

const tag = screen.queryByText(/Type: /);
Expand All @@ -197,16 +201,21 @@ 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
.mockReturnValueOnce([[TagsEnum.Coding, TagsEnum.Test, TagsEnum.Interview], setFilterMock, jest.fn()]);
// Mock useLocalStorage for combinedFilter
.mockReturnValueOnce([
{ types, statuses: [], filterTags: types.map(t => `${ColumnName.Type}: ${t}`) },
setFilterMock,
jest.fn(),
]);
render(<TableView settings={PROPS_SETTINGS_MOCK} data={generateCourseData()} />);

const clearAllBtn = screen.getByText(/Clear all/);
fireEvent.click(clearAllBtn);

expect(setFilterMock).toHaveBeenCalledWith([]);
expect(setFilterMock).toHaveBeenCalledWith({ types: [], statuses: [], filterTags: [] });
});
});

Expand Down
113 changes: 91 additions & 22 deletions client/src/modules/Schedule/components/TableView/TableView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,38 +17,51 @@ import {
ColumnName,
CONFIGURABLE_COLUMNS,
LocalStorageKeys,
REVERSE_TAG_NAME_MAP,
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);

const getColumns = ({
timezone,
tagColors,
tagFilter,
combinedFilter,
filteredInfo,
currentTabKey,
}: {
tagFilter: string[];
combinedFilter: CombinedFilter;
timezone: string;
tagColors: Record<string, string>;
filteredInfo: Record<string, FilterValue | null>;
currentTabKey: string;
}): ColumnsType<CourseScheduleItemDto> => {
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,
Expand All @@ -64,9 +77,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,
Expand Down Expand Up @@ -122,39 +135,99 @@ export interface TableViewProps {
statusFilter?: string;
}

export interface CombinedFilter {
types: string[];
apalchys marked this conversation as resolved.
Show resolved Hide resolved
statuses: string[];

filterTags?: string[];
}
ThorsAngerVaNeT marked this conversation as resolved.
Show resolved Hide resolved

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<string[]>(LocalStorageKeys.TagFilter);
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | string[] | null>>({});
const [combinedFilter = { types: [], statuses: [], filterTags: [] }, setCombinedFilter] =
useLocalStorage<CombinedFilter>(LocalStorageKeys.Filters);

useEffect(() => {
if (statusFilter !== ALL_TAB_KEY && combinedFilter.statuses.length) {
const filterTags = combinedFilter.filterTags?.filter(tag => !tag.startsWith(ColumnName.Status));
setCombinedFilter({ ...combinedFilter, statuses: [], filterTags });
}
}, [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<CourseScheduleItemDto>;

const handleTagClose = (removedTag: string) => {
setTagFilter(tagFilter.filter(t => t !== removedTag));
const [removedTagName, ...removedTagValueParts] = removedTag.split(':');
const removedTagValue = removedTagValueParts.join(':');

const filterTags = combinedFilter.filterTags?.filter(filter => filter !== removedTag);

switch (removedTagName) {
case ColumnName.Type:
setCombinedFilter({
...combinedFilter,
types: combinedFilter.types.filter(tag => tag !== REVERSE_TAG_NAME_MAP[removedTagValue.trim()]),
filterTags,
});
break;
case ColumnName.Status:
setCombinedFilter({
...combinedFilter,
statuses: combinedFilter.statuses.filter(tag => tag !== removedTagValue.trim().toLowerCase()),
filterTags,
});
break;
default:
message.error('An error occurred. Please try again later.');
break;
apalchys marked this conversation as resolved.
Show resolved Hide resolved
}
};

const handleClearAllButtonClick = () => {
setTagFilter([]);
setCombinedFilter({ types: [], statuses: [], filterTags: [] });
};

const handleTableChange = (_: any, filters: Record<ColumnKey, FilterValue | string[] | null>) => {
const combinedFilter: CombinedFilter = {
types: filters.type?.map(tag => tag.toString()) ?? [],
statuses: filters.status?.map(status => status.toString()) ?? [],
};

combinedFilter.filterTags = [
...combinedFilter.types.map(tag => `${ColumnName.Type}: ${TAG_NAME_MAP[tag as CourseScheduleItemDto['tag']]}`),
...combinedFilter.statuses.map(status => `${ColumnName.Status}: ${capitalize(status)}`),
apalchys marked this conversation as resolved.
Show resolved Hide resolved
];

setCombinedFilter(combinedFilter);
setFilteredInfo(filters);
};

const generateUniqueRowKey = ({ id, name, tag }: CourseScheduleItemDto) => [id, name, tag].join('|');
Expand All @@ -164,8 +237,7 @@ export function TableView({ data, settings, statusFilter = ALL_TAB_KEY }: TableV
<Col span={24}>
<Form form={form} component={false}>
<FilteredTags
filterName={`${ColumnName.Type}: `}
tagFilters={tagFilter}
tagFilters={combinedFilter.filterTags ?? []}
onTagClose={handleTagClose}
onClearAllButtonClick={handleClearAllButtonClick}
/>
Expand All @@ -176,10 +248,7 @@ export function TableView({ data, settings, statusFilter = ALL_TAB_KEY }: TableV
triggerAsc: undefined,
cancelSort: undefined,
}}
onChange={(_, filters: Record<ColumnKey, FilterValue | string[] | null>) => {
setTagFilter(filters?.type as string[]);
setFilteredInfo(filters);
}}
onChange={handleTableChange}
pagination={false}
dataSource={filteredData}
rowKey={generateUniqueRowKey}
Expand Down
10 changes: 9 additions & 1 deletion client/src/modules/Schedule/components/TableView/renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -11,6 +11,14 @@ export function statusRenderer(value: CourseScheduleItemDtoStatusEnum) {
return <Badge color={color} text={label} />;
}

export function renderStatusWithStyle(statusName: CourseScheduleItemDtoStatusEnum) {
return (
<Tag style={getStatusStyle(statusName)} key={statusName}>
{capitalize(statusName)}
</Tag>
);
}

export function renderTagWithStyle(tagName: CourseScheduleItemDto['tag'], tagColors = DEFAULT_TAG_COLOR_MAP) {
return (
<Tag style={getTagStyle(tagName, tagColors)} key={tagName}>
Expand Down
6 changes: 5 additions & 1 deletion client/src/modules/Schedule/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export enum LocalStorageKeys {
ColumnsHidden = 'scheduleColumnsHidden',
EventTypesHidden = 'scheduleEventTypesHidden',
StatusFilter = 'scheduleStatusFilter',
TagFilter = 'scheduleTagFilter',
Filters = 'scheduleFilters',
}

export const TAG_NAME_MAP: Record<CourseScheduleItemDto['tag'], string> = {
Expand All @@ -55,6 +55,10 @@ export const TAG_NAME_MAP: Record<CourseScheduleItemDto['tag'], string> = {
lecture: 'Lecture',
};

export const REVERSE_TAG_NAME_MAP: Record<string, CourseScheduleItemDto['tag']> = Object.fromEntries(
Object.entries(TAG_NAME_MAP).map(entry => entry.reverse()),
);

export const SCHEDULE_STATUSES = Object.keys(CourseScheduleItemDtoStatusEnum).map(key => ({
value: (CourseScheduleItemDtoStatusEnum as any)[key] as CourseScheduleItemDtoStatusEnum,
text: key,
Expand Down
10 changes: 10 additions & 0 deletions client/src/modules/Schedule/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
};
};
Loading