diff --git a/CHANGELOG.md b/CHANGELOG.md index 309f2d92a1..4624299c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ You can also check the - Hiding a temporal column and then enabling an interactive filter doesn't result in broken state anymore - Fixed workflow for populating Varnish cache +- Maintenance + - Improved code organization around Browse page ### 6.0.0 - 2025-09-05 diff --git a/app/browse/cube-data-table-preview.tsx b/app/browse/cube-data-table-preview.tsx deleted file mode 100644 index 7341723e46..0000000000 --- a/app/browse/cube-data-table-preview.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useMemo } from "react"; - -import { DataTablePreview } from "@/browse/data-table-preview"; -import { getSortedComponents } from "@/browse/utils"; -import { Loading } from "@/components/hint"; -import { Dimension, Measure, Observation } from "@/domain/data"; - -export const CubeDataTablePreview = ({ - title, - dimensions, - measures, - observations, -}: { - title: string; - dimensions: Dimension[]; - measures: Measure[]; - observations: Observation[] | undefined; -}) => { - const sortedComponents = useMemo(() => { - return getSortedComponents([...dimensions, ...measures]); - }, [dimensions, measures]); - - return observations ? ( - - ) : ( - - ); -}; diff --git a/app/browse/lib/create-use-state.ts b/app/browse/lib/create-use-state.ts new file mode 100644 index 0000000000..0d70f06380 --- /dev/null +++ b/app/browse/lib/create-use-state.ts @@ -0,0 +1,110 @@ +import { useMemo, useRef, useState } from "react"; + +import { + BrowseFilter, + getFiltersFromParams, + getParamsFromFilters, +} from "@/browse/lib/filters"; +import { BrowseParams } from "@/browse/lib/params"; +import { useUrlSyncState } from "@/browse/lib/use-url-sync-state"; +import { SearchCubeResultOrder } from "@/graphql/query-hooks"; +import { useEvent } from "@/utils/use-event"; + +/** + * Creates a hook that provides the current browse state and actions to update it. + * + * It will persist/recover this state from the URL if `syncWithUrl` is true. + * + * TODO: Might be a good idea to use a zustand store, where the persistency is controlled + * via syncWithUrl. It would be a bit more explicit and easier to understand. + */ +export const createUseBrowseState = ({ + syncWithUrl, +}: { + syncWithUrl: boolean; +}) => { + const useParamsHook = syncWithUrl ? useUrlSyncState : useState; + return () => { + const inputRef = useRef(null); + const [browseParams, setParams] = useParamsHook({}); + const { + search, + type, + order, + iri, + includeDrafts, + dataset: paramDataset, + } = browseParams; + const previousOrderRef = useRef(SearchCubeResultOrder.Score); + + // Support /browse?dataset= and legacy /browse/dataset/ + const dataset = type === "dataset" ? iri : paramDataset; + const filters = getFiltersFromParams(browseParams); + + const setSearch = useEvent((v: string) => + setParams((prev) => ({ ...prev, search: v })) + ); + const setIncludeDrafts = useEvent((v: boolean) => + setParams((prev) => ({ ...prev, includeDrafts: v })) + ); + const setOrder = useEvent((v: SearchCubeResultOrder) => + setParams((prev) => ({ ...prev, order: v })) + ); + const setDataset = useEvent((v: string) => + setParams((prev) => ({ ...prev, dataset: v })) + ); + + return useMemo(() => { + const { CreatedDesc, Score } = SearchCubeResultOrder; + const previousOrder = previousOrderRef.current; + + return { + inputRef, + includeDrafts: !!includeDrafts, + setIncludeDrafts, + onReset: () => { + setParams((prev) => ({ + ...prev, + search: "", + order: previousOrder === Score ? CreatedDesc : previousOrder, + })); + }, + onSubmitSearch: (newSearch: string) => { + setParams((prev) => ({ + ...prev, + search: newSearch, + order: newSearch === "" ? CreatedDesc : previousOrder, + })); + }, + search, + order, + onSetOrder: (order: SearchCubeResultOrder) => { + previousOrderRef.current = order; + setOrder(order); + }, + setSearch, + setOrder, + dataset, + setDataset, + filters, + setFilters: (filters: BrowseFilter[]) => { + setParams((prev) => ({ + ...prev, + ...getParamsFromFilters(filters), + })); + }, + }; + }, [ + includeDrafts, + setIncludeDrafts, + search, + order, + setSearch, + setOrder, + dataset, + setDataset, + filters, + setParams, + ]); + }; +}; diff --git a/app/browser/filters.tsx b/app/browse/lib/filters.tsx similarity index 75% rename from app/browser/filters.tsx rename to app/browse/lib/filters.tsx index 7dfec673bd..d0eb0a5176 100644 --- a/app/browser/filters.tsx +++ b/app/browse/lib/filters.tsx @@ -1,10 +1,10 @@ +import { BrowseParams } from "@/browse/lib/params"; import { DataCubeOrganization, DataCubeTermset, DataCubeTheme, SearchCubeFilterType, } from "@/graphql/query-hooks"; -import { BrowseParams } from "@/pages/browse"; export type DataCubeAbout = { __typename: "DataCubeAbout"; @@ -18,10 +18,17 @@ export type BrowseFilter = | DataCubeTermset; /** Builds the state search filters from query params */ - -export const getFiltersFromParams = (params: BrowseParams) => { +export const getFiltersFromParams = ({ + type, + subtype, + subsubtype, + iri, + subiri, + subsubiri, + topic, +}: BrowseParams) => { const filters: BrowseFilter[] = []; - const { type, subtype, subsubtype, iri, subiri, subsubiri, topic } = params; + for (const [t, i] of [ [type, iri], [subtype, subiri], @@ -66,9 +73,11 @@ export const getParamsFromFilters = (filters: BrowseFilter[]) => { topic: undefined, }; let i = 0; + for (const filter of filters) { const typeAttr = i === 0 ? "type" : i === 1 ? "subtype" : "subsubtype"; const iriAttr = i === 0 ? "iri" : i === 1 ? "subiri" : "subsubiri"; + switch (filter.__typename) { case "DataCubeTheme": params[typeAttr] = "theme"; @@ -89,7 +98,29 @@ export const getParamsFromFilters = (filters: BrowseFilter[]) => { const _exhaustiveCheck: never = filter; return _exhaustiveCheck; } + i++; } + return params; }; + +export const encodeFilter = ({ __typename, iri }: BrowseFilter) => { + const folder = (() => { + switch (__typename) { + case "DataCubeTheme": + return "theme"; + case "DataCubeOrganization": + return "organization"; + case "DataCubeAbout": + return "topic"; + case "DataCubeTermset": + return "termset"; + default: + const _exhaustiveCheck: never = __typename; + return _exhaustiveCheck; + } + })(); + + return `${folder}/${encodeURIComponent(iri)}`; +}; diff --git a/app/browse/lib/params.ts b/app/browse/lib/params.ts new file mode 100644 index 0000000000..cf8a11ceb1 --- /dev/null +++ b/app/browse/lib/params.ts @@ -0,0 +1,110 @@ +import { ParsedUrlQuery } from "querystring"; + +import mapValues from "lodash/mapValues"; +import pick from "lodash/pick"; +import pickBy from "lodash/pickBy"; +import NextLink from "next/link"; +import { Router } from "next/router"; +import { ComponentProps } from "react"; + +import { truthy } from "@/domain/types"; +import { SearchCubeResultOrder } from "@/graphql/query-hooks"; + +const params = [ + "type", + "iri", + "subtype", + "subiri", + "subsubtype", + "subsubiri", + "topic", + "includeDrafts", + "order", + "search", + "dataset", + "previous", +] as const; + +export type BrowseParams = { + type?: "theme" | "organization" | "dataset" | "termset"; + iri?: string; + subtype?: "theme" | "organization" | "termset"; + subiri?: string; + subsubtype?: "theme" | "organization" | "termset"; + subsubiri?: string; + topic?: string; + includeDrafts?: boolean; + order?: SearchCubeResultOrder; + search?: string; + dataset?: string; + previous?: string; +}; + +export const getBrowseParamsFromQuery = ( + query: Router["query"] +): BrowseParams => { + const { + type, + iri, + subtype, + subiri, + subsubtype, + subsubiri, + topic, + includeDrafts, + ...values + } = mapValues(pick(query, params), (v) => (Array.isArray(v) ? v[0] : v)); + const previous: BrowseParams | undefined = values.previous + ? JSON.parse(values.previous) + : undefined; + + return pickBy( + { + ...values, + type: type ?? previous?.type, + iri: iri ?? previous?.iri, + subtype: subtype ?? previous?.subtype, + subiri: subiri ?? previous?.subiri, + subsubtype: subsubtype ?? previous?.subsubtype, + subsubiri: subsubiri ?? previous?.subsubiri, + topic: topic ?? previous?.topic, + includeDrafts: includeDrafts ?? previous?.includeDrafts, + }, + (d) => d !== undefined + ); +}; + +export const buildURLFromBrowseParams = ({ + type, + iri, + subtype, + subiri, + subsubtype, + subsubiri, + ...query +}: BrowseParams): ComponentProps["href"] => { + const typePart = buildQueryPart(type, iri); + const subtypePart = buildQueryPart(subtype, subiri); + const subsubtypePart = buildQueryPart(subsubtype, subsubiri); + const pathname = ["/browse", typePart, subtypePart, subsubtypePart] + .filter(truthy) + .join("/"); + + return { pathname, query }; +}; + +const buildQueryPart = (type: string | undefined, iri: string | undefined) => { + if (!type || !iri) { + return undefined; + } + + return `${encodeURIComponent(type)}/${encodeURIComponent(iri)}`; +}; + +export const extractParamFromPath = (path: string, param: string) => { + return path.match(new RegExp(`[&?]${param}=(.*?)(&|$)`)); +}; + +export const isOdsIframe = (query: ParsedUrlQuery) => { + return query["odsiframe"] === "true"; +}; diff --git a/app/components/use-redirect-to-latest-cube.spec.tsx b/app/browse/lib/use-redirect-to-latest-cube.spec.tsx similarity index 93% rename from app/components/use-redirect-to-latest-cube.spec.tsx rename to app/browse/lib/use-redirect-to-latest-cube.spec.tsx index f40d66b94f..3d6bd034b3 100644 --- a/app/components/use-redirect-to-latest-cube.spec.tsx +++ b/app/browse/lib/use-redirect-to-latest-cube.spec.tsx @@ -2,9 +2,10 @@ import { renderHook } from "@testing-library/react"; import { NextRouter, useRouter } from "next/router"; import { describe, expect, it, Mock, vi } from "vitest"; -import { useRedirectToLatestCube } from "@/components/use-redirect-to-latest-cube"; +import { useRedirectToLatestCube } from "@/browse/lib/use-redirect-to-latest-cube"; import { useLocale } from "@/locales/use-locale"; import { queryLatestCubeIri } from "@/rdf/query-latest-cube-iri"; +import { sleep } from "@/utils/sleep"; vi.mock("@/rdf/query-latest-cube-iri", () => ({ queryLatestCubeIri: vi.fn(), @@ -18,9 +19,6 @@ vi.mock("@/locales/use-locale", () => ({ useLocale: vi.fn(), })); -const sleep = (duration: number) => - new Promise((resolve) => setTimeout(resolve, duration)); - describe("use redirect to versioned cube", () => { const setup = async ({ versionedCube, diff --git a/app/components/use-redirect-to-latest-cube.tsx b/app/browse/lib/use-redirect-to-latest-cube.tsx similarity index 100% rename from app/components/use-redirect-to-latest-cube.tsx rename to app/browse/lib/use-redirect-to-latest-cube.tsx diff --git a/app/browse/lib/use-url-sync-state.ts b/app/browse/lib/use-url-sync-state.ts new file mode 100644 index 0000000000..d24d05aefd --- /dev/null +++ b/app/browse/lib/use-url-sync-state.ts @@ -0,0 +1,62 @@ +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +import { + BrowseParams, + buildURLFromBrowseParams, + extractParamFromPath, + getBrowseParamsFromQuery, +} from "@/browse/lib/params"; +import { maybeWindow } from "@/utils/maybe-window"; +import { useEvent } from "@/utils/use-event"; + +export const useUrlSyncState = (initialState: BrowseParams) => { + const router = useRouter(); + const [state, rawSetState] = useState(() => { + // Rely directly on window instead of router since router takes a bit of time + // to be initialized + const window = maybeWindow(); + const searchParams = window + ? new URL(window.location.href).searchParams + : undefined; + const dataset = extractParamFromPath(router.asPath, "dataset"); + const query = searchParams + ? Object.fromEntries(searchParams.entries()) + : undefined; + + if (dataset && query && !query.dataset) { + query.dataset = dataset[0]; + } + + if (query) { + return getBrowseParamsFromQuery(query); + } + + return initialState; + }); + + useEffect(() => { + if (router.isReady) { + const browseParams = getBrowseParamsFromQuery(router.query); + rawSetState(browseParams); + } + }, [router.isReady, router.query]); + + const setState = useEvent( + (stateUpdate: BrowseParams | ((prev: BrowseParams) => BrowseParams)) => { + rawSetState((prev) => { + const newState = { + ...(stateUpdate instanceof Function + ? stateUpdate(prev) + : stateUpdate), + } satisfies BrowseParams; + const url = buildURLFromBrowseParams(newState); + router.replace(url, undefined, { shallow: true }); + + return newState; + }); + } + ); + + return [state, setState] as const; +}; diff --git a/app/browse/model/context.tsx b/app/browse/model/context.tsx new file mode 100644 index 0000000000..081a13ba55 --- /dev/null +++ b/app/browse/model/context.tsx @@ -0,0 +1,48 @@ +import { createContext, ReactNode, useContext, useState } from "react"; + +import { createUseBrowseState } from "@/browse/lib/create-use-state"; + +export type BrowseState = ReturnType>; +const BrowseContext = createContext(undefined); + +const useBrowseState = ({ syncWithUrl }: { syncWithUrl: boolean }) => { + // Use useState here to make sure that the hook is only created once. + // /!\ It will not react if syncWithUrl changes + const [useBrowseStateHook] = useState(() => { + return createUseBrowseState({ syncWithUrl }); + }); + + return useBrowseStateHook(); +}; + +/** + * Provides browse context to children below + * Responsible for connecting the router to the browsing state + */ +export const BrowseStateProvider = ({ + children, + syncWithUrl, +}: { + children: ReactNode; + syncWithUrl: boolean; +}) => { + const browseState = useBrowseState({ syncWithUrl }); + + return ( + + {children} + + ); +}; + +export const useBrowseContext = () => { + const ctx = useContext(BrowseContext); + + if (!ctx) { + throw Error( + "To be able useBrowseContext, you must wrap it into a BrowseStateProvider" + ); + } + + return ctx; +}; diff --git a/app/browse/data-table-preview.tsx b/app/browse/ui/data-table-preview.tsx similarity index 70% rename from app/browse/data-table-preview.tsx rename to app/browse/ui/data-table-preview.tsx index 94cbcd719d..2ffc2b7638 100644 --- a/app/browse/data-table-preview.tsx +++ b/app/browse/ui/data-table-preview.tsx @@ -9,28 +9,41 @@ import { import { ascending, descending } from "d3-array"; import { useCallback, useMemo, useRef, useState } from "react"; -import { ComponentLabel } from "@/browse/component-label"; -import { Component, isNumericalMeasure, Observation } from "@/domain/data"; +import { ComponentLabel } from "@/components/chart-data-table-preview/ui/component-label"; +import { Loading } from "@/components/hint"; +import { + Component, + Dimension, + isNumericalMeasure, + Measure, + Observation, +} from "@/domain/data"; +import { getSortedComponents } from "@/domain/get-sorted-components"; import { useDimensionFormatters } from "@/formatters"; import SvgIcChevronDown from "@/icons/components/IcChevronDown"; import { uniqueMapBy } from "@/utils/unique-map-by"; export const DataTablePreview = ({ title, - sortedComponents, + dimensions, + measures, observations, linkToMetadataPanel, }: { title: string; - sortedComponents: Component[]; - observations: Observation[]; + dimensions: Dimension[]; + measures: Measure[]; + observations: Observation[] | undefined; linkToMetadataPanel: boolean; }) => { const [sortBy, setSortBy] = useState(); const [sortDirection, setSortDirection] = useState<"asc" | "desc">(); + const sortedComponents = useMemo(() => { + return getSortedComponents([...dimensions, ...measures]); + }, [dimensions, measures]); const formatters = useDimensionFormatters(sortedComponents); const sortedObservations = useMemo(() => { - if (!sortBy) { + if (!sortBy || !observations) { return observations; } @@ -108,27 +121,31 @@ export const DataTablePreview = ({ - {sortedObservations.map((obs, i) => { - return ( - - {sortedComponents.map((c) => { - const numerical = isNumericalMeasure(c); - const format = formatters[c.id]; - const v = obs[c.id]; + {sortedObservations ? ( + sortedObservations.map((obs, i) => { + return ( + + {sortedComponents.map((c) => { + const numerical = isNumericalMeasure(c); + const format = formatters[c.id]; + const v = obs[c.id]; - return ( - - {format(numerical && v ? +v : v)} - - ); - })} - - ); - })} + return ( + + {format(numerical && v ? +v : v)} + + ); + })} + + ); + }) + ) : ( + + )} diff --git a/app/browse/ui/dataset-metadata-single-cube.tsx b/app/browse/ui/dataset-metadata-single-cube.tsx new file mode 100644 index 0000000000..e1e903389f --- /dev/null +++ b/app/browse/ui/dataset-metadata-single-cube.tsx @@ -0,0 +1,34 @@ +import { DatasetMetadata } from "@/components/dataset-metadata"; +import { DataSource } from "@/config-types"; +import { useDataCubeMetadataQuery } from "@/graphql/query-hooks"; +import { useLocale } from "@/locales/use-locale"; + +export const DatasetMetadataSingleCube = ({ + dataSource, + datasetIri, +}: { + dataSource: DataSource; + datasetIri: string; +}) => { + const locale = useLocale(); + const [data] = useDataCubeMetadataQuery({ + variables: { + cubeFilter: { iri: datasetIri }, + locale: locale, + sourceType: dataSource.type, + sourceUrl: dataSource.url, + }, + }); + + if (!data.data) { + return null; + } + + return ( + + ); +}; diff --git a/app/browser/dataset-preview.tsx b/app/browse/ui/dataset-preview.tsx similarity index 84% rename from app/browser/dataset-preview.tsx rename to app/browse/ui/dataset-preview.tsx index 13bcac53a5..3c961ea710 100644 --- a/app/browser/dataset-preview.tsx +++ b/app/browse/ui/dataset-preview.tsx @@ -1,14 +1,12 @@ -import { ParsedUrlQuery } from "querystring"; - import { Trans } from "@lingui/macro"; import { Box, Paper, Theme, Typography } from "@mui/material"; import { makeStyles } from "@mui/styles"; import Head from "next/head"; -import { useRouter } from "next/router"; import { ComponentProps, useEffect } from "react"; import { UseQueryResponse } from "urql"; -import { CubeDataTablePreview } from "@/browse/cube-data-table-preview"; +import { DataTablePreview } from "@/browse/ui/data-table-preview"; +import { FirstTenRowsCaption } from "@/browse/ui/first-ten-rows-caption"; import { useFootnotesStyles } from "@/components/chart-footnotes"; import { DataDownloadMenu } from "@/components/data-download"; import { Flex } from "@/components/flex"; @@ -21,66 +19,21 @@ import { import { DataCubePublicationStatus } from "@/graphql/resolver-types"; import { useLocale } from "@/locales/use-locale"; -export const isOdsIframe = (query: ParsedUrlQuery) => { - return query["odsiframe"] === "true"; -}; - -const useStyles = makeStyles< - Theme, - { isOdsIframe: boolean; descriptionPresent: boolean } ->((theme) => ({ - root: { - flexGrow: 1, - flexDirection: "column", - justifyContent: "space-between", - }, - header: { - marginBottom: ({ isOdsIframe }) => (isOdsIframe ? 0 : theme.spacing(4)), - }, - paper: { - borderRadius: theme.spacing(4), - boxShadow: "none", - }, - description: { - marginBottom: theme.spacing(6), - }, - tableOuterWrapper: { - width: "100%", - boxShadow: theme.shadows[4], - }, - tableInnerWrapper: { - flexGrow: 1, - width: "100%", - position: "relative", - overflowX: "auto", - marginTop: ({ descriptionPresent }) => - descriptionPresent ? theme.spacing(6) : 0, - }, - footnotesWrapper: { - marginTop: theme.spacing(4), - justifyContent: "space-between", - }, - loadingWrapper: { - flexDirection: "column", - justifyContent: "space-between", - flexGrow: 1, - padding: theme.spacing(5), - }, -})); +export type DataSetPreviewProps = ComponentProps; export const DataSetPreview = ({ dataSetIri, dataSource, dataCubeMetadataQuery, + odsIframe, }: { dataSetIri: string; dataSource: DataSource; dataCubeMetadataQuery: UseQueryResponse; + odsIframe: boolean; }) => { const footnotesClasses = useFootnotesStyles({ useMarginTop: false }); const locale = useLocale(); - const router = useRouter(); - const odsIframe = isOdsIframe(router.query); const variables = { sourceType: dataSource.type, sourceUrl: dataSource.url, @@ -94,7 +47,7 @@ export const DataSetPreview = ({ ] = useDataCubePreviewQuery({ variables }); const classes = useStyles({ descriptionPresent: !!metadata?.dataCubeMetadata.description, - isOdsIframe: odsIframe, + odsIframe, }); useEffect(() => { @@ -127,9 +80,7 @@ export const DataSetPreview = ({ )} @@ -150,17 +101,18 @@ export const DataSetPreview = ({ )} <div className={classes.tableOuterWrapper}> <Flex className={classes.tableInnerWrapper}> - <CubeDataTablePreview + <DataTablePreview title={dataCubeMetadata.title} dimensions={dataCubePreview.dimensions} measures={dataCubePreview.measures} observations={dataCubePreview.observations} + linkToMetadataPanel={false} /> </Flex> </div> <Flex className={classes.footnotesWrapper}> <Flex className={footnotesClasses.actions}> - {!isOdsIframe(router.query) && ( + {odsIframe ? null : ( <DataDownloadMenu dataSource={dataSource} title={dataCubeMetadata.title} @@ -184,12 +136,45 @@ export const DataSetPreview = ({ } }; -export type DataSetPreviewProps = ComponentProps<typeof DataSetPreview>; - -export const FirstTenRowsCaption = () => { - return ( - <Typography variant="h6" component="span" color="monochrome.500"> - <Trans id="datatable.showing.first.rows">Showing first 10 rows</Trans> - </Typography> - ); -}; +const useStyles = makeStyles< + Theme, + { odsIframe: boolean; descriptionPresent: boolean } +>((theme) => ({ + root: { + flexGrow: 1, + flexDirection: "column", + justifyContent: "space-between", + }, + header: { + marginBottom: ({ odsIframe }) => (odsIframe ? 0 : theme.spacing(4)), + }, + paper: { + borderRadius: theme.spacing(4), + boxShadow: "none", + }, + description: { + marginBottom: theme.spacing(6), + }, + tableOuterWrapper: { + width: "100%", + boxShadow: theme.shadows[4], + }, + tableInnerWrapper: { + flexGrow: 1, + width: "100%", + position: "relative", + overflowX: "auto", + marginTop: ({ descriptionPresent }) => + descriptionPresent ? theme.spacing(6) : 0, + }, + footnotesWrapper: { + marginTop: theme.spacing(4), + justifyContent: "space-between", + }, + loadingWrapper: { + flexDirection: "column", + justifyContent: "space-between", + flexGrow: 1, + padding: theme.spacing(5), + }, +})); diff --git a/app/browse/ui/dataset-result.tsx b/app/browse/ui/dataset-result.tsx new file mode 100644 index 0000000000..e47f13b82b --- /dev/null +++ b/app/browse/ui/dataset-result.tsx @@ -0,0 +1,235 @@ +import { Trans } from "@lingui/macro"; +import { Box, CardProps, Stack, Theme, Typography } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import clsx from "clsx"; +import sortBy from "lodash/sortBy"; +import NextLink from "next/link"; +import { useRouter } from "next/router"; +import { ComponentProps, MouseEvent } from "react"; + +import { getBrowseParamsFromQuery } from "@/browse/lib/params"; +import { DateFormat } from "@/browse/ui/date-format"; +import { Flex } from "@/components/flex"; +import { MaybeTooltip } from "@/components/maybe-tooltip"; +import { MotionCard, smoothPresenceProps } from "@/components/presence"; +import { Tag } from "@/components/tag"; +import { PartialSearchCube } from "@/domain/data"; +import { DataCubePublicationStatus } from "@/graphql/query-hooks"; +import { useEvent } from "@/utils/use-event"; + +export type DatasetResultProps = ComponentProps<typeof DatasetResult>; + +export const DatasetResult = ({ + dataCube: { + iri, + publicationStatus, + title, + description, + themes, + datePublished, + creator, + dimensions, + }, + highlightedTitle, + highlightedDescription, + showTags = true, + disableTitleLink, + showDimensions, + onClickTitle, + ...cardProps +}: { + dataCube: PartialSearchCube; + highlightedTitle?: string | null; + highlightedDescription?: string | null; + showTags?: boolean; + disableTitleLink?: boolean; + showDimensions?: boolean; + onClickTitle?: (e: MouseEvent<HTMLDivElement>, iri: string) => void; +} & CardProps) => { + const isDraft = publicationStatus === DataCubePublicationStatus.Draft; + const router = useRouter(); + const classes = useStyles(); + + const handleTitleClick = useEvent((e: MouseEvent<HTMLDivElement>) => { + onClickTitle?.(e, iri); + + if (e.defaultPrevented) { + return; + } + + const browseParams = getBrowseParamsFromQuery(router.query); + const query = { + previous: JSON.stringify(browseParams), + dataset: iri, + }; + router.push({ pathname: "/browse", query }, undefined, { + shallow: true, + scroll: false, + }); + }); + + return ( + <MotionCard + elevation={1} + {...smoothPresenceProps} + {...cardProps} + className={clsx(classes.root, cardProps.className)} + > + <Stack spacing={2}> + <Flex + justifyContent="space-between" + width="100%" + // To account for the space taken by the draft tag + minHeight={24} + > + <Typography variant="body2" color="monochrome.500"> + {datePublished && <DateFormat date={datePublished} />} + </Typography> + {isDraft ? ( + <Tag type="draft"> + <Trans id="dataset.tag.draft">Draft</Trans> + </Tag> + ) : null} + </Flex> + <Typography + className={disableTitleLink ? undefined : classes.titleClickable} + fontWeight={700} + onClick={disableTitleLink ? undefined : handleTitleClick} + > + {highlightedTitle ? ( + <Box + className={classes.textWrapper} + component="span" + fontWeight={highlightedTitle === title ? 700 : 400} + dangerouslySetInnerHTML={{ __html: highlightedTitle }} + /> + ) : ( + title + )} + </Typography> + <Typography + className={classes.description} + variant="body2" + title={description ?? ""} + > + {highlightedDescription ? ( + <Box + className={classes.textWrapper} + component="span" + dangerouslySetInnerHTML={{ __html: highlightedDescription }} + /> + ) : ( + description + )} + </Typography> + </Stack> + <Flex sx={{ flexWrap: "wrap", gap: 2 }}> + {themes && showTags + ? sortBy(themes, (t) => t.label).map( + (t) => + t.iri && + t.label && ( + <NextLink + key={t.iri} + href={`/browse/theme/${encodeURIComponent(t.iri)}`} + passHref + legacyBehavior + scroll={false} + > + <Tag type="theme">{t.label}</Tag> + </NextLink> + ) + ) + : null} + {creator?.label ? ( + <NextLink + key={creator.iri} + href={`/browse/organization/${encodeURIComponent(creator.iri)}`} + passHref + legacyBehavior + scroll={false} + > + <Tag type="organization">{creator.label}</Tag> + </NextLink> + ) : null} + {showDimensions && + dimensions?.length !== undefined && + dimensions.length > 0 && ( + <> + {sortBy(dimensions, (dimension) => dimension.label).map( + (dimension) => { + return ( + <MaybeTooltip + key={dimension.id} + title={ + dimension.termsets.length > 0 ? ( + <> + <Typography variant="caption"> + <Trans id="dataset-result.dimension-joined-by"> + Contains values of + </Trans> + <Stack flexDirection="row" gap={1} mt={1}> + {dimension.termsets.map((termset) => { + return ( + <Tag + key={termset.iri} + type="termset" + style={{ flexShrink: 0 }} + > + {termset.label} + </Tag> + ); + })} + </Stack> + </Typography> + </> + ) : null + } + > + <Tag style={{ cursor: "default" }} type="dimension"> + {dimension.label} + </Tag> + </MaybeTooltip> + ); + } + )} + </> + )} + </Flex> + </MotionCard> + ); +}; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + position: "relative", + display: "flex", + flexDirection: "column", + gap: theme.spacing(4), + padding: `${theme.spacing(8)} 0`, + borderTop: `1px solid ${theme.palette.monochrome[400]}`, + borderRadius: 0, + textAlign: "left", + boxShadow: "none", + }, + textWrapper: { + "& > b": { + fontWeight: 700, + }, + }, + titleClickable: { + display: "inline-block", + cursor: "pointer", + transition: "color 0.2s ease", + + "&:hover": { + color: theme.palette.primary.main, + }, + }, + description: { + display: "-webkit-box", + overflow: "hidden", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + }, +})); diff --git a/app/browse/ui/dataset-results.tsx b/app/browse/ui/dataset-results.tsx new file mode 100644 index 0000000000..b7e8a190b6 --- /dev/null +++ b/app/browse/ui/dataset-results.tsx @@ -0,0 +1,60 @@ +import { Trans } from "@lingui/macro"; +import { Typography } from "@mui/material"; +import { ComponentProps } from "react"; +import { CombinedError } from "urql"; + +import { DatasetResult, DatasetResultProps } from "@/browse/ui/dataset-result"; +import { Loading, LoadingDataError } from "@/components/hint"; +import { SearchCube } from "@/domain/data"; +import { SearchCubeResult } from "@/graphql/query-hooks"; + +export type DatasetResultsProps = ComponentProps<typeof DatasetResults>; + +export const DatasetResults = ({ + fetching, + error, + cubes, + datasetResultProps, +}: { + fetching: boolean; + error?: CombinedError; + cubes: SearchCubeResult[]; + datasetResultProps?: ({ + cube, + }: { + cube: SearchCube; + }) => Partial<DatasetResultProps>; +}) => { + if (fetching) { + return <Loading />; + } + + if (error) { + return <LoadingDataError message={error.message} />; + } + + if (cubes.length === 0) { + return ( + <Typography + variant="h2" + sx={{ mt: 8, color: "grey.600", textAlign: "center" }} + > + <Trans id="No results">No results</Trans> + </Typography> + ); + } + + return ( + <div> + {cubes.map(({ cube, highlightedTitle, highlightedDescription }) => ( + <DatasetResult + key={cube.iri} + dataCube={cube} + highlightedTitle={highlightedTitle} + highlightedDescription={highlightedDescription} + {...datasetResultProps?.({ cube })} + /> + ))} + </div> + ); +}; diff --git a/app/browse/ui/date-format.tsx b/app/browse/ui/date-format.tsx new file mode 100644 index 0000000000..45ac7b11d2 --- /dev/null +++ b/app/browse/ui/date-format.tsx @@ -0,0 +1,12 @@ +import { useMemo } from "react"; + +import { useFormatDate } from "@/formatters"; + +export const DateFormat = ({ date }: { date: string }) => { + const formatter = useFormatDate(); + const formatted = useMemo(() => { + return formatter(date); + }, [formatter, date]); + + return <>{formatted}</>; +}; diff --git a/app/browse/ui/first-ten-rows-caption.tsx b/app/browse/ui/first-ten-rows-caption.tsx new file mode 100644 index 0000000000..ffbc4e3dbe --- /dev/null +++ b/app/browse/ui/first-ten-rows-caption.tsx @@ -0,0 +1,10 @@ +import { Trans } from "@lingui/macro"; +import { Typography } from "@mui/material"; + +export const FirstTenRowsCaption = () => { + return ( + <Typography variant="h6" component="span" color="monochrome.500"> + <Trans id="datatable.showing.first.rows">Showing first 10 rows</Trans> + </Typography> + ); +}; diff --git a/app/browse/ui/navigation-chip.tsx b/app/browse/ui/navigation-chip.tsx new file mode 100644 index 0000000000..2df291f10c --- /dev/null +++ b/app/browse/ui/navigation-chip.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from "react"; + +import { Flex } from "@/components/flex"; + +export const NavigationChip = ({ + children, + backgroundColor, +}: { + children: ReactNode; + backgroundColor: string; +}) => { + return ( + <Flex + data-testid="navChip" + justifyContent="center" + alignItems="center" + minWidth={32} + height={24} + borderRadius={9999} + typography="caption" + bgcolor={backgroundColor} + > + {children} + </Flex> + ); +}; diff --git a/app/browse/ui/navigation-item.tsx b/app/browse/ui/navigation-item.tsx new file mode 100644 index 0000000000..ae50572dc5 --- /dev/null +++ b/app/browse/ui/navigation-item.tsx @@ -0,0 +1,177 @@ +import { ButtonBase, Link, LinkProps, Theme } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import pickBy from "lodash/pickBy"; +import { stringify } from "qs"; +import { ReactNode, useMemo } from "react"; + +import { BrowseFilter, encodeFilter } from "@/browse/lib/filters"; +import { useBrowseContext } from "@/browse/model/context"; +import { NavigationChip } from "@/browse/ui/navigation-chip"; +import { MaybeLink } from "@/components/maybe-link"; +import { accordionPresenceProps, MotionBox } from "@/components/presence"; +import SvgIcClose from "@/icons/components/IcClose"; + +export const NavigationItem = ({ + children, + filters, + next, + count, + active, + level = 1, + disableLink, + countBg, +}: { + children: ReactNode; + filters: BrowseFilter[]; + next: BrowseFilter; + count?: number; + active: boolean; + /** Level is there to differentiate between organizations and organization subtopics */ + level?: number; + disableLink?: boolean; + countBg: string; +} & LinkProps) => { + const { includeDrafts, search, setFilters } = useBrowseContext(); + const classes = useStyles({ level }); + const highlighted = active && level === 1; + + const [newFiltersAdd, href] = useMemo(() => { + const extraURLParams = stringify( + pickBy( + { + includeDrafts, + search, + topic: level === 2 && !disableLink ? next.iri : undefined, + }, + Boolean + ) + ); + const newFilters = [...filters].filter( + (f) => + (disableLink ? true : f.__typename !== "DataCubeAbout") && + (level === 1 ? f.__typename !== next.__typename : true) + ); + + if (level === 1 || disableLink) { + newFilters.push(next); + } + + return [ + newFilters, + `/browse/${newFilters.map(encodeFilter).join("/")}?${extraURLParams}`, + ] as const; + }, [includeDrafts, search, level, next, filters, disableLink]); + + const [newFiltersRemove, removeFilterPath] = useMemo(() => { + const extraURLParams = stringify( + pickBy({ includeDrafts, search }, Boolean) + ); + const newFilters = filters.filter( + (f) => f.__typename !== "DataCubeAbout" && f.iri !== next.iri + ); + + return [ + newFilters, + `/browse/${newFilters.map(encodeFilter).join("/")}?${extraURLParams}`, + ] as const; + }, [includeDrafts, search, filters, next.iri]); + + return ( + <MotionBox {...accordionPresenceProps} data-testid="navItem"> + <MaybeLink + href={href} + passHref + legacyBehavior + disabled={!!disableLink} + scroll={false} + shallow + > + <Link + className={classes.root} + variant="body3" + onClick={ + disableLink && !active + ? (e) => { + e.preventDefault(); + setFilters(newFiltersAdd); + } + : undefined + } + sx={{ + p: 2, + backgroundColor: highlighted ? "cobalt.50" : "transparent", + color: active + ? level === 1 + ? "text.primary" + : "cobalt.50" + : "text.primary", + cursor: active ? "default" : "pointer", + }} + > + {children} + {highlighted ? ( + <MaybeLink + href={removeFilterPath} + passHref + legacyBehavior + disabled={!!disableLink} + scroll={false} + shallow + > + <ButtonBase + className={classes.removeFilterButton} + onClick={ + disableLink + ? (e) => { + e.preventDefault(); + setFilters(newFiltersRemove); + } + : undefined + } + > + <SvgIcClose width={24} height={24} /> + </ButtonBase> + </MaybeLink> + ) : count !== undefined ? ( + <NavigationChip backgroundColor={countBg}>{count}</NavigationChip> + ) : null} + </Link> + </MaybeLink> + </MotionBox> + ); +}; + +const useStyles = makeStyles<Theme, { level: number }>((theme) => ({ + root: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing(3), + width: "100%", + padding: theme.spacing(2), + borderRadius: 2, + transition: "background-color 0.1s ease", + + "&:hover": { + backgroundColor: theme.palette.cobalt[50], + }, + }, + removeFilterButton: ({ level }) => ({ + display: "flex", + alignItems: "center", + width: "auto", + height: "auto", + minWidth: 16, + minHeight: 16, + marginRight: 2, + padding: 0, + borderRadius: 2, + backgroundColor: level === 1 ? "cobalt.50" : "transparent", + color: level === 1 ? theme.palette.text.primary : "cobalt.50", + transition: "background-color 0.1s ease", + + "&:hover": { + backgroundColor: theme.palette.cobalt[100], + }, + }), +})); diff --git a/app/browse/ui/navigation-section-title.tsx b/app/browse/ui/navigation-section-title.tsx new file mode 100644 index 0000000000..bc0f7b0c1f --- /dev/null +++ b/app/browse/ui/navigation-section-title.tsx @@ -0,0 +1,18 @@ +import { Box, Typography } from "@mui/material"; +import { ReactNode } from "react"; + +export const NavigationSectionTitle = ({ + label, + backgroundColor, +}: { + label: ReactNode; + backgroundColor: string; +}) => { + return ( + <Box sx={{ mb: 2, px: 2, py: 3, borderRadius: "6px", backgroundColor }}> + <Typography variant="h4" component="div" sx={{ fontWeight: 700 }}> + {label} + </Typography> + </Box> + ); +}; diff --git a/app/browse/ui/navigation-section.tsx b/app/browse/ui/navigation-section.tsx new file mode 100644 index 0000000000..96de36aacc --- /dev/null +++ b/app/browse/ui/navigation-section.tsx @@ -0,0 +1,91 @@ +import { Trans } from "@lingui/macro"; +import { Button } from "@mui/material"; +import { Reorder } from "framer-motion"; +import orderBy from "lodash/orderBy"; +import sortBy from "lodash/sortBy"; +import { ReactNode, useMemo } from "react"; + +import { BrowseFilter } from "@/browse/lib/filters"; +import { NavigationItem } from "@/browse/ui/navigation-item"; +import { NavigationSectionTitle } from "@/browse/ui/navigation-section-title"; +import { useDisclosure } from "@/components/use-disclosure"; +import { + DataCubeOrganization, + DataCubeTermset, + DataCubeTheme, +} from "@/graphql/query-hooks"; +import { Icon } from "@/icons"; + +export const NavigationSection = ({ + label, + items, + backgroundColor, + currentFilter, + filters, + counts, + extra, + disableLinks, +}: { + label: ReactNode; + items: (DataCubeTheme | DataCubeOrganization | DataCubeTermset)[]; + backgroundColor: string; + currentFilter?: DataCubeTheme | DataCubeOrganization | DataCubeTermset; + filters: BrowseFilter[]; + counts: Record<string, number>; + extra?: ReactNode; + disableLinks?: boolean; +}) => { + const { isOpen, open, close } = useDisclosure(!!currentFilter); + const topItems = useMemo(() => { + return sortBy( + orderBy(items, (item) => counts[item.iri], "desc").slice(0, 7), + (item) => item.label + ); + }, [counts, items]); + + return ( + <div> + <NavigationSectionTitle label={label} backgroundColor={backgroundColor} /> + <Reorder.Group + axis="y" + as="div" + onReorder={() => {}} + values={isOpen ? items : topItems} + > + {(isOpen ? items : topItems).map((item) => { + return ( + <Reorder.Item key={item.iri} as="div" value={item}> + <NavigationItem + active={currentFilter?.iri === item.iri} + filters={filters} + next={item} + count={counts[item.iri]} + disableLink={disableLinks} + countBg={backgroundColor} + > + {item.label} + </NavigationItem> + </Reorder.Item> + ); + })} + {topItems.length !== items.length ? ( + <Button + variant="text" + color="primary" + size="sm" + onClick={isOpen ? close : open} + endIcon={<Icon name={isOpen ? "arrowUp" : "arrowDown"} size={20} />} + sx={{ width: "100%", mt: 2 }} + > + {isOpen ? ( + <Trans id="show.less">Show less</Trans> + ) : ( + <Trans id="show.all">Show all</Trans> + )} + </Button> + ) : null} + </Reorder.Group> + {extra} + </div> + ); +}; diff --git a/app/browse/ui/search-dataset-controls.tsx b/app/browse/ui/search-dataset-controls.tsx new file mode 100644 index 0000000000..fcf685cad7 --- /dev/null +++ b/app/browse/ui/search-dataset-controls.tsx @@ -0,0 +1,57 @@ +import { Divider } from "@mui/material"; + +import { BrowseState } from "@/browse/model/context"; +import { SearchDatasetDraftsControl } from "@/browse/ui/search-dataset-drafts-control"; +import { SearchDatasetResultsCount } from "@/browse/ui/search-dataset-results-count"; +import { SearchDatasetSortControl } from "@/browse/ui/search-dataset-sort-control"; +import { Flex } from "@/components/flex"; +import { SearchCubeResult, SearchCubeResultOrder } from "@/graphql/query-hooks"; +import { sleep } from "@/utils/sleep"; +import { useEvent } from "@/utils/use-event"; + +export const SearchDatasetControls = ({ + browseState: { + inputRef, + search, + onSubmitSearch, + includeDrafts, + setIncludeDrafts, + order = SearchCubeResultOrder.CreatedDesc, + onSetOrder, + }, + cubes, +}: { + browseState: BrowseState; + cubes: SearchCubeResult[]; +}) => { + const isSearching = search !== "" && search !== undefined; + + const onToggleIncludeDrafts = useEvent(async () => { + setIncludeDrafts(!includeDrafts); + const input = inputRef.current; + + if (input && input.value.length > 0) { + // We need to wait here otherwise the includeDrafts is reset :/ + await sleep(200); + onSubmitSearch(input.value); + } + }); + + return ( + <Flex sx={{ justifyContent: "space-between", alignItems: "center", mb: 2 }}> + <SearchDatasetResultsCount cubes={cubes} /> + <Flex sx={{ alignItems: "center", gap: 5 }}> + <SearchDatasetDraftsControl + checked={includeDrafts} + onChange={onToggleIncludeDrafts} + /> + <Divider flexItem orientation="vertical" /> + <SearchDatasetSortControl + value={order} + onChange={onSetOrder} + disableScore={!isSearching} + /> + </Flex> + </Flex> + ); +}; diff --git a/app/browse/ui/search-dataset-drafts-control.tsx b/app/browse/ui/search-dataset-drafts-control.tsx new file mode 100644 index 0000000000..3f980f4a1c --- /dev/null +++ b/app/browse/ui/search-dataset-drafts-control.tsx @@ -0,0 +1,24 @@ +import { t } from "@lingui/macro"; + +import { Checkbox } from "@/components/form"; + +export const SearchDatasetDraftsControl = ({ + checked, + onChange, +}: { + checked: boolean; + onChange: (value: boolean) => void; +}) => { + return ( + <Checkbox + label={t({ + id: "dataset.includeDrafts", + message: "Include draft datasets", + })} + name="dataset-include-drafts" + value="dataset-include-drafts" + checked={checked} + onChange={() => onChange(!checked)} + /> + ); +}; diff --git a/app/browse/ui/search-dataset-input.tsx b/app/browse/ui/search-dataset-input.tsx new file mode 100644 index 0000000000..0a0e0f2f79 --- /dev/null +++ b/app/browse/ui/search-dataset-input.tsx @@ -0,0 +1,52 @@ +import { t } from "@lingui/macro"; +import { KeyboardEvent, useState } from "react"; + +import { BrowseState } from "@/browse/model/context"; +import { Flex } from "@/components/flex"; +import { SearchField, SearchFieldProps } from "@/components/form"; + +export const SearchDatasetInput = ({ + browseState: { inputRef, search, onSubmitSearch, onReset }, + searchFieldProps, +}: { + browseState: BrowseState; + searchFieldProps?: Partial<SearchFieldProps>; +}) => { + const [_, setShowDraftCheckbox] = useState(false); + const searchLabel = t({ + id: "dataset.search.label", + message: "Search", + }); + const placeholderLabel = t({ + id: "dataset.search.placeholder", + message: "Name, description, organization, theme, keyword", + }); + const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter" && inputRef.current) { + onSubmitSearch(inputRef.current.value); + } + }; + + return ( + <Flex sx={{ alignItems: "center", gap: 2, pt: 4 }}> + <SearchField + key={search} + inputRef={inputRef} + id="datasetSearch" + label={searchLabel} + defaultValue={search ?? ""} + InputProps={{ + inputProps: { + "data-testid": "datasetSearch", + }, + onKeyPress: handleKeyPress, + onReset, + onFocus: () => setShowDraftCheckbox(true), + }} + placeholder={placeholderLabel} + {...searchFieldProps} + sx={{ ...searchFieldProps?.sx, width: "100%", maxWidth: 820 }} + /> + </Flex> + ); +}; diff --git a/app/browse/ui/search-dataset-results-count.tsx b/app/browse/ui/search-dataset-results-count.tsx new file mode 100644 index 0000000000..72149ddf16 --- /dev/null +++ b/app/browse/ui/search-dataset-results-count.tsx @@ -0,0 +1,29 @@ +import { Plural } from "@lingui/macro"; +import { Typography } from "@mui/material"; + +import { SearchCubeResult } from "@/graphql/query-hooks"; + +export const SearchDatasetResultsCount = ({ + cubes, +}: { + cubes: SearchCubeResult[]; +}) => { + return ( + <Typography + variant="h5" + component="p" + aria-live="polite" + data-testid="search-results-count" + > + {cubes.length > 0 && ( + <Plural + id="dataset.results" + value={cubes.length} + zero="No datasets" + one="# dataset" + other="# datasets" + /> + )} + </Typography> + ); +}; diff --git a/app/browse/ui/search-dataset-sort-control.tsx b/app/browse/ui/search-dataset-sort-control.tsx new file mode 100644 index 0000000000..847c2252d5 --- /dev/null +++ b/app/browse/ui/search-dataset-sort-control.tsx @@ -0,0 +1,63 @@ +import { t, Trans } from "@lingui/macro"; +import { Typography } from "@mui/material"; +import { useMemo } from "react"; + +import { Flex } from "@/components/flex"; +import { Select } from "@/components/form"; +import { SearchCubeResultOrder } from "@/graphql/query-hooks"; + +export const SearchDatasetSortControl = ({ + value, + onChange, + disableScore, +}: { + value: SearchCubeResultOrder; + onChange: (order: SearchCubeResultOrder) => void; + disableScore?: boolean; +}) => { + const options = useMemo(() => { + const options = [ + { + value: SearchCubeResultOrder.Score, + label: t({ id: "dataset.order.relevance", message: "Relevance" }), + }, + { + value: SearchCubeResultOrder.TitleAsc, + label: t({ id: "dataset.order.title", message: "Title" }), + }, + { + value: SearchCubeResultOrder.CreatedDesc, + label: t({ id: "dataset.order.newest", message: "Newest" }), + }, + ]; + + if (disableScore) { + return options.filter((o) => o.value !== SearchCubeResultOrder.Score); + } + + return options; + }, [disableScore]); + + return ( + <Flex alignItems="center"> + <label htmlFor="datasetSort"> + <Typography variant="body3"> + <Trans id="dataset.sortby">Sort by</Trans> + </Typography> + </label> + <Select + id="datasetSort" + data-testId="datasetSort" + variant="standard" + size="sm" + onChange={(e) => { + onChange(e.target.value as SearchCubeResultOrder); + }} + value={value} + options={options} + sort={false} + sx={{ width: "fit-content" }} + /> + </Flex> + ); +}; diff --git a/app/browser/dataset-browse.spec.tsx b/app/browse/ui/search-filters.spec.tsx similarity index 94% rename from app/browser/dataset-browse.spec.tsx rename to app/browse/ui/search-filters.spec.tsx index e6a17acd2b..6d02ee0ce8 100644 --- a/app/browser/dataset-browse.spec.tsx +++ b/app/browse/ui/search-filters.spec.tsx @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getFiltersFromParams } from "@/browser/filters"; -import { BrowseParams } from "@/pages/browse"; +import { getFiltersFromParams } from "@/browse/lib/filters"; +import { BrowseParams } from "@/browse/lib/params"; describe("getFiltersFromParams", () => { it("should work only for organization", () => { diff --git a/app/browse/ui/search-filters.tsx b/app/browse/ui/search-filters.tsx new file mode 100644 index 0000000000..242310a28b --- /dev/null +++ b/app/browse/ui/search-filters.tsx @@ -0,0 +1,240 @@ +import { Trans } from "@lingui/macro"; +import { Stack } from "@mui/material"; +import { AnimatePresence } from "framer-motion"; +import keyBy from "lodash/keyBy"; +import sortBy from "lodash/sortBy"; +import uniqBy from "lodash/uniqBy"; +import { ReactNode, useMemo } from "react"; + +import { BrowseFilter } from "@/browse/lib/filters"; +import { useBrowseContext } from "@/browse/model/context"; +import { NavigationSection } from "@/browse/ui/navigation-section"; +import { SubthemeFilters } from "@/browse/ui/subtheme-filters"; +import { Flex } from "@/components/flex"; +import { InfoIconTooltip } from "@/components/info-icon-tooltip"; +import { truthy } from "@/domain/types"; +import { + DataCubeOrganization, + DataCubeTermset, + DataCubeTheme, +} from "@/graphql/query-hooks"; +import { SearchCubeResult } from "@/graphql/resolver-types"; + +const navigationOrder: Record<BrowseFilter["__typename"], number> = { + DataCubeTheme: 1, + DataCubeOrganization: 2, + DataCubeTermset: 3, + // Not used in the navigation + DataCubeAbout: 4, +}; + +export const SearchFilters = ({ + cubes, + themes, + orgs, + termsets, + disableNavLinks, +}: { + cubes: SearchCubeResult[]; + themes: DataCubeTheme[]; + orgs: DataCubeOrganization[]; + termsets: DataCubeTermset[]; + disableNavLinks?: boolean; +}) => { + const { filters } = useBrowseContext(); + const counts = useMemo(() => { + const result: Record<string, number> = {}; + + for (const { cube } of cubes) { + const countable = [ + ...cube.themes, + ...cube.subthemes, + ...cube.termsets, + cube.creator, + ].filter(truthy); + + for (const { iri } of countable) { + if (iri) { + result[iri] = (result[iri] ?? 0) + 1; + } + } + } + + return result; + }, [cubes]); + + const { themeFilter, organizationFilter, termsetFilter } = useMemo(() => { + const result = keyBy(filters, (f) => f.__typename); + + return { + themeFilter: result.DataCubeTheme as DataCubeTheme, + organizationFilter: result.DataCubeOrganization as + | DataCubeOrganization + | undefined, + termsetFilter: result.DataCubeTermset as DataCubeTermset | undefined, + }; + }, [filters]); + + const displayedThemes = themes.filter((theme) => { + if (!theme.label) { + return false; + } + + if (!counts[theme.iri]) { + return false; + } + + if (themeFilter && themeFilter.iri !== theme.iri) { + return false; + } + + return true; + }); + + const displayedOrgs = orgs.filter((org) => { + if (!org.label) { + return false; + } + + if (!counts[org.iri] && organizationFilter?.iri !== org.iri) { + return false; + } + + if (organizationFilter && organizationFilter.iri !== org.iri) { + return false; + } + + return true; + }); + + const displayedTermsets = termsets.filter((termset) => { + if (!termset.label) { + return false; + } + + if (!counts[termset.iri] && termsetFilter?.iri !== termset.iri) { + return false; + } + + if (termsetFilter && termsetFilter.iri !== termset.iri) { + return false; + } + + return true; + }); + + const themeNavigation = + displayedThemes.length > 0 ? ( + <NavigationSection + key="themes" + items={displayedThemes} + backgroundColor="green.100" + currentFilter={themeFilter} + counts={counts} + filters={filters} + label={<Trans id="browse-panel.themes">Themes</Trans>} + disableLinks={disableNavLinks} + /> + ) : null; + + const subthemes = useMemo(() => { + return sortBy( + uniqBy( + cubes.flatMap((d) => d.cube.subthemes), + (d) => d.iri + ), + (d) => d.label + ); + }, [cubes]); + + const organizationBackgroundColor = "blue.100"; + const organizationNavigation = + displayedOrgs.length > 0 ? ( + <NavigationSection + key="orgs" + items={displayedOrgs} + backgroundColor={organizationBackgroundColor} + currentFilter={organizationFilter} + counts={counts} + filters={filters} + label={<Trans id="browse-panel.organizations">Organizations</Trans>} + extra={ + organizationFilter && + filters.map((d) => d.iri).includes(organizationFilter.iri) ? ( + <SubthemeFilters + subthemes={subthemes} + filters={filters} + counts={counts} + disableLinks={disableNavLinks} + countBg={organizationBackgroundColor} + /> + ) : null + } + disableLinks={disableNavLinks} + /> + ) : null; + + const termsetNavigation = + termsets.length > 0 ? ( + <NavigationSection + key="termsets" + items={displayedTermsets} + backgroundColor="monochrome.200" + currentFilter={termsetFilter} + counts={counts} + filters={filters} + label={ + <Stack + direction="row" + justifyContent="space-between" + alignItems="center" + gap={2} + width="100%" + > + <Trans id="browse-panel.termsets">Concepts</Trans> + <InfoIconTooltip + title={ + <Trans id="browse-panel.termsets.explanation"> + Concepts represent values that can be shared across different + dimensions and datasets. + </Trans> + } + /> + </Stack> + } + extra={null} + disableLinks={disableNavLinks} + /> + ) : null; + + const baseNavigations: { + element: ReactNode; + __typename: BrowseFilter["__typename"]; + }[] = [ + { element: themeNavigation, __typename: "DataCubeTheme" }, + { element: organizationNavigation, __typename: "DataCubeOrganization" }, + { element: termsetNavigation, __typename: "DataCubeTermset" }, + ]; + + const navigations = sortBy(baseNavigations, (nav) => { + const i = filters.findIndex((f) => f.__typename === nav.__typename); + + return i === -1 + ? // If the filter is not in the list, we want to put it at the end + navigationOrder[nav.__typename] + Object.keys(navigationOrder).length + : i; + }); + + return ( + <div key={filters.length} role="search"> + {/* Need to "catch" the Reorder items here, as otherwise there is an exiting + bug as they get picked by parent AnimatePresence. Probably related to + https://github.com/framer/motion/issues/1619. */} + <AnimatePresence> + <Flex sx={{ flexDirection: "column", rowGap: 8, width: "100%" }}> + {navigations.map((nav) => nav.element)} + </Flex> + </AnimatePresence> + </div> + ); +}; diff --git a/app/browse/ui/select-dataset-banner.tsx b/app/browse/ui/select-dataset-banner.tsx new file mode 100644 index 0000000000..7bab257d1c --- /dev/null +++ b/app/browse/ui/select-dataset-banner.tsx @@ -0,0 +1,84 @@ +import { ContentWrapper } from "@interactivethings/swiss-federal-ci/dist/components"; +import { Trans } from "@lingui/macro"; +import { Theme, Typography } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import { AnimatePresence } from "framer-motion"; + +import { useBrowseContext } from "@/browse/model/context"; +import { SearchDatasetInput } from "@/browse/ui/search-dataset-input"; +import { + __BANNER_MARGIN_CSS_VAR, + bannerPresenceProps, + MotionBox, +} from "@/components/presence"; +import { useResizeObserver } from "@/utils/use-resize-observer"; + +export const SelectDatasetBanner = ({ + dataset, + variant, +}: { + dataset: string | undefined; + variant: "page" | "drawer"; +}) => { + const [ref] = useResizeObserver<HTMLDivElement>(({ height }) => { + if (height) { + document.documentElement.style.setProperty( + __BANNER_MARGIN_CSS_VAR, + `-${height}px` + ); + } + }); + const classes = useStyles(); + const show = !dataset && variant === "page"; + const browseState = useBrowseContext(); + + return ( + <AnimatePresence> + {show ? ( + <MotionBox key="banner" ref={ref} {...bannerPresenceProps}> + <section className={classes.outerWrapper} role="banner"> + <ContentWrapper className={classes.innerWrapper}> + <div className={classes.content}> + <Typography className={classes.title} variant="h1"> + Swiss Open Government Data + </Typography> + <Typography className={classes.description} variant="body2"> + <Trans id="browse.datasets.description"> + Explore datasets provided by the LINDAS Linked Data Service + by either filtering by categories or organizations or search + directly for specific keywords. Click on a dataset to see + more detailed information and start creating your own + visualizations. + </Trans> + </Typography> + <SearchDatasetInput browseState={browseState} /> + </div> + </ContentWrapper> + </section> + </MotionBox> + ) : null} + </AnimatePresence> + ); +}; + +const useStyles = makeStyles<Theme>((theme) => ({ + outerWrapper: { + backgroundColor: theme.palette.monochrome[100], + }, + innerWrapper: { + padding: `${theme.spacing(25)} 0`, + }, + content: { + display: "flex", + flexDirection: "column", + justifyContent: "center", + maxWidth: 940, + }, + title: { + marginBottom: theme.spacing(4), + fontWeight: 700, + }, + description: { + marginBottom: theme.spacing(10), + }, +})); diff --git a/app/browser/select-dataset-step.tsx b/app/browse/ui/select-dataset-step.tsx similarity index 65% rename from app/browser/select-dataset-step.tsx rename to app/browse/ui/select-dataset-step.tsx index 96f85a59b6..ba7d6e9798 100644 --- a/app/browser/select-dataset-step.tsx +++ b/app/browse/ui/select-dataset-step.tsx @@ -7,42 +7,36 @@ import sortBy from "lodash/sortBy"; import uniqBy from "lodash/uniqBy"; import Head from "next/head"; import NextLink from "next/link"; -import { Router, useRouter } from "next/router"; +import { useRouter } from "next/router"; import { ComponentProps, type MouseEvent, useCallback, useMemo } from "react"; import { useDebounce } from "use-debounce"; +import { BrowseFilter, DataCubeAbout } from "@/browse/lib/filters"; +import { buildURLFromBrowseParams, isOdsIframe } from "@/browse/lib/params"; +import { useRedirectToLatestCube } from "@/browse/lib/use-redirect-to-latest-cube"; +import { BrowseStateProvider, useBrowseContext } from "@/browse/model/context"; +import { DatasetMetadataSingleCube } from "@/browse/ui/dataset-metadata-single-cube"; import { - BrowseStateProvider, - buildURLFromBrowseState, - useBrowseContext, -} from "@/browser/context"; + DataSetPreview, + DataSetPreviewProps, +} from "@/browse/ui/dataset-preview"; import { DatasetResults, DatasetResultsProps, - SearchDatasetControls, - SearchDatasetInput, - SearchFilters, -} from "@/browser/dataset-browse"; -import { - DataSetPreview, - DataSetPreviewProps, - isOdsIframe, -} from "@/browser/dataset-preview"; -import { BrowseFilter, DataCubeAbout } from "@/browser/filters"; +} from "@/browse/ui/dataset-results"; +import { SearchDatasetControls } from "@/browse/ui/search-dataset-controls"; +import { SearchDatasetInput } from "@/browse/ui/search-dataset-input"; +import { SearchFilters } from "@/browse/ui/search-filters"; +import { SelectDatasetBanner } from "@/browse/ui/select-dataset-banner"; import { CHART_RESIZE_EVENT_TYPE } from "@/charts/shared/use-size"; -import { DatasetMetadata } from "@/components/dataset-metadata"; import { Flex } from "@/components/flex"; import { Footer } from "@/components/footer"; import { - __BANNER_MARGIN_CSS_VAR, - bannerPresenceProps, DURATION, MotionBox, navPresenceProps, smoothPresenceProps, } from "@/components/presence"; -import { useRedirectToLatestCube } from "@/components/use-redirect-to-latest-cube"; -import { DataSource } from "@/configurator"; import { PanelBodyWrapper, PanelLayout, @@ -59,115 +53,28 @@ import { } from "@/graphql/query-hooks"; import { Icon } from "@/icons"; import { useConfiguratorState, useLocale } from "@/src"; +import { softJSONParse } from "@/utils/soft-json-parse"; import { useResizeObserver } from "@/utils/use-resize-observer"; -const softJSONParse = (v: string) => { - try { - return JSON.parse(v); - } catch (e) { - return null; - } -}; - -const useStyles = makeStyles< - Theme, - { - datasetPresent: boolean; - isOdsIframe: boolean; - } ->((theme) => ({ - panelLayout: { - position: "static", - height: "auto", - margin: "auto", - marginTop: ({ isOdsIframe }) => (isOdsIframe ? 0 : theme.spacing(12)), - backgroundColor: theme.palette.background.paper, - transition: "margin-top 0.5s ease", - }, - panelLeft: { - marginBottom: theme.spacing(12), - boxShadow: "none", - outline: "none", - transition: "padding-top 0.5s ease", - - [theme.breakpoints.up("md")]: { - position: ({ datasetPresent }) => (datasetPresent ? "static" : "sticky"), - top: ({ datasetPresent }) => (datasetPresent ? 0 : theme.spacing(8)), - overflowY: "auto", - minHeight: "100vh", - maxHeight: ({ datasetPresent }) => (datasetPresent ? "none" : "100vh"), - marginBottom: "unset", - paddingBottom: ({ datasetPresent }) => - datasetPresent ? 0 : theme.spacing(16), - }, - }, - panelMiddle: { - gridColumnStart: "middle", - gridColumnEnd: "right", - backgroundColor: theme.palette.background.paper, - transition: "padding-top 0.5s ease", - - [theme.breakpoints.up("md")]: { - marginLeft: ({ isOdsIframe }) => (isOdsIframe ? 0 : theme.spacing(8)), - }, - }, - panelBannerOuterWrapper: { - backgroundColor: theme.palette.monochrome[100], - }, - panelBannerInnerWrapper: { - paddingTop: theme.spacing(25), - paddingBottom: theme.spacing(25), - }, - panelBannerContent: { - flexDirection: "column", - justifyContent: "center", - maxWidth: 940, - }, - panelBannerTitle: { - marginBottom: theme.spacing(4), - fontWeight: 700, - }, - panelBannerDescription: { - marginBottom: theme.spacing(10), - }, - filters: { - display: "block", - color: theme.palette.grey[800], - }, -})); - -const formatBackLink = ( - query: Router["query"] -): ComponentProps<typeof NextLink>["href"] => { - const backParameters = softJSONParse(query.previous as string); - - if (!backParameters) { - return "/browse"; +export const SelectDatasetStep = ( + props: Omit<ComponentProps<typeof SelectDatasetStepInner>, "variant"> & { + /** + * Is passed to the content component. At this level, it controls which whether the + * browsing state is synced with the URL or not. + * At the SelectDatasetStepContent level, it tweaks UI elements. + * /!\ It should not change during the lifetime of the component. + */ + variant: "page" | "drawer"; } - - return buildURLFromBrowseState(backParameters); -}; - -const prepareSearchQueryFilters = (filters: BrowseFilter[]) => { +) => { return ( - filters - // Subthemes are filtered on client side. - .filter( - (d): d is Exclude<BrowseFilter, DataCubeAbout> => - d.__typename !== SearchCubeFilterType.DataCubeAbout - ) - .map((d) => { - const type = SearchCubeFilterType[d.__typename]; - return { - type, - label: d.label, - value: d.iri, - }; - }) + <BrowseStateProvider syncWithUrl={props.variant === "page"}> + <SelectDatasetStepInner {...props} /> + </BrowseStateProvider> ); }; -const SelectDatasetStepContent = ({ +const SelectDatasetStepInner = ({ datasetPreviewProps, datasetResultsProps, dataset: propsDataset, @@ -186,47 +93,66 @@ const SelectDatasetStepContent = ({ variant?: "page" | "drawer"; }) => { const locale = useLocale(); - const [configState] = useConfiguratorState(); - const router = useRouter(); - const odsIframe = isOdsIframe(router.query); - + const [{ state, dataSource }] = useConfiguratorState(); const browseState = useBrowseContext(); const { search, order, includeDrafts, filters, - dataset: browseStateDataset, + dataset: browseDataset, } = browseState; - const dataset = propsDataset ?? browseStateDataset; + const dataset = propsDataset ?? browseDataset; + const router = useRouter(); + const odsIframe = isOdsIframe(router.query); + const classes = useStyles({ datasetPresent: !!dataset, odsIframe }); - const [debouncedQuery] = useDebounce(search, 500, { - leading: true, - }); + const [debouncedQuery] = useDebounce(search, 500, { leading: true }); const handleHeightChange = useCallback( ({ height }: { width: number; height: number }) => { window.parent.postMessage({ type: CHART_RESIZE_EVENT_TYPE, height }, "*"); }, [] ); - const [ref] = useResizeObserver(handleHeightChange); - const classes = useStyles({ - datasetPresent: !!dataset, - isOdsIframe: odsIframe, - }); + const [ref] = useResizeObserver<HTMLDivElement>(handleHeightChange); const backLink = useMemo(() => { - return formatBackLink(router.query); + const backParameters = softJSONParse(router.query.previous as string); + + if (!backParameters) { + return "/browse"; + } + + return buildURLFromBrowseParams(backParameters); }, [router.query]); const queryFilters = useMemo(() => { - return filters ? prepareSearchQueryFilters(filters) : []; + if (!filters) { + return []; + } + + return ( + filters + // Subthemes are filtered on client side. + .filter( + (d): d is Exclude<BrowseFilter, DataCubeAbout> => + d.__typename !== SearchCubeFilterType.DataCubeAbout + ) + .map((d) => { + const type = SearchCubeFilterType[d.__typename]; + return { + type, + label: d.label, + value: d.iri, + }; + }) + ); }, [filters]); // Use the debounced query value here only! const [{ data, fetching, error }] = useSearchCubesQuery({ variables: { - sourceType: configState.dataSource.type, - sourceUrl: configState.dataSource.url, + sourceType: dataSource.type, + sourceUrl: dataSource.url, locale, query: debouncedQuery, order, @@ -236,13 +162,10 @@ const SelectDatasetStepContent = ({ pause: !!dataset, }); - useRedirectToLatestCube({ - dataSource: configState.dataSource, - datasetIri: dataset, - }); + useRedirectToLatestCube({ dataSource, datasetIri: dataset }); const { allCubes, cubes } = useMemo(() => { - if ((data && data.searchCubes.length === 0) || !data) { + if (!data || data.searchCubes.length === 0) { return { allCubes: [], cubes: [], @@ -336,19 +259,10 @@ const SelectDatasetStepContent = ({ .join(", "); }, [orgs, queryFilters, termsets, themes]); - const [bannerRef] = useResizeObserver<HTMLDivElement>(({ height }) => { - if (height) { - document.documentElement.style.setProperty( - __BANNER_MARGIN_CSS_VAR, - `-${height}px` - ); - } - }); - const dataCubeMetadataQuery = useDataCubeMetadataQuery({ variables: { - sourceType: configState.dataSource.type, - sourceUrl: configState.dataSource.url, + sourceType: dataSource.type, + sourceUrl: dataSource.url, locale, cubeFilter: { iri: dataset ?? "", @@ -358,40 +272,13 @@ const SelectDatasetStepContent = ({ }); const [{ data: dataCubeMetadata }] = dataCubeMetadataQuery; - if (configState.state !== "SELECTING_DATASET") { + if (state !== "SELECTING_DATASET") { return null; } return ( - <Box ref={odsIframe ? ref : null}> - <AnimatePresence> - {!dataset && variant === "page" && ( - <MotionBox key="banner" ref={bannerRef} {...bannerPresenceProps}> - <section role="banner" className={classes.panelBannerOuterWrapper}> - <ContentWrapper className={classes.panelBannerInnerWrapper}> - <Flex className={classes.panelBannerContent}> - <Typography variant="h1" className={classes.panelBannerTitle}> - Swiss Open Government Data - </Typography> - <Typography - variant="body2" - className={classes.panelBannerDescription} - > - <Trans id="browse.datasets.description"> - Explore datasets provided by the LINDAS Linked Data - Service by either filtering by categories or organizations - or search directly for specific keywords. Click on a - dataset to see more detailed information and start - creating your own visualizations. - </Trans> - </Typography> - <SearchDatasetInput browseState={browseState} /> - </Flex> - </ContentWrapper> - </section> - </MotionBox> - )} - </AnimatePresence> + <div ref={odsIframe ? ref : null}> + <SelectDatasetBanner dataset={dataset} variant={variant} /> <Box sx={{ borderBottom: @@ -450,11 +337,10 @@ const SelectDatasetStepContent = ({ size="sm" target={odsIframe ? "_blank" : undefined} endIcon={ - odsIframe ? ( - <Icon name="legacyLinkExternal" size={20} /> - ) : ( - <Icon name="arrowRight" size={20} /> - ) + <Icon + name={odsIframe ? "legacyLinkExternal" : "arrowRight"} + size={20} + /> } onClick={(e) => onCreateChartFromDataset?.(e, dataset)} > @@ -472,7 +358,7 @@ const SelectDatasetStepContent = ({ <NextLink href={`/create/new?cube=${ dataCubeMetadata?.dataCubeMetadata.iri - }&dataSource=${sourceToLabel(configState.dataSource)}`} + }&dataSource=${sourceToLabel(dataSource)}`} passHref legacyBehavior={!odsIframe} target={odsIframe ? "_blank" : undefined} @@ -480,17 +366,18 @@ const SelectDatasetStepContent = ({ <Button size="sm" endIcon={ - odsIframe ? ( - <Icon name="legacyLinkExternal" size={20} /> - ) : ( - <Icon name="arrowRight" size={20} /> - ) + <Icon + name={ + odsIframe ? "legacyLinkExternal" : "arrowRight" + } + size={20} + /> } sx={ - // Could be extracted in case we have more - // openData.swiss dependencies odsIframe ? { + // Could be extracted in case we have more + // openData.swiss dependencies backgroundColor: "#009688", "&:hover": { @@ -535,9 +422,9 @@ const SelectDatasetStepContent = ({ {dataset ? ( <MotionBox key="metadata" {...navPresenceProps}> <MotionBox {...smoothPresenceProps}> - <DatasetMetadataSingleCubeAdapter + <DatasetMetadataSingleCube datasetIri={dataset} - dataSource={configState.dataSource} + dataSource={dataSource} /> </MotionBox> </MotionBox> @@ -570,8 +457,9 @@ const SelectDatasetStepContent = ({ > <DataSetPreview dataSetIri={dataset} - dataSource={configState.dataSource} + dataSource={dataSource} dataCubeMetadataQuery={dataCubeMetadataQuery} + odsIframe={odsIframe} {...datasetPreviewProps} /> </MotionBox> @@ -579,7 +467,10 @@ const SelectDatasetStepContent = ({ <MotionBox key="filters" {...navPresenceProps}> <AnimatePresence> {variant === "drawer" ? ( - <Box mb="2rem" mt="0.125rem" key="select-dataset"> + <div + key="select-dataset" + style={{ marginTop: "0.125rem", marginBottom: "2rem" }} + > <Typography variant="h2"> <Trans id="chart.datasets.add-dataset-drawer.title"> Select dataset @@ -595,48 +486,50 @@ const SelectDatasetStepContent = ({ }, }} /> - </Box> + </div> ) : null} {queryFilters.length > 0 && ( - <MotionBox - key="query-filters" - {...{ - initial: { - transform: "translateY(-16px)", - height: 0, - marginBottom: 0, - opacity: 0, - }, - animate: { - transform: "translateY(0px)", - height: "auto", - marginBottom: 16, - opacity: 1, - }, - exit: { - transform: "translateY(-16px)", - height: 0, - marginBottom: 0, - opacity: 0, - }, - transition: { - duration: DURATION, - }, - }} - > + <> <Head> <title key="title"> - {pageTitle}- visualize.admin.ch + {pageTitle} - visualize.admin.ch - - {pageTitle} - - + + {pageTitle} + + + )} ({ - showDimensions: true, - })} + datasetResultProps={() => ({ showDimensions: true })} {...datasetResultsProps} /> @@ -659,76 +550,55 @@ const SelectDatasetStepContent = ({ {variant == "page" && !odsIframe ? ( - -