(frontend) filter items by modification date

Add a "Modified" filter to the topbar with Today / Last 7 days /
Last 30 days / This year presets and a compact custom date range
picker, wired to the updated_at range filter.
This commit is contained in:
Nicolas Clerc
2026-06-29 15:42:58 +02:00
parent 66692b3264
commit 84986fdf6c
7 changed files with 218 additions and 0 deletions
+1
View File
@@ -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
@@ -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 = {
@@ -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 {
@@ -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 (
<div className="explorer__filters">
<ExplorerFilterCategory
@@ -71,6 +81,7 @@ export const ExplorerFilters = () => {
onChange={(value) => onChange("contact", value ?? ALL)}
/>
)}
<ExplorerFilterModified onChange={onModifiedChange} />
</div>
);
};
@@ -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<Key | null>(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: () => (
<div className="explorer__filters__item">
{t(`explorer.filters.modified.options.${value}`)}
</div>
),
})),
{
label: t("explorer.filters.modified.options.custom"),
value: MODIFIED_CUSTOM,
render: () => (
<div className="explorer__filters__item">
{t("explorer.filters.modified.options.custom")}
</div>
),
},
],
[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 (
<>
<Filter
label={t("explorer.filters.modified.label")}
options={options}
selectedKey={preset}
onSelectionChange={onSelectionChange}
/>
{isCustom && (
<DateRangePicker
compact
hideLabel
startLabel={t("explorer.filters.modified.start")}
endLabel={t("explorer.filters.modified.end")}
onChange={(range) =>
// 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;
@@ -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");
});
});
@@ -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),
};
};
@@ -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"
},