diff --git a/CHANGELOG.md b/CHANGELOG.md index f72ed99d..bdc43ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(backend) allow converting a file while it is being analyzed +- ✨(frontend) add file type, contact and modification date topbar filters ### Fixed diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index 7b23e1c3..049acb57 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -42,6 +42,8 @@ export type ItemFilters = { is_favorite?: boolean; category?: string; contact?: string; + updated_at_after?: string; + updated_at_before?: string; }; export type PaginatedChildrenResult = { diff --git a/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.scss b/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.scss index 6af6bb20..12049adb 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.scss +++ b/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.scss @@ -2,6 +2,9 @@ display: flex; align-items: center; gap: 0.5rem; + + // Match the custom date range picker height with the filter buttons (32px). + --c--components--forms-datepicker--height: 32px; } .explorer__filters__item { diff --git a/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.tsx b/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.tsx index 30d47506..4eb79f6f 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/app-view/ExplorerFilters.tsx @@ -6,6 +6,7 @@ import { SearchFilter, SearchUserItem, } from "@gouvfr-lasuite/ui-kit"; +import { DateRangePicker } from "@gouvfr-lasuite/cunningham-react"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import folderIcon from "@/assets/folder/folder.svg"; @@ -16,6 +17,7 @@ import { ItemType, UserLight } from "@/features/drivers/types"; import { ItemFilters, ItemFiltersScope } from "@/features/drivers/Driver"; import { useItems } from "../../hooks/useQueries"; import { useContacts, useUsers } from "@/features/users/hooks/useUserQueries"; +import { DatePreset, DateRange, presetRange } from "../../utils/dateFilters"; import { TFunction } from "i18next"; import { ItemIcon } from "../icons/ItemIcon"; import { getItemTitle } from "../../utils/utils"; @@ -58,6 +60,14 @@ export const ExplorerFilters = () => { onFiltersChange?.(handleFilterChange(filters, name, value)); }; + // The modification date filter drives two query params at once. + const onModifiedChange = (range: DateRange | null) => { + const newFilters = { ...filters }; + delete newFilters.updated_at_after; + delete newFilters.updated_at_before; + onFiltersChange?.({ ...newFilters, ...(range ?? {}) }); + }; + return (
{ onChange={(value) => onChange("contact", value ?? ALL)} /> )} +
); }; @@ -220,6 +231,94 @@ export const ExplorerFilterCategory = (props: { ); }; +const MODIFIED_PRESETS: DatePreset[] = [ + "today", + "last_7_days", + "last_30_days", + "this_year", +]; +const MODIFIED_CUSTOM = "custom"; + +export const ExplorerFilterModified = (props: { + onChange: (range: DateRange | null) => void; +}) => { + const { t } = useTranslation(); + const [preset, setPreset] = useState(null); + const [isCustom, setIsCustom] = useState(false); + + const options: FilterOption[] = useMemo( + () => [ + { ...getResetOption(t), showSeparator: true }, + ...MODIFIED_PRESETS.map((value) => ({ + label: t(`explorer.filters.modified.options.${value}`), + value, + render: () => ( +
+ {t(`explorer.filters.modified.options.${value}`)} +
+ ), + })), + { + label: t("explorer.filters.modified.options.custom"), + value: MODIFIED_CUSTOM, + render: () => ( +
+ {t("explorer.filters.modified.options.custom")} +
+ ), + }, + ], + [t], + ); + + const onSelectionChange = (key: Key | null) => { + if (key === ALL) { + setPreset(null); + setIsCustom(false); + props.onChange(null); + return; + } + if (key === MODIFIED_CUSTOM) { + setPreset(key); + setIsCustom(true); + return; + } + setPreset(key); + setIsCustom(false); + props.onChange(presetRange(key as DatePreset)); + }; + + return ( + <> + + {isCustom && ( + + // The picker emits ISO datetimes; the backend wants date-only bounds. + props.onChange( + range + ? { + updated_at_after: range[0].slice(0, 10), + updated_at_before: range[1].slice(0, 10), + } + : null, + ) + } + /> + )} + + ); +}; + export const ExplorerFilterType = (props: { value: ItemType | null; onChange: (value: Key | null) => void; diff --git a/src/frontend/apps/drive/src/features/explorer/utils/__tests__/dateFilters.test.ts b/src/frontend/apps/drive/src/features/explorer/utils/__tests__/dateFilters.test.ts new file mode 100644 index 00000000..8d4b8692 --- /dev/null +++ b/src/frontend/apps/drive/src/features/explorer/utils/__tests__/dateFilters.test.ts @@ -0,0 +1,40 @@ +import { presetRange, toISODate } from "../dateFilters"; + +describe("presetRange", () => { + // 2026-05-29 in local time (month is 0-indexed). + const today = new Date(2026, 4, 29); + + it("returns the current day for the today preset", () => { + expect(presetRange("today", today)).toEqual({ + updated_at_after: "2026-05-29", + updated_at_before: "2026-05-29", + }); + }); + + it("spans the last 7 days, today included", () => { + expect(presetRange("last_7_days", today)).toEqual({ + updated_at_after: "2026-05-23", + updated_at_before: "2026-05-29", + }); + }); + + it("spans the last 30 days, today included", () => { + expect(presetRange("last_30_days", today)).toEqual({ + updated_at_after: "2026-04-30", + updated_at_before: "2026-05-29", + }); + }); + + it("spans from the first day of the year", () => { + expect(presetRange("this_year", today)).toEqual({ + updated_at_after: "2026-01-01", + updated_at_before: "2026-05-29", + }); + }); +}); + +describe("toISODate", () => { + it("formats a local date as YYYY-MM-DD", () => { + expect(toISODate(new Date(2026, 0, 5))).toBe("2026-01-05"); + }); +}); diff --git a/src/frontend/apps/drive/src/features/explorer/utils/dateFilters.ts b/src/frontend/apps/drive/src/features/explorer/utils/dateFilters.ts new file mode 100644 index 00000000..2739a0c2 --- /dev/null +++ b/src/frontend/apps/drive/src/features/explorer/utils/dateFilters.ts @@ -0,0 +1,49 @@ +import { ItemFilters } from "@/features/drivers/Driver"; + +export type DatePreset = "today" | "last_7_days" | "last_30_days" | "this_year"; + +export type DateRange = Pick< + ItemFilters, + "updated_at_after" | "updated_at_before" +>; + +const pad = (value: number) => String(value).padStart(2, "0"); + +/** + * Format a date as a local YYYY-MM-DD string, matching the date-only bounds the + * backend filter expects (it compares on the date part, whole-day inclusive). + */ +export const toISODate = (date: Date): string => + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + +const addDays = (date: Date, days: number): Date => { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +}; + +/** + * Compute the modification date range for a preset, today included as the upper + * bound. `today` is injectable for deterministic tests. + */ +export const presetRange = (preset: DatePreset, today: Date = new Date()): DateRange => { + let after: Date; + switch (preset) { + case "today": + after = today; + break; + case "last_7_days": + after = addDays(today, -6); + break; + case "last_30_days": + after = addDays(today, -29); + break; + case "this_year": + after = new Date(today.getFullYear(), 0, 1); + break; + } + return { + updated_at_after: toISODate(after), + updated_at_before: toISODate(today), + }; +}; diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index 5c085f90..c11e1b9a 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -282,6 +282,18 @@ "empty": "No contact found", "reset": "Reset" }, + "modified": { + "label": "Modified", + "options": { + "today": "Today", + "last_7_days": "Last 7 days", + "last_30_days": "Last 30 days", + "this_year": "This year", + "custom": "Custom range" + }, + "start": "Start", + "end": "End" + }, "workspace": { "label": "Workspace" }, @@ -907,6 +919,18 @@ "empty": "Aucun contact trouvé", "reset": "Réinitialiser" }, + "modified": { + "label": "Modifié", + "options": { + "today": "Aujourd'hui", + "last_7_days": "7 derniers jours", + "last_30_days": "30 derniers jours", + "this_year": "Cette année", + "custom": "Période personnalisée" + }, + "start": "Début", + "end": "Fin" + }, "workspace": { "label": "Espace" },