diff --git a/CHANGELOG.md b/CHANGELOG.md index a15d16a22..c9ef8a256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ You can also check the [release page](https://github.com/visualize-admin/visuali - Added a "Run SPARQL query" button - UI: - Introduced new UI for search & dataset preview pages + - Updated filters editing by introducing the tree view for hierarchical dimensions + - Introduced a structure to hierarchical dimensions in select elements in published charts ## [3.11.0] - 2022-10-26 diff --git a/app/components/chart-footnotes.tsx b/app/components/chart-footnotes.tsx index 9fdd3bda8..b3997c316 100644 --- a/app/components/chart-footnotes.tsx +++ b/app/components/chart-footnotes.tsx @@ -30,18 +30,22 @@ export const useFootnotesStyles = makeStyles( flexWrap: "wrap", overflow: "hidden", - // Separator between flex elements, the trick to have them not displayed - // for each line leftmost element is to have them negatively positioned - // cut by the overflow hidden - "& > *:before": { - content: '" "', - display: "block", - height: "3px", - width: "3px ", - borderRadius: "3px", - position: "relative", - left: "calc(-1 * var(--column-gap) / 2)", - backgroundColor: theme.palette.grey[600], + "& > button": { + minWidth: "auto", + + // Separator between flex elements, the trick to have them not displayed + // for each line leftmost element is to have them negatively positioned + // cut by the overflow hidden + "&:before": { + content: '" "', + display: "block", + height: "3px", + width: "3px ", + borderRadius: "3px", + position: "relative", + left: "calc(-1 * var(--column-gap) / 2)", + backgroundColor: theme.palette.grey[600], + }, }, }, }) diff --git a/app/configurator/components/filters.tsx b/app/configurator/components/filters.tsx index e49b7f5c8..b0ed6a5cf 100644 --- a/app/configurator/components/filters.tsx +++ b/app/configurator/components/filters.tsx @@ -1,19 +1,18 @@ import { t, Trans } from "@lingui/macro"; import { - autocompleteClasses, Box, Button, ClickAwayListener, - Input, - InputAdornment, Typography, - ListSubheader, - AutocompleteProps, - Autocomplete, Divider, Theme, IconButton, Tooltip, + Accordion, + AccordionSummary, + AccordionDetails, + InputAdornment, + Input, } from "@mui/material"; import { styled } from "@mui/material/styles"; import { makeStyles } from "@mui/styles"; @@ -22,16 +21,19 @@ import get from "lodash/get"; import groupBy from "lodash/groupBy"; import keyBy from "lodash/keyBy"; import sortBy from "lodash/sortBy"; +import uniqBy from "lodash/uniqBy"; import React, { forwardRef, MouseEventHandler, MutableRefObject, + ReactNode, useCallback, useMemo, useRef, useState, } from "react"; +import { useFootnotesStyles } from "@/components/chart-footnotes"; import Flex from "@/components/flex"; import { Loading } from "@/components/hint"; import { @@ -55,19 +57,20 @@ import { } from "@/formatters"; import { DimensionMetadataFragment, + Maybe, useDimensionHierarchyQuery, useDimensionValuesQuery, useTemporalDimensionValuesQuery, } from "@/graphql/query-hooks"; import { HierarchyValue } from "@/graphql/resolver-types"; +import { Icon } from "@/icons"; import SvgIcCheck from "@/icons/components/IcCheck"; import SvgIcChevronRight from "@/icons/components/IcChevronRight"; import SvgIcClose from "@/icons/components/IcClose"; import SvgIcFormatting from "@/icons/components/IcFormatting"; import SvgIcRefresh from "@/icons/components/IcRefresh"; -import SvgIcSearch from "@/icons/components/IcSearch"; import { useLocale } from "@/locales/use-locale"; -import { dfs } from "@/utils/dfs"; +import { getOptionsFromTree, joinParents, pruneTree } from "@/rdf/tree-utils"; import { valueComparator } from "@/utils/sorting-values"; import useEvent from "@/utils/use-event"; @@ -87,11 +90,6 @@ const useStyles = makeStyles((theme: Theme) => { autocompleteHeader: { margin: "1rem var(--mx)", }, - autocompleteInputContainer: { - margin: "0 var(--mx) 0rem", - paddingBottom: "1rem", - borderBottom: `1px solid ${theme.palette.divider}`, - }, autocompleteApplyButtonContainer: { position: "sticky", zIndex: 1000, @@ -105,8 +103,12 @@ const useStyles = makeStyles((theme: Theme) => { autocompleteApplyButton: { justifyContent: "center", }, - autocompleteInput: { + textInput: { + margin: `${theme.spacing(4)} 0px`, + padding: "0px 12px", width: "100%", + height: 40, + minHeight: 40, }, optionColor: { borderRadius: "4px", @@ -117,7 +119,8 @@ const useStyles = makeStyles((theme: Theme) => { border: `1px solid ${theme.palette.divider}`, transition: "background-color 0.125s ease-out", alignSelf: "flex-start", - marginTop: "0.125rem", + marginTop: "0.375rem", + marginRight: "0.5rem", }, optionLabel: { flexGrow: 1, @@ -129,24 +132,6 @@ const useStyles = makeStyles((theme: Theme) => { alignSelf: "flex-start", marginTop: "0.125rem", }, - listSubheader: { - minHeight: "3rem", - padding: "0.5rem 0rem", - margin: "0.5rem 1rem", - alignItems: "center", - gridTemplateRows: "auto min-content", - border: `1px solid ${theme.palette.divider}`, - borderWidth: "1px 0 1px 0", - "& button": { - padding: 0, - minHeight: "auto", - }, - - "&:before, &:after": { - display: "block", - content: "' '", - }, - }, selectedValueRow: { display: "flex", alignItems: "flex-start", @@ -156,88 +141,14 @@ const useStyles = makeStyles((theme: Theme) => { }; }); -const AutocompletePopperStyled = styled("div")(({ theme }) => ({ - // The autocomplete styles the Popper and sets its width - // to its anchorEl width via the style attribute - // Since we cannot override the style attribute through - // componentsProps.popper yet, we have to use !important - // here - width: "100% !important", - [`& .${autocompleteClasses.paper}`]: { - boxShadow: "none", - margin: 0, - color: "inherit", - fontSize: theme.typography.body2.fontSize, - padding: 0, - }, - [`& .${autocompleteClasses.listbox}`]: { - maxHeight: "max-content", - [`& .${autocompleteClasses.option}`]: { - display: "flex", - minHeight: "auto", - alignItems: "center", - justifyContent: "flex-start", - gap: "0.5rem", - padding: "8px 16px", - - '&[aria-selected="true"]': { - // We can see the selection status via the color box + selected icon - backgroundColor: "transparent", - }, - [`&.${autocompleteClasses.focused}, &.${autocompleteClasses.focused}[aria-selected="true"]`]: - { - backgroundColor: theme.palette.action.hover, - }, - }, - }, - [`&.${autocompleteClasses.popper}`]: { - width: 200, - }, - [`&.${autocompleteClasses.popperDisablePortal}`]: { - position: "relative", - }, -})); - -const joinParents = (parents?: HierarchyValue[]) => { - return parents?.map((x) => x.label).join(" > ") || ""; -}; - const explodeParents = (parents: string) => { return parents ? parents.split(" > ") : []; }; -const AutocompletePopper: AutocompleteProps< - unknown, - true, - true, - true ->["PopperComponent"] = ({ disablePortal, anchorEl, open, ...rest }) => { - return ; -}; - const groupByParent = (node: { parents: HierarchyValue[] }) => { return joinParents(node?.parents); }; -const isDimensionOptionEqualToDimensionValue = ( - option: HierarchyValue, - value: HierarchyValue -) => { - return option.value === value?.value; -}; - -const getOptionsFromTree = (tree: HierarchyValue[]) => { - return sortBy( - dfs(tree, (node, { parents }) => ({ - ...node, - parents, - })), - (node) => joinParents(node.parents) - ); -}; - -type AutocompleteOption = ReturnType[number]; - const getColorConfig = ( config: ConfiguratorState, colorConfigPath: string | undefined @@ -255,6 +166,39 @@ const getColorConfig = ( | undefined; }; +const FilterControls = ({ + selectAll, + selectNone, + allKeysLength, + activeKeysLength, +}: { + selectAll: () => void; + selectNone: () => void; + allKeysLength: number; + activeKeysLength: number; +}) => { + const classes = useFootnotesStyles({ useMarginTop: false }); + + return ( + + + + + ); +}; + const MultiFilterContent = ({ field, colorComponent, @@ -273,14 +217,12 @@ const MultiFilterContent = ({ const { selectAll, selectNone } = useDimensionSelection(dimensionIri); - const { options, optionsByValue, optionsByParent } = useMemo(() => { - const flat = getOptionsFromTree(tree); - const optionsByValue = keyBy(flat, (x) => x.value); - const optionsByParent = groupBy(flat, groupByParent); + const { flatOptions, optionsByValue } = useMemo(() => { + const flatOptions = getOptionsFromTree(tree); + const optionsByValue = keyBy(flatOptions, (x) => x.value); return { - options: sortBy(flat, [groupByParent, (x) => x.label]), + flatOptions, optionsByValue, - optionsByParent, }; }, [tree]); @@ -288,12 +230,17 @@ const MultiFilterContent = ({ const values = ( (rawValues?.type === "multi" && Object.keys(rawValues.values)) || Object.keys(optionsByValue) - ).map((v) => optionsByValue[v]); + ) + .map((v) => optionsByValue[v]) + .filter(isHierarchyOptionSelectable); const grouped = groups(values, groupByParent) - .sort( - (a, b) => - ascending(explodeParents(a[0]).length, explodeParents(b[0]).length) || - ascending(a[0], b[0]) + .sort((a, b) => + a[0].length === 0 + ? 1 + : ascending( + explodeParents(a[0]).length, + explodeParents(b[0]).length + ) || ascending(a[0], b[0]) ) .map(([parent, group]) => { return [ @@ -301,10 +248,11 @@ const MultiFilterContent = ({ group.sort( (a, b) => ascending(a.position ?? 0, b.position ?? 0) || - ascending(a.label, b.label) + ascending(a.label.toLowerCase(), b.label.toLowerCase()) ), ] as const; }); + return { values, valueGroups: grouped, @@ -322,7 +270,7 @@ const MultiFilterContent = ({ // The popover content is responsible for keeping this ref up-to-date. // This is so that the click-away close event can still access the pending values // without the state changing (triggering a repositioning of the popover). - const pendingValuesRef = useRef([]); + const pendingValuesRef = useRef([]); const handleCloseAutocomplete = useEvent(() => { setAnchorEl(undefined); const newValues = pendingValuesRef.current @@ -394,22 +342,12 @@ const MultiFilterContent = ({ - - - - +
- - ) : null} - {params.children} - - ); - }, - [classes.listSubheader, handleSelectGroup, hasSelectedAllGroup] - ); + return { selectableDepthsMap, uniqueSelectableFlatOptions }; + }, [flatOptions]); + + const filteredOptions = useMemo(() => { + return pruneTree(options, (d) => + d.label.toLowerCase().includes(textInput.toLowerCase()) + ); + }, [textInput, options]); - const renderOption = useCallback( - (props, option, { selected }) => { - return ( -
  • - {hasColorMapping ? ( -
    - ) : null} -
    {option.label}
    - -
  • - ); - }, - [ - classes.optionCheck, - classes.optionColor, - classes.optionLabel, - getValueColor, - hasColorMapping, - ] - ); return (
    - - Set filters - + + + + Edit filters + + @@ -710,26 +770,34 @@ const DrawerContent = forwardRef< visualization. + setTextInput(e.target.value)} + placeholder={t({ id: "select.controls.filters.search" })} + startAdornment={ + + + + } + sx={{ typography: "body2" }} + /> + setPendingValues(uniqueSelectableFlatOptions)} + selectNone={() => setPendingValues([])} + allKeysLength={uniqueSelectableFlatOptions.length} + activeKeysLength={pendingValues.length} + /> - null} - onChange={handleSelect} - onClose={handleAutocompleteClose} - renderGroup={renderGroup} - options={options} - groupBy={groupByParent} - disableCloseOnSelect - isOptionEqualToValue={isDimensionOptionEqualToDimensionValue} - renderOption={renderOption} - renderInput={renderInput} - onInputChange={handleChangeInput} - inputValue={inputValue} + setPendingValues(newValues)} /> +