diff --git a/catalog/.eslintrc.js b/catalog/.eslintrc.js index dcdfb4a0cb7..f8e68546c82 100644 --- a/catalog/.eslintrc.js +++ b/catalog/.eslintrc.js @@ -51,7 +51,7 @@ module.exports = { 'max-classes-per-file': 0, 'no-console': 2, 'no-nested-ternary': 1, - 'no-underscore-dangle': [2, { allow: ['_', '__', '__typename'] }], + 'no-underscore-dangle': [2, { allow: ['_', '__', '__typename', '_tag'] }], 'prefer-arrow-callback': [2, { allowNamedFunctions: true }], 'prefer-template': 2, 'react-hooks/exhaustive-deps': 2, diff --git a/catalog/app/app.tsx b/catalog/app/app.tsx index 60c8019045f..5416347b04d 100644 --- a/catalog/app/app.tsx +++ b/catalog/app/app.tsx @@ -5,6 +5,7 @@ import { createBrowserHistory as createHistory } from 'history' import * as React from 'react' import * as ReactDOM from 'react-dom' import { Router } from 'react-router-dom' +import { createSelector } from 'reselect' import * as M from '@material-ui/core' // initialize config from window.QUILT_CATALOG_CONFIG @@ -67,16 +68,15 @@ const MOUNT_NODE = document.getElementById('app') // TODO: make storage injectable const storage = mkStorage({ user: 'USER', tokens: 'TOKENS' }) -const intercomUserSelector = (state: $TSFixMe) => { - const { user: u } = Auth.selectors.domain(state) - return ( +const intercomUserSelector = createSelector( + Auth.selectors.domain, + ({ user: u }) => u && { user_id: u.current_user, name: u.current_user, email: u.email, - } - ) -} + }, +) const render = () => { ReactDOM.render( diff --git a/catalog/app/components/Filters/Activator.tsx b/catalog/app/components/Filters/Activator.tsx new file mode 100644 index 00000000000..632c99b83fc --- /dev/null +++ b/catalog/app/components/Filters/Activator.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import * as M from '@material-ui/core' + +const useStyles = M.makeStyles((t) => ({ + icon: { + minWidth: t.spacing(4), + }, +})) + +interface ActivatorProps { + disabled?: boolean + onClick: () => void + title: React.ReactNode +} + +export default React.forwardRef(function Activator( + { disabled, onClick, title }, + ref, +) { + const classes = useStyles() + return ( + + + add_circle_outline + + + + ) +}) diff --git a/catalog/app/components/Filters/BooleanFilter.tsx b/catalog/app/components/Filters/BooleanFilter.tsx new file mode 100644 index 00000000000..7009760e945 --- /dev/null +++ b/catalog/app/components/Filters/BooleanFilter.tsx @@ -0,0 +1,77 @@ +import cx from 'classnames' +import * as React from 'react' +import * as M from '@material-ui/core' + +import useId from 'utils/useId' + +const useStyles = M.makeStyles((t) => ({ + root: { + border: `1px solid ${t.palette.divider}`, + borderRadius: t.shape.borderRadius, + }, + checkboxWrapper: { + minWidth: t.spacing(4), + paddingLeft: '2px', + }, + label: { + cursor: 'pointer', + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + listItem: { + padding: 0, + }, +})) + +const KEYS = ['true', 'false'] as const + +type Key = (typeof KEYS)[number] + +type BooleanFilterValue = { + [key in Key]: boolean +} + +interface BooleanFilterProps { + className?: string + value: BooleanFilterValue + onChange: (v: BooleanFilterValue) => void +} + +export default function BooleanFilter({ + className, + value, + onChange, +}: BooleanFilterProps) { + const classes = useStyles() + const id = useId() + const handleChange = React.useCallback( + (key: Key, checked: boolean) => { + onChange({ ...value, [key]: checked }) + }, + [onChange, value], + ) + return ( +
+ + {KEYS.map((key) => ( + + + handleChange(key, checked)} + size="small" + /> + + + + + + ))} + +
+ ) +} diff --git a/catalog/app/components/Filters/Container.tsx b/catalog/app/components/Filters/Container.tsx new file mode 100644 index 00000000000..6187307f5c1 --- /dev/null +++ b/catalog/app/components/Filters/Container.tsx @@ -0,0 +1,93 @@ +import cx from 'classnames' +import * as React from 'react' +import * as M from '@material-ui/core' +import { fade } from '@material-ui/core/styles' + +import Skeleton from 'components/Skeleton' + +const useStyles = M.makeStyles((t) => ({ + root: {}, + close: {}, + content: { + display: 'flex', + flexDirection: 'column', + padding: t.spacing(0, 0, 2), + position: 'relative', + }, + header: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + margin: t.spacing(0, 0, 1.5), + }, + title: { + ...t.typography.body2, + fontWeight: 500, + }, + lock: { + alignItems: 'center', + animation: '$showLock .3s ease-out', + background: fade(t.palette.background.paper, 0.7), + bottom: 0, + display: 'flex', + justifyContent: 'center', + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + spinner: {}, + '@keyframes showLock': { + '0%': { + transform: 'scale(1.2x)', + }, + '100%': { + transform: 'scale(1)', + }, + }, +})) + +interface ContainerProps { + children?: React.ReactNode + className?: string + extenting?: boolean + onDeactivate?: () => void + title: React.ReactNode + defaultExpanded?: boolean +} + +export default function Container({ + className, + children, + title, + extenting, + onDeactivate, +}: ContainerProps) { + const classes = useStyles() + return ( +
+
+
{title}
+ {onDeactivate && ( + + clear + + )} +
+
+ {children ? ( + <> + {children} + {extenting && ( +
+ +
+ )} + + ) : ( + + )} +
+
+ ) +} diff --git a/catalog/app/components/Filters/DatesRange.tsx b/catalog/app/components/Filters/DatesRange.tsx new file mode 100644 index 00000000000..084dd788306 --- /dev/null +++ b/catalog/app/components/Filters/DatesRange.tsx @@ -0,0 +1,63 @@ +import * as dateFns from 'date-fns' +import * as React from 'react' +import * as M from '@material-ui/core' + +const ymdToDate = (ymd: string): Date => new Date(ymd) + +const dateToYmd = (date: Date): string => dateFns.format(date, 'yyyy-MM-dd') + +const useStyles = M.makeStyles((t) => { + const gap = t.spacing(1) + return { + root: { + display: 'grid', + gridTemplateColumns: `calc(50% - ${gap / 2}px) calc(50% - ${gap / 2}px)`, + columnGap: gap, + }, + input: { + background: t.palette.background.paper, + }, + } +}) + +interface DateRangeProps { + extents: { min: Date; max: Date } + onChange: (v: { min: Date | null; max: Date | null }) => void + value: { min: Date | null; max: Date | null } +} + +export default function DatesRange({ extents, value, onChange }: DateRangeProps) { + const classes = useStyles() + const min = value.min || extents.min + const max = value.max || extents.max + const handleFrom = React.useCallback( + (event) => onChange({ min: ymdToDate(event.target.value), max }), + [onChange, max], + ) + const handleTo = React.useCallback( + (event) => onChange({ min, max: ymdToDate(event.target.value) }), + [onChange, min], + ) + return ( +
+ + +
+ ) +} diff --git a/catalog/app/components/Filters/Enum.tsx b/catalog/app/components/Filters/Enum.tsx new file mode 100644 index 00000000000..88289de5904 --- /dev/null +++ b/catalog/app/components/Filters/Enum.tsx @@ -0,0 +1,96 @@ +import * as R from 'ramda' +import * as React from 'react' +import * as M from '@material-ui/core' +import * as Lab from '@material-ui/lab' + +function withoutOnDeleteWhen(condition: boolean, props: M.ChipProps) { + return condition ? R.dissoc('onDelete', props) : props +} + +const useStyles = M.makeStyles((t) => ({ + checkbox: { + marginRight: t.spacing(1), + }, + option: { + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + input: { + background: t.palette.background.paper, + }, +})) + +interface EnumFilterProps { + extents: string[] + onChange: (v: string[]) => void + value: string[] + selectAll?: string +} + +interface EnumProps + extends Omit, + EnumFilterProps {} + +export default function Enum({ + selectAll, + extents, + value, + onChange, + ...props +}: EnumProps) { + const classes = useStyles() + const allExtents = React.useMemo( + () => (selectAll ? [selectAll, ...extents] : extents), + [extents, selectAll], + ) + const handleChange = React.useCallback( + (event, newValue: string[]) => { + if (!selectAll) { + onChange(newValue) + return + } + + if (value.length && newValue.includes(selectAll)) { + onChange([]) + return + } + onChange(newValue.filter((b) => b !== selectAll)) + }, + [onChange, selectAll, value], + ) + return ( + ( + + )} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + // eslint-disable-next-line react/jsx-key + + )) + } + renderOption={(option, { selected }) => ( + <> + check_box_outline_blank} + checkedIcon={check_box} + className={classes.checkbox} + checked={selected} + size="small" + /> + + {option.trim() ? option : Empty string} + + + )} + value={selectAll && !value.length ? [selectAll] : value} + /> + ) +} diff --git a/catalog/app/components/Filters/List.tsx b/catalog/app/components/Filters/List.tsx new file mode 100644 index 00000000000..befddded53e --- /dev/null +++ b/catalog/app/components/Filters/List.tsx @@ -0,0 +1,143 @@ +import cx from 'classnames' +import Fuse from 'fuse.js' +import * as R from 'ramda' +import * as React from 'react' +import * as M from '@material-ui/core' + +import TinyTextField from './TinyTextField' + +// Number of items, when we show search text field +const TEXT_FIELD_VISIBLE_THRESHOLD = 8 + +function fuzzySearchExtents(extents: string[], searchStr: string): string[] { + if (!searchStr) return extents + const fuse = new Fuse(extents, { includeScore: true }) + return fuse + .search(searchStr) + .sort((a, b) => (a.score || Infinity) - (b.score || Infinity)) + .map(({ item }) => item) +} + +const useStyles = M.makeStyles((t) => ({ + root: { + display: 'flex', + flexDirection: 'column', + maxHeight: t.spacing(40), + }, + checkboxWrapper: { + minWidth: t.spacing(4), + paddingLeft: '2px', + }, + help: { + marginTop: t.spacing(1), + }, + label: { + cursor: 'pointer', + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + listItem: { + padding: 0, + }, + scrollArea: { + border: `1px solid ${M.fade(t.palette.text.primary, 0.23)}`, + flexGrow: 1, + overflow: 'hidden auto', + borderRadius: t.shape.borderRadius, + }, + filter: { + background: t.palette.background.paper, + borderRadius: `${t.shape.borderRadius}px ${t.shape.borderRadius}px 0 0 `, + '& + $scrollArea': { + borderWidth: '0 1px 1px', + borderRadius: `0 0 ${t.shape.borderRadius}px ${t.shape.borderRadius}px`, + }, + }, +})) + +interface ListProps { + className?: string + extents: readonly string[] + onChange: (v: string[]) => void + placeholder?: string + searchThreshold?: number + value: string[] +} + +export default function List({ + className, + extents: rawExtents, + onChange, + placeholder, + value, + searchThreshold = TEXT_FIELD_VISIBLE_THRESHOLD, +}: ListProps) { + const extents = React.useMemo( + () => R.uniq([...value, ...rawExtents]), + [value, rawExtents], + ) + const [filter, setFilter] = React.useState('') + const classes = useStyles() + const valueMap = value.reduce( + (memo, v) => ({ ...memo, [v]: true }), + {} as Record, + ) + const filteredExtents = React.useMemo( + () => fuzzySearchExtents(extents, filter), + [filter, extents], + ) + const handleChange = React.useCallback( + (extent, checked) => { + const newValue = checked ? [...value, extent] : value.filter((v) => v !== extent) + onChange(newValue) + }, + [onChange, value], + ) + const hiddenNumber = extents.length - filteredExtents.length + return ( +
+ {extents.length > searchThreshold && ( + + )} +
+ + {filteredExtents.map((extent) => ( + + + handleChange(extent, checked)} + size="small" + /> + + + + + + ))} + +
+ {!!hiddenNumber && ( + + {filteredExtents.length + ? `There are ${hiddenNumber} more items available. Loosen search query to see more.` + : `${hiddenNumber} available items are hidden. Clear filters to see them.`} + + )} +
+ ) +} diff --git a/catalog/app/components/Filters/NumbersRange.tsx b/catalog/app/components/Filters/NumbersRange.tsx new file mode 100644 index 00000000000..300e855afac --- /dev/null +++ b/catalog/app/components/Filters/NumbersRange.tsx @@ -0,0 +1,117 @@ +import * as React from 'react' +import * as M from '@material-ui/core' + +import * as Notifications from 'containers/Notifications' +import { formatQuantity } from 'utils/string' + +const isNumber = (v: unknown): v is number => typeof v === 'number' && !Number.isNaN(v) + +const valueLabelFormat = (number: number) => + // @ts-expect-error + formatQuantity(number, { + suffixes: ['', 'K', 'M', 'B', 'T', 'P', 'E', 'Z', 'Y'], + }) + +const useStyles = M.makeStyles((t) => { + const gap = t.spacing(1) + return { + input: { + background: t.palette.background.paper, + }, + inputs: { + display: 'grid', + gridTemplateColumns: `calc(50% - ${gap / 2}px) calc(50% - ${gap / 2}px)`, + columnGap: gap, + }, + slider: { + padding: t.spacing(0, 1), + }, + } +}) + +interface NumbersRangeProps { + extents: { min: number; max: number } + onChange: (v: { min: number | null; max: number | null }) => void + value: { min: number | null; max: number | null } +} + +export default function NumbersRange({ extents, value, onChange }: NumbersRangeProps) { + const [invalidId, setInvalidId] = React.useState('') + const { push: notify, dismiss } = Notifications.use() + const classes = useStyles() + const validate = React.useCallback( + (v) => { + if (invalidId) { + setInvalidId('') + dismiss(invalidId) + } + + if (!isNumber(v)) { + const { + notification: { id }, + } = notify('Enter valid number, please') + setInvalidId(id) + } + }, + [dismiss, invalidId, notify], + ) + const handleSlider = React.useCallback( + (event, [min, max]) => onChange({ min, max }), + [onChange], + ) + const min = value.min || extents.min + const max = value.max || extents.max + const handleFrom = React.useCallback( + (event) => { + const newMin = Number(event.target.value) + if (isNumber(newMin)) { + onChange({ min: newMin, max }) + } + validate(newMin) + }, + [onChange, max, validate], + ) + const handleTo = React.useCallback( + (event) => { + const newMax = Number(event.target.value) + if (isNumber(newMax)) { + onChange({ min, max: newMax }) + } + validate(newMax) + }, + [onChange, min, validate], + ) + const sliderValue = React.useMemo(() => [min, max], [min, max]) + return ( +
+
+ +
+
+ + +
+
+ ) +} diff --git a/catalog/app/components/Filters/Select.tsx b/catalog/app/components/Filters/Select.tsx new file mode 100644 index 00000000000..ce74c882290 --- /dev/null +++ b/catalog/app/components/Filters/Select.tsx @@ -0,0 +1,46 @@ +import cx from 'classnames' +import * as React from 'react' +import * as M from '@material-ui/core' + +const useStyles = M.makeStyles((t) => ({ + root: { + background: t.palette.background.paper, + }, +})) + +interface SelectFilterProps { + className?: string + extents: T[] + getOptionLabel?: (o: T) => string + onChange: (v: T) => void + value: T | null +} + +interface SelectProps + extends Omit>, + SelectFilterProps {} + +export default function Select({ + className, + extents, + getOptionLabel, + onChange, + value, + ...props +}: SelectProps) { + const classes = useStyles() + return ( + onChange(event.target.value as T)} + {...props} + > + {extents.map((extent) => ( + + {getOptionLabel ? getOptionLabel(extent) : extent} + + ))} + + ) +} diff --git a/catalog/app/components/Filters/TextField.tsx b/catalog/app/components/Filters/TextField.tsx new file mode 100644 index 00000000000..e9e5e229453 --- /dev/null +++ b/catalog/app/components/Filters/TextField.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import * as M from '@material-ui/core' + +interface TextFieldFilterProps { + value: string + onChange: (v: string) => void +} + +interface TextFieldProps + extends Omit, + TextFieldFilterProps {} + +export default function TextField({ value, onChange, ...props }: TextFieldProps) { + return ( + onChange(event.target.value)} + size="small" + value={value} + variant="outlined" + {...props} + /> + ) +} diff --git a/catalog/app/components/Filters/TinyTextField.tsx b/catalog/app/components/Filters/TinyTextField.tsx new file mode 100644 index 00000000000..e0545975055 --- /dev/null +++ b/catalog/app/components/Filters/TinyTextField.tsx @@ -0,0 +1,42 @@ +import cx from 'classnames' +import * as React from 'react' +import * as M from '@material-ui/core' + +const useStyles = M.makeStyles((t) => ({ + root: { + border: `1px solid ${M.fade(t.palette.text.primary, 0.23)}`, + borderRadius: t.shape.borderRadius, + fontSize: t.typography.body2.fontSize, + padding: t.spacing(0, 1), + }, +})) + +type TinyTextFieldProps = Omit & { + onChange: (value: string) => void +} + +export default function TinyTextField({ + className, + onChange, + value, + ...props +}: TinyTextFieldProps) { + const classes = useStyles() + return ( + + onChange('')}> + clear + + + ) + } + onChange={(event) => onChange(event.target.value)} + value={value} + {...props} + /> + ) +} diff --git a/catalog/app/components/Filters/index.ts b/catalog/app/components/Filters/index.ts new file mode 100644 index 00000000000..5dfed84d9d9 --- /dev/null +++ b/catalog/app/components/Filters/index.ts @@ -0,0 +1,10 @@ +export { default as Activator } from './Activator' +export { default as BooleanFilter } from './BooleanFilter' +export { default as Container } from './Container' +export { default as DatesRange } from './DatesRange' +export { default as Enum } from './Enum' +export { default as List } from './List' +export { default as NumbersRange } from './NumbersRange' +export { default as Select } from './Select' +export { default as TextField } from './TextField' +export { default as TinyTextField } from './TinyTextField' diff --git a/catalog/app/components/Layout/Layout.tsx b/catalog/app/components/Layout/Layout.tsx index 31a993ee418..f74da8f2b7d 100644 --- a/catalog/app/components/Layout/Layout.tsx +++ b/catalog/app/components/Layout/Layout.tsx @@ -48,12 +48,14 @@ export function Layout({ bare = false, dark = false, children, pre }: LayoutProp const bookmarks = Bookmarks.use() return ( - {bare ? : } - {!!pre && pre} - {!!children && {children}} - - {!!isHomepage && isHomepage.isExact &&