diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index dd46e2b1..7b23e1c3 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -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; + abstract getContacts(): Promise; abstract updateUser(payload: Partial & { id: string }): Promise; // Tree abstract getTree(id: string): Promise; diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index 0f6d75d0..35367b39 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -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 { + const response = await fetchAPI(`users/contacts/`); + const data = await response.json(); + return data; + } + async updateUser(payload: Partial & { id: string }): Promise { const response = await fetchAPI(`users/${payload.id}/`, { method: "PATCH", diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index f88c6093..476d1713 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -189,6 +189,8 @@ export type User = { column_preferences?: ColumnPreferences | null; }; +export type UserLight = Pick + export type LocalizedThemeCustomization = { default: T; [key: string]: T; 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 15afad63..6af6bb20 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 @@ -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; +} 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 fecfa59b..30d47506 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 @@ -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 && ( + onChange("contact", value ?? ALL)} + /> + )} ); }; +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 ( + + 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 ? ( +
+ undo + {t("explorer.filters.contact.reset")} +
+ ) : ( +
+ + {item.id === props.value && ( + check + )} +
+ ) + } + 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 }[] = [ diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index aec73603..5c085f90 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -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" }, diff --git a/src/frontend/apps/drive/src/features/users/hooks/useUserQueries.tsx b/src/frontend/apps/drive/src/features/users/hooks/useUserQueries.tsx index 785eef4d..f2369a73 100644 --- a/src/frontend/apps/drive/src/features/users/hooks/useUserQueries.tsx +++ b/src/frontend/apps/drive/src/features/users/hooks/useUserQueries.tsx @@ -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 @@ -15,3 +15,13 @@ export const useUsers = ( queryFn: () => driver.getUsers(filters), }); }; + +export const useContacts = (options?: HookUseQueryOptions) => { + const driver = getDriver(); + + return useQuery({ + ...options, + queryKey: ["contacts"], + queryFn: () => driver.getContacts(), + }); +};