fix(site): improve chat sharing mobile layout (#25687)

This commit is contained in:
Danielle Maywood
2026-05-27 15:03:29 +01:00
committed by GitHub
parent 5603be19cc
commit d1e27889eb
5 changed files with 197 additions and 73 deletions
@@ -22,6 +22,7 @@ type UserOrGroupAutocompleteProps = {
onChange: (value: UserOrGroupAutocompleteValue) => void;
organizationId: string;
exclude: ExcludableOption[];
className?: string;
};
const normalizeMember = (
@@ -36,6 +37,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
onChange,
organizationId,
exclude,
className = "w-80",
}) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
@@ -132,7 +134,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
loading={membersQuery.isFetching || groupsQuery.isFetching}
placeholder="Search for user or group"
noOptionsText="No users or groups found"
className="w-80"
className={className}
id="workspace-user-or-group-autocomplete"
/>
);
@@ -1220,7 +1220,7 @@ export const RootChatShareActionAvailable: Story = {
await userEvent.click(canvas.getByLabelText("Share chat"));
const body = within(document.body);
await waitFor(() => {
expect(body.getByText("Chat Sharing")).toBeVisible();
expect(body.getByText("Chat sharing")).toBeVisible();
});
await waitFor(() => {
expect(body.getByText("No shared members or groups yet")).toBeVisible();
@@ -1511,7 +1511,7 @@ export const ArchivedWithSharing: Story = {
await userEvent.click(canvas.getByLabelText("Share chat"));
const body = within(document.body);
await waitFor(() => {
expect(body.getByText("Chat Sharing")).toBeVisible();
expect(body.getByText("Chat sharing")).toBeVisible();
});
await waitFor(() => {
expect(body.getByText("No shared members or groups yet")).toBeVisible();
@@ -1543,7 +1543,7 @@ export const ShareChatPopoverFromTopBar: Story = {
await userEvent.click(canvas.getByLabelText("Share chat"));
const body = within(document.body);
await waitFor(() => {
expect(body.getByText("Chat Sharing")).toBeVisible();
expect(body.getByText("Chat sharing")).toBeVisible();
});
await waitFor(() => {
expect(body.getByText("No shared members or groups yet")).toBeVisible();
@@ -119,7 +119,7 @@ const openChatSharing = async (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Share" }));
const body = within(canvasElement.ownerDocument.body);
await body.findByText("Chat Sharing");
await body.findByText("Chat sharing");
return body;
};
@@ -128,7 +128,7 @@ const closeChatSharing = async (canvasElement: HTMLElement) => {
const body = within(canvasElement.ownerDocument.body);
await userEvent.click(canvas.getByRole("button", { name: "Share" }));
await waitFor(() => {
expect(body.queryByText("Chat Sharing")).not.toBeInTheDocument();
expect(body.queryByText("Chat sharing")).not.toBeInTheDocument();
});
};
@@ -153,6 +153,12 @@ const addAutocompleteOption = async (
await userEvent.click(body.getByRole("button", { name: "Add member" }));
};
const MobileFrame = (Story: React.FC) => (
<div className="w-[390px] max-w-full">
<Story />
</div>
);
const meta: Meta<typeof ChatShareButton> = {
title: "pages/AgentsPage/ChatSharingPopover",
component: ChatShareButton,
@@ -174,10 +180,13 @@ export const EmptyACL: Story = {
play: async ({ canvasElement }) => {
const body = await openChatSharing(canvasElement);
await waitFor(() => {
expect(body.getByText("No shared members or groups yet")).toBeVisible();
expect(
body.getByText("Add a member or group using the controls above."),
).toBeVisible();
body.getAllByText("No shared members or groups yet").length,
).toBeGreaterThan(0);
expect(
body.getAllByText("Add a member or group using the controls above.")
.length,
).toBeGreaterThan(0);
});
},
};
@@ -187,11 +196,32 @@ export const PopulatedACL: Story = {
play: async ({ canvasElement }) => {
const body = await openChatSharing(canvasElement);
await waitFor(() => {
expect(body.getByText(chatUser.username)).toBeInTheDocument();
expect(body.getByText(chatGroup.name)).toBeInTheDocument();
expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0);
expect(body.getAllByText(chatGroup.name).length).toBeGreaterThan(0);
expect(body.getAllByText("Read").length).toBeGreaterThan(0);
});
expect(body.getAllByRole("button", { name: "Open menu" })).toHaveLength(2);
expect(
body.getAllByRole("button", { name: "Open menu" }).length,
).toBeGreaterThan(0);
},
};
export const MobilePopulatedACL: Story = {
decorators: [MobileFrame],
parameters: {
chromatic: { viewports: [390] },
},
beforeEach: () => mockDialogRequests({ acl: populatedACL }),
play: async ({ canvasElement }) => {
const body = await openChatSharing(canvasElement);
await waitFor(() => {
expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0);
expect(body.getAllByText(chatGroup.name).length).toBeGreaterThan(0);
expect(body.getAllByText("Read").length).toBeGreaterThan(0);
});
expect(
body.getAllByRole("button", { name: "Open menu" }).length,
).toBeGreaterThan(0);
},
};
@@ -209,7 +239,7 @@ export const CurrentUserHidden: Story = {
expect(
body.queryByText(currentChatUser.username),
).not.toBeInTheDocument();
expect(body.getByText(chatUser.username)).toBeVisible();
expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0);
});
},
};
@@ -307,7 +337,7 @@ export const RemoveUser: Story = {
play: async ({ canvasElement }) => {
const body = await openChatSharing(canvasElement);
await waitFor(() => {
expect(body.getByText(chatUser.username)).toBeInTheDocument();
expect(body.getAllByText(chatUser.username).length).toBeGreaterThan(0);
});
// Groups render before users, so the user row menu is the second one.
const menuButtons = await body.findAllByRole("button", {
@@ -334,7 +364,7 @@ export const RemoveGroup: Story = {
play: async ({ canvasElement }) => {
const body = await openChatSharing(canvasElement);
await waitFor(() => {
expect(body.getByText(chatGroup.name)).toBeInTheDocument();
expect(body.getAllByText(chatGroup.name).length).toBeGreaterThan(0);
});
// Groups render before users, so the group row menu is the first one.
const menuButtons = await body.findAllByRole("button", {
@@ -1,5 +1,5 @@
import { EllipsisVerticalIcon, Share2Icon } from "lucide-react";
import { type FC, useState } from "react";
import { EllipsisVerticalIcon, Share2Icon, UserPlusIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { toast } from "sonner";
import {
@@ -18,7 +18,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { TopbarButton } from "#/components/FullPageLayout/Topbar";
import {
Popover,
@@ -40,7 +39,6 @@ import {
UserOrGroupAutocomplete,
type UserOrGroupAutocompleteValue,
} from "#/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete";
import { AddWorkspaceMemberForm } from "#/modules/workspaces/WorkspaceSharingForm/WorkspaceSharingForm";
type ChatShareButtonProps = {
chatId: string;
@@ -57,7 +55,7 @@ type MemberRowMenuProps = {
};
const ReadRoleBadge: FC = () => (
<span className="bg-surface-secondary rounded-md px-3 py-0.5 inline-block">
<span className="inline-block shrink-0 rounded-md bg-surface-secondary px-2 py-0.5 text-xs leading-5">
Read
</span>
);
@@ -85,6 +83,89 @@ const MemberRowMenu: FC<MemberRowMenuProps> = ({ disabled, onRemove }) => (
</DropdownMenu>
);
type AddChatMemberFormProps = {
isLoading: boolean;
onSubmit: () => void;
disabled: boolean;
children: ReactNode;
};
const AddChatMemberForm: FC<AddChatMemberFormProps> = ({
isLoading,
onSubmit,
disabled,
children,
}) => (
<form action={onSubmit}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">{children}</div>
<Button
disabled={disabled || isLoading}
type="submit"
className="w-full sm:w-auto"
>
<Spinner loading={isLoading}>
<UserPlusIcon className="size-icon-sm" />
</Spinner>
Add member
</Button>
</div>
</form>
);
type MemberIdentityProps =
| { kind: "group"; group: TypesGen.ChatGroup }
| { kind: "user"; user: TypesGen.ChatUser };
const MemberIdentity: FC<MemberIdentityProps> = (props) => {
if (props.kind === "group") {
const { group } = props;
return (
<AvatarData
title={group.display_name || group.name}
subtitle={getGroupSubtitle(group)}
src={group.avatar_url}
avatar={
<Avatar
src={group.avatar_url}
fallback={group.display_name || group.name}
variant="icon"
/>
}
/>
);
}
const { user } = props;
return (
<AvatarData
title={user.username}
subtitle={user.name}
src={user.avatar_url}
/>
);
};
type MobileMemberRowProps = {
children: ReactNode;
disabled: boolean;
onRemove: () => void;
};
const MobileMemberRow: FC<MobileMemberRowProps> = ({
children,
disabled,
onRemove,
}) => (
<div className="flex items-center justify-between gap-3 border-0 border-b border-solid border-border last:border-b-0 px-1 py-3">
<div className="min-w-0 flex-1">{children}</div>
<div className="flex shrink-0 items-center gap-2">
<ReadRoleBadge />
<MemberRowMenu disabled={disabled} onRemove={onRemove} />
</div>
</div>
);
export const ChatSharingPopoverContent: FC<ChatSharingPopoverContentProps> = ({
chatId,
organizationId,
@@ -196,9 +277,12 @@ export const ChatSharingPopoverContent: FC<ChatSharingPopoverContentProps> = ({
const isEmpty = groups.length === 0 && users.length === 0;
return (
<PopoverContent align="end" className="w-[580px] p-4">
<PopoverContent
align="end"
className="w-[calc(100vw-2rem)] p-3 sm:w-[580px] sm:p-4"
>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold m-0">Chat Sharing</h3>
<h3 className="text-lg font-semibold m-0">Chat sharing</h3>
</div>
<div className="flex flex-col gap-4">
@@ -212,7 +296,7 @@ export const ChatSharingPopoverContent: FC<ChatSharingPopoverContentProps> = ({
</div>
) : acl ? (
<>
<AddWorkspaceMemberForm
<AddChatMemberForm
isLoading={isMutating}
disabled={!selectedOption}
onSubmit={handleAddMember}
@@ -222,52 +306,64 @@ export const ChatSharingPopoverContent: FC<ChatSharingPopoverContentProps> = ({
onChange={setSelectedOption}
organizationId={organizationId}
exclude={excludeFromAutocomplete}
className="w-full sm:w-80"
/>
</AddWorkspaceMemberForm>
</AddChatMemberForm>
<Table
aria-label="Shared chat members and groups"
wrapperClassName="max-h-60 overflow-y-auto"
>
<TableHeader>
<TableRow>
<TableHead className="sticky top-0 z-10 w-[50%] bg-surface-primary py-2">
Member
</TableHead>
<TableHead className="sticky top-0 z-10 w-[40%] bg-surface-primary py-2">
Role
</TableHead>
<TableHead className="sticky top-0 z-10 w-[10%] bg-surface-primary py-2" />
</TableRow>
</TableHeader>
<TableBody>
{isEmpty ? (
<TableRow>
<TableCell colSpan={3}>
<EmptyState
message="No shared members or groups yet"
description="Add a member or group using the controls above."
isCompact
/>
</TableCell>
</TableRow>
) : (
<>
{isEmpty ? (
<div className="flex min-h-44 flex-col items-center justify-center px-6 py-6 text-center">
<h4 className="m-0 text-sm font-medium text-content-secondary">
No shared members or groups yet
</h4>
<p className="m-0 mt-2 text-sm text-content-secondary">
Add a member or group using the controls above.
</p>
</div>
) : (
<div className="max-h-[min(60vh,24rem)] overflow-y-auto rounded-md border border-solid border-border sm:hidden">
{groups.map((group) => (
<MobileMemberRow
key={group.id}
disabled={isMutating}
onRemove={() => handleRemoveGroup(group)}
>
<MemberIdentity kind="group" group={group} />
</MobileMemberRow>
))}
{users.map((user) => (
<MobileMemberRow
key={user.id}
disabled={isMutating}
onRemove={() => handleRemoveUser(user)}
>
<MemberIdentity kind="user" user={user} />
</MobileMemberRow>
))}
</div>
)}
{!isEmpty && (
<div className="hidden sm:block">
<Table
aria-label="Shared chat members and groups"
wrapperClassName="max-h-60 overflow-y-auto"
>
<TableHeader>
<TableRow>
<TableHead className="sticky top-0 z-10 w-[50%] bg-surface-primary py-2">
Member
</TableHead>
<TableHead className="sticky top-0 z-10 w-[40%] bg-surface-primary py-2">
Role
</TableHead>
<TableHead className="sticky top-0 z-10 w-[10%] bg-surface-primary py-2" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="py-2 w-[50%]">
<AvatarData
title={group.display_name || group.name}
subtitle={getGroupSubtitle(group)}
src={group.avatar_url}
avatar={
<Avatar
src={group.avatar_url}
fallback={group.display_name || group.name}
variant="icon"
/>
}
/>
<MemberIdentity kind="group" group={group} />
</TableCell>
<TableCell className="py-2 w-[40%]">
<ReadRoleBadge />
@@ -283,11 +379,7 @@ export const ChatSharingPopoverContent: FC<ChatSharingPopoverContentProps> = ({
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="py-2 w-[50%]">
<AvatarData
title={user.username}
subtitle={user.name}
src={user.avatar_url}
/>
<MemberIdentity kind="user" user={user} />
</TableCell>
<TableCell className="py-2 w-[40%]">
<ReadRoleBadge />
@@ -300,10 +392,10 @@ export const ChatSharingPopoverContent: FC<ChatSharingPopoverContentProps> = ({
</TableCell>
</TableRow>
))}
</>
)}
</TableBody>
</Table>
</TableBody>
</Table>
</div>
)}
</>
) : null}
</div>