mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(site): remove search users and groups xservice (#10353)
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
type TemplateVersion,
|
||||
CreateTemplateRequest,
|
||||
ProvisionerJob,
|
||||
UsersRequest,
|
||||
TemplateRole,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
@@ -156,6 +157,16 @@ export const updateActiveTemplateVersion = (
|
||||
};
|
||||
};
|
||||
|
||||
export const templaceACLAvailable = (
|
||||
templateId: string,
|
||||
options: UsersRequest,
|
||||
) => {
|
||||
return {
|
||||
queryKey: ["template", templateId, "aclAvailable", options],
|
||||
queryFn: () => API.getTemplateACLAvailable(templateId, options),
|
||||
};
|
||||
};
|
||||
|
||||
export const templateVersionExternalAuthKey = (versionId: string) => [
|
||||
"templateVersion",
|
||||
versionId,
|
||||
|
||||
+1
-3
@@ -25,7 +25,7 @@ import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
|
||||
import {
|
||||
UserOrGroupAutocomplete,
|
||||
UserOrGroupAutocompleteValue,
|
||||
} from "components/UserOrGroupAutocomplete/UserOrGroupAutocomplete";
|
||||
} from "./UserOrGroupAutocomplete";
|
||||
import { type FC, useState } from "react";
|
||||
import { GroupAvatar } from "components/GroupAvatar/GroupAvatar";
|
||||
import { getGroupSubtitle } from "utils/groups";
|
||||
@@ -46,7 +46,6 @@ type AddTemplateUserOrGroupProps = {
|
||||
const AddTemplateUserOrGroup: React.FC<AddTemplateUserOrGroupProps> = ({
|
||||
isLoading,
|
||||
onSubmit,
|
||||
organizationId,
|
||||
templateID,
|
||||
templateACL,
|
||||
}) => {
|
||||
@@ -82,7 +81,6 @@ const AddTemplateUserOrGroup: React.FC<AddTemplateUserOrGroupProps> = ({
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<UserOrGroupAutocomplete
|
||||
exclude={excludeFromAutocomplete}
|
||||
organizationId={organizationId}
|
||||
templateID={templateID}
|
||||
value={selectedOption}
|
||||
onChange={(newValue) => {
|
||||
|
||||
+61
-43
@@ -1,83 +1,85 @@
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import { useMachine } from "@xstate/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { type ChangeEvent, useState } from "react";
|
||||
import { css } from "@emotion/react";
|
||||
import type { Group, User } from "api/typesGenerated";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { getGroupSubtitle } from "utils/groups";
|
||||
import { searchUsersAndGroupsMachine } from "xServices/template/searchUsersAndGroupsXService";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import { useQuery } from "react-query";
|
||||
import { templaceACLAvailable } from "api/queries/templates";
|
||||
import { prepareQuery } from "utils/filters";
|
||||
|
||||
export type UserOrGroupAutocompleteValue = User | Group | null;
|
||||
|
||||
const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => {
|
||||
return value !== null && "members" in value;
|
||||
};
|
||||
|
||||
export type UserOrGroupAutocompleteProps = {
|
||||
value: UserOrGroupAutocompleteValue;
|
||||
onChange: (value: UserOrGroupAutocompleteValue) => void;
|
||||
organizationId: string;
|
||||
templateID?: string;
|
||||
templateID: string;
|
||||
exclude: UserOrGroupAutocompleteValue[];
|
||||
};
|
||||
|
||||
const autoCompleteStyles = css`
|
||||
width: 300px;
|
||||
|
||||
& .MuiFormControl-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& .MuiInputBase-root {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const UserOrGroupAutocomplete: React.FC<
|
||||
UserOrGroupAutocompleteProps
|
||||
> = ({ value, onChange, organizationId, templateID, exclude }) => {
|
||||
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
|
||||
const [searchState, sendSearch] = useMachine(searchUsersAndGroupsMachine, {
|
||||
context: {
|
||||
userResults: [],
|
||||
groupResults: [],
|
||||
organizationId,
|
||||
templateID,
|
||||
},
|
||||
> = ({ value, onChange, templateID, exclude }) => {
|
||||
const [autoComplete, setAutoComplete] = useState<{
|
||||
value: string;
|
||||
open: boolean;
|
||||
}>({
|
||||
value: "",
|
||||
open: false,
|
||||
});
|
||||
const { userResults, groupResults } = searchState.context;
|
||||
const options = [...groupResults, ...userResults].filter((result) => {
|
||||
const excludeIds = exclude.map((optionToExclude) => optionToExclude?.id);
|
||||
return !excludeIds.includes(result.id);
|
||||
const aclAvailableQuery = useQuery({
|
||||
...templaceACLAvailable(templateID, {
|
||||
q: prepareQuery(encodeURI(autoComplete.value)),
|
||||
limit: 25,
|
||||
}),
|
||||
enabled: autoComplete.open,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
const options = aclAvailableQuery.data
|
||||
? [
|
||||
...aclAvailableQuery.data.groups,
|
||||
...aclAvailableQuery.data.users,
|
||||
].filter((result) => {
|
||||
const excludeIds = exclude.map(
|
||||
(optionToExclude) => optionToExclude?.id,
|
||||
);
|
||||
return !excludeIds.includes(result.id);
|
||||
})
|
||||
: [];
|
||||
|
||||
const { debounced: handleFilterChange } = useDebouncedFunction(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
sendSearch("SEARCH", { query: event.target.value });
|
||||
setAutoComplete((state) => ({
|
||||
...state,
|
||||
value: event.target.value,
|
||||
}));
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
noOptionsText="No users or groups found"
|
||||
value={value}
|
||||
id="user-or-group-autocomplete"
|
||||
open={isAutocompleteOpen}
|
||||
open={autoComplete.open}
|
||||
onOpen={() => {
|
||||
setIsAutocompleteOpen(true);
|
||||
setAutoComplete((state) => ({
|
||||
...state,
|
||||
open: true,
|
||||
}));
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsAutocompleteOpen(false);
|
||||
setAutoComplete({
|
||||
value: isGroup(value) ? value.display_name : value?.email ?? "",
|
||||
open: false,
|
||||
});
|
||||
}}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue === null) {
|
||||
sendSearch("CLEAR_RESULTS");
|
||||
}
|
||||
|
||||
onChange(newValue);
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
@@ -102,7 +104,7 @@ export const UserOrGroupAutocomplete: React.FC<
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
loading={searchState.matches("searching")}
|
||||
loading={aclAvailableQuery.isFetching}
|
||||
css={autoCompleteStyles}
|
||||
renderInput={(params) => (
|
||||
<>
|
||||
@@ -116,7 +118,7 @@ export const UserOrGroupAutocomplete: React.FC<
|
||||
onChange: handleFilterChange,
|
||||
endAdornment: (
|
||||
<>
|
||||
{searchState.matches("searching") ? (
|
||||
{aclAvailableQuery.isFetching ? (
|
||||
<CircularProgress size={16} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
@@ -129,3 +131,19 @@ export const UserOrGroupAutocomplete: React.FC<
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => {
|
||||
return value !== null && "members" in value;
|
||||
};
|
||||
|
||||
const autoCompleteStyles = css`
|
||||
width: 300px;
|
||||
|
||||
& .MuiFormControl-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& .MuiInputBase-root {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
} from "components/Filter/filter";
|
||||
import { BaseOption } from "components/Filter/options";
|
||||
import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu";
|
||||
import { userFilterQuery } from "utils/filters";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
const userFilterQuery = {
|
||||
active: "status:active",
|
||||
all: "",
|
||||
};
|
||||
|
||||
type StatusOption = BaseOption & {
|
||||
color: string;
|
||||
};
|
||||
|
||||
@@ -16,9 +16,16 @@ import {
|
||||
useFilter,
|
||||
} from "components/Filter/filter";
|
||||
import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
|
||||
import { workspaceFilterQuery } from "utils/filters";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
export const workspaceFilterQuery = {
|
||||
me: "owner:me",
|
||||
all: "",
|
||||
running: "status:running",
|
||||
failed: "status:failed",
|
||||
dormant: "is-dormant:true",
|
||||
};
|
||||
|
||||
type FilterPreset = {
|
||||
query: string;
|
||||
name: string;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type Health,
|
||||
} from "api/api";
|
||||
import { FieldError } from "api/errors";
|
||||
import { everyOneGroup } from "utils/groups";
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
import range from "lodash/range";
|
||||
import { Permissions } from "components/AuthProvider/permissions";
|
||||
@@ -2145,6 +2144,17 @@ export const MockGroup: TypesGen.Group = {
|
||||
source: "user",
|
||||
};
|
||||
|
||||
const everyOneGroup = (organizationId: string): TypesGen.Group => ({
|
||||
id: organizationId,
|
||||
name: "Everyone",
|
||||
display_name: "",
|
||||
organization_id: organizationId,
|
||||
members: [],
|
||||
avatar_url: "",
|
||||
quota_allowance: 0,
|
||||
source: "user",
|
||||
});
|
||||
|
||||
export const MockTemplateACL: TypesGen.TemplateACL = {
|
||||
group: [
|
||||
{ ...everyOneGroup(MockOrganization.id), role: "use" },
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
import { queryToFilter } from "./filters";
|
||||
|
||||
describe("queryToFilter", () => {
|
||||
it.each<
|
||||
[string | undefined, TypesGen.WorkspaceFilter | TypesGen.UsersRequest]
|
||||
>([
|
||||
[undefined, {}],
|
||||
["", { q: "" }],
|
||||
["asdkfvjn", { q: "asdkfvjn" }],
|
||||
["owner:me", { q: "owner:me" }],
|
||||
["owner:me owner:me2", { q: "owner:me owner:me2" }],
|
||||
["me/dev", { q: "me/dev" }],
|
||||
["me/", { q: "me/" }],
|
||||
[" key:val owner:me ", { q: "key:val owner:me" }],
|
||||
["status:failed", { q: "status:failed" }],
|
||||
])(`query=%p, filter=%p`, (query, filter) => {
|
||||
expect(queryToFilter(query)).toEqual(filter);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,3 @@
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
|
||||
export const queryToFilter = (
|
||||
query?: string,
|
||||
): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
|
||||
return {
|
||||
q: prepareQuery(query),
|
||||
};
|
||||
};
|
||||
|
||||
export const prepareQuery = (query?: string) => {
|
||||
return query?.trim().replace(/ +/g, " ");
|
||||
};
|
||||
|
||||
export const workspaceFilterQuery = {
|
||||
me: "owner:me",
|
||||
all: "",
|
||||
running: "status:running",
|
||||
failed: "status:failed",
|
||||
dormant: "is-dormant:true",
|
||||
};
|
||||
|
||||
export const userFilterQuery = {
|
||||
active: "status:active",
|
||||
all: "",
|
||||
};
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { Group } from "api/typesGenerated";
|
||||
|
||||
export const everyOneGroup = (organizationId: string): Group => ({
|
||||
id: organizationId,
|
||||
name: "Everyone",
|
||||
display_name: "",
|
||||
organization_id: organizationId,
|
||||
members: [],
|
||||
avatar_url: "",
|
||||
quota_allowance: 0,
|
||||
source: "user",
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns true if the provided group is the 'Everyone' group.
|
||||
* The everyone group represents all the users in an organization
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { getGroups, getTemplateACLAvailable, getUsers } from "api/api";
|
||||
import { Group, User } from "api/typesGenerated";
|
||||
import { queryToFilter } from "utils/filters";
|
||||
import { everyOneGroup } from "utils/groups";
|
||||
import { assign, createMachine } from "xstate";
|
||||
|
||||
export type SearchUsersAndGroupsEvent =
|
||||
| { type: "SEARCH"; query: string }
|
||||
| { type: "CLEAR_RESULTS" };
|
||||
|
||||
export const searchUsersAndGroupsMachine = createMachine(
|
||||
{
|
||||
id: "searchUsersAndGroups",
|
||||
predictableActionArguments: true,
|
||||
schema: {
|
||||
context: {} as {
|
||||
organizationId: string;
|
||||
templateID?: string;
|
||||
userResults: User[];
|
||||
groupResults: Group[];
|
||||
},
|
||||
events: {} as SearchUsersAndGroupsEvent,
|
||||
services: {} as {
|
||||
search: {
|
||||
data: {
|
||||
users: User[];
|
||||
groups: Group[];
|
||||
};
|
||||
};
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./searchUsersAndGroupsXService.typegen").Typegen0,
|
||||
initial: "idle",
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
SEARCH: {
|
||||
target: "searching",
|
||||
cond: "queryHasMinLength",
|
||||
},
|
||||
CLEAR_RESULTS: {
|
||||
actions: ["clearResults"],
|
||||
target: "idle",
|
||||
},
|
||||
},
|
||||
},
|
||||
searching: {
|
||||
invoke: {
|
||||
src: "search",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: ["assignSearchResults"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
search: async ({ organizationId, templateID }, { query }) => {
|
||||
let users, groups;
|
||||
if (templateID && templateID !== "") {
|
||||
const res = await getTemplateACLAvailable(
|
||||
templateID,
|
||||
queryToFilter(query),
|
||||
);
|
||||
users = res.users;
|
||||
groups = res.groups;
|
||||
} else {
|
||||
const [userRes, groupsRes] = await Promise.all([
|
||||
getUsers(queryToFilter(query)),
|
||||
getGroups(organizationId),
|
||||
]);
|
||||
|
||||
users = userRes.users;
|
||||
groups = groupsRes;
|
||||
}
|
||||
|
||||
// The Everyone groups is not returned by the API so we have to add it
|
||||
// manually
|
||||
return {
|
||||
users: users,
|
||||
groups: [everyOneGroup(organizationId), ...groups],
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
assignSearchResults: assign({
|
||||
userResults: (_, { data }) => data.users,
|
||||
groupResults: (_, { data }) => data.groups,
|
||||
}),
|
||||
clearResults: assign({
|
||||
userResults: (_) => [],
|
||||
groupResults: (_) => [],
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
queryHasMinLength: (_, { query }) => query.length >= 3,
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user