Skip to content

Commit 88d7181

Browse files
authored
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
1 parent 83f9ea1 commit 88d7181

File tree

8 files changed

+306
-59
lines changed

8 files changed

+306
-59
lines changed

site/.storybook/preview.jsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
77
import { DecoratorHelpers } from "@storybook/addon-themes";
88
import { withRouter } from "storybook-addon-remix-react-router";
99
import { StrictMode } from "react";
10-
import { QueryClient, QueryClientProvider } from "react-query";
10+
import { parseQueryArgs, QueryClient, QueryClientProvider } from "react-query";
1111
import { HelmetProvider } from "react-helmet-async";
1212
import themes from "theme";
1313
import "theme/globalFonts";
@@ -93,7 +93,18 @@ function withQuery(Story, { parameters }) {
9393

9494
if (parameters.queries) {
9595
parameters.queries.forEach((query) => {
96-
queryClient.setQueryData(query.key, query.data);
96+
if (query.data instanceof Error) {
97+
// This is copied from setQueryData() but sets the error.
98+
const cache = queryClient.getQueryCache();
99+
const parsedOptions = parseQueryArgs(query.key)
100+
const defaultedOptions = queryClient.defaultQueryOptions(parsedOptions)
101+
const cachedQuery = cache.build(queryClient, defaultedOptions);
102+
// Set manual data so react-query will not try to refetch.
103+
cachedQuery.setData(undefined, { manual: true });
104+
cachedQuery.setState({ error: query.data });
105+
} else {
106+
queryClient.setQueryData(query.key, query.data);
107+
}
97108
});
98109
}
99110

site/src/api/queries/groups.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const groups = (organization: string) => {
2121
} satisfies UseQueryOptions<Group[]>;
2222
};
2323

