mirror of
https://github.com/suitenumerique/drive.git
synced 2026-08-17 20:15:40 +02:00
✨(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:
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user