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

Add new options to Article Filters #534

Merged
merged 16 commits into from
Mar 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
!public/*
!pages/*
!typegen/*
!tsconfig.json
63 changes: 0 additions & 63 deletions components/ListPageControls/ArticleStatusFilter.js

This file was deleted.

164 changes: 164 additions & 0 deletions components/ListPageControls/ArticleStatusFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { memo } from 'react';
import { useRouter } from 'next/router';
import { t } from 'ttag';
import { ParsedUrlQuery } from 'querystring';

import { ListArticleFilter } from 'typegen/graphql';
import BaseFilter from './BaseFilter';
import useCurrentUser from 'lib/useCurrentUser';
import { goToUrlQueryAndResetPagination } from 'lib/listPage';

import * as FILTERS from 'constants/articleFilters';

/**
* URL param name to read from and write to
*/
const PARAM_NAME = 'filters';

const OPTIONS = [
{ value: FILTERS.ASKED_ONCE, label: t`Asked only once` },
{ value: FILTERS.ASKED_MANY_TIMES, label: t`Asked many times` },
{ value: FILTERS.NO_REPLY, label: t`Zero replies` },
{ value: FILTERS.REPLIED_MANY_TIMES, label: t`Replied many times` },
{ value: FILTERS.NO_USEFUL_REPLY_YET, label: t`No useful reply yet` },
{ value: FILTERS.HAS_USEFUL_REPLY, label: t`Has useful replies` },
{ value: FILTERS.REPLIED_BY_ME, label: t`Replied by me` },
{ value: FILTERS.NOT_REPLIED_BY_ME, label: t`Not replied by me` },
];

/** Filters that is only enabled when user is logged in */
const LOGIN_ONLY_OPTIONS = [FILTERS.REPLIED_BY_ME, FILTERS.NOT_REPLIED_BY_ME];

const MUTUALLY_EXCLUSIVE_FILTERS: ReadonlyArray<
ReadonlyArray<keyof typeof FILTERS>
> = [
// Sets of filters that are mutually exclusive (cannot be selected together)
[FILTERS.ASKED_ONCE, FILTERS.ASKED_MANY_TIMES],
[FILTERS.NO_REPLY, FILTERS.REPLIED_MANY_TIMES],
[FILTERS.NO_USEFUL_REPLY_YET, FILTERS.HAS_USEFUL_REPLY],
[FILTERS.REPLIED_BY_ME, FILTERS.NOT_REPLIED_BY_ME],
];

/**
* @param query - query from router
* @returns list of selected filter values; see constants/articleFilters for all possible values
*/
export function getValues(query: ParsedUrlQuery): Array<keyof typeof FILTERS> {
return query[PARAM_NAME]
? query[PARAM_NAME].toString()
.split(',')
.filter((param): param is keyof typeof FILTERS => param in FILTERS)
: [];
}

/**
* @param query - query from router
* @param userId - currently logged in user ID. Can be undefined if not logged in.
* @returns a ListArticleFilter with filter fields from router query
*/
export function getFilter(
query: ParsedUrlQuery,
userId?: string
): ListArticleFilter {
const filterObj: ListArticleFilter = {};

for (const filter of getValues(query)) {
// Skip login only filters when user is not logged in
if (!userId && LOGIN_ONLY_OPTIONS.includes(filter)) break;

switch (filter) {
case FILTERS.REPLIED_BY_ME:
filterObj.articleRepliesFrom = {
userId: userId,
exists: true,
};
break;
case FILTERS.NOT_REPLIED_BY_ME:
filterObj.articleRepliesFrom = {
userId: userId,
exists: false,
};
break;
case FILTERS.NO_USEFUL_REPLY_YET:
filterObj.hasArticleReplyWithMorePositiveFeedback = false;
break;
case FILTERS.HAS_USEFUL_REPLY:
filterObj.hasArticleReplyWithMorePositiveFeedback = true;
break;
case FILTERS.ASKED_ONCE:
filterObj.replyRequestCount = { EQ: 1 };
break;
case FILTERS.ASKED_MANY_TIMES:
filterObj.replyRequestCount = { GTE: 2 };
break;
case FILTERS.NO_REPLY:
filterObj.replyCount = { EQ: 0 };
break;
case FILTERS.REPLIED_MANY_TIMES:
filterObj.replyCount = { GTE: 3 };
break;
default: {
const exhaustiveCheck: never = filter;
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
}
}
}

return filterObj;
}

type Props = {
/** setting FILTERS.XXX false means that XXX option should be hidden in ArticleStatusFilter. */
filterMap?: Partial<Record<keyof typeof FILTERS, boolean>>;
};

