fix: filter "add group member" by organization (#14404)

This is accomplished by using the members endpoint instead of the users
endpoint, and to that end the UserAutocomplete component has been
reworked to support either endpoint as separate components with a shared
base.

* Add Storybook for groups page

This ensures it is using the right endpoint for the add member dropdown.

* Add ability to mock react-query errors
This commit is contained in:
Asher
2024-08-26 11:13:34 -08:00
committed by GitHub
parent 83f9ea17b4
commit 88d7181a47
8 changed files with 308 additions and 61 deletions
+13 -2
View File
@@ -7,7 +7,7 @@ import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
import { DecoratorHelpers } from "@storybook/addon-themes";
import { withRouter } from "storybook-addon-remix-react-router";
import { StrictMode } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { parseQueryArgs, QueryClient, QueryClientProvider } from "react-query";
import { HelmetProvider } from "react-helmet-async";
import themes from "theme";
import "theme/globalFonts";
@@ -93,7 +93,18 @@ function withQuery(Story, { parameters }) {
if (parameters.queries) {
parameters.queries.forEach((query) => {
queryClient.setQueryData(query.key, query.data);
if (query.data instanceof Error) {
// This is copied from setQueryData() but sets the error.
const cache = queryClient.getQueryCache();
const parsedOptions = parseQueryArgs(query.key)
const defaultedOptions = queryClient.defaultQueryOptions(parsedOptions)
const cachedQuery = cache.build(queryClient, defaultedOptions);
// Set manual data so react-query will not try to refetch.
cachedQuery.setData(undefined, { manual: true });
cachedQuery.setState({ error: query.data });
} else {
queryClient.setQueryData(query.key, query.data);
}
});
}
+8 -2
View File
@@ -21,7 +21,7 @@ export const groups = (organization: string) => {
} satisfies UseQueryOptions<Group[]>;
};
const getGroupQueryKey = (organization: string, groupName: string) => [
export const getGroupQueryKey = (organization: string, groupName: string) => [
"organization",
organization,
"group",
@@ -77,9 +77,15 @@ export function groupsForUser(organization: string, userId: string) {
} as const satisfies UseQueryOptions<Group[], unknown, readonly Group[]>;
}
export const groupPermissionsKey = (groupId: string) => [
"group",
groupId,
"permissions",
];
export const groupPermissions = (groupId: string) => {
return {
queryKey: ["group", groupId, "permissions"],
queryKey: groupPermissionsKey(groupId),
queryFn: () =>
API.checkAuthorization({
checks: {
+7 -1
View File
@@ -47,10 +47,16 @@ export const deleteOrganization = (queryClient: QueryClient) => {
};
};
export const organizationMembersKey = (id: string) => [
"organization",
id,
"members",
];
export const organizationMembers = (id: string) => {
return {
queryFn: () => API.getOrganizationMembers(id),
queryKey: ["organization", id, "members"],
queryKey: organizationMembersKey(id),
};
};
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockOrganizationMember } from "testHelpers/entities";
import { MemberAutocomplete } from "./UserAutocomplete";
const meta: Meta<typeof MemberAutocomplete> = {
title: "components/MemberAutocomplete",
component: MemberAutocomplete,
};
export default meta;
type Story = StoryObj<typeof MemberAutocomplete>;
export const WithLabel: Story = {
args: {
value: MockOrganizationMember,
organizationId: MockOrganizationMember.organization_id,
label: "Member",
},
};
export const NoLabel: Story = {
args: {
value: MockOrganizationMember,
organizationId: MockOrganizationMember.organization_id,
},
};
@@ -10,7 +10,7 @@ const meta: Meta<typeof UserAutocomplete> = {
export default meta;
type Story = StoryObj<typeof UserAutocomplete>;
export const Example: Story = {
export const WithLabel: Story = {
args: {
value: MockUser,
label: "User",
@@ -2,8 +2,10 @@ import { css } from "@emotion/css";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import { getErrorMessage } from "api/errors";
import { organizationMembers } from "api/queries/organizations";
import { users } from "api/queries/users";
import type { User } from "api/typesGenerated";
import type { OrganizationMemberWithUserData, User } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/AvatarData/AvatarData";
import { useDebouncedFunction } from "hooks/debounce";
@@ -16,71 +18,128 @@ import {
import { useQuery } from "react-query";
import { prepareQuery } from "utils/filters";
export type UserAutocompleteProps = {
value: User | null;
onChange: (user: User | null) => void;
label?: string;
className?: string;
size?: ComponentProps<typeof TextField>["size"];
required?: boolean;
// The common properties between users and org members that we need.
export type SelectedUser = {
avatar_url: string;
email: string;
username: string;
};
export const UserAutocomplete: FC<UserAutocompleteProps> = ({
value,
onChange,
label,
className,
size = "small",
required,
}) => {
const [autoComplete, setAutoComplete] = useState<{
value: string;
open: boolean;
}>({
value: value?.email ?? "",
open: false,
});
export type CommonAutocompleteProps<T extends SelectedUser> = {
className?: string;
label?: string;
onChange: (user: T | null) => void;
required?: boolean;
size?: ComponentProps<typeof TextField>["size"];
value: T | null;
};
export type UserAutocompleteProps = CommonAutocompleteProps<User>;
export const UserAutocomplete: FC<UserAutocompleteProps> = (props) => {
const [filter, setFilter] = useState<string>();
const usersQuery = useQuery({
...users({
q: prepareQuery(encodeURI(autoComplete.value)),
q: prepareQuery(encodeURI(filter ?? "")),
limit: 25,
}),
enabled: autoComplete.open,
enabled: filter !== undefined,
keepPreviousData: true,
});
return (
<InnerAutocomplete<User>
error={usersQuery.error}
isFetching={usersQuery.isFetching}
setFilter={setFilter}
users={usersQuery.data?.users}
{...props}
/>
);
};
export type MemberAutocompleteProps =
CommonAutocompleteProps<OrganizationMemberWithUserData> & {
organizationId: string;
};
export const MemberAutocomplete: FC<MemberAutocompleteProps> = ({
organizationId,
...props
}) => {
const [filter, setFilter] = useState<string>();
// Currently this queries all members, as there is no pagination.
const membersQuery = useQuery({
...organizationMembers(organizationId),
enabled: filter !== undefined,
keepPreviousData: true,
});
return (
<InnerAutocomplete<OrganizationMemberWithUserData>
error={membersQuery.error}
isFetching={membersQuery.isFetching}
setFilter={setFilter}
users={membersQuery.data}
{...props}
/>
);
};
type InnerAutocompleteProps<T extends SelectedUser> =
CommonAutocompleteProps<T> & {
/** The error is null if not loaded or no error. */
error: unknown;
isFetching: boolean;
/** Filter is undefined if the autocomplete is closed. */
setFilter: (filter: string | undefined) => void;
/** Users are undefined if not loaded or errored. */
users: readonly T[] | undefined;
};
const InnerAutocomplete = <T extends SelectedUser>({
className,
error,
isFetching,
label,
onChange,
required,
setFilter,
size = "small",
users,
value,
}: InnerAutocompleteProps<T>) => {
const [open, setOpen] = useState(false);
const { debounced: debouncedInputOnChange } = useDebouncedFunction(
(event: ChangeEvent<HTMLInputElement>) => {
setAutoComplete((state) => ({
...state,
value: event.target.value,
}));
setFilter(event.target.value ?? "");
},
750,
);
return (
<Autocomplete
noOptionsText="No users found"
noOptionsText={
error
? getErrorMessage(error, "Unable to fetch users")
: "No users found"
}
className={className}
options={usersQuery.data?.users ?? []}
loading={usersQuery.isLoading}
options={users ?? []}
loading={!users && !error}
value={value}
data-testid="user-autocomplete"
open={autoComplete.open}
open={open}
isOptionEqualToValue={(a, b) => a.username === b.username}
getOptionLabel={(option) => option.email}
onOpen={() => {
setAutoComplete((state) => ({
...state,
open: true,
}));
setOpen(true);
setFilter(value?.email ?? "");
}}
onClose={() => {
setAutoComplete({
value: value?.email ?? "",
open: false,
});
setOpen(false);
setFilter(undefined);
}}
onChange={(_, newValue) => {
onChange(newValue);
@@ -117,9 +176,7 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
),
endAdornment: (
<>
{usersQuery.isFetching && autoComplete.open && (
<CircularProgress size={16} />
)}
{isFetching && open && <CircularProgress size={16} />}
{params.InputProps.endAdornment}
</>
),
@@ -0,0 +1,131 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { getGroupQueryKey, groupPermissionsKey } from "api/queries/groups";
import { organizationMembersKey } from "api/queries/organizations";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import {
MockDefaultOrganization,
MockGroup,
MockOrganizationMember,
MockOrganizationMember2,
} from "testHelpers/entities";
import GroupPage from "./GroupPage";
const meta: Meta<typeof GroupPage> = {
title: "pages/OrganizationGroupsPage/GroupPage",
component: GroupPage,
parameters: {
reactRouter: reactRouterParameters({
location: {
pathParams: {
organization: MockDefaultOrganization.name,
groupName: MockGroup.name,
},
},
routing: { path: "/organizations/:organization/groups/:groupName" },
}),
},
};
const groupQuery = (data: unknown) => ({
key: getGroupQueryKey(MockDefaultOrganization.name, MockGroup.name),
data,
});
const permissionsQuery = (data: unknown, id?: string) => ({
key: groupPermissionsKey(id ?? MockGroup.id),
data,
});
const membersQuery = (data: unknown) => ({
key: organizationMembersKey(MockDefaultOrganization.id),
data,
});
export default meta;
type Story = StoryObj<typeof GroupPage>;
export const LoadingGroup: Story = {
parameters: {
queries: [groupQuery(null), permissionsQuery({})],
},
};
export const GroupError: Story = {
parameters: {
queries: [groupQuery(new Error("test group error")), permissionsQuery({})],
},
};
export const LoadingPermissions: Story = {
parameters: {
queries: [groupQuery(MockGroup), permissionsQuery(null)],
},
};
export const NoUpdatePermission: Story = {
parameters: {
queries: [
groupQuery(MockGroup),
permissionsQuery({ canUpdateGroup: false }),
],
},
};
export const EveryoneGroup: Story = {
parameters: {
queries: [
groupQuery({
...MockGroup,
// The everyone group has the same ID as the organization.
id: MockDefaultOrganization.id,
}),
permissionsQuery({ canUpdateGroup: true }, MockDefaultOrganization.id),
],
},
};
export const MembersError: Story = {
parameters: {
queries: [
groupQuery(MockGroup),
permissionsQuery({ canUpdateGroup: true }),
membersQuery(new Error("test members error")),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open" }));
},
};
export const NoMembers: Story = {
parameters: {
queries: [
groupQuery({
...MockGroup,
members: [],
}),
permissionsQuery({ canUpdateGroup: true }),
membersQuery([]),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open" }));
},
};
export const FiltersByMembers: Story = {
parameters: {
queries: [
groupQuery(MockGroup),
permissionsQuery({ canUpdateGroup: true }),
membersQuery([MockOrganizationMember, MockOrganizationMember2]),
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Open" }));
},
};
@@ -18,7 +18,11 @@ import {
groupPermissions,
removeMember,
} from "api/queries/groups";
import type { Group, ReducedUser, User } from "api/typesGenerated";
import type {
Group,
OrganizationMemberWithUserData,
ReducedUser,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { AvatarData } from "components/AvatarData/AvatarData";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
@@ -39,7 +43,7 @@ import {
PaginationStatus,
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { MemberAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async";
@@ -58,9 +62,7 @@ export const GroupPage: FC = () => {
const groupQuery = useQuery(group(organization, groupName));
const groupData = groupQuery.data;
const { data: permissions } = useQuery(
groupData !== undefined
? groupPermissions(groupData.id)
: { enabled: false },
groupData ? groupPermissions(groupData.id) : { enabled: false },
);
const addMemberMutation = useMutation(addMember(queryClient));
const removeMemberMutation = useMutation(removeMember(queryClient));
@@ -133,11 +135,12 @@ export const GroupPage: FC = () => {
{canUpdateGroup && groupData && !isEveryoneGroup(groupData) && (
<AddGroupMember
isLoading={addMemberMutation.isLoading}
onSubmit={async (user, reset) => {
organizationId={groupData.organization_id}
onSubmit={async (member, reset) => {
try {
await addMemberMutation.mutateAsync({
groupId,
userId: user.id,
userId: member.user_id,
});
reset();
await groupQuery.refetch();
@@ -231,11 +234,17 @@ export const GroupPage: FC = () => {
interface AddGroupMemberProps {
isLoading: boolean;
onSubmit: (user: User, reset: () => void) => void;
onSubmit: (user: OrganizationMemberWithUserData, reset: () => void) => void;
organizationId: string;
}
const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const AddGroupMember: FC<AddGroupMemberProps> = ({
isLoading,
onSubmit,
organizationId,
}) => {
const [selectedUser, setSelectedUser] =
useState<OrganizationMemberWithUserData | null>(null);
const resetValues = () => {
setSelectedUser(null);
@@ -252,9 +261,10 @@ const AddGroupMember: FC<AddGroupMemberProps> = ({ isLoading, onSubmit }) => {
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserAutocomplete
<MemberAutocomplete
css={styles.autoComplete}
value={selectedUser}
organizationId={organizationId}
onChange={(newValue) => {
setSelectedUser(newValue);
}}