Skip to content

Commit 0a71c34

Browse files
authored
feat: create and modify organization groups (#13887)
1 parent dd99457 commit 0a71c34

24 files changed

+1205
-89
lines changed

enterprise/coderd/groups.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package coderd
22

33
import (
44
"database/sql"
5+
"errors"
56
"fmt"
67
"net/http"
78

@@ -170,9 +171,9 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
170171
OrganizationID: group.OrganizationID,
171172
UserID: uuid.MustParse(id),
172173
}))
173-
if xerrors.Is(err, sql.ErrNoRows) {
174+
if errors.Is(err, sql.ErrNoRows) {
174175
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
175-
Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID),
176+
Message: fmt.Sprintf("User must be a member of organization %q", group.Name),
176177
})
177178
return
178179
}
@@ -364,7 +365,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
364365
)
365366

366367
users, err := api.Database.GetGroupMembersByGroupID(ctx, group.ID)
367-
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
368+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
368369
httpapi.InternalServerError(rw, err)
369370
return
370371
}
@@ -391,7 +392,7 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) {
391392
)
392393

393394
groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID)
394-
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
395+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
395396
httpapi.InternalServerError(rw, err)
396397
return
397398
}

site/src/api/api.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -515,19 +515,19 @@ class ApiMethods {
515515
};
516516

517517
updateOrganization = async (
518-
orgId: string,
518+
organizationId: string,
519519
params: TypesGen.UpdateOrganizationRequest,
520520
) => {
521521
const response = await this.axios.patch<TypesGen.Organization>(
522-
`/api/v2/organizations/${orgId}`,
522+
`/api/v2/organizations/${organizationId}`,
523523
params,
524524
);
525525
return response.data;
526526
};
527527

