Move to Zustand and add Filters to web_test (#1077)
* Moved to zustand and started refactored filters - Move from redux to zustand - Refactor Filters typings and add some helper types - Add Filters button and Picker/Switch filter options - Fixed Syosetu typings * Add rule consistent-type-definitions - Add consistent-type-definitions eslint rule to force consistent use of types instead of interfaces - Run eslint to auto-change every interface to types https://dev.to/atheodosiou/types-vs-interfaces-in-typescript-making-the-right-choice-48a4 https://www.youtube.com/watch?v=zM9UPcIyyhQ * Add other filters and refactor - Move Dialog to FiltersDialog - CheckboxFilter, TextFilter, XCheckboxFilter added - Added TripleCheckbox and TripleSwitch as tri-state checkbox/switch items using Mui stylings * TriStateCheckbox/Switch refactor - Change name of TripleSwitch and TripleCheckbox to TriState - Change slightly TriStateSwitch and StyledSwitch styles - Add option to change into TriStateSwitches via `localStorage` value `useSwitches` * Refactor code and fix issues * fix: prettier --------- Co-authored-by: nyagami <hoangquan05112002@gmail.com>
This commit is contained in:
@@ -18,42 +18,29 @@ import { Search, Translate } from '@mui/icons-material';
|
||||
import { searchPlugins } from '@provider/plugins';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { selectPlugin } from '@redux/pluginSlice';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import { useAppStore } from '@store';
|
||||
|
||||
const resovleIcon = (iconPath: string) => {
|
||||
return '/static/' + iconPath;
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [listVisible, setListVisible] = useState(false);
|
||||
const plugin = usePlugin();
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
const plugins = searchPlugins(keyword);
|
||||
setSearchPlugins(plugins);
|
||||
}, [keyword]);
|
||||
|
||||
function renderPlugin(props: ListChildComponentProps<Plugin.PluginBase[]>) {
|
||||
function renderPlugin(setListVisible: (v: boolean) => void) {
|
||||
return function (props: ListChildComponentProps<Plugin.PluginBase[]>) {
|
||||
const { index, style, data } = props;
|
||||
const plugin = data[index];
|
||||
const selectPlugin = useAppStore(state => state.selectPlugin);
|
||||
|
||||
return (
|
||||
<ListItem style={style} key={index} component="div" disablePadding>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
selectPlugin({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
icon: plugin.icon,
|
||||
site: plugin.site,
|
||||
}),
|
||||
);
|
||||
selectPlugin({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
icon: plugin.icon,
|
||||
site: plugin.site,
|
||||
});
|
||||
setListVisible(false);
|
||||
}}
|
||||
>
|
||||
@@ -64,7 +51,20 @@ export default function () {
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function Appbar() {
|
||||
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [listVisible, setListVisible] = useState(false);
|
||||
|
||||
const plugin = useAppStore(state => state.plugin);
|
||||
|
||||
useEffect(() => {
|
||||
const plugins = searchPlugins(keyword);
|
||||
setSearchPlugins(plugins);
|
||||
}, [keyword]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
@@ -111,7 +111,7 @@ export default function () {
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
{renderPlugin}
|
||||
{renderPlugin(setListVisible)}
|
||||
</FixedSizeList>
|
||||
) : null}
|
||||
</FormControl>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import CopiableItem from './CopiableItem';
|
||||
|
||||
interface Validation {
|
||||
type Validation = {
|
||||
type?: 'error' | 'warning';
|
||||
message?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePath = (path: string): Validation => {
|
||||
if (path.includes('http'))
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { styled, Switch } from '@mui/material';
|
||||
|
||||
export const StyledSwitch = styled(Switch)(() => ({
|
||||
padding: 8,
|
||||
'& .MuiSwitch-track': {
|
||||
borderRadius: 22 / 2,
|
||||
},
|
||||
'& .MuiSwitch-thumb': {
|
||||
boxShadow: 'none',
|
||||
width: 16,
|
||||
height: 16,
|
||||
margin: 2,
|
||||
},
|
||||
}));
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Box, Button, Stack, TextField } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import Textarea from '@components/Textarea';
|
||||
import { useAppStore } from '@store';
|
||||
export default function ParseChapter() {
|
||||
const plugin = usePlugin();
|
||||
const plugin = useAppStore(state => state.plugin);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chapterPath, setchapterPath] = useState('');
|
||||
const [chapterText, setChapterText] = useState('');
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Box, Button, Pagination, Stack, TextField } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import SourceNovelCard from './SourceNovelCard';
|
||||
import ChapterList from './ChaptertList';
|
||||
import { useAppStore } from '@store';
|
||||
|
||||
export default function ParseNovel() {
|
||||
const plugin = usePlugin();
|
||||
const plugin = useAppStore(state => state.plugin);
|
||||
const [sourceNovel, setSouceNovel] = useState<
|
||||
Plugin.SourceNovel | undefined
|
||||
>();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#filtersContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.switch_filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -8,40 +8,42 @@ import {
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
} from '@mui/material';
|
||||
import React, { useEffect, useState, ChangeEvent } from 'react';
|
||||
import React, { useEffect, useState, ChangeEvent, useRef } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import NovelItemCard from '../components/NovelItemCard';
|
||||
import { Filters } from '@libs/filterInputs';
|
||||
import usePlugin from '@hooks/usePlugin';
|
||||
import { Filters, FilterToValues } from '@libs/filterInputs';
|
||||
import { useAppStore } from '@store';
|
||||
import './index.css';
|
||||
import { DialogRef, FiltersDialog } from '../components/filters/FiltersDialog';
|
||||
|
||||
export default function PopularNovels() {
|
||||
const plugin = usePlugin();
|
||||
const plugin = useAppStore(state => state.plugin);
|
||||
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Filters | undefined>();
|
||||
const [filterValues, setFilterValues] = useState<
|
||||
FilterToValues<Filters> | undefined
|
||||
>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [maxIndex, setMaxIndex] = useState(0);
|
||||
const [isLatest, setIsLatest] = useState(true);
|
||||
const dialogRef = useRef<DialogRef>(null);
|
||||
|
||||
const fetchNovelsByIndex = (index: number) => {
|
||||
const fetchNovelsByIndex = async (index: number) => {
|
||||
if (plugin && index) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.popularNovels(index, {
|
||||
filters: filterValues,
|
||||
showLatestNovels: isLatest,
|
||||
})
|
||||
.then(res => {
|
||||
if (res.length !== 0) {
|
||||
setCurrentIndex(index);
|
||||
if (index > maxIndex) {
|
||||
setMaxIndex(index);
|
||||
}
|
||||
setNovels(res);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
const novels = await plugin.popularNovels(index, {
|
||||
filters: filterValues || {},
|
||||
showLatestNovels: isLatest,
|
||||
});
|
||||
if (novels.length !== 0) {
|
||||
setCurrentIndex(index);
|
||||
if (index > maxIndex) {
|
||||
setMaxIndex(index);
|
||||
}
|
||||
setNovels(novels);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,14 +61,15 @@ export default function PopularNovels() {
|
||||
|
||||
useEffect(() => {
|
||||
// Reset when changing plugins.
|
||||
// TODO: Fix crashing on plugin change!
|
||||
setCurrentIndex(0);
|
||||
setMaxIndex(0);
|
||||
setNovels([]);
|
||||
|
||||
if (plugin?.filters) {
|
||||
const filters = {};
|
||||
const filters: FilterToValues<typeof plugin.filters> = {};
|
||||
for (const fKey in plugin.filters) {
|
||||
filters[fKey] = {
|
||||
filters[fKey as keyof typeof filters] = {
|
||||
type: plugin.filters[fKey].type,
|
||||
value: plugin.filters[fKey].value,
|
||||
};
|
||||
@@ -74,6 +77,7 @@ export default function PopularNovels() {
|
||||
setFilterValues(filters);
|
||||
}
|
||||
}, [plugin]);
|
||||
|
||||
return (
|
||||
<AccordionContainer title="Popular Novels" loading={loading}>
|
||||
<Stack direction={'row'} spacing={2} alignItems={'center'}>
|
||||
@@ -103,10 +107,25 @@ export default function PopularNovels() {
|
||||
>
|
||||
Next Page
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!plugin || isLatest || !plugin.filters}
|
||||
onClick={() => {
|
||||
dialogRef.current?.show();
|
||||
}}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
<FiltersDialog
|
||||
ref={dialogRef}
|
||||
values={filterValues}
|
||||
filters={plugin?.filters}
|
||||
setValues={setFilterValues}
|
||||
refetch={() => fetchNovelsByIndex(1)}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
value={currentIndex}
|
||||
exclusive
|
||||
onChange={(event, value) => fetchNovelsByIndex(value)}
|
||||
onChange={(_event, value) => fetchNovelsByIndex(value)}
|
||||
>
|
||||
{Array.from({ length: maxIndex }, (_, index) => index + 1).map(
|
||||
number => (
|
||||
|
||||
@@ -3,35 +3,34 @@ import React, { useEffect, useState } from 'react';
|
||||
import AccordionContainer from '../components/AccordionContainer';
|
||||
import { Plugin } from '@typings/plugin';
|
||||
import NovelItemCard from '../components/NovelItemCard';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from '@redux/store';
|
||||
import { getPlugin } from '@provider/plugins';
|
||||
import { useAppStore } from '@store';
|
||||
|
||||
export default function SearchNovels() {
|
||||
const selectedPluginItem = useSelector(
|
||||
(state: AppState) => state.plugin.selected,
|
||||
);
|
||||
const plugin = useAppStore(state => state.plugin);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [page] = useState(1);
|
||||
const [plugin, setPlugin] = useState<Plugin.PluginBase | undefined>();
|
||||
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (selectedPluginItem) {
|
||||
setPlugin(getPlugin(selectedPluginItem.id));
|
||||
}
|
||||
}, [selectedPluginItem]);
|
||||
|
||||
const [fetchError, setFetchError] = useState('');
|
||||
|
||||
const fetchNovels = () => {
|
||||
if (plugin) {
|
||||
setLoading(true);
|
||||
plugin
|
||||
.searchNovels(searchTerm, page)
|
||||
.then(res => setNovels(res))
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => setLoading(false))
|
||||
.catch(e => {
|
||||
setFetchError(e.message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionContainer title="Search Novels" loading={loading}>
|
||||
{fetchError && <span style={{ color: 'red' }}>{fetchError}</span>}
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<TextField
|
||||
value={searchTerm}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
|
||||
import {
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
|
||||
type FilterType = FilterTypes.CheckboxGroup;
|
||||
|
||||
export const CheckboxFilter = ({
|
||||
filter: { key, filter },
|
||||
value,
|
||||
set,
|
||||
}: {
|
||||
filter: { key: string; filter: Filter<FilterType> };
|
||||
value: ValueOfFilter<FilterType>;
|
||||
set: (v: typeof value) => void;
|
||||
}) => {
|
||||
const label = filter.label.replace(/s+/gi, '-');
|
||||
return (
|
||||
<FormControl key={key} variant="outlined">
|
||||
<FormLabel>{filter.label}</FormLabel>
|
||||
<Grid
|
||||
container
|
||||
columns={{ xs: 3 }}
|
||||
id={`checkbox_filter_${key}_${label}`}
|
||||
>
|
||||
{filter.options.map(option => {
|
||||
return (
|
||||
<Grid
|
||||
key={`${key}_${option.label}`}
|
||||
id={`checkbox_filter_option_${key}_${option.value.replace(/\s+/gi, '-')}`}
|
||||
item
|
||||
xs={1}
|
||||
justifyContent={'center'}
|
||||
display={'flex'}
|
||||
>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
onChange={() => {
|
||||
set(
|
||||
value.includes(option.value)
|
||||
? value.filter(q => q !== option.value)
|
||||
: [...value, option.value],
|
||||
);
|
||||
}}
|
||||
checked={value.includes(option.value)}
|
||||
/>
|
||||
}
|
||||
label={option.label}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AnyFilterValue,
|
||||
Filters,
|
||||
FilterToValues,
|
||||
FilterTypes,
|
||||
} from '@libs/filterInputs';
|
||||
import { PickerFilter } from './PickerFilter';
|
||||
import { SwitchFilter } from './SwitchFilter';
|
||||
import { TextFilter } from './TextFilter';
|
||||
import { CheckboxFilter } from './CheckboxFilter';
|
||||
import { XCheckboxFilter } from './XCheckboxFilter';
|
||||
|
||||
const renderFilters = (
|
||||
filters: Filters | undefined,
|
||||
values: FilterToValues<Filters> | undefined,
|
||||
set: (key: string, v: AnyFilterValue) => void,
|
||||
): React.ReactNode => {
|
||||
const isValueCorrectType = <T extends AnyFilterValue>(
|
||||
o: AnyFilterValue,
|
||||
f: T,
|
||||
): o is T => {
|
||||
const checkIfIsCorrectObjectType = (o: AnyFilterValue, f: T): o is T => {
|
||||
const areArrays = Array.isArray(o) && Array.isArray(f);
|
||||
const areObjects = typeof o === 'object' && typeof f === 'object';
|
||||
return areArrays || areObjects;
|
||||
};
|
||||
return typeof o === typeof f || checkIfIsCorrectObjectType(o, f);
|
||||
};
|
||||
|
||||
if (!filters || !values) return false;
|
||||
return (
|
||||
<>
|
||||
{Object.entries(filters).map(([key, filter]) => {
|
||||
if (!(key in values)) {
|
||||
console.error(`No filter value for ${key} in filter values!`);
|
||||
return null;
|
||||
}
|
||||
switch (filter.type) {
|
||||
case FilterTypes.Picker: {
|
||||
// Check if filterValues have correct type
|
||||
// this needs to be inside of every case in this switch to get correct value type
|
||||
const value = values[key].value; // here value has every possible value type
|
||||
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
|
||||
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
|
||||
console.error(
|
||||
`FilterValue for filter [${key}] has a wrong type!`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// value; // here value has only Picker's value
|
||||
return (
|
||||
<PickerFilter
|
||||
key={`picker_filter_${key}`}
|
||||
filter={{ key, filter }}
|
||||
value={value}
|
||||
set={newValue => set(key, newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case FilterTypes.Switch: {
|
||||
// Check if filterValues have correct type
|
||||
// this needs to be inside of every case in this switch to get correct value type
|
||||
const value = values[key].value; // here value has every possible value type
|
||||
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
|
||||
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
|
||||
console.error(
|
||||
`FilterValue for filter [${key}] has a wrong type!`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// value; // here value has only Picker's value
|
||||
return (
|
||||
<SwitchFilter
|
||||
filter={{ key, filter }}
|
||||
key={`switch_filter_${key}`}
|
||||
value={value}
|
||||
set={newValue => set(key, newValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case FilterTypes.TextInput: {
|
||||
const value = values[key].value;
|
||||
if (!isValueCorrectType(value, filter.value)) {
|
||||
console.error(
|
||||
`FilterValue for filter [${key}] has a wrong type!`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TextFilter
|
||||
filter={{ key, filter }}
|
||||
set={newValue => set(key, newValue)}
|
||||
value={value}
|
||||
key={`text_filter_${key}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case FilterTypes.CheckboxGroup: {
|
||||
const value = values[key].value;
|
||||
if (!isValueCorrectType(value, filter.value)) {
|
||||
console.error(
|
||||
`FilterValue for filter [${key}] has a wrong type!`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CheckboxFilter
|
||||
filter={{ key, filter }}
|
||||
set={newValue => set(key, newValue)}
|
||||
value={value}
|
||||
key={`checkbox_filter_${key}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case FilterTypes.ExcludableCheckboxGroup: {
|
||||
const value = values[key].value;
|
||||
if (!isValueCorrectType(value, filter.value)) {
|
||||
console.error(
|
||||
`FilterValue for filter [${key}] has a wrong type!`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<XCheckboxFilter
|
||||
filter={{ key, filter }}
|
||||
set={newValue => set(key, newValue)}
|
||||
value={value}
|
||||
key={`checkbox_filter_${key}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export type DialogRef = { show(): void; hide(): void };
|
||||
|
||||
export const FiltersDialog = forwardRef<
|
||||
DialogRef,
|
||||
{
|
||||
values: FilterToValues<Filters> | undefined;
|
||||
filters: Filters | undefined;
|
||||
setValues: React.Dispatch<
|
||||
React.SetStateAction<FilterToValues<Filters> | undefined>
|
||||
>;
|
||||
refetch(): void;
|
||||
}
|
||||
>(function FiltersDialog({ values, setValues, filters, refetch }, ref) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [filterElements, setFilterElements] = useState<ReactNode>(false);
|
||||
const setFilterWithKey = (key: string, newValue: AnyFilterValue) =>
|
||||
setValues(fValues =>
|
||||
!fValues
|
||||
? fValues
|
||||
: {
|
||||
...fValues,
|
||||
[key]: {
|
||||
...fValues[key],
|
||||
value: newValue,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show() {
|
||||
setDialogOpen(true);
|
||||
},
|
||||
hide() {
|
||||
setDialogOpen(false);
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setFilterElements(renderFilters(filters, values, setFilterWithKey));
|
||||
}, [values]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
scroll="paper"
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Filters</DialogTitle>
|
||||
<DialogContent dividers id="filtersContent">
|
||||
{filterElements}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => refetch()}>Refetch</Button>
|
||||
<Button style={{ color: 'red' }} onClick={() => setDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
|
||||
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
|
||||
|
||||
type FilterType = FilterTypes.Picker;
|
||||
|
||||
export const PickerFilter = ({
|
||||
filter: { key, filter },
|
||||
value,
|
||||
set,
|
||||
}: {
|
||||
filter: { key: string; filter: Filter<FilterType> };
|
||||
value: ValueOfFilter<FilterType>;
|
||||
set: (v: ValueOfFilter<FilterType>) => void;
|
||||
}) => {
|
||||
const label = filter.label.replace(/s+/gi, '-');
|
||||
return (
|
||||
<FormControl
|
||||
key={key}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
style={{ alignSelf: 'center' }}
|
||||
>
|
||||
<InputLabel id={`picker_filter_label_${label}_${key}`}>
|
||||
{filter.label}
|
||||
</InputLabel>
|
||||
<Select
|
||||
size="medium"
|
||||
labelId={`picker_filter_label_${label}_${key}`}
|
||||
id={`picker_filter_${label}_${key}`}
|
||||
value={value || key}
|
||||
onChange={({ target: { value: newValue } }) => {
|
||||
if (newValue === key) newValue = '';
|
||||
set(newValue);
|
||||
}}
|
||||
MenuProps={{ slotProps: { paper: { sx: { maxHeight: 36 * 4 } } } }}
|
||||
label={filter.label}
|
||||
>
|
||||
{filter.options.map(option => {
|
||||
return (
|
||||
<MenuItem
|
||||
style={{ minHeight: 36 }}
|
||||
key={`${key}_${option.value}`}
|
||||
value={
|
||||
/** Because Mui treats empty string as `empty value`
|
||||
* and doesn't show the MenuItem, if the value is empty, just use the key...
|
||||
* We could do some other weird stuff, but I doubt there will be a filter
|
||||
* that has the same name as the value being used inside of it.
|
||||
* We can warn people in the documentation about this behavior */
|
||||
option.value || key
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
|
||||
import { FormControl, FormControlLabel } from '@mui/material';
|
||||
import { StyledSwitch } from '../../../StyledSwitch';
|
||||
|
||||
type FilterType = FilterTypes.Switch;
|
||||
|
||||
export const SwitchFilter = ({
|
||||
filter: { key, filter },
|
||||
value,
|
||||
set,
|
||||
}: {
|
||||
filter: { key: string; filter: Filter<FilterType> };
|
||||
value: ValueOfFilter<FilterType>;
|
||||
set: (v: typeof value) => void;
|
||||
}) => {
|
||||
const label = filter.label.replace(/s+/gi, '-');
|
||||
return (
|
||||
<FormControl
|
||||
key={key}
|
||||
className="switch_filter"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
>
|
||||
<FormControlLabel
|
||||
style={{ userSelect: 'none' }}
|
||||
label={filter.label}
|
||||
id={`switch_filter_${label}_${key}`}
|
||||
control={<StyledSwitch onChange={() => set(!value)} />}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
|
||||
import { FormControl, TextField } from '@mui/material';
|
||||
|
||||
type FilterType = FilterTypes.TextInput;
|
||||
|
||||
export const TextFilter = ({
|
||||
filter: { key, filter },
|
||||
value,
|
||||
set,
|
||||
}: {
|
||||
filter: { key: string; filter: Filter<FilterType> };
|
||||
value: ValueOfFilter<FilterType>;
|
||||
set: (v: typeof value) => void;
|
||||
}) => {
|
||||
const label = filter.label.replace(/s+/gi, '-');
|
||||
const textRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<FormControl key={key} className="switch_filter">
|
||||
<TextField
|
||||
color="primary"
|
||||
id={`text_filter_${label}_${key}`}
|
||||
inputRef={textRef}
|
||||
label={filter.label}
|
||||
value={value}
|
||||
type="text"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
onInput={() => {
|
||||
if (textRef.current) {
|
||||
set(textRef.current.value ?? '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { styled } from '@mui/system';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
class TriStateCheckboxClasses {
|
||||
root = 'TriStateCheckboxRoot';
|
||||
base = 'TriStateCheckboxBase';
|
||||
input = 'TriStateCheckboxInput';
|
||||
svg = 'TriStateCheckboxSVG';
|
||||
checked: string;
|
||||
unchecked: string;
|
||||
neutral: string;
|
||||
offsets = {
|
||||
neutral: 16,
|
||||
checked: 16 * 2,
|
||||
unchecked: 0,
|
||||
};
|
||||
constructor() {
|
||||
this.checked = `${this.base}-checked`;
|
||||
this.neutral = `${this.base}-neutral`;
|
||||
this.unchecked = `${this.base}-unchecked`;
|
||||
}
|
||||
}
|
||||
|
||||
const classes = new TriStateCheckboxClasses();
|
||||
|
||||
const TriStateCheckboxRoot = styled(`span`, {
|
||||
name: classes.root,
|
||||
slot: 'root',
|
||||
})(({ theme }) => ({
|
||||
[`&.${classes.checked}`]: {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
[`&.${classes.unchecked}`]: {
|
||||
color: 'red',
|
||||
},
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxSizing: 'border-box',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '50%',
|
||||
padding: '9px',
|
||||
}));
|
||||
|
||||
const TriStateCheckboxInput = styled(`input`, {
|
||||
name: classes.input,
|
||||
slot: 'input',
|
||||
})(() => ({
|
||||
cursor: 'inherit',
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
zIndex: 1,
|
||||
}));
|
||||
|
||||
const TriStateCheckboxSVG = styled(`svg`, { name: classes.svg, slot: 'image' })(
|
||||
() => ({
|
||||
userSelect: 'none',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
display: 'inline-block',
|
||||
fill: 'currentcolor',
|
||||
flexShrink: 0,
|
||||
fontSize: '1.5rem',
|
||||
transition: 'fill 200ms cubic-bezier(0.4,0,0.2,1) 0ms',
|
||||
}),
|
||||
);
|
||||
|
||||
const emptyPath = (
|
||||
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"></path>
|
||||
);
|
||||
|
||||
const checkmarkPath = (
|
||||
<path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
|
||||
);
|
||||
|
||||
const uncheckedPath = (
|
||||
<path
|
||||
d={`M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zM8 6l4 4l4-4l2 2l-4 4l4,4l-2 2l-4-4l-4 4l-2-2l4-4l-4-4z`}
|
||||
></path>
|
||||
);
|
||||
|
||||
export type TriStateCheckboxState = 'checked' | 'unchecked' | 'neutral';
|
||||
|
||||
export const TriStateCheckbox = function ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: TriStateCheckboxState;
|
||||
onChange?: (newValue: TriStateCheckboxState) => void;
|
||||
}) {
|
||||
const [state, sSt] = useState<TriStateCheckboxState>(value ?? 'neutral');
|
||||
const setState = (v: TriStateCheckboxState) => {
|
||||
onChange?.(v);
|
||||
sSt(value ?? v);
|
||||
};
|
||||
const root = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const curVal = value ?? state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TriStateCheckboxRoot
|
||||
className={classes[curVal]}
|
||||
data-state={curVal}
|
||||
ref={root}
|
||||
onClick={() => {
|
||||
switch (curVal) {
|
||||
case 'checked':
|
||||
return setState('unchecked');
|
||||
case 'neutral':
|
||||
return setState('checked');
|
||||
case 'unchecked':
|
||||
return setState('neutral');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TriStateCheckboxInput />
|
||||
<TriStateCheckboxSVG>
|
||||
{curVal === 'checked'
|
||||
? checkmarkPath
|
||||
: curVal === 'unchecked'
|
||||
? uncheckedPath
|
||||
: emptyPath}
|
||||
</TriStateCheckboxSVG>
|
||||
</TriStateCheckboxRoot>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { styled } from '@mui/system';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
class TriStateSwitchClasses {
|
||||
root = 'TriStateSwitchRoot';
|
||||
base = 'TriStateSwitchBase';
|
||||
track = 'TriStateSwitchTrack';
|
||||
thumb = 'TriStateSwitchThumb';
|
||||
input = 'TriStateSwitchInput';
|
||||
checked: string;
|
||||
unchecked: string;
|
||||
neutral: string;
|
||||
offsets = {
|
||||
neutral: 18,
|
||||
checked: 18 * 2,
|
||||
unchecked: 0,
|
||||
};
|
||||
width = 16 * 4 + 10;
|
||||
constructor() {
|
||||
this.base;
|
||||
this.checked = `${this.base}-checked`;
|
||||
this.neutral = `${this.base}-neutral`;
|
||||
this.unchecked = `${this.base}-unchecked`;
|
||||
}
|
||||
}
|
||||
|
||||
const classes = new TriStateSwitchClasses();
|
||||
|
||||
const TriStateSwitchRoot = styled(`span`, {
|
||||
name: classes.root,
|
||||
slot: 'root',
|
||||
})(() => ({
|
||||
display: 'inline-flex',
|
||||
width: `${classes.width}px`,
|
||||
height: '38px',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
zIndex: 0,
|
||||
verticalAlign: 'middle',
|
||||
padding: '8px',
|
||||
}));
|
||||
|
||||
const TriStateSwitchTrack = styled(`span`, {
|
||||
name: classes.track,
|
||||
slot: 'track',
|
||||
})(() => ({
|
||||
// Android12 visuals:
|
||||
borderRadius: 22 / 2,
|
||||
// base Track
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: -1,
|
||||
content: `''`,
|
||||
transition:
|
||||
'opacity 150ms cubic-bezier(0.4,0,0.2,1) 0ms, background-color 150ms cubic-bezier(0.4,0,0.2,1) 0ms',
|
||||
backgroundColor: '#000',
|
||||
opacity: 0.38,
|
||||
}));
|
||||
|
||||
const TriStateSwitchBase = styled(`span`, {
|
||||
name: classes.base,
|
||||
slot: 'base',
|
||||
})(({ theme }) => ({
|
||||
[`&.${classes.unchecked}`]: {
|
||||
transform: `translateX(${classes.offsets.unchecked}px)`,
|
||||
color: '#f33',
|
||||
},
|
||||
[`&.${classes.checked}`]: {
|
||||
transform: `translateX(${classes.offsets.checked}px)`,
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
[`&.${classes.neutral}`]: {
|
||||
transform: `translateX(${classes.offsets.neutral}px)`,
|
||||
color: 'gray',
|
||||
},
|
||||
|
||||
[`&.${classes.checked} + span`]: {
|
||||
opacity: 0.5,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
},
|
||||
[`&.${classes.unchecked} + span`]: {
|
||||
opacity: 0.5,
|
||||
backgroundColor: '#f33',
|
||||
},
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
textDecoration: 'none',
|
||||
padding: '9px',
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
color: '#fff',
|
||||
transition:
|
||||
'left 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;',
|
||||
}));
|
||||
|
||||
const TriStateSwitchInput = styled(`input`, {
|
||||
name: classes.input,
|
||||
slot: 'input',
|
||||
})(() => ({
|
||||
left: '-100%',
|
||||
width: '300%',
|
||||
cursor: 'inherit',
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
height: '100%',
|
||||
top: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
zIndex: 1,
|
||||
}));
|
||||
|
||||
const TriStateSwitchThumb = styled(`span`, {
|
||||
name: classes.thumb,
|
||||
slot: 'thumb',
|
||||
})(() => ({
|
||||
boxShadow: 'none',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
margin: '2px',
|
||||
backgroundColor: 'currentColor',
|
||||
borderRadius: '50%',
|
||||
}));
|
||||
type TriStateSwitchState = 'checked' | 'unchecked' | 'neutral';
|
||||
|
||||
export const TriStateSwitch = function ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: TriStateSwitchState;
|
||||
onChange?: (newValue: TriStateSwitchState) => void;
|
||||
}) {
|
||||
const [state, sSt] = useState<TriStateSwitchState>(value ?? 'neutral');
|
||||
const setState = (v: TriStateSwitchState) => {
|
||||
onChange?.(v);
|
||||
sSt(value ?? v);
|
||||
};
|
||||
|
||||
const root = useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<>
|
||||
<TriStateSwitchRoot
|
||||
data-state={state}
|
||||
ref={root}
|
||||
onClick={e => {
|
||||
const offsetX =
|
||||
e.clientX - (root.current?.getBoundingClientRect().x ?? 0);
|
||||
|
||||
const inputWidth = parseFloat(
|
||||
getComputedStyle(e.currentTarget).width,
|
||||
);
|
||||
const clickPercentage = offsetX / inputWidth;
|
||||
const sidePressed =
|
||||
clickPercentage < 0.33
|
||||
? 'left'
|
||||
: clickPercentage > 0.66
|
||||
? 'right'
|
||||
: 'center';
|
||||
|
||||
console.log(inputWidth, offsetX);
|
||||
|
||||
switch (sidePressed) {
|
||||
case 'center':
|
||||
return setState('neutral');
|
||||
case 'left':
|
||||
return setState('unchecked');
|
||||
case 'right':
|
||||
return setState('checked');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TriStateSwitchBase className={classes[value ?? state]}>
|
||||
<TriStateSwitchInput />
|
||||
<TriStateSwitchThumb />
|
||||
</TriStateSwitchBase>
|
||||
<TriStateSwitchTrack />
|
||||
</TriStateSwitchRoot>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
|
||||
import { FormControl, FormControlLabel, FormLabel, Grid } from '@mui/material';
|
||||
import { useAppStore } from '@store';
|
||||
import { TriStateCheckbox, TriStateCheckboxState } from './TriStateCheckbox';
|
||||
import { TriStateSwitch } from './TriStateSwitch';
|
||||
|
||||
type FilterType = FilterTypes.ExcludableCheckboxGroup;
|
||||
|
||||
export const XCheckboxFilter = ({
|
||||
filter: { key, filter },
|
||||
value,
|
||||
set,
|
||||
}: {
|
||||
filter: { key: string; filter: Filter<FilterType> };
|
||||
value: ValueOfFilter<FilterType>;
|
||||
set: (v: typeof value) => void;
|
||||
}) => {
|
||||
const useSwitches = useAppStore(state => state.useSwitches);
|
||||
|
||||
const getCheckboxState = (v: string): TriStateCheckboxState => {
|
||||
if (value.include?.includes(v)) return 'checked';
|
||||
if (value.exclude?.includes(v)) return 'unchecked';
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
const handleChange = (key: string, v: TriStateCheckboxState) => {
|
||||
console.log(`setting ${key} to`, v);
|
||||
if (v === 'unchecked')
|
||||
return set({
|
||||
exclude: value.exclude
|
||||
? Array.from(new Set([...value.exclude, key]))
|
||||
: [key],
|
||||
include: value.include?.filter(f => f !== key),
|
||||
});
|
||||
if (v === 'checked')
|
||||
return set({
|
||||
include: value.include
|
||||
? Array.from(new Set([...value.include, key]))
|
||||
: [key],
|
||||
exclude: value.exclude?.filter(f => f !== key),
|
||||
});
|
||||
return set({
|
||||
include: value.include?.filter(f => f !== key),
|
||||
exclude: value.exclude?.filter(f => f !== key),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl key={key} variant="outlined">
|
||||
<FormLabel>{filter.label}</FormLabel>
|
||||
<Grid container columns={{ xs: 3 }}>
|
||||
{filter.options.map(option => {
|
||||
return (
|
||||
<Grid
|
||||
key={`${key}_${option.label}`}
|
||||
item
|
||||
xs={1}
|
||||
justifyContent={'center'}
|
||||
display={'flex'}
|
||||
>
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
labelPlacement={useSwitches ? 'top' : 'end'}
|
||||
style={{ userSelect: 'none' }}
|
||||
control={
|
||||
useSwitches ? (
|
||||
<TriStateSwitch
|
||||
value={getCheckboxState(option.value)}
|
||||
onChange={c => handleChange(option.value, c)}
|
||||
/>
|
||||
) : (
|
||||
<TriStateCheckbox
|
||||
value={getCheckboxState(option.value)}
|
||||
onChange={c => handleChange(option.value, c)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
label={option.label}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user