(frontend) filter items by shared contact

Add a "Shared with" filter to the topbar, listing frequent contacts
and searching people by name, wired to the contacts endpoint and the
backend contact filter.
This commit is contained in:
Nicolas Clerc
2026-06-29 15:42:58 +02:00
parent 650a29a5ed
commit 66692b3264
7 changed files with 177 additions and 4 deletions
@@ -18,6 +18,7 @@ import {
ItemBreadcrumb,
ItemType,
User,
UserLight,
WopiInfo,
WorkspaceType,
} from "./types";
@@ -40,6 +41,7 @@ export type ItemFilters = {
ordering?: string;
is_favorite?: boolean;
category?: string;
contact?: string;
};
export type PaginatedChildrenResult = {
@@ -135,6 +137,7 @@ export abstract class Driver {
// Users
abstract getUsers(filters?: UserFilters): Promise<User[]>;
abstract getContacts(): Promise<UserLight[]>;
abstract updateUser(payload: Partial<User> & { id: string }): Promise<User>;
// Tree
abstract getTree(id: string): Promise<Item>;
@@ -25,6 +25,7 @@ import {
ItemBreadcrumb,
ItemType,
User,
UserLight,
WopiInfo,
} from "../types";
import { DTODeleteAccess } from "../DTOs/AccessesDTO";
@@ -109,6 +110,12 @@ export class StandardDriver extends Driver {
return data;
}
async getContacts(): Promise<UserLight[]> {
const response = await fetchAPI(`users/contacts/`);
const data = await response.json();
return data;
}
async updateUser(payload: Partial<User> & { id: string }): Promise<User> {
const response = await fetchAPI(`users/${payload.id}/`, {
method: "PATCH",
@@ -189,6 +189,8 @@ export type User = {
column_preferences?: ColumnPreferences | null;
};
export type UserLight = Pick<User, "id" | "full_name" | "short_name">
export type LocalizedThemeCustomization<T> = {
default: T;
[key: string]: T;
@@ -1,5 +1,30 @@
.explorer__filters {
display: flex;
align-items: center;
gap: 0.5rem;
}
.explorer__filters__item {
display: flex;
align-items: center;
gap: 0.5em;
}
.explorer__filters__contact {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.explorer__filters__check {
color: var(--c--components--button--primary--background--color, #2976d8);
font-size: 20px;
}
// The ui-kit caps the SearchFilter list at 240px, which clips the last contact
// even though the popover has room. Let the popover handle the overflow instead.
.c__search-filter__popover .c__search-filter__list {
max-height: none;
}
@@ -1,16 +1,25 @@
import { FileIcon, Filter, FilterOption, IconSize } from "@gouvfr-lasuite/ui-kit";
import { useMemo } from "react";
import {
FileIcon,
Filter,
FilterOption,
IconSize,
SearchFilter,
SearchUserItem,
} from "@gouvfr-lasuite/ui-kit";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import folderIcon from "@/assets/folder/folder.svg";
import mimeOther from "@/assets/files/icons/mime-other.svg";
import { Key } from "react-aria-components";
import { useAppExplorer } from "./AppExplorer";
import { ItemType } from "@/features/drivers/types";
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 { TFunction } from "i18next";
import { ItemIcon } from "../icons/ItemIcon";
import { getItemTitle } from "../../utils/utils";
import { useAuth } from "@/features/auth/Auth";
const ALL = "all";
@@ -43,6 +52,7 @@ const getResetOption = (t: TFunction) => {
export const ExplorerFilters = () => {
const { filters, onFiltersChange } = useAppExplorer();
const { user } = useAuth();
const onChange = (name: string, value: Key | null) => {
onFiltersChange?.(handleFilterChange(filters, name, value));
@@ -54,10 +64,114 @@ export const ExplorerFilters = () => {
value={filters?.category ?? null}
onChange={(value) => onChange("category", value)}
/>
{/* Contacts and user search both require authentication. */}
{user && (
<ExplorerFilterContact
value={filters?.contact ?? null}
onChange={(value) => onChange("contact", value ?? ALL)}
/>
)}
</div>
);
};
const CONTACT_RESET = "__contact_reset__";
const contactLabel = (user: UserLight) => user.full_name || user.short_name || "";
type ContactItem = { id: string; label: string; user?: UserLight };
export const ExplorerFilterContact = (props: {
value: string | null;
onChange: (value: string | null) => void;
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const isSearching = search.length >= 5;
const { data: contacts, isLoading: isLoadingContacts } = useContacts({
enabled: !isSearching,
});
const { data: results, isLoading: isLoadingResults } = useUsers(
{ q: search },
{ enabled: isSearching },
);
// Below the search threshold, filter the frequent contacts locally so the
// displayed list always matches what the user typed.
const users = useMemo(() => {
if (isSearching) {
return results ?? [];
}
const list = contacts ?? [];
if (!search) {
return list;
}
const query = search.toLowerCase();
return list.filter((user) =>
contactLabel(user).toLowerCase().includes(query),
);
}, [isSearching, results, contacts, search]);
// Derive the active label from the loaded data so it survives a remount,
// where local state would be lost while the filter value persists. A contact
// picked through global search and absent from the frequent list still loses
// its label after a remount; the filter itself stays active.
const activeContact =
contacts?.find((user) => user.id === props.value) ??
results?.find((user) => user.id === props.value);
// Reset is always present so the list height does not change on selection,
// which the popover would not recompute (it would clip the last row).
const items: ContactItem[] = useMemo(() => {
const userItems = users.map((user) => ({
id: user.id,
label: contactLabel(user),
user,
}));
return [
{ id: CONTACT_RESET, label: t("explorer.filters.contact.reset") },
...userItems,
];
}, [users, t]);
const onItemSelect = (item: ContactItem) => {
props.onChange(item.id === CONTACT_RESET ? null : item.id);
};
return (
<SearchFilter<ContactItem>
label={t("explorer.filters.contact.label")}
activeLabel={
activeContact ? contactLabel(activeContact) : undefined
}
isActive={!!props.value}
placeholder={t("explorer.filters.contact.placeholder")}
searchValue={search}
onSearchChange={setSearch}
items={items}
isLoading={isSearching ? isLoadingResults : isLoadingContacts}
emptyState={t("explorer.filters.contact.empty")}
renderItem={(item) =>
item.id === CONTACT_RESET ? (
<div className="explorer__filters__item">
<span className="material-icons">undo</span>
{t("explorer.filters.contact.reset")}
</div>
) : (
<div className="explorer__filters__contact">
<SearchUserItem user={{ ...item.user!, email: "" }} />
{item.id === props.value && (
<span className="material-icons explorer__filters__check">check</span>
)}
</div>
)
}
onItemSelect={onItemSelect}
/>
);
};
// A representative mimetype per category, resolved to an icon by the ui-kit's
// getMimeCategory so filter options match the icons shown on items.
const CATEGORY_OPTIONS: { value: string; mimetype: string }[] = [
@@ -276,6 +276,12 @@
"other": "Other"
}
},
"contact": {
"label": "Shared with",
"placeholder": "Search for a name",
"empty": "No contact found",
"reset": "Reset"
},
"workspace": {
"label": "Workspace"
},
@@ -895,6 +901,12 @@
"other": "Autre"
}
},
"contact": {
"label": "Partagé avec",
"placeholder": "Rechercher un nom",
"empty": "Aucun contact trouvé",
"reset": "Réinitialiser"
},
"workspace": {
"label": "Espace"
},
@@ -2,7 +2,7 @@ import { getDriver } from "@/features/config/Config";
import { UserFilters } from "@/features/drivers/Driver";
import { useQuery } from "@tanstack/react-query";
import { HookUseQueryOptions } from "@/utils/useQueries";
import { User } from "@/features/drivers/types";
import { User, UserLight } from "@/features/drivers/types";
export const useUsers = (
filters?: UserFilters,
options?: HookUseQueryOptions<User[]>
@@ -15,3 +15,13 @@ export const useUsers = (
queryFn: () => driver.getUsers(filters),
});
};
export const useContacts = (options?: HookUseQueryOptions<UserLight[]>) => {
const driver = getDriver();
return useQuery({
...options,
queryKey: ["contacts"],
queryFn: () => driver.getContacts(),
});
};