528-
deleteOrganization = async (orgId: string) => {
528+
deleteOrganization = async (organizationId: string) => {
529529
await this.axios.delete<TypesGen.Organization>(
530-
`/api/v2/organizations/${orgId}`,
530+
`/api/v2/organizations/${organizationId}`,
531531
);
532532
};
533533

@@ -1485,9 +1485,12 @@ class ApiMethods {
14851485
return response.data;
14861486
};
14871487

1488-
getGroup = async (groupName: string): Promise<TypesGen.Group> => {
1488+
getGroup = async (
1489+
organizationId: string,
1490+
groupName: string,
1491+
): Promise<TypesGen.Group> => {
14891492
const response = await this.axios.get(
1490-
`/api/v2/organizations/default/groups/${groupName}`,
1493+
`/api/v2/organizations/${organizationId}/groups/${groupName}`,
14911494
);
14921495
return response.data;
14931496
};

site/src/api/queries/groups.ts

+24-16
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import type {
99
const GROUPS_QUERY_KEY = ["groups"];
1010
type GroupSortOrder = "asc" | "desc";
1111

12-
const getGroupQueryKey = (groupName: string) => ["group", groupName];
12+
const getGroupQueryKey = (organizationId: string, groupName: string) => [
13+
organizationId,
14+
"group",
15+
groupName,
16+
];
1317

1418
export const groups = (organizationId: string) => {
1519
return {
@@ -18,10 +22,10 @@ export const groups = (organizationId: string) => {
1822
} satisfies UseQueryOptions<Group[]>;
1923
};
2024

21-
export const group = (groupName: string) => {
25+
export const group = (organizationId: string, groupName: string) => {
2226
return {
23-
queryKey: getGroupQueryKey(groupName),
24-
queryFn: () => API.getGroup(groupName),
27+
queryKey: getGroupQueryKey(organizationId, groupName),
28+
queryFn: () => API.getGroup(organizationId, groupName),
2529
};
2630
};
2731

@@ -69,7 +73,7 @@ export function groupsForUser(organizationId: string, userId: string) {
6973

7074
export const groupPermissions = (groupId: string) => {
7175
return {
72-
queryKey: [...getGroupQueryKey(groupId), "permissions"],
76+
queryKey: ["group", groupId, "permissions"],
7377
queryFn: () =>
7478
API.checkAuthorization({
7579
checks: {
@@ -85,12 +89,12 @@ export const groupPermissions = (groupId: string) => {
8589
};
8690
};
8791

88-
export const createGroup = (queryClient: QueryClient) => {
92+
export const createGroup = (
93+
queryClient: QueryClient,
94+
organizationId: string,
95+
) => {
8996
return {
90-
mutationFn: ({
91-
organizationId,
92-
...request
93-
}: CreateGroupRequest & { organizationId: string }) =>
97+
mutationFn: (request: CreateGroupRequest) =>
9498
API.createGroup(organizationId, request),
9599
onSuccess: async () => {
96100
await queryClient.invalidateQueries(GROUPS_QUERY_KEY);
@@ -106,15 +110,15 @@ export const patchGroup = (queryClient: QueryClient) => {
106110
}: PatchGroupRequest & { groupId: string }) =>
107111
API.patchGroup(groupId, request),
108112
onSuccess: async (updatedGroup: Group) =>
109-
invalidateGroup(queryClient, updatedGroup.id),
113+
invalidateGroup(queryClient, "default", updatedGroup.id),
110114
};
111115
};
112116

113117
export const deleteGroup = (queryClient: QueryClient) => {
114118
return {
115119
mutationFn: API.deleteGroup,
116120
onSuccess: async (_: void, groupId: string) =>
117-
invalidateGroup(queryClient, groupId),
121+
invalidateGroup(queryClient, "default", groupId),
118122
};
119123
};
120124

@@ -123,7 +127,7 @@ export const addMember = (queryClient: QueryClient) => {
123127
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
124128
API.addMember(groupId, userId),
125129
onSuccess: async (updatedGroup: Group) =>
126-
invalidateGroup(queryClient, updatedGroup.id),
130+
invalidateGroup(queryClient, "default", updatedGroup.id),
127131
};
128132
};
129133

@@ -132,14 +136,18 @@ export const removeMember = (queryClient: QueryClient) => {
132136
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
133137
API.removeMember(groupId, userId),
134138
onSuccess: async (updatedGroup: Group) =>
135-
invalidateGroup(queryClient, updatedGroup.id),
139+
invalidateGroup(queryClient, "default", updatedGroup.id),
136140
};
137141
};
138142

139-
export const invalidateGroup = (queryClient: QueryClient, groupId: string) =>
143+
export const invalidateGroup = (
144+
queryClient: QueryClient,
145+
organizationId: string,
146+
groupId: string,
147+
) =>
140148
Promise.all([
141149
queryClient.invalidateQueries(GROUPS_QUERY_KEY),
142-
queryClient.invalidateQueries(getGroupQueryKey(groupId)),
150+
queryClient.invalidateQueries(getGroupQueryKey(organizationId, groupId)),
143151
]);
144152

145153
export function sortGroupsByName(

site/src/api/queries/organizations.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ export const createOrganization = (queryClient: QueryClient) => {
1919
};
2020

2121
interface UpdateOrganizationVariables {
22-
orgId: string;
22+
organizationId: string;
2323
req: UpdateOrganizationRequest;
2424
}
2525

2626
export const updateOrganization = (queryClient: QueryClient) => {
2727
return {
2828
mutationFn: (variables: UpdateOrganizationVariables) =>
29-
API.updateOrganization(variables.orgId, variables.req),
29+
API.updateOrganization(variables.organizationId, variables.req),
3030

3131
onSuccess: async () => {
3232
await queryClient.invalidateQueries(organizationsKey);
@@ -36,7 +36,8 @@ export const updateOrganization = (queryClient: QueryClient) => {
3636

3737
export const deleteOrganization = (queryClient: QueryClient) => {
3838
return {
39-
mutationFn: (orgId: string) => API.deleteOrganization(orgId),
39+
mutationFn: (organizationId: string) =>
40+
API.deleteOrganization(organizationId),
4041

4142
onSuccess: async () => {
4243
await queryClient.invalidateQueries(meKey);
@@ -79,7 +80,7 @@ export const removeOrganizationMember = (
7980
};
8081
};
8182

82-
export const organizationsKey = ["organizations", "me"] as const;
83+
export const organizationsKey = ["organizations"] as const;
8384

8485
export const organizations = () => {
8586
return {

site/src/components/PageHeader/PageHeader.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,23 @@ export const PageHeaderCaption: FC<PropsWithChildren> = ({ children }) => {
107107
</span>
108108
);
109109
};
110+
111+
interface ResourcePageHeaderProps extends Omit<PageHeaderProps, "children"> {
112+
displayName?: string;
113+
name: string;
114+
}
115+
116+
export const ResourcePageHeader: FC<ResourcePageHeaderProps> = ({
117+
displayName,
118+
name,
119+
...props
120+
}) => {
121+
const title = displayName || name;
122+
123+
return (
124+
<PageHeader {...props}>
125+
<PageHeaderTitle>{title}</PageHeaderTitle>
126+
{name !== title && <PageHeaderSubtitle>{name}</PageHeaderSubtitle>}
127+
</PageHeader>
128+
);
129+
};

site/src/pages/GroupsPage/CreateGroupPage.tsx

+2-7
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@ import { Helmet } from "react-helmet-async";
33
import { useMutation, useQueryClient } from "react-query";
44
import { useNavigate } from "react-router-dom";
55
import { createGroup } from "api/queries/groups";
6-
import { useDashboard } from "modules/dashboard/useDashboard";
76
import { pageTitle } from "utils/page";
87
import CreateGroupPageView from "./CreateGroupPageView";
98

109
export const CreateGroupPage: FC = () => {
1110
const queryClient = useQueryClient();
1211
const navigate = useNavigate();
13-
const { organizationId } = useDashboard();
14-
const createGroupMutation = useMutation(createGroup(queryClient));
12+
const createGroupMutation = useMutation(createGroup(queryClient, "default"));
1513

1614
return (
1715
<>
@@ -20,10 +18,7 @@ export const CreateGroupPage: FC = () => {
2018
</Helmet>
2119
<CreateGroupPageView
2220
onSubmit={async (data) => {
23-
const newGroup = await createGroupMutation.mutateAsync({
24-
organizationId,
25-
...data,
26-
});
21+
const newGroup = await createGroupMutation.mutateAsync(data);
2722
navigate(`/groups/${newGroup.name}`);
2823
}}
2924
error={createGroupMutation.error}

site/src/pages/GroupsPage/GroupPage.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ import { isEveryoneGroup } from "utils/groups";
5454
import { pageTitle } from "utils/page";
5555

5656
export const GroupPage: FC = () => {
57-
const { groupName } = useParams() as { groupName: string };
57+
const { groupName, organization } = useParams() as {
58+
organization: string;
59+
groupName: string;
60+
};
5861
const queryClient = useQueryClient();
5962
const navigate = useNavigate();
60-
const groupQuery = useQuery(group(groupName));
63+
const groupQuery = useQuery(group(organization, groupName));
6164
const groupData = groupQuery.data;
6265
const { data: permissions } = useQuery(
6366
groupData !== undefined

site/src/pages/GroupsPage/SettingsGroupPage.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import SettingsGroupPageView from "./SettingsGroupPageView";
1313
export const SettingsGroupPage: FC = () => {
1414
const { groupName } = useParams() as { groupName: string };
1515
const queryClient = useQueryClient();
16-
const groupQuery = useQuery(group(groupName));
17-
const { data: groupData, isLoading, error } = useQuery(group(groupName));
16+
const groupQuery = useQuery(group("default", groupName));
1817
const patchGroupMutation = useMutation(patchGroup(queryClient));
1918
const navigate = useNavigate();
2019

@@ -28,19 +27,20 @@ export const SettingsGroupPage: FC = () => {
2827
</Helmet>
2928
);
3029

31-
if (error) {
32-
return <ErrorAlert error={error} />;
30+
if (groupQuery.error) {
31+
return <ErrorAlert error={groupQuery.error} />;
3332
}
3433

35-
if (isLoading || !groupData) {
34+
if (groupQuery.isLoading || !groupQuery.data) {
3635
return (
3736
<>
3837
{helmet}
3938
<Loader />
4039
</>
4140
);
4241
}
43-
const groupId = groupData.id;
42+
43+
const groupId = groupQuery.data.id;
4444

4545
return (
4646
<>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { FC } from "react";
2+
import { Helmet } from "react-helmet-async";
3+
import { useMutation, useQueryClient } from "react-query";
4+
import { useNavigate, useParams } from "react-router-dom";
5+
import { createGroup } from "api/queries/groups";
6+
import { pageTitle } from "utils/page";
7+
import CreateGroupPageView from "./CreateGroupPageView";
8+
9+
export const CreateGroupPage: FC = () => {
10+
const queryClient = useQueryClient();
11+
const navigate = useNavigate();
12+
const { organization } = useParams() as { organization: string };
13+
const createGroupMutation = useMutation(
14+
createGroup(queryClient, organization),
15+
);
16+
17+
return (
18+
<>
19+
<Helmet>
20+
<title>{pageTitle("Create Group")}</title>
21+
</Helmet>
22+
<CreateGroupPageView
23+
onSubmit={async (data) => {
24+
const newGroup = await createGroupMutation.mutateAsync(data);
25+
navigate(`/organizations/${organization}/groups/${newGroup.name}`);
26+
}}
27+
error={createGroupMutation.error}
28+
isLoading={createGroupMutation.isLoading}
29+
/>
30+
</>
31+
);
32+
};
33+
export default CreateGroupPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { userEvent, within } from "@storybook/test";
3+
import { mockApiError } from "testHelpers/entities";
4+
import { CreateGroupPageView } from "./CreateGroupPageView";
5+
6+
const meta: Meta<typeof CreateGroupPageView> = {
7+
title: "pages/OrganizationGroupsPage/CreateGroupPageView",
8+
component: CreateGroupPageView,
9+
};
10+
11+
export default meta;
12+
type Story = StoryObj<typeof CreateGroupPageView>;
13+
14+
export const Example: Story = {};
15+
16+
export const WithError: Story = {
17+
args: {
18+
error: mockApiError({
19+
message: "A group named new-group already exists.",
20+
validations: [{ field: "name", detail: "Group names must be unique" }],
21+
}),
22+
},
23+
play: async ({ canvasElement, step }) => {
24+
const canvas = within(canvasElement);
25+
26+
await step("Enter name", async () => {
27+
const input = canvas.getByLabelText("Name");
28+
await userEvent.type(input, "new-group");
29+
input.blur();
30+
});
31+
},
32+
};

0 commit comments

Comments
 (0)