diff --git a/frontend/package.json b/frontend/package.json index a5c437c7..49a6c296 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@loaders.gl/zip": "^3.4.14", "@mapbox/mapbox-gl-draw": "^1.4.2", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-label": "^2.0.2", @@ -59,6 +60,7 @@ "postcss": "8.4.21", "react": "18.2.0", "react-dom": "18.2.0", + "react-hook-form": "^7.48.2", "react-icons": "4.11.0", "react-map-gl": "7.1.6", "recharts": "^2.9.0", diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..fd082a5c --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { CheckIcon } from 'lucide-react'; + +import { cn } from '@/lib/classnames'; + +const checkboxVariants = cva(''); + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, name, ...props }, ref) => ( + + + + + +)); + +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/frontend/src/containers/data-tool/constants.ts b/frontend/src/containers/data-tool/constants.ts new file mode 100644 index 00000000..f634ca26 --- /dev/null +++ b/frontend/src/containers/data-tool/constants.ts @@ -0,0 +1,19 @@ +// ? This should be a Collection Type on Strapi, relation with location, which we would then pull from the API +export const LOCATION_TYPES_FILTER_OPTIONS = [ + { + name: 'Country', + value: 'country', + }, + { + name: 'Worldwide', + value: 'worldwide', + }, + { + name: 'HighSeas', + value: 'highseas', + }, + { + name: 'Region', + value: 'region', + }, +]; diff --git a/frontend/src/containers/data-tool/content/details/table/filters-button/index.tsx b/frontend/src/containers/data-tool/content/details/table/filters-button/index.tsx new file mode 100644 index 00000000..8905a113 --- /dev/null +++ b/frontend/src/containers/data-tool/content/details/table/filters-button/index.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useForm } from 'react-hook-form'; + +import { xor } from 'lodash-es'; +import { Filter } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const ICON_CLASSNAMES = 'h-4 w-4 fill-black'; + +type FiltersButtonProps = { + field: string; + options: { + name: string; + value: string; + }[]; + values: string[]; + onChange: (field: string, values: string[]) => void; +}; + +type FormValues = { + filters: string[]; +}; + +const FiltersButton: React.FC = ({ field, options, values, onChange }) => { + const allFilterValues = useMemo(() => options.map(({ value }) => value), [options]); + + const [isFiltersOpen, setIsFiltersOpen] = useState(false); + + const { watch, setValue } = useForm({ + mode: 'onChange', + defaultValues: { + filters: values, + }, + }); + + const filters = watch('filters'); + + useEffect(() => { + const filtersChanged = xor(filters, values).length > 0; + const allFiltersSelected = filters.length === allFilterValues.length; + + if (filtersChanged || allFiltersSelected) { + onChange(field, filters); + } + }, [field, filters, values, onChange, allFilterValues.length]); + + const handleSelectAll = () => { + setValue('filters', allFilterValues); + }; + + const handleClearAll = () => { + setValue('filters', []); + }; + + const handleOnCheckedChange = (type, checked) => { + if (checked) { + setValue('filters', [...filters, type]); + } else { + setValue( + 'filters', + filters.filter((entry) => entry !== type) + ); + } + }; + + const noFiltersSelected = filters.length === 0; + + return ( +
+ + + + + +
+ + +
+
+
+ {options.map(({ name, value }) => { + return ( +
+ handleOnCheckedChange(value, v)} + /> + +
+ ); + })} +
+
+ {noFiltersSelected && ( +
Please, select at least one option
+ )} +
+
+
+ ); +}; + +export default FiltersButton; diff --git a/frontend/src/containers/data-tool/content/details/table/index.tsx b/frontend/src/containers/data-tool/content/details/table/index.tsx index 7020d445..dc45881f 100644 --- a/frontend/src/containers/data-tool/content/details/table/index.tsx +++ b/frontend/src/containers/data-tool/content/details/table/index.tsx @@ -31,7 +31,7 @@ const DataToolTable = ({ columns, data }) => { getSortedRowModel: getSortedRowModel(), }); - const hasData = table.getRowModel().rows?.length; + const hasData = table.getRowModel().rows?.length > 0; const firstColumn = columns[0]; const lastColumn = columns[columns.length - 1]; diff --git a/frontend/src/containers/data-tool/content/details/table/sorting-button/index.tsx b/frontend/src/containers/data-tool/content/details/table/sorting-button/index.tsx index 4eb2f419..f593088d 100644 --- a/frontend/src/containers/data-tool/content/details/table/sorting-button/index.tsx +++ b/frontend/src/containers/data-tool/content/details/table/sorting-button/index.tsx @@ -2,8 +2,9 @@ import { Column } from '@tanstack/react-table'; import { ArrowDownNarrowWide, ArrowUpNarrowWide, ArrowUpDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { GlobalRegionalTableColumns } from '@/containers/data-tool/content/details/tables/global-regional/columns'; +import { GlobalRegionalTableColumns } from '@/containers/data-tool/content/details/tables/global-regional/useColumns'; +const BUTTON_CLASSNAMES = '-ml-4'; const ICON_CLASSNAMES = 'h-4 w-4'; type SortingButtonProps = { @@ -16,19 +17,34 @@ const SortingButton: React.FC = ({ column }) => { return ( <> {!isSorted && ( - )} {isSorted === 'asc' && ( - )} {isSorted === 'desc' && ( - diff --git a/frontend/src/containers/data-tool/content/details/tables/global-regional/columns.tsx b/frontend/src/containers/data-tool/content/details/tables/global-regional/columns.tsx deleted file mode 100644 index 29cd0c0f..00000000 --- a/frontend/src/containers/data-tool/content/details/tables/global-regional/columns.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { ColumnDef } from '@tanstack/react-table'; - -import HeaderItem from '@/containers/data-tool/content/details/table/header-item'; -import { cellFormatter } from '@/containers/data-tool/content/details/table/helpers'; -import SortingButton from '@/containers/data-tool/content/details/table/sorting-button'; - -export type GlobalRegionalTableColumns = { - location: string; - coverage: number; - locationType: string; - mpas: number; - oecms: number; - area: number; - fullyHighProtected: number; - highlyProtectedLFP: number; - globalContribution: number; -}; - -const columns: ColumnDef[] = [ - { - accessorKey: 'location', - header: 'Location', - cell: ({ row }) => { - const { location } = row.original; - return {location}; - }, - }, - { - accessorKey: 'coverage', - header: ({ column }) => ( - - - Coverage - - ), - cell: ({ row }) => { - const { coverage: value } = row.original; - if (!value) return <>—; - - const formattedCoverage = cellFormatter.percentage(value); - - return ( - - {formattedCoverage} - % - - ); - }, - }, - { - accessorKey: 'locationType', - header: 'Location type', - cell: ({ row }) => { - const { locationType: value } = row.original; - const formattedValue = cellFormatter.capitalize(value); - return <>{formattedValue}; - }, - }, - { - accessorKey: 'mpas', - header: ({ column }) => ( - - - MPAs - - ), - }, - { - accessorKey: 'oecms', - header: ({ column }) => ( - - - OECMs - - ), - }, - { - accessorKey: 'area', - header: ({ column }) => ( - - - Area - - ), - cell: ({ row }) => { - const { area: value } = row.original; - const formattedValue = cellFormatter.area(value); - return ( - - {formattedValue} km2 - - ); - }, - }, - { - accessorKey: 'fullyHighProtected', - header: ({ column }) => ( - - - Fully/Highly Protected - - ), - cell: ({ row }) => { - const { fullyHighProtected: value } = row.original; - if (!value) return <>No data; - const formattedValue = cellFormatter.percentage(value); - return {formattedValue}%; - }, - }, - { - accessorKey: 'highlyProtectedLFP', - header: ({ column }) => ( - - - Highly Protected LFP - - ), - cell: ({ row }) => { - const { highlyProtectedLFP: value } = row.original; - if (!value) return <>No data; - const formattedValue = cellFormatter.percentage(value); - return {formattedValue}%; - }, - }, - { - accessorKey: 'globalContribution', - header: ({ column }) => ( - - - Global contribution - - ), - cell: ({ row }) => { - const { globalContribution: value } = row.original; - if (!value) return <>No data; - const formattedValue = cellFormatter.percentage(value); - return {formattedValue}%; - }, - }, -]; - -export default columns; diff --git a/frontend/src/containers/data-tool/content/details/tables/global-regional/index.tsx b/frontend/src/containers/data-tool/content/details/tables/global-regional/index.tsx index 6224765e..420adc32 100644 --- a/frontend/src/containers/data-tool/content/details/tables/global-regional/index.tsx +++ b/frontend/src/containers/data-tool/content/details/tables/global-regional/index.tsx @@ -1,9 +1,9 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useAtomValue } from 'jotai'; import Table from '@/containers/data-tool/content/details/table'; -import columns from '@/containers/data-tool/content/details/tables/global-regional/columns'; +import useColumns from '@/containers/data-tool/content/details/tables/global-regional/useColumns'; import { locationAtom } from '@/store/location'; import { useGetLocations } from '@/types/generated/location'; import type { LocationListResponseDataItem } from '@/types/generated/strapi.schemas'; @@ -13,6 +13,17 @@ const DATA_YEAR = 2023; const GlobalRegionalTable: React.FC = () => { const location = useAtomValue(locationAtom); + const [filters, setFilters] = useState({ + // ! This shouldn't be hardcoded. The setup needs to be able to work the same without any default filters here. + locationType: ['country', 'worldwide', 'highseas', 'region'], + }); + + const handleOnFiltersChange = (field, values) => { + setFilters({ ...filters, [field]: values }); + }; + + const columns = useColumns({ filters, onFiltersChange: handleOnFiltersChange }); + // Get worldwide data in order to calculate contributions per location const { data: globalData }: { data: LocationListResponseDataItem[] } = useGetLocations( { @@ -120,7 +131,7 @@ const GlobalRegionalTable: React.FC = () => { }, [globalData]); // Calculate table data - const tableData = useMemo(() => { + const parsedData = useMemo(() => { return locationsData.map(({ attributes: location }) => { // Base stats needed for calculations const coverageStats = location?.protection_coverage_stats?.data; @@ -186,6 +197,16 @@ const GlobalRegionalTable: React.FC = () => { }); }, [globalStats, locationsData]); + const tableData = useMemo(() => { + const filteredData = parsedData.filter((item) => { + for (const key in filters) { + return filters[key].includes(item[key]); + } + return true; + }); + return filteredData; + }, [filters, parsedData]); + return ; }; diff --git a/frontend/src/containers/data-tool/content/details/tables/global-regional/useColumns.tsx b/frontend/src/containers/data-tool/content/details/tables/global-regional/useColumns.tsx new file mode 100644 index 00000000..f0b3a404 --- /dev/null +++ b/frontend/src/containers/data-tool/content/details/tables/global-regional/useColumns.tsx @@ -0,0 +1,169 @@ +import { useMemo } from 'react'; + +import { ColumnDef } from '@tanstack/react-table'; + +import { LOCATION_TYPES_FILTER_OPTIONS } from '@/containers/data-tool/constants'; +import FiltersButton from '@/containers/data-tool/content/details/table/filters-button'; +import HeaderItem from '@/containers/data-tool/content/details/table/header-item'; +import { cellFormatter } from '@/containers/data-tool/content/details/table/helpers'; +import SortingButton from '@/containers/data-tool/content/details/table/sorting-button'; + +export type GlobalRegionalTableColumns = { + location: string; + coverage: number; + locationType: string; + mpas: number; + oecms: number; + area: number; + fullyHighProtected: number; + highlyProtectedLFP: number; + globalContribution: number; +}; + +type UseColumnsProps = { + filters: { [key: string]: string[] }; + onFiltersChange: (field: string, values: string[]) => void; +}; + +const useColumns = ({ filters, onFiltersChange }: UseColumnsProps) => { + const columns: ColumnDef[] = useMemo(() => { + return [ + { + accessorKey: 'location', + header: 'Location', + cell: ({ row }) => { + const { location } = row.original; + return {location}; + }, + }, + { + accessorKey: 'coverage', + header: ({ column }) => ( + + + Coverage + + ), + cell: ({ row }) => { + const { coverage: value } = row.original; + if (!value) return <>—; + + const formattedCoverage = cellFormatter.percentage(value); + + return ( + + {formattedCoverage} + % + + ); + }, + }, + { + accessorKey: 'locationType', + header: ({ column }) => ( + + + Location type + + ), + cell: ({ row }) => { + const { locationType: value } = row.original; + const formattedValue = cellFormatter.capitalize(value); + return <>{formattedValue}; + }, + }, + { + accessorKey: 'mpas', + header: ({ column }) => ( + + + MPAs + + ), + }, + { + accessorKey: 'oecms', + header: ({ column }) => ( + + + OECMs + + ), + }, + { + accessorKey: 'area', + header: ({ column }) => ( + + + Area + + ), + cell: ({ row }) => { + const { area: value } = row.original; + const formattedValue = cellFormatter.area(value); + return ( + + {formattedValue} km2 + + ); + }, + }, + { + accessorKey: 'fullyHighProtected', + header: ({ column }) => ( + + + Fully/Highly Protected + + ), + cell: ({ row }) => { + const { fullyHighProtected: value } = row.original; + if (!value) return <>No data; + const formattedValue = cellFormatter.percentage(value); + return {formattedValue}%; + }, + }, + { + accessorKey: 'highlyProtectedLFP', + header: ({ column }) => ( + + + Highly Protected LFP + + ), + cell: ({ row }) => { + const { highlyProtectedLFP: value } = row.original; + if (!value) return <>No data; + const formattedValue = cellFormatter.percentage(value); + return {formattedValue}%; + }, + }, + { + accessorKey: 'globalContribution', + header: ({ column }) => ( + + + Global contribution + + ), + cell: ({ row }) => { + const { globalContribution: value } = row.original; + if (!value) return <>No data; + const formattedValue = cellFormatter.percentage(value); + return {formattedValue}%; + }, + }, + ]; + // ! If we add dependencies, the columns will re-render and the popovers will close on updates / act funny + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return columns; +}; + +export default useColumns; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c1e7daf1..09f331d6 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1388,6 +1388,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-checkbox@npm:^1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-checkbox@npm:1.0.4" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.1 + "@radix-ui/react-context": 1.0.1 + "@radix-ui/react-presence": 1.0.1 + "@radix-ui/react-primitive": 1.0.3 + "@radix-ui/react-use-controllable-state": 1.0.1 + "@radix-ui/react-use-previous": 1.0.1 + "@radix-ui/react-use-size": 1.0.1 + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 6dac5bddd9e1c42b149555501440918d9eae70da13b6d8539c3bf46b6c07681119d865d2106a43f729884ae8e2043bedc34c4d00a09a527b3bf0feade088d188 + languageName: node + linkType: hard + "@radix-ui/react-collapsible@npm:1.0.3, @radix-ui/react-collapsible@npm:^1.0.3": version: 1.0.3 resolution: "@radix-ui/react-collapsible@npm:1.0.3" @@ -10861,6 +10888,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.48.2": + version: 7.48.2 + resolution: "react-hook-form@npm:7.48.2" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + checksum: 39652d08f78f16e5234049ceeb794d8bcd4d146772aca99c6db8c5aea8926cd4a230d02098886a441074deefbc24ffcb5ed57bc7cb0009d950115f5b3e627f12 + languageName: node + linkType: hard + "react-icons@npm:4.11.0": version: 4.11.0 resolution: "react-icons@npm:4.11.0" @@ -11653,6 +11689,7 @@ __metadata: "@loaders.gl/zip": ^3.4.14 "@mapbox/mapbox-gl-draw": ^1.4.2 "@radix-ui/react-accordion": ^1.1.2 + "@radix-ui/react-checkbox": ^1.0.4 "@radix-ui/react-collapsible": ^1.0.3 "@radix-ui/react-dialog": ^1.0.4 "@radix-ui/react-label": ^2.0.2 @@ -11702,6 +11739,7 @@ __metadata: prettier-plugin-tailwindcss: 0.2.1 react: 18.2.0 react-dom: 18.2.0 + react-hook-form: ^7.48.2 react-icons: 4.11.0 react-map-gl: 7.1.6 recharts: ^2.9.0