diff --git a/app/scripts/components/common/form/checkable-filter/index.tsx b/app/scripts/components/common/form/checkable-filter/index.tsx index 86e7667cd..3780c0c7f 100644 --- a/app/scripts/components/common/form/checkable-filter/index.tsx +++ b/app/scripts/components/common/form/checkable-filter/index.tsx @@ -10,7 +10,7 @@ import { CardTitle } from '$components/common/card/styles'; const FilterMenu = styled.div` border: 2px solid ${themeVal('color.base-200')}; - border-radius: 4px; + border-radius: ${themeVal('shape.rounded')}; padding: 12px; margin-bottom: 1.5rem; `; @@ -83,8 +83,14 @@ export interface OptionItem { } export default function CheckableFilters(props: CheckableFiltersProps) { - const {items, title, onChanges, globallySelected, tagItemCleared} = props; - const [show, setShow] = useState(false); + const { + items, + title, + onChanges, + globallySelected, + tagItemCleared + } = props; + const [show, setShow] = useState(true); const [count, setCount] = useState(0); const [selected, setSelected] = useState([]); @@ -112,7 +118,7 @@ export default function CheckableFilters(props: CheckableFiltersProps) { }, [selected]); const isChecked = (item: OptionItem) => globallySelected.some((selected) => selected.id == item.id && selected.taxonomy == item.taxonomy); - + useEffect(() => { if(!globallySelected || globallySelected.length === 0) { setCount(0); diff --git a/app/scripts/components/data-catalog/container.tsx b/app/scripts/components/data-catalog/container.tsx index 25748212d..8ac3236d7 100644 --- a/app/scripts/components/data-catalog/container.tsx +++ b/app/scripts/components/data-catalog/container.tsx @@ -1,16 +1,32 @@ import React from 'react'; +import { getString } from 'veda'; import { allDatasets } from '$components/exploration/data-utils'; import DataCatalog from '$components/data-catalog'; +import { PageMainContent } from '$styles/page'; +import { LayoutProps } from '$components/common/layout-root'; +import PageHero from '$components/common/page-hero'; +import { FeaturedDatasets } from '$components/common/featured-slider-section'; -// @VEDA2-REFACTOR-WORK - -// @NOTE: This container component serves as a wrapper for the purpose of data management, this is ONLY to support current instances. -// veda2 instances can just use the direct component, 'DataCatalog', and manage data directly in their page views +/** + * @VEDA2-REFACTOR-WORK + * + * @NOTE: This container component serves as a wrapper for the purpose of data management, this is ONLY to support current instances. + * veda2 instances can just use the direct component, 'DataCatalog', and manage data directly in their page views + */ export default function DataCatalogContainer() { return ( - <> + + + + - + ); } \ No newline at end of file diff --git a/app/scripts/components/data-catalog/filters-control.tsx b/app/scripts/components/data-catalog/filters-control.tsx index 5aee9eeb6..a18fb196a 100644 --- a/app/scripts/components/data-catalog/filters-control.tsx +++ b/app/scripts/components/data-catalog/filters-control.tsx @@ -1,13 +1,18 @@ -import React, {useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import styled from 'styled-components'; import { Taxonomy } from 'veda'; import SearchField from '$components/common/search-field'; import CheckableFilters, { OptionItem } from '$components/common/form/checkable-filter'; import { Actions, useBrowserControls } from '$components/common/browse-controls/use-browse-controls'; +import { useSlidingStickyHeader, HEADER_TRANSITION_DURATION } from '$utils/use-sliding-sticky-header'; -const ControlsWrapper = styled.div<{ width?: string; }>` +const ControlsWrapper = styled.div<{ widthValue?: string; heightValue?: string; topValue: string }>` min-width: 20rem; - width: ${props => props.width ?? '3rem'}; + width: ${props => props.widthValue ?? '20rem'}; + position: sticky; + top: calc(${props => props.topValue} + 1rem); + height: ${props => props.heightValue}; + transition: top ${HEADER_TRANSITION_DURATION}ms ease-out; `; interface FiltersMenuProps extends ReturnType { @@ -28,9 +33,14 @@ export default function FiltersControl(props: FiltersMenuProps) { width, onChangeToFilters, clearedTagItem, - setClearedTagItem, + setClearedTagItem } = props; + const controlsRef = useRef(null); + const [controlsHeight, setControlsHeight] = useState(0); + const { isHeaderHidden, wrapperHeight } = useSlidingStickyHeader(); + + const handleChanges = useCallback((item: OptionItem) => { if(allSelected.some((selected) => selected.id == item.id && selected.taxonomy == item.taxonomy)) { setClearedTagItem?.(undefined); @@ -42,29 +52,49 @@ export default function FiltersControl(props: FiltersMenuProps) { } }, [allSelected, setClearedTagItem, onChangeToFilters]); - return ( - - onAction(Actions.SEARCH, v)} - /> - { - taxonomiesOptions.map((taxonomy) => { - const items = taxonomy.values.map((t) => ({...t, taxonomy: taxonomy.name})); - return ( - - ); - }) + useEffect(() => { + if (!controlsRef.current) return; + + const height = controlsRef.current.offsetHeight; + setControlsHeight(height); + // Observe the height change of controls (from accordion folding) + const resizeObserver = new ResizeObserver(([entry]) => { + if (entry.borderBoxSize.length > 0) { + const borderBoxSize = entry.borderBoxSize[0]; + // blockSize: For boxes with a horizontal writing-mode, this is the vertical dimension + setControlsHeight(borderBoxSize.blockSize); } + }); + resizeObserver.observe(controlsRef.current); + return () => resizeObserver.disconnect(); // clean up + }, [controlsRef]); + + + return ( + +
+ onAction(Actions.SEARCH, v)} + /> + { + taxonomiesOptions.map((taxonomy) => { + const items = taxonomy.values.map((t) => ({...t, taxonomy: taxonomy.name})); + return ( + + ); + }) + } +
); -} \ No newline at end of file +} diff --git a/app/scripts/components/data-catalog/index.tsx b/app/scripts/components/data-catalog/index.tsx index 78308abfd..bc711facd 100644 --- a/app/scripts/components/data-catalog/index.tsx +++ b/app/scripts/components/data-catalog/index.tsx @@ -1,6 +1,6 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react'; import styled from 'styled-components'; -import { DatasetData, getString } from 'veda'; +import { DatasetData } from 'veda'; import { Link, useNavigate } from 'react-router-dom'; import { themeVal } from '@devseed-ui/theme-provider'; import { VerticalDivider } from '@devseed-ui/toolbar'; @@ -8,16 +8,15 @@ import { VerticalDivider } from '@devseed-ui/toolbar'; import DatasetMenu from './dataset-menu'; import FiltersControl from './filters-control'; import FilterTag from './filter-tag'; +import prepareDatasets from './prepare-datasets'; import { Actions, - optionAll, useBrowserControls } from '$components/common/browse-controls/use-browse-controls'; import { - LayoutProps, useSlidingStickyHeaderProps } from '$components/common/layout-root'; -import PageHero from '$components/common/page-hero'; + import { FoldHeader, FoldHeadline, @@ -26,11 +25,9 @@ import { import { Card } from '$components/common/card'; import { CardList, CardMeta, CardTopicsList } from '$components/common/card/styles'; import EmptyHub from '$components/common/empty-hub'; -import { PageMainContent } from '$styles/page'; import { DATASETS_PATH, getDatasetPath } from '$utils/routes'; import TextHighlight from '$components/common/text-highlight'; import { Pill } from '$styles/pill'; -import { FeaturedDatasets } from '$components/common/featured-slider-section'; import { CardSourcesList } from '$components/common/card-sources'; import { getAllTaxonomyValues, @@ -46,6 +43,11 @@ import { OptionItem } from '$components/common/form/checkable-filter'; import { usePreviousValue } from '$utils/use-effect-previous'; import { getAllDatasetsWithEnhancedLayers } from '$components/exploration/data-utils'; +/** + * DATA CATALOG Feature component + * Allows you to browse through datasets using the filters sidebar control + */ + const BrowseFoldHeader = styled(FoldHeader)` margin-bottom: 4rem; `; @@ -53,6 +55,7 @@ const BrowseFoldHeader = styled(FoldHeader)` const Content = styled.div` display: flex; margin-bottom: 8rem; + position: relative; `; const CatalogWrapper = styled.div` @@ -98,83 +101,6 @@ const EmptyState = styled(EmptyHub)` export const sortOptions = [{ id: 'name', name: 'Name' }]; -export const prepareDatasets = ( - data: DatasetData[], - options: { - search: string; - taxonomies: Record | null; - sortField: string | null; - sortDir: string | null; - filterLayers: boolean | null; - } -) => { - const { sortField, sortDir, search, taxonomies, filterLayers } = options; - let filtered = [...data]; - - // Does the free text search appear in specific fields? - if (search.length >= 3) { - const searchLower = search.toLowerCase(); - // Function to check if searchLower is included in any of the string fields - const includesSearchLower = (str) => str.toLowerCase().includes(searchLower); - // Function to determine if a layer matches the search criteria - const layerMatchesSearch = (layer) => - includesSearchLower(layer.stacCol) || - includesSearchLower(layer.name) || - includesSearchLower(layer.parentDataset.name) || - includesSearchLower(layer.parentDataset.id) || - includesSearchLower(layer.description); - - filtered = filtered - .filter((d) => { - // Pre-calculate lowercased versions to use in comparisons - const idLower = d.id.toLowerCase(); - const nameLower = d.name.toLowerCase(); - const descriptionLower = d.description.toLowerCase(); - const topicsTaxonomy = d.taxonomy.find((t) => t.name === TAXONOMY_TOPICS); - // Check if any of the conditions for including the item are met - return ( - idLower.includes(searchLower) || - nameLower.includes(searchLower) || - descriptionLower.includes(searchLower) || - d.layers.some(layerMatchesSearch) || - topicsTaxonomy?.values.some((t) => includesSearchLower(t.name)) - ); - }); - - if (filterLayers) - filtered = filtered.map((d) => ({ - ...d, - layers: d.layers.filter(layerMatchesSearch), - })); - } - - taxonomies && - Object.entries(taxonomies).forEach(([name, value]) => { - if (!value.includes(optionAll.id)) { - filtered = filtered.filter((d) => - d.taxonomy.some( - (t) => t.name === name && t.values.some((v) => value.includes(v.id)) - ) - ); - } - }); - - sortField && - /* eslint-disable-next-line fp/no-mutating-methods */ - filtered.sort((a, b) => { - if (!a[sortField]) return Infinity; - - return a[sortField]?.localeCompare(b[sortField]); - }); - - if (sortDir === 'desc') { - /* eslint-disable-next-line fp/no-mutating-methods */ - filtered.reverse(); - } - - return filtered; -}; - export interface DataCatalogProps { datasets: DatasetData[]; } @@ -188,17 +114,15 @@ function DataCatalog({ datasets }: DataCatalogProps) { const { taxonomies, sortField, sortDir, onAction } = controlVars; const search = controlVars.search ?? ''; - let urlTaxonomyItems: OptionItem[] = []; const datasetTaxonomies = generateTaxonomies(datasets); - if (taxonomies) { - urlTaxonomyItems = Object.entries(taxonomies).map(([key, val]) => getTaxonomyByIds(key, val, datasetTaxonomies)).flat() || []; - } - - const allDatasetsWithEnhancedLayers = React.useMemo(() => getAllDatasetsWithEnhancedLayers(datasets), [datasets]); + + const urlTaxonomyItems = taxonomies? Object.entries(taxonomies).map(([key, val]) => getTaxonomyByIds(key, val, datasetTaxonomies)).flat(): []; + + const allDatasetsWithEnhancedLayers = useMemo(() => getAllDatasetsWithEnhancedLayers(datasets), [datasets]); - const [datasetsToDisplay, setDatasetsToDisplay] = React.useState( + const [datasetsToDisplay, setDatasetsToDisplay] = useState( prepareDatasets(allDatasetsWithEnhancedLayers, { search, taxonomies, @@ -207,12 +131,13 @@ function DataCatalog({ datasets }: DataCatalogProps) { filterLayers: false })); - const [allSelectedFilters, setAllSelectedFilters] = React.useState(urlTaxonomyItems); - const [clearedTagItem, setClearedTagItem] = React.useState(); + const [allSelectedFilters, setAllSelectedFilters] = useState(urlTaxonomyItems); + const [clearedTagItem, setClearedTagItem] = useState(); const prevSelectedFilters = usePreviousValue(allSelectedFilters) || []; - const handleChangeAllSelectedFilters = React.useCallback((item: OptionItem, action: 'add' | 'remove') => { + // Handlers + const handleChangeAllSelectedFilters = useCallback((item: OptionItem, action: 'add' | 'remove') => { if(action == 'add') { setAllSelectedFilters([...allSelectedFilters, item]); } @@ -223,31 +148,31 @@ function DataCatalog({ datasets }: DataCatalogProps) { onAction(Actions.TAXONOMY_MULTISELECT, { key: item.taxonomy, value: item.id }); }, [setAllSelectedFilters, allSelectedFilters, onAction]); - const handleClearTag = React.useCallback((item: OptionItem) => { + const handleClearTag = useCallback((item: OptionItem) => { setAllSelectedFilters(allSelectedFilters.filter((selected) => selected !== item)); setClearedTagItem(item); }, [allSelectedFilters]); - const handleClearTags = React.useCallback(() => { + const handleClearTags = useCallback(() => { setAllSelectedFilters([]); }, [setAllSelectedFilters]); - React.useEffect(() => { + useEffect(() => { if (clearedTagItem && (allSelectedFilters.length == prevSelectedFilters.length-1)) { onAction(Actions.TAXONOMY_MULTISELECT, { key: clearedTagItem.taxonomy, value: clearedTagItem.id}); setClearedTagItem(undefined); } }, [allSelectedFilters, clearedTagItem]); - React.useEffect(() => { + useEffect(() => { if(!allSelectedFilters.length) { onAction(Actions.CLEAR); navigate(DATASETS_PATH); } }, [allSelectedFilters]); - React.useEffect(() => { + useEffect(() => { const updated = prepareDatasets(allDatasetsWithEnhancedLayers, { search, taxonomies, @@ -261,8 +186,8 @@ function DataCatalog({ datasets }: DataCatalogProps) { const browseControlsHeaderRef = useRef(null); const { headerHeight } = useSlidingStickyHeaderProps(); - const renderTags = React.useMemo(() => { - if(allSelectedFilters.length > 0 || urlTaxonomyItems.length > 0) { + const renderTags = useMemo(() => { + if (allSelectedFilters.length > 0 || urlTaxonomyItems.length > 0) { return ( { @@ -280,152 +205,140 @@ function DataCatalog({ datasets }: DataCatalogProps) { }, [allSelectedFilters, handleClearTag, handleClearTags, urlTaxonomyItems]); return ( - - - - - - - - - Search datasets - - - - - - {renderTags} - {datasetsToDisplay.length ? ( - - {datasetsToDisplay.map((d) => { - const topics = getTaxonomy(d, TAXONOMY_TOPICS)?.values; - const allTaxonomyValues = getAllTaxonomyValues(d).map((v) => v.name); - return ( -
  • - - - { - onAction(Actions.TAXONOMY_MULTISELECT, { - key: TAXONOMY_SOURCE, - value: id - }); - browseControlsHeaderRef.current?.scrollIntoView(); - }} - /> - - {/* TODO: Implement modified date: https://github.com/NASA-IMPACT/veda-ui/issues/514 */} - {/* - { - e.preventDefault(); - onAction(Actions.SORT_FIELD, 'date'); + + + + Search datasets + + + + + + {renderTags} + {datasetsToDisplay.length ? ( + + {datasetsToDisplay.map((d) => { + const topics = getTaxonomy(d, TAXONOMY_TOPICS)?.values; + const allTaxonomyValues = getAllTaxonomyValues(d).map((v) => v.name); + return ( +
  • + + + { + onAction(Actions.TAXONOMY_MULTISELECT, { + key: TAXONOMY_SOURCE, + value: id + }); + browseControlsHeaderRef.current?.scrollIntoView(); }} - > - Updated - */} - - } - linkLabel='View more' - linkTo={getDatasetPath(d)} - title={ - - {d.name} - - } - description={ - - {d.description} - - } - imgSrc={d.media?.src} - imgAlt={d.media?.alt} - footerContent={ - <> - {topics?.length ? ( - -
    Topics
    - {topics.map((t) => { - const path = `${DATASETS_PATH}?${ - Actions.TAXONOMY - }=${encodeURIComponent( - JSON.stringify({ Topics: [t.id] }) - )}`; - return ( -
    - { - e.preventDefault(); - onAction(Actions.TAXONOMY_MULTISELECT, { - key: TAXONOMY_TOPICS, - value: t.id - }); - browseControlsHeaderRef.current?.scrollIntoView(); - }} + /> + + {/* TODO: Implement modified date: https://github.com/NASA-IMPACT/veda-ui/issues/514 */} + {/* + { + e.preventDefault(); + onAction(Actions.SORT_FIELD, 'date'); + }} + > + Updated + */} + + } + linkLabel='View more' + linkTo={getDatasetPath(d)} + title={ + + {d.name} + + } + description={ + + {d.description} + + } + imgSrc={d.media?.src} + imgAlt={d.media?.alt} + footerContent={ + <> + {topics?.length ? ( + +
    Topics
    + {topics.map((t) => { + const path = `${DATASETS_PATH}?${ + Actions.TAXONOMY + }=${encodeURIComponent( + JSON.stringify({ Topics: [t.id] }) + )}`; + return ( +
    + { + e.preventDefault(); + onAction(Actions.TAXONOMY_MULTISELECT, { + key: TAXONOMY_TOPICS, + value: t.id + }); + browseControlsHeaderRef.current?.scrollIntoView(); + }} + > + - - {t.name} - - -
    - ); - })} -
    - ) : null} - - - } - /> -
  • - ); - })} -
    - ) : ( - - There are no datasets to show with the selected filters. - - )} -
    -
    -
    -
    + {t.name} + + + + ); + })} + + ) : null} + + + } + /> + + ); + })} + + ) : ( + + There are no datasets to show with the selected filters. + + )} + + + ); } diff --git a/app/scripts/components/data-catalog/prepare-datasets.ts b/app/scripts/components/data-catalog/prepare-datasets.ts new file mode 100644 index 000000000..4d18a1ee0 --- /dev/null +++ b/app/scripts/components/data-catalog/prepare-datasets.ts @@ -0,0 +1,82 @@ +import { DatasetData } from 'veda'; +import { optionAll } from '$components/common/browse-controls/use-browse-controls'; +import { TAXONOMY_TOPICS } from '$utils/veda-data'; + +const prepareDatasets = ( + data: DatasetData[], + options: { + search: string; + taxonomies: Record | null; + sortField: string | null; + sortDir: string | null; + filterLayers: boolean | null; + } +) => { + const { sortField, sortDir, search, taxonomies, filterLayers } = options; + let filtered = [...data]; + + // Does the free text search appear in specific fields? + if (search.length >= 3) { + const searchLower = search.toLowerCase(); + // Function to check if searchLower is included in any of the string fields + const includesSearchLower = (str) => str.toLowerCase().includes(searchLower); + // Function to determine if a layer matches the search criteria + const layerMatchesSearch = (layer) => + includesSearchLower(layer.stacCol) || + includesSearchLower(layer.name) || + includesSearchLower(layer.parentDataset.name) || + includesSearchLower(layer.parentDataset.id) || + includesSearchLower(layer.description); + + filtered = filtered + .filter((d) => { + // Pre-calculate lowercased versions to use in comparisons + const idLower = d.id.toLowerCase(); + const nameLower = d.name.toLowerCase(); + const descriptionLower = d.description.toLowerCase(); + const topicsTaxonomy = d.taxonomy.find((t) => t.name === TAXONOMY_TOPICS); + // Check if any of the conditions for including the item are met + return ( + idLower.includes(searchLower) || + nameLower.includes(searchLower) || + descriptionLower.includes(searchLower) || + d.layers.some(layerMatchesSearch) || + topicsTaxonomy?.values.some((t) => includesSearchLower(t.name)) + ); + }); + + if (filterLayers) + filtered = filtered.map((d) => ({ + ...d, + layers: d.layers.filter(layerMatchesSearch), + })); + } + + taxonomies && + Object.entries(taxonomies).forEach(([name, value]) => { + if (!value.includes(optionAll.id)) { + filtered = filtered.filter((d) => + d.taxonomy.some( + (t) => t.name === name && t.values.some((v) => value.includes(v.id)) + ) + ); + } + }); + + sortField && + /* eslint-disable-next-line fp/no-mutating-methods */ + filtered.sort((a, b) => { + if (!a[sortField]) return Infinity; + + return a[sortField]?.localeCompare(b[sortField]); + }); + + if (sortDir === 'desc') { + /* eslint-disable-next-line fp/no-mutating-methods */ + filtered.reverse(); + } + + return filtered; +}; + +export default prepareDatasets; \ No newline at end of file diff --git a/app/scripts/components/exploration/components/dataset-selector-modal/index.tsx b/app/scripts/components/exploration/components/dataset-selector-modal/index.tsx index 543156b17..61de1d5df 100644 --- a/app/scripts/components/exploration/components/dataset-selector-modal/index.tsx +++ b/app/scripts/components/exploration/components/dataset-selector-modal/index.tsx @@ -28,7 +28,8 @@ import { TaxonomyFilterOption, useBrowserControls } from '$components/common/browse-controls/use-browse-controls'; -import { prepareDatasets, sortOptions } from '$components/data-catalog'; +import { sortOptions } from '$components/data-catalog'; +import prepareDatasets from '$components/data-catalog/prepare-datasets'; import { TAXONOMY_SOURCE, getTaxonomy } from '$utils/veda-data'; import { usePreviousValue } from '$utils/use-effect-previous';