Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 87 additions & 23 deletions src/components/share/modal/ShareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PropsWithChildren,
ReactNode,
useCallback,
Fragment,
} from "react";
import {
Button,
Expand Down Expand Up @@ -71,6 +72,32 @@ type ShareModalAccessProps<UserType, AccessType> = {
accessRoleTopMessage?: (
access: AccessData<UserType, AccessType>
) => string | ReactNode | undefined;
/**
* Rendered directly below each access row inside the members list. Lets
* consumers attach extra content to an access (e.g. a per-access sub-list).
*/
renderAccessFooter?: (
access: AccessData<UserType, AccessType>
) => ReactNode;
/**
* Rendered on the right side of each access row, inline with the role
* dropdown. Lets consumers surface a per-access action (e.g. an "Assign" CTA).
*/
renderAccessRightExtras?: (
access: AccessData<UserType, AccessType>
) => ReactNode;
/**
* Extra class name applied to each access row wrapper. Lets consumers flag
* row-level state (e.g. assignment) so CSS can decorate the row.
*/
getAccessClassName?: (
access: AccessData<UserType, AccessType>
) => string | undefined;
/**
* Overrides the default "N members" section heading (which otherwise comes
* from `useCunningham()` translations).
*/
membersTitle?: (members: AccessData<UserType, AccessType>[]) => ReactNode;
};

// We separate the props into two types to make them lighter. Here are only the search-specific props
Expand All @@ -80,6 +107,17 @@ type ShareModalSearchProps<UserType> = {
searchPlaceholder?: string;
onInviteUser?: (users: UserData<UserType>[], role: string) => void;
loading?: boolean;
/**
* Overrides the heading rendered above the search results group (defaults to
* Cunningham's `components.share.search.group_name`).
*/
searchGroupName?: string;
/**
* When `false`, typing an email that does not match any search result will
* NOT surface an "invite" action: only users returned by `onSearchUsers` can
* be selected. Defaults to `true`.
*/
allowInvitation?: boolean;
};

type ShareModalLinkSettingsProps = {
Expand Down Expand Up @@ -135,6 +173,11 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
hideMembers = false,
cannotViewChildren,
customTranslations,
renderAccessFooter,
renderAccessRightExtras,
getAccessClassName,
membersTitle,
allowInvitation = true,
...props
}: PropsWithChildren<
ShareModalProps<UserType, InvitationType, AccessType>
Expand Down Expand Up @@ -217,8 +260,12 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
};

const usersData: QuickSearchData<UserData<UserType>> = useMemo(() => {
// Filter pending users by id rather than reference: after a search refetch
// the server returns freshly allocated objects, so reference equality would
// let an already-pending user reappear and be picked twice.
const pendingIds = new Set(pendingInvitationUsers.map((u) => u.id));
const searchMemberResult = searchUsersResult?.filter(
(user) => !pendingInvitationUsers.includes(user)
(user) => !pendingIds.has(user.id)
);
let emptyString: string | undefined =
searchQuery !== ""
Expand All @@ -236,6 +283,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
* then we consider that we need to invite this person
*/
const isInvitationMode =
allowInvitation &&
isValidEmail(searchQuery ?? "") &&
!searchMemberResult?.some((user) => user.email === searchQuery);

Expand All @@ -254,7 +302,8 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
}

const group: QuickSearchData<UserData<UserType>> = {
groupName: t("components.share.search.group_name"),
groupName:
props.searchGroupName ?? t("components.share.search.group_name"),
elements: searchMemberResult ?? [],
showWhenEmpty: true,
emptyString,
Expand All @@ -268,7 +317,15 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
: undefined,
};
return group;
}, [searchUsersResult, searchQuery, t, pendingInvitationUsers, onSelect]);
}, [
searchUsersResult,
searchQuery,
t,
pendingInvitationUsers,
onSelect,
allowInvitation,
props.searchGroupName,
]);

/**
* Set the height of the list of the quick search content.
Expand Down Expand Up @@ -422,28 +479,35 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
data-testid="members-list"
>
<span className="c__share-modal__members-title">
{t(
members.length > 1
? "components.share.members.title_plural"
: "components.share.members.title_singular",
{
count: members.length,
}
)}
{membersTitle
? membersTitle(members)
: t(
members.length > 1
? "components.share.members.title_plural"
: "components.share.members.title_singular",
{
count: members.length,
}
)}
</span>
{members.map((member) => (
<ShareMemberItem
key={member.id}
accessData={member}
accessRoleKey={props.accessRoleKey ?? "role"}
canUpdate={canUpdate}
roleTopMessage={props.accessRoleTopMessage?.(member)}
roles={
props.getAccessRoles?.(member) ?? props.invitationRoles!
}
updateRole={props.onUpdateAccess}
deleteAccess={props.onDeleteAccess}
/>
<Fragment key={member.id}>
<ShareMemberItem
accessData={member}
accessRoleKey={props.accessRoleKey ?? "role"}
canUpdate={canUpdate}
roleTopMessage={props.accessRoleTopMessage?.(member)}
roles={
props.getAccessRoles?.(member) ??
props.invitationRoles!
}
updateRole={props.onUpdateAccess}
deleteAccess={props.onDeleteAccess}
rightExtras={renderAccessRightExtras?.(member)}
wrapperClassName={getAccessClassName?.(member)}
/>
{renderAccessFooter?.(member)}
</Fragment>
))}
<ShowMoreButton
show={hasNextMembers}
Expand Down
18 changes: 17 additions & 1 deletion src/components/share/modal/items/ShareMemberItem.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ReactNode } from "react";
import clsx from "clsx";
import { QuickSearchItemTemplate } from ":/components/quick-search";
import { AccessData } from "../../types";
import { UserRow } from ":/components/users/rows/UserRow";
Expand All @@ -16,6 +18,17 @@ export type ShareMemberItemProps<UserType, AccessType> = {
canUpdate?: boolean;
roleTopMessage?: DropdownMenuProps["topMessage"];
accessRoleKey?: keyof AccessData<UserType, AccessType>; // The key of the role in the access data, default to "role"
/**
* Rendered on the right side of the row, inline before the role dropdown.
* Lets consumers surface a per-access action (e.g. an "Assign" CTA).
*/
rightExtras?: ReactNode;
/**
* Optional extra class on the row wrapper. Lets consumers flag row-level
* state (e.g. assignment) that this component does not know about — CSS can
* then hook into it to decorate the row or its avatar.
*/
wrapperClassName?: string;
};

