From 70b2a65f0b759ff54d5785a6aad39fb48adae00f Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Tue, 16 Jun 2026 09:23:13 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20allow=20to=20extend=20Sha?= =?UTF-8?q?redItemRow=20in=20ShareModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In some UI Kit consumer, we need to extend ShareModal rows to add extra action and information. We add 2 slots to add cta into right section of row and also other stuff below the row. Furthermore, the invitation feature can sometime does not make sense, so we add a prop to disable it if needed. --- src/components/share/modal/ShareModal.tsx | 110 ++++++++++--- .../share/modal/items/ShareMemberItem.tsx | 18 ++- .../stories/ShareModalExtensionsExample.tsx | 144 ++++++++++++++++++ .../modal/stories/share-modal.stories.tsx | 38 +++++ 4 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 src/components/share/modal/stories/ShareModalExtensionsExample.tsx diff --git a/src/components/share/modal/ShareModal.tsx b/src/components/share/modal/ShareModal.tsx index 115bdaa4..cafd9e3a 100644 --- a/src/components/share/modal/ShareModal.tsx +++ b/src/components/share/modal/ShareModal.tsx @@ -10,6 +10,7 @@ import { PropsWithChildren, ReactNode, useCallback, + Fragment, } from "react"; import { Button, @@ -71,6 +72,32 @@ type ShareModalAccessProps = { accessRoleTopMessage?: ( access: AccessData ) => 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 + ) => 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 + ) => 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 + ) => string | undefined; + /** + * Overrides the default "N members" section heading (which otherwise comes + * from `useCunningham()` translations). + */ + membersTitle?: (members: AccessData[]) => ReactNode; }; // We separate the props into two types to make them lighter. Here are only the search-specific props @@ -80,6 +107,17 @@ type ShareModalSearchProps = { searchPlaceholder?: string; onInviteUser?: (users: UserData[], 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 = { @@ -135,6 +173,11 @@ export const ShareModal = ({ hideMembers = false, cannotViewChildren, customTranslations, + renderAccessFooter, + renderAccessRightExtras, + getAccessClassName, + membersTitle, + allowInvitation = true, ...props }: PropsWithChildren< ShareModalProps @@ -217,8 +260,12 @@ export const ShareModal = ({ }; const usersData: QuickSearchData> = 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 !== "" @@ -236,6 +283,7 @@ export const ShareModal = ({ * then we consider that we need to invite this person */ const isInvitationMode = + allowInvitation && isValidEmail(searchQuery ?? "") && !searchMemberResult?.some((user) => user.email === searchQuery); @@ -254,7 +302,8 @@ export const ShareModal = ({ } const group: QuickSearchData> = { - groupName: t("components.share.search.group_name"), + groupName: + props.searchGroupName ?? t("components.share.search.group_name"), elements: searchMemberResult ?? [], showWhenEmpty: true, emptyString, @@ -268,7 +317,15 @@ export const ShareModal = ({ : 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. @@ -422,28 +479,35 @@ export const ShareModal = ({ data-testid="members-list" > - {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, + } + )} {members.map((member) => ( - + + + {renderAccessFooter?.(member)} + ))} = { canUpdate?: boolean; roleTopMessage?: DropdownMenuProps["topMessage"]; accessRoleKey?: keyof AccessData; // 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 = ({ @@ -26,12 +39,14 @@ export const ShareMemberItem = ({ deleteAccess, canUpdate = true, roleTopMessage, + rightExtras, + wrapperClassName, }: ShareMemberItemProps) => { const roleDropdown = useDropdownMenu(); const canDelete = accessData.is_explicit !== false && accessData.can_delete !== false; return ( -
+
({ alwaysShowRight={true} right={
+ {rightExtras} ; + +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([]); + const [loading, setLoading] = useState(false); + const [assignedIds, setAssignedIds] = useState>(new Set()); + const [members, setMembers] = useState(() => { + 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 ( + {}} + 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) => ( + + )} + getAccessClassName={(access) => + assignedIds.has(access.id) ? "c__share-member-item--assigned" : undefined + } + renderAccessFooter={(access) => + assignedIds.has(access.id) ? ( +
+ Assigned to this resource +
+ ) : null + } + /> + ); +}; diff --git a/src/components/share/modal/stories/share-modal.stories.tsx b/src/components/share/modal/stories/share-modal.stories.tsx index 38b60bf7..4f9c3288 100644 --- a/src/components/share/modal/stories/share-modal.stories.tsx +++ b/src/components/share/modal/stories/share-modal.stories.tsx @@ -1,6 +1,7 @@ import type { Meta } from "@storybook/react"; import { Description, Title, Subtitle, ArgTypes } from "@storybook/blocks"; import { ShareModalExample } from "./ShareModalExample"; +import { ShareModalExtensionsExample } from "./ShareModalExtensionsExample"; import { ShareModal } from "../ShareModal"; /** @@ -211,6 +212,32 @@ const meta: Meta = { description: "Custom translations for component texts", control: false, }, + renderAccessFooter: { + description: "Renders custom content directly below each access row", + control: false, + }, + renderAccessRightExtras: { + description: + "Renders custom content on the right of each access row, before the role dropdown", + control: false, + }, + getAccessClassName: { + description: "Returns an extra class name applied to each access row wrapper", + control: false, + }, + membersTitle: { + description: "Overrides the default members section heading", + control: false, + }, + searchGroupName: { + description: "Overrides the search results group heading", + control: "text", + }, + allowInvitation: { + description: + "When false, typing an unknown email won't offer an invite action (default: true)", + control: "boolean", + }, }, }; @@ -228,6 +255,17 @@ export const WithoutLinkSettings = { render: () => , }; +/** + * Demonstrates the access-row extension slots: an inline "Assign" CTA + * (`renderAccessRightExtras`), a per-row class for assigned state + * (`getAccessClassName`), a note below assigned rows (`renderAccessFooter`), + * a custom members heading (`membersTitle`) and invitation-by-email disabled + * (`allowInvitation={false}`). + */ +export const WithAccessExtensions = { + render: () => , +}; + export const DefaultCannotView = { render: () => , };