24-
const getGroupQueryKey = (organization: string, groupName: string) => [
24+
export const getGroupQueryKey = (organization: string, groupName: string) => [
2525
"organization",
2626
organization,
2727
"group",
@@ -77,9 +77,15 @@ export function groupsForUser(organization: string, userId: string) {
7777
} as const satisfies UseQueryOptions<Group[], unknown, readonly Group[]>;
7878
}
7979

80+
export const groupPermissionsKey = (groupId: string) => [
81+
"group",
82+
groupId,
83+
"permissions",
84+
];
85+
8086
export const groupPermissions = (groupId: string) => {
8187
return {
82-
queryKey: ["group", groupId, "permissions"],
88+
queryKey: groupPermissionsKey(groupId),
8389
queryFn: () =>
8490
API.checkAuthorization({
8591
checks: {

site/src/api/queries/organizations.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,16 @@ export const deleteOrganization = (queryClient: QueryClient) => {
4747
};
4848
};
4949

50+
export const organizationMembersKey = (id: string) => [
51+
"organization",
52+
id,
53+
"members",
54+
];
55+
5056
export const organizationMembers = (id: string) => {
5157
return {
5258
queryFn: () => API.getOrganizationMembers(id),
53-
queryKey: ["organization", id, "members"],
59+
queryKey: organizationMembersKey(id),
5460
};
5561
};
5662

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { MockOrganizationMember } from "testHelpers/entities";
3+
import { MemberAutocomplete } from "./UserAutocomplete";
4+
5+
const meta: Meta<typeof MemberAutocomplete> = {
6+
title: "components/MemberAutocomplete",
7+
component: MemberAutocomplete,
8+
};
9+
10+
export default meta;
11+
type Story = StoryObj<typeof MemberAutocomplete>;
12+
13+
export const WithLabel: Story = {
14+
args: {
15+
value: MockOrganizationMember,
16+
organizationId: MockOrganizationMember.organization_id,
17+
label: "Member",
18+
},
19+
};
20+
21+
export const NoLabel: Story = {
22+
args: {
23+
value: MockOrganizationMember,
24+
organizationId: MockOrganizationMember.organization_id,
25+
},
26+
};

site/src/components/UserAutocomplete/UserAutocomplete.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const meta: Meta<typeof UserAutocomplete> = {
1010
export default meta;
1111
type Story = StoryObj<typeof UserAutocomplete>;
1212

13-
export const Example: Story = {
13+
export const WithLabel: Story = {
1414
args: {
1515
value: MockUser,
1616
label: "User",

site/src/components/UserAutocomplete/UserAutocomplete.tsx

+99-42
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { css } from "@emotion/css";
22
import Autocomplete from "@mui/material/Autocomplete";
33
import CircularProgress from "@mui/material/CircularProgress";
44
import TextField from "@mui/material/TextField";
5+
import { getErrorMessage } from "api/errors";
6+
import { organizationMembers } from "api/queries/organizations";
57
import { users } from "api/queries/users";
6-
import type { User } from "api/typesGenerated";
8+
import type { OrganizationMemberWithUserData, User } from "api/typesGenerated";
79
import { Avatar } from "components/Avatar/Avatar";
810
import { AvatarData } from "components/AvatarData/AvatarData";
911
import { useDebouncedFunction } from "hooks/debounce";
@@ -16,71 +18,128 @@ import {
1618
import { useQuery } from "react-query";
1719
import { prepareQuery } from "utils/filters";
1820

19-
export type UserAutocompleteProps = {
20-
value: User | null;
21-
onChange: (user: User | null) => void;
22-
label?: string;
21+
// The common properties between users and org members that we need.
22+
export type SelectedUser = {
23+
avatar_url: string;
24+
email: string;
25+
username: string;
26+
};
27+
28+
export type CommonAutocompleteProps<T extends SelectedUser> = {
2329
className?: string;
24-
size?: ComponentProps<typeof TextField>["size"];
30+
label?: string;
31+
onChange: (user: T | null) => void;
2532
required?: boolean;
33+
size?: ComponentProps<typeof TextField>["size"];
34+
value: T | null;
2635
};
2736

28-
export const UserAutocomplete: FC<UserAutocompleteProps> = ({
29-
value,
30-
onChange,
31-
label,
32-
className,
33-
size = "small",
34-
required,
35-
}) => {
36-
const [autoComplete, setAutoComplete] = useState<{
37-
value: string;
38-
open: boolean;
39-
}>({
40-
value: value?.email ?? "",
41-
open: false,
42-
});
37+
export type UserAutocompleteProps = CommonAutocompleteProps<User>;
38+
39+
export const UserAutocomplete: FC<UserAutocompleteProps> = (props) => {
40+
const [filter, setFilter] = useState<string>();
41+
4342
const usersQuery = useQuery({
4443
...users({
45-
q: prepareQuery(encodeURI(autoComplete.value)),
44+
q: prepareQuery(encodeURI(filter ?? "")),
4645
limit: 25,
4746
}),
48-
enabled: autoComplete.open,
47+
enabled: filter !== undefined,
48+
keepPreviousData: true,
49+
});
50+
return (
51+
<InnerAutocomplete<User>
52+
error={usersQuery.error}
53+
isFetching={usersQuery.isFetching}
54+
setFilter={setFilter}
55+
users={usersQuery.data?.users}
56+
{...props}
57+
/>
58+
);
59+
};
60+
61+
export type MemberAutocompleteProps =
62+
CommonAutocompleteProps<OrganizationMemberWithUserData> & {
63+
organizationId: string;
64+
};
65+
66+
export const MemberAutocomplete: FC<MemberAutocompleteProps> = ({
67+
organizationId,
68+
...props
69+
}) => {
70+
const [filter, setFilter] = useState<string>();
71+
72+
// Currently this queries all members, as there is no pagination.
73+
const membersQuery = useQuery({
74+
...organizationMembers(organizationId),
75+
enabled: filter !== undefined,
4976
keepPreviousData: true,
5077
});
78+
return (
79+
<InnerAutocomplete<OrganizationMemberWithUserData>
80+
error={membersQuery.error}
81+
isFetching={membersQuery.isFetching}
82+
setFilter={setFilter}
83+
users={membersQuery.data}
84+
{...props}
85+
/>
86+
);
87+
};
88+
89+
type InnerAutocompleteProps<T extends SelectedUser> =
90+
CommonAutocompleteProps<T> & {
91+
/** The error is null if not loaded or no error. */
92+
error: unknown;
93+
isFetching: boolean;
94+
/** Filter is undefined if the autocomplete is closed. */
95+
setFilter: (filter: string | undefined) => void;
96+
/** Users are undefined if not loaded or errored. */
97+
users: readonly T[] | undefined;
98+
};
99+
100+
const InnerAutocomplete = <T extends SelectedUser>({
101+
className,
102+
error,
103+
isFetching,
104+
label,
105+
onChange,
106+
required,
107+
setFilter,
108+
size = "small",
109+
users,
110+
value,
111+
}: InnerAutocompleteProps<T>) => {
112+
const [open, setOpen] = useState(false);
51113

52114
const { debounced: debouncedInputOnChange } = useDebouncedFunction(
53115
(event: ChangeEvent<HTMLInputElement>) => {
54-
setAutoComplete((state) => ({
55-
...state,
56-
value: event.target.value,
57-
}));
116+
setFilter(event.target.value ?? "");
58117
},
59118
750,
60119
);
61120

62121
return (
63122
<Autocomplete
64-
noOptionsText="No users found"
123+
noOptionsText={
124+
error
125+
? getErrorMessage(error, "Unable to fetch users")
126+
: "No users found"
127+
}
65128
className={className}
66-
options={usersQuery.data?.users ?? []}
67-
loading={usersQuery.isLoading}
129+
options={users ?? []}
130+
loading={!users && !error}
68131
value={value}
69132
data-testid="user-autocomplete"
70-
open={autoComplete.open}
133+
open={open}
71134
isOptionEqualToValue={(a, b) => a.username === b.username}
72135
getOptionLabel={(option) => option.email}
73136
onOpen={() => {
74-
setAutoComplete((state) => ({
75-
...state,
76-
open: true,
77-
}));
137+
setOpen(true);
138+
setFilter(value?.email ?? "");
78139
}}
79140
onClose={() => {
80-
setAutoComplete({
81-
value: value?.email ?? "",
82-
open: false,
83-
});
141+
setOpen(false);
142+
setFilter(undefined);
84143
}}
85144
onChange={(_, newValue) => {
86145
onChange(newValue);
@@ -117,9 +176,7 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
117176
),
118177
endAdornment: (
119178
<>
120-
{usersQuery.isFetching && autoComplete.open && (
121-
<CircularProgress size={16} />
122-
)}
179+
{isFetching && open && <CircularProgress size={16} />}
123180
{params.InputProps.endAdornment}
124181
</>
125182
),

0 commit comments

Comments
 (0)