diff --git a/.changeset/tired-areas-take.md b/.changeset/tired-areas-take.md new file mode 100644 index 000000000..c2d87664d --- /dev/null +++ b/.changeset/tired-areas-take.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Improve the user experience when they come back to the field and rotation tables, by storing their filters in the session storage. diff --git a/fdm-app/app/components/blocks/farm/farm-content.tsx b/fdm-app/app/components/blocks/farm/farm-content.tsx index 235073ea4..1cc8fa77e 100644 --- a/fdm-app/app/components/blocks/farm/farm-content.tsx +++ b/fdm-app/app/components/blocks/farm/farm-content.tsx @@ -20,7 +20,7 @@ export function FarmContent({ sidebarItems, children }: FarmContentProps) { )} -
{children || }
+
{children || }
) diff --git a/fdm-app/app/components/blocks/fields/table.tsx b/fdm-app/app/components/blocks/fields/table.tsx index ebbd6db2e..e8ea4114b 100644 --- a/fdm-app/app/components/blocks/fields/table.tsx +++ b/fdm-app/app/components/blocks/fields/table.tsx @@ -9,6 +9,7 @@ import { type Row, type RowSelectionState, type SortingState, + type Updater, useReactTable, type VisibilityState, } from "@tanstack/react-table" @@ -16,6 +17,8 @@ import fuzzysort from "fuzzysort" import { ChevronDown, Plus } from "lucide-react" import { useEffect, useMemo, useRef, useState } from "react" import { NavLink, useParams } from "react-router-dom" +import { useFieldFilterStore } from "@/app/store/field-filter" +import { useFieldSelectionStore } from "@/app/store/field-selection" import { Button } from "~/components/ui/button" import { DropdownMenu, @@ -56,15 +59,33 @@ export function DataTable({ }: DataTableProps) { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) - const [globalFilter, setGlobalFilter] = useState("") const isMobile = useIsMobile() const [columnVisibility, setColumnVisibility] = useState( isMobile ? { a_som_loi: false, b_soiltype_agr: false, b_area: false } : {}, ) - const [rowSelection, setRowSelection] = useState({}) + const fieldIds = useFieldSelectionStore((state) => state.fieldIds) + const setFieldIds = useFieldSelectionStore((state) => state.setFieldIds) + const syncFarm = useFieldSelectionStore((state) => state.syncFarm) const lastSelectedRowIndex = useRef(null) + const fieldFilter = useFieldFilterStore() + + const rowSelection = useMemo( + () => Object.fromEntries(fieldIds.map((id) => [id, true])), + [fieldIds], + ) + + const params = useParams() + const b_id_farm = params.b_id_farm + const calendar = params.calendar + + useEffect(() => { + if (b_id_farm) { + syncFarm(b_id_farm) + fieldFilter.syncFarm(b_id_farm) + } + }, [b_id_farm, syncFarm, fieldFilter.syncFarm]) useEffect(() => { setColumnVisibility( @@ -74,10 +95,6 @@ export function DataTable({ ) }, [isMobile]) - const params = useParams() - const b_id_farm = params.b_id_farm - const calendar = params.calendar - const handleRowClick = ( row: Row, event: React.MouseEvent, @@ -103,13 +120,11 @@ export function DataTable({ const rowsToSelect = table .getRowModel() .rows.slice(start, end + 1) - .map((r) => r.id) + .map((r) => r.original.b_id) // Use b_id directly - const newRowSelection = { ...rowSelection } - rowsToSelect.forEach((id) => { - newRowSelection[id] = true - }) - setRowSelection(newRowSelection) + const newFieldIds = new Set(fieldIds) + rowsToSelect.forEach((id) => newFieldIds.add(id)) + setFieldIds(Array.from(newFieldIds)) } else { row.toggleSelected() } @@ -123,8 +138,9 @@ export function DataTable({ })) }, [data]) - const fuzzyFilter: FilterFn = (row, _columnId, filterValue) => { - const result = fuzzysort.go(filterValue, [ + const fuzzyFilter: FilterFn = (row, _columnId, { searchTerms }) => { + if (searchTerms === "") return true + const result = fuzzysort.go(searchTerms, [ (row.original as any).searchTarget, ]) return result.length > 0 @@ -133,20 +149,32 @@ export function DataTable({ const table = useReactTable({ data: memoizedData, columns, + getRowId: (row) => row.b_id, getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, - onGlobalFilterChange: setGlobalFilter, - onRowSelectionChange: setRowSelection, + onGlobalFilterChange: (fn) => { + const result = typeof fn === "function" ? fn(fieldFilter) : fn + // Ensure we are dealing with the store object structure before updating + const newSearchTerms = + typeof result === "string" ? result : result?.searchTerms + if ((newSearchTerms ?? "") !== fieldFilter.searchTerms) { + fieldFilter.setSearchTerms(newSearchTerms ?? "") + } + }, + onRowSelectionChange: (fn) => { + const selection = typeof fn === "function" ? fn(rowSelection) : fn + setFieldIds(Object.keys(selection).filter((k) => selection[k])) + }, globalFilterFn: fuzzyFilter, state: { sorting, columnFilters, columnVisibility, - globalFilter, + globalFilter: fieldFilter, rowSelection, }, }) @@ -170,8 +198,10 @@ export function DataTable({
setGlobalFilter(event.target.value)} + value={fieldFilter.searchTerms ?? ""} + onChange={(event) => + fieldFilter.setSearchTerms(event.target.value) + } className="w-full sm:w-auto sm:flex-grow" />
diff --git a/fdm-app/app/components/blocks/rotation/columns.tsx b/fdm-app/app/components/blocks/rotation/columns.tsx index b24928d94..72dd8b22d 100644 --- a/fdm-app/app/components/blocks/rotation/columns.tsx +++ b/fdm-app/app/components/blocks/rotation/columns.tsx @@ -131,20 +131,6 @@ export const columns: ColumnDef[] = [ } onCheckedChange={(value) => { row.toggleSelected(!!value) - const parentRow = row.getParentRow() - if (parentRow) { - const wantedValue = parentRow.subRows.every( - (childRow) => - childRow.id === row.id - ? value - : childRow.getIsSelected(), - ) - if (parentRow.getIsSelected() !== wantedValue) { - parentRow.toggleSelected(wantedValue, { - selectChildren: false, - }) - } - } }} aria-label="Selecteer deze rij" className="text-muted-foreground" diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx index 790b5cf1a..8daff223b 100644 --- a/fdm-app/app/components/blocks/rotation/table.tsx +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -9,7 +9,6 @@ import { getFilteredRowModel, getSortedRowModel, type Row, - type RowSelectionState, type SortingState, useReactTable, type VisibilityState, @@ -24,6 +23,7 @@ import { toast as notify } from "sonner" import { modifySearchParams } from "@/app/lib/url-utils" import { useActiveTableFormStore } from "@/app/store/active-table-form" import { useFieldFilterStore } from "@/app/store/field-filter" +import { useFieldSelectionStore } from "@/app/store/field-selection" import { Button } from "~/components/ui/button" import { DropdownMenu, @@ -64,17 +64,20 @@ export function DataTable({ }: DataTableProps) { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) - const [searchTerms, setSearchTerms] = useState("") + const fieldFilter = useFieldFilterStore() const isMobile = useIsMobile() const [columnVisibility, setColumnVisibility] = useState( isMobile ? { a_som_loi: false, b_soiltype_agr: false, b_area: false } : {}, ) - const [rowSelection, setRowSelection] = useState({}) const lastSelectedRowIndex = useRef(null) const location = useLocation() + const fieldIds = useFieldSelectionStore((state) => state.fieldIds) + const setFieldIds = useFieldSelectionStore((state) => state.setFieldIds) + const syncFarm = useFieldSelectionStore((state) => state.syncFarm) + useEffect(() => { setColumnVisibility( isMobile @@ -87,6 +90,13 @@ export function DataTable({ const b_id_farm = params.b_id_farm const calendar = params.calendar + useEffect(() => { + if (b_id_farm) { + syncFarm(b_id_farm) + fieldFilter.syncFarm(b_id_farm) + } + }, [b_id_farm, syncFarm, fieldFilter.syncFarm]) + const clearActiveForm = useActiveTableFormStore( (store) => store.clearActiveForm, ) @@ -115,7 +125,7 @@ export function DataTable({ lastSelectedRowIndex.current && table.getRow(lastSelectedRowIndex.current) if (lastSelectedRow) { - const newRowSelection = { ...rowSelection } + const newRowSelection = { ...table.getState().rowSelection } const visibleRows = table.getRowModel().flatRows // Select or deselect everything in between @@ -130,46 +140,27 @@ export function DataTable({ const start = Math.min(lastIndex, currentIndex) const end = Math.max(lastIndex, currentIndex) - const affectedCropRows = [] for (let i = start; i <= end; i++) { - const parentRow = visibleRows[i].getParentRow() - if (parentRow) { - affectedCropRows.push(parentRow) - newRowSelection[visibleRows[i].id] = mode + const r = visibleRows[i] + newRowSelection[r.id] = mode + if (r.original.type === "crop" && r.getCanExpand()) { + // Also select subrows + for (const sub of r.subRows) { + newRowSelection[sub.id] = mode + } } } - // Toggle selection of the currently clicked row - // This behavior can be removed as needed - if (row.getIsSelected() === mode) { - newRowSelection[row.id] = !mode - } - - // Update the derived selection state of crop rows - for (const row of affectedCropRows) { - newRowSelection[row.id] = row.subRows.every( - (subRow) => newRowSelection[subRow.id], - ) - } - setRowSelection(newRowSelection) + // Sync to store + const newFieldIds = Object.keys(newRowSelection).filter( + (k) => !k.startsWith("crop_") && newRowSelection[k], + ) + setFieldIds(newFieldIds) } } else { lastSelectedRowIndex.current = null const newIsSelected = !row.getIsSelected() row.toggleSelected(newIsSelected) - const parentRow = row.getParentRow() - if (parentRow) { - const wantedValue = parentRow?.subRows.every((otherRow) => - otherRow.id === row.id - ? newIsSelected - : otherRow.getIsSelected(), - ) - if (parentRow.getIsSelected() !== wantedValue) { - parentRow.toggleSelected(wantedValue, { - selectChildren: false, - }) - } - } } lastSelectedRowIndex.current = row.id } @@ -230,14 +221,40 @@ export function DataTable({ ) } - const showProductiveOnly = useFieldFilterStore((s) => s.showProductiveOnly) - const globalFilter = useMemo( - () => ({ searchTerms, showProductiveOnly }), - [searchTerms, showProductiveOnly], - ) + const rowSelection = useMemo(() => { + const sel: Record = {} + for (const id of fieldIds) { + sel[id] = true + } + for (const row of memoizedData) { + if (row.type === "crop") { + let all = true + let has = false + for (const field of row.fields) { + has = true + if (fieldIds.includes(field.b_id)) { + sel[field.b_id] = true + } else { + all = false + } + } + if (has && all) { + sel[`crop_${row.b_lu_catalogue}`] = true + } + } else { + if (fieldIds.includes(row.b_id)) { + sel[row.b_id] = true + } + } + } + return sel + }, [fieldIds, memoizedData]) + const table = useReactTable({ data: memoizedData, columns, + getRowId: (row) => + row.type === "crop" ? `crop_${row.b_lu_catalogue}` : row.b_id, getCoreRowModel: getCoreRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), @@ -248,11 +265,20 @@ export function DataTable({ getSubRows: (row) => row.type === "crop" ? (row.fields as TData[]) : undefined, onColumnVisibilityChange: setColumnVisibility, - onGlobalFilterChange: (globalFilter) => { - if (globalFilter?.searchTerms ?? "" !== searchTerms) - setSearchTerms(globalFilter?.searchTerms ?? "") + onGlobalFilterChange: (fn) => { + const result = typeof fn === "function" ? fn(fieldFilter) : fn + const newSearchTerms = + typeof result === "string" ? result : result?.searchTerms + if ((newSearchTerms ?? "") !== fieldFilter.searchTerms) + fieldFilter.setSearchTerms(newSearchTerms ?? "") + }, + onRowSelectionChange: (fn) => { + const selection = typeof fn === "function" ? fn(rowSelection) : fn + const newFieldIds = Object.keys(selection).filter( + (k) => !k.startsWith("crop_") && selection[k], + ) + setFieldIds(newFieldIds) }, - onRowSelectionChange: setRowSelection, globalFilterFn: fuzzySearchAndProductivityFilter, // There are nulls in the columns which can cause false assumptions if this is not provided // The global filter checks the searchTarget field anyways @@ -263,7 +289,7 @@ export function DataTable({ sorting, columnFilters, columnVisibility, - globalFilter, + globalFilter: fieldFilter, rowSelection, }, }) @@ -327,12 +353,14 @@ export function DataTable({ }) } return ( -
-
+
+
setSearchTerms(event.target.value)} + value={fieldFilter?.searchTerms ?? ""} + onChange={(event) => + fieldFilter.setSearchTerms(event.target.value) + } className="w-full sm:w-auto sm:grow" />
@@ -458,7 +486,7 @@ export function DataTable({
- + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { @@ -554,4 +582,4 @@ export function DataTable({ ) -} +} \ No newline at end of file diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx index 45d216ce8..cbd2811f2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -535,7 +535,7 @@ export default function FarmRotationIndex() { Bouwplan -
+
{loaderData.fieldOptions.length === 0 ? ( <> ) : ( <> -
- -
+
void + setSearchTerms: (value: string) => void + syncFarm: (farmId: string) => void } export const useFieldFilterStore = create()( persist( - (set) => ({ + (set, get) => ({ + farmId: null, showProductiveOnly: false, // Default to showing all fields + searchTerms: "", toggleShowProductiveOnly: () => set((state) => ({ showProductiveOnly: !state.showProductiveOnly, })), + setSearchTerms: (value) => { + set({ + searchTerms: value, + }) + }, + syncFarm(farmId: string) { + if (get().farmId !== farmId) { + set({ farmId, searchTerms: "" }) + } + }, }), { name: "field-filter-storage", // unique name - storage: createJSONStorage(() => ssrSafeJSONStorage), // Use SSR-safe storage + storage: createJSONStorage(() => ssrSafeSessionJSONStorage), // Use SSR-safe storage }, ), ) diff --git a/fdm-app/app/store/field-selection.ts b/fdm-app/app/store/field-selection.ts new file mode 100644 index 000000000..654b7d7f6 --- /dev/null +++ b/fdm-app/app/store/field-selection.ts @@ -0,0 +1,31 @@ +import { create } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" +import { ssrSafeSessionJSONStorage } from "./storage" + +interface FieldSelectionState { + farmId: string | null + fieldIds: string[] + setFieldIds: (fieldIds: string[]) => void + syncFarm: (farmId: string) => void +} + +export const useFieldSelectionStore = create()( + persist( + (set, get) => ({ + farmId: null, + fieldIds: [], + setFieldIds(fieldIds: string[]) { + set({ fieldIds }) + }, + syncFarm(farmId: string) { + if (get().farmId !== farmId) { + set({ farmId, fieldIds: [] }) + } + }, + }), + { + name: "field-selection-storage", // unique name + storage: createJSONStorage(() => ssrSafeSessionJSONStorage), // Use SSR-safe storage + }, + ), +) diff --git a/fdm-app/app/store/storage.ts b/fdm-app/app/store/storage.ts index 4c8339626..b07b2a948 100644 --- a/fdm-app/app/store/storage.ts +++ b/fdm-app/app/store/storage.ts @@ -1,8 +1,8 @@ import type { StateStorage } from "zustand/middleware" -const createSSRStorage = (): StateStorage => { +const createSSRStorage = (name: keyof Window): StateStorage => { if (typeof window !== "undefined") { - return localStorage + return window[name] } // Return a no-op storage for SSR @@ -13,4 +13,5 @@ const createSSRStorage = (): StateStorage => { } } -export const ssrSafeJSONStorage = createSSRStorage() +export const ssrSafeJSONStorage = createSSRStorage("localStorage") +export const ssrSafeSessionJSONStorage = createSSRStorage("sessionStorage")