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