diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index a295832fca00e..337925a6583a4 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -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, diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index a4cded2abe1b6..d0e2610ccf17d 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -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 = ({ isLoading, onSubmit, - organizationId, templateID, templateACL, }) => { @@ -82,7 +81,6 @@ const AddTemplateUserOrGroup: React.FC = ({ { diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx similarity index 67% rename from site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx rename to site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index 207a3366f7ea5..70a3001d73b5f 100644 --- a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -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) => { - sendSearch("SEARCH", { query: event.target.value }); + setAutoComplete((state) => ({ + ...state, + value: event.target.value, + })); }, 500, ); return ( { - 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 ? ( ) : 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%; + } +`; diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index 588925fe56aa8..366900690b804 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/pages/UsersPage/UsersFilter.tsx @@ -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; }; diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index cdc5d39ae68cf..57d7260fbd4b3 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -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; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7cf0e8c8a6c28..226c6d34b93b1 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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"; @@ -2123,6 +2122,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" }, diff --git a/site/src/utils/filters.test.ts b/site/src/utils/filters.test.ts deleted file mode 100644 index 35d261a659d2a..0000000000000 --- a/site/src/utils/filters.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index 2c8dc63c07501..beb850a65e218 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -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: "", -}; diff --git a/site/src/utils/groups.ts b/site/src/utils/groups.ts index 7b89f8e6d19d4..a5321d6e614f8 100644 --- a/site/src/utils/groups.ts +++ b/site/src/utils/groups.ts @@ -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 diff --git a/site/src/xServices/template/searchUsersAndGroupsXService.ts b/site/src/xServices/template/searchUsersAndGroupsXService.ts deleted file mode 100644 index aee89da2819da..0000000000000 --- a/site/src/xServices/template/searchUsersAndGroupsXService.ts +++ /dev/null @@ -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, - }, - }, -);