export const ShareMemberItem = <UserType, AccessType>({
Expand All @@ -26,12 +39,14 @@ export const ShareMemberItem = <UserType, AccessType>({
deleteAccess,
canUpdate = true,
roleTopMessage,
rightExtras,
wrapperClassName,
}: ShareMemberItemProps<UserType, AccessType>) => {
const roleDropdown = useDropdownMenu();
const canDelete =
accessData.is_explicit !== false && accessData.can_delete !== false;
return (
<div className="c__share-member-item">
<div className={clsx("c__share-member-item", wrapperClassName)}>
<QuickSearchItemTemplate
testId="share-member-item"
left={
Expand All @@ -44,6 +59,7 @@ export const ShareMemberItem = <UserType, AccessType>({
alwaysShowRight={true}
right={
<div className="c__share-member-item__right">
{rightExtras}
<AccessRoleDropdown
roles={roles}
selectedRole={accessData[accessRoleKey] as string}
Expand Down
144 changes: 144 additions & 0 deletions src/components/share/modal/stories/ShareModalExtensionsExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useEffect, useState } from "react";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { ShareModal } from "../ShareModal";

import { AccessData, UserData } from ":/components/share/types.ts";
import { DropdownMenuOption } from ":/components/dropdown-menu";

type UserType = UserData<object>;

type AccessType = AccessData<
UserType,
{
name: string;
}
>;

/**
* Demonstrates the access-row extension slots:
* - `renderAccessRightExtras`: an inline "Assign" CTA next to the role dropdown
* - `getAccessClassName`: flags the assigned rows so CSS can decorate them
* - `renderAccessFooter`: a note rendered directly below the assigned rows
* - `membersTitle`: a custom members section heading
* - `allowInvitation={false}`: only known users can be added, no invite-by-email
*/
export const ShareModalExtensionsExample = () => {
const [userQuery, setUserQuery] = useState("");
const [users, setUsers] = useState<UserType[]>([]);
const [loading, setLoading] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
const [members, setMembers] = useState<AccessType[]>(() => {
const ids = [1, 2, 3];
return ids.map((id) => ({
id: id.toString(),
name: "John Doe " + id,
email: "john.doe@example.com " + id,
role: id === 1 ? "viewer" : "admin",
user: {
id: id.toString(),
full_name: "John Doe " + id,
email: "john.doe@example.com " + id,
},
}));
});

const invitationRoles = [
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
{ label: "Viewer", value: "viewer" },
];

const getAccessRoles = (access: AccessType): DropdownMenuOption[] => {
const isAdmin = access.role === "admin";
return [
{ label: "Admin", value: "admin", isDisabled: false },
{ label: "Editor", value: "editor", isDisabled: isAdmin },
{ label: "Viewer", value: "viewer", isDisabled: isAdmin },
];
};

useEffect(() => {
if (userQuery === "") {
setUsers([]);
return;
}
const id1 = Math.floor(Math.random() * 999) + 1;
setLoading(true);
const timeout = setTimeout(() => {
setUsers([
{
id: id1.toString(),
full_name: "Jane Doe " + id1,
email: "jane.doe@example.com " + id1,
},
]);
setLoading(false);
}, 1000);
return () => clearTimeout(timeout);
}, [userQuery]);

const toggleAssign = (id: string) => {
setAssignedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};

const onUpdateAccess = (access: AccessType, role: string) => {
setMembers(
members.map((member) =>
member.id === access.id ? { ...member, role } : member
)
);
};

const onDeleteAccess = (access: AccessType) => {
setMembers(members.filter((member) => member.id !== access.id));
};

return (
<ShareModal
isOpen={true}
onClose={() => {}}
accesses={members}
onSearchUsers={setUserQuery}
onInviteUser={console.log}
onUpdateAccess={onUpdateAccess}
onDeleteAccess={onDeleteAccess}
loading={loading}
invitationRoles={invitationRoles}
searchUsersResult={users}
getAccessRoles={getAccessRoles}
hideInvitations
// Only known users can be added — no invite-by-email action.
allowInvitation={false}
searchGroupName="Suggested users"
membersTitle={(m) => `Team (${m.length})`}
renderAccessRightExtras={(access) => (
<Button
size="small"
variant={assignedIds.has(access.id) ? "tertiary" : "secondary"}
color={assignedIds.has(access.id) ? "error" : undefined}
onClick={() => toggleAssign(access.id)}
>
{assignedIds.has(access.id) ? "Unassign" : "Assign"}
</Button>
)}
getAccessClassName={(access) =>
assignedIds.has(access.id) ? "c__share-member-item--assigned" : undefined
}
renderAccessFooter={(access) =>
assignedIds.has(access.id) ? (
<div style={{ padding: "0 var(--c--globals--spacings--base) 8px 56px" }}>
<small>Assigned to this resource</small>
</div>
) : null
}
/>
);
};
Loading
Loading