function ArticleStatusFilter({ filterMap = {} }: Props) {
const { query } = useRouter();
const user = useCurrentUser();
const selectedValues = getValues(query);

// Disable login-only options when not logged in
let options = OPTIONS.filter(f => filterMap[f.value] !== false);

if (!user) {
options = options.map(option => ({
...option,
disabled: LOGIN_ONLY_OPTIONS.includes(option.value),
}));
}

return (
<BaseFilter
title={t`Filter`}
options={options}
selected={selectedValues}
onChange={newValues => {
MUTUALLY_EXCLUSIVE_FILTERS.forEach(mutuallyExclusiveFilters => {
for (const filter of mutuallyExclusiveFilters) {
if (
!selectedValues.includes(filter) &&
newValues.includes(filter)
) {
// This filter is being toggled on;
// remove others in the same mutually exclusive filters set
newValues = newValues.filter(v =>
mutuallyExclusiveFilters.includes(v) ? v === filter : true
);

// Found the toggled filter, can skip the rest.
break;
}
}
});

goToUrlQueryAndResetPagination({
...query,
[PARAM_NAME]: newValues.join(','),
});
}}
data-ga="Filter(filter)"
/>
);
}

export default memo(ArticleStatusFilter);
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import cx from 'clsx';

import { Theme } from 'lib/theme';
import BaseFilterOption from './BaseFilterOption';

const useStyles = makeStyles(theme => ({
Expand Down Expand Up @@ -86,20 +88,32 @@ const useStyles = makeStyles(theme => ({
* @param {Array<BaseFilterOptionProps>} props.options
* @param {(selected: string[]) => void} props.onChange
*/
function BaseFilter({

type Props<V> = {
title: string;
expandable?: boolean;
placeholder?: string;
selected: ReadonlyArray<V>;
options: ReadonlyArray<
React.ComponentPropsWithoutRef<typeof BaseFilterOption>
>;
onChange: (selected: V[]) => void;
};

function BaseFilter<V extends string>({
title,
onChange = () => null,
placeholder,
expandable,
selected = [],
options = [],
}) {
}: Props<V>) {
const classes = useStyles();
const [expandEl, setExpandEl] = useState(null);

// Note: this is implemented using JS, don't use it on places
// that is going to cause flicker on page load!
const isDesktop = useMediaQuery(theme => theme.breakpoints.up('md'));
const isDesktop = useMediaQuery<Theme>(theme => theme.breakpoints.up('md'));

const isValueSelected = Object.fromEntries(
selected.map(value => [value, true])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { makeStyles } from '@material-ui/core/styles';
import Chip from '@material-ui/core/Chip';

type Props<V extends string> = {
/** toggle chip-like border on display */
chip?: boolean;
label: string;
value: V;
disabled?: boolean;
selected?: boolean;
onClick?: (value: string) => void;
};

type StyleProps = Pick<Props<string>, 'selected' | 'chip'>;

const useStyles = makeStyles(theme => ({
root: {
height: 'auto' /* Override material-ui */,
padding: '3px 9px' /* consider 1px border */,

// Add background color on selected
backgroundColor: ({ selected }) =>
backgroundColor: ({ selected }: StyleProps) =>
selected ? theme.palette.secondary[50] : '',

// Hide border color when not chip and not selected
borderColor: ({ chip, selected }) =>
borderColor: ({ chip, selected }: StyleProps) =>
!chip && !selected ? `transparent` : '',
},
/* Chip label */
Expand All @@ -26,22 +38,14 @@ const useStyles = makeStyles(theme => ({
},
}));

/**
* @param {boolean} props.chip - toggle chip-like border on display
* @param {string} props.label
* @param {string} props.value
* @param {boolean} props.disabled
* @param {boolean} props.selected
* @param {(value: string) => void} props.onClick
*/
function BaseFilterOption({
function BaseFilterOption<V extends string>({
chip,
selected,
label,
value,
disabled,
onClick = () => {},
}) {
onClick = () => undefined,
}: Props<V>) {
const classes = useStyles({ chip, selected });
const handleClick = () => {
onClick(value);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import Tools from './Tools';
import Filters from './Filters';
import BaseFilter from './BaseFilter';
import ArticleStatusFilter from './ArticleStatusFilter';
import ArticleStatusFilter, {
getFilter as getArticleStatusFilter,
getValues as getArticleStatusFilterValues,
} from './ArticleStatusFilter';
import CategoryFilter from './CategoryFilter';
import ArticleTypeFilter from './ArticleTypeFilter';
import ReplyTypeFilter from './ReplyTypeFilter';
Expand All @@ -14,6 +17,8 @@ export {
Filters,
BaseFilter,
ArticleStatusFilter,
getArticleStatusFilter,
getArticleStatusFilterValues,
CategoryFilter,
ArticleTypeFilter,
ReplyTypeFilter,
Expand Down
6 changes: 5 additions & 1 deletion constants/articleFilters.js → constants/articleFilters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export const REPLIED_BY_ME = 'REPLIED_BY_ME';
export const NO_USEFUL_REPLY_YET = 'NO_USEFUL_REPLY_YET';
export const NOT_REPLIED_BY_ME = 'NOT_REPLIED_BY_ME';
export const ASKED_ONCE = 'ASKED_ONCE';
export const ASKED_MANY_TIMES = 'ASKED_MANY_TIMES';
export const NO_REPLY = 'NO_REPLY';
export const REPLIED_MANY_TIMES = 'REPLIED_MANY_TIMES';
export const NO_USEFUL_REPLY_YET = 'NO_USEFUL_REPLY_YET';
export const HAS_USEFUL_REPLY = 'HAS_USEFUL_REPLY';
Loading