Skip to content

Commit

Permalink
Merge pull request #534 from cofacts/article-filters
Browse files Browse the repository at this point in the history
Add new options to Article Filters
  • Loading branch information
MrOrz committed Mar 18, 2023
2 parents 0396606 + 60138cd commit 81e0116
Show file tree
Hide file tree
Showing 13 changed files with 395 additions and 258 deletions.
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

0 comments on commit 81e0116

Please sign in to comment.