Skip to content

refactor(site): Improve workspaces filtering #7681

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Organize folder
  • Loading branch information
BrunoQuaresma committed May 25, 2023
commit 82720e4028c00be4f276ed7ef45b5474ee9b8be8
10 changes: 5 additions & 5 deletions site/src/pages/WorkspacesPage/WorkspacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { Helmet } from "react-helmet-async"
import { pageTitle } from "utils/page"
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
import { WorkspacesPageView } from "./WorkspacesPageView"
import { useFilter } from "./filter/filter"
import { useOrganizationId, usePermissions } from "hooks"
import {
useFilter,
useStatusAutocomplete,
useTemplatesAutocomplete,
useUsersAutocomplete,
} from "./Filter"
import { useOrganizationId, usePermissions } from "hooks"
useTemplatesAutocomplete,
useStatusAutocomplete,
} from "./filter/autocompletes"

const WorkspacesPage: FC = () => {
const orgId = useOrganizationId()
Expand Down
2 changes: 1 addition & 1 deletion site/src/pages/WorkspacesPage/WorkspacesPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useLocalStorage } from "hooks"
import difference from "lodash/difference"
import { ImpendingDeletionBanner } from "components/WorkspaceDeletion"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { Filter } from "./Filter"
import { Filter } from "./filter/filter"
import { hasError, isApiValidationError } from "api/errors"

export const Language = {
Expand Down
208 changes: 208 additions & 0 deletions site/src/pages/WorkspacesPage/filter/autocompletes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { useMemo, useState } from "react"
import {
BaseOption,
OwnerOption,
StatusOption,
TemplateOption,
} from "./options"
import { useQuery } from "@tanstack/react-query"
import { getTemplates, getUsers } from "api/api"
import { WorkspaceStatuses } from "api/typesGenerated"
import { getDisplayWorkspaceStatus } from "utils/workspace"

type UseAutocompleteOptions<TOption extends BaseOption> = {
id: string
initialQuery?: string
// Using null because of react-query
// https://tanstack.com/query/v4/docs/react/guides/migrating-to-react-query-4#undefined-is-an-illegal-cache-value-for-successful-queries
getInitialOption: () => Promise<TOption | null>
getOptions: (query: string) => Promise<TOption[]>
onChange: (option: TOption | undefined) => void
enabled?: boolean
}

const useAutocomplete = <TOption extends BaseOption = BaseOption>({
id,
getInitialOption,
getOptions,
onChange,
enabled,
}: UseAutocompleteOptions<TOption>) => {
const [query, setQuery] = useState("")
const [selectedOption, setSelectedOption] = useState<TOption>()
const initialOptionQuery = useQuery({
queryKey: [id, "autocomplete", "initial"],
queryFn: () => getInitialOption(),
onSuccess: (option) => setSelectedOption(option ?? undefined),
enabled,
})
const searchOptionsQuery = useQuery({
queryKey: [id, "autoComplete", "search"],
queryFn: () => getOptions(query),
enabled,
})
const searchOptions = useMemo(() => {
const isDataLoaded =
searchOptionsQuery.isFetched && initialOptionQuery.isFetched

if (!isDataLoaded) {
return undefined
}

let options = searchOptionsQuery.data as TOption[]

if (!selectedOption) {
return options
}

// We will add the initial option on the top of the options
// 1 - remove the initial option from the search options if it exists
// 2 - add the initial option on the top
options = options.filter((option) => option.value !== selectedOption.value)
options.unshift(selectedOption)

// Filter data based o search query
options = options.filter(
(option) =>
option.label.toLowerCase().includes(query.toLowerCase()) ||
option.value.toLowerCase().includes(query.toLowerCase()),
)

return options
}, [
initialOptionQuery.isFetched,
query,
searchOptionsQuery.data,
searchOptionsQuery.isFetched,
selectedOption,
])

const selectOption = (option: TOption) => {
let newSelectedOptionValue: TOption | undefined = option

if (option.value === selectedOption?.value) {
newSelectedOptionValue = undefined
}

if (onChange) {
onChange(newSelectedOptionValue)
}
setSelectedOption(newSelectedOptionValue)
}
const clearSelection = () => {
setSelectedOption(undefined)
}

return {
query,
setQuery,
selectedOption,
selectOption,
clearSelection,
isInitializing: initialOptionQuery.isInitialLoading,
initialOption: initialOptionQuery.data,
isSearching: searchOptionsQuery.isFetching,
searchOptions,
}
}

export const useUsersAutocomplete = (
initialOptionValue: string | undefined,
onChange: (option: OwnerOption | undefined) => void,
enabled?: boolean,
) =>
useAutocomplete({
id: "owner",
getInitialOption: async () => {
const usersRes = await getUsers({ q: initialOptionValue, limit: 1 })
const firstUser = usersRes.users.at(0)
if (firstUser && firstUser.username === initialOptionValue) {
return {
label: firstUser.username,
value: firstUser.username,
avatarUrl: firstUser.avatar_url,
}
}
return null
},
getOptions: async (query) => {
const usersRes = await getUsers({ q: query, limit: 25 })
return usersRes.users.map((user) => ({
label: user.username,
value: user.username,
avatarUrl: user.avatar_url,
}))
},
onChange,
enabled,
})

export type UsersAutocomplete = ReturnType<typeof useUsersAutocomplete>

export const useTemplatesAutocomplete = (
orgId: string,
initialOptionValue: string | undefined,
onChange: (option: TemplateOption | undefined) => void,
) => {
return useAutocomplete({
id: "template",
getInitialOption: async () => {
const templates = await getTemplates(orgId)
const template = templates.find(
(template) => template.name === initialOptionValue,
)
if (template) {
return {
label:
template.display_name !== ""
? template.display_name
: template.name,
value: template.name,
icon: template.icon,
}
}
return null
},
getOptions: async (query) => {
const templates = await getTemplates(orgId)
const filteredTemplates = templates.filter(
(template) =>
template.name.toLowerCase().includes(query.toLowerCase()) ||
template.display_name.toLowerCase().includes(query.toLowerCase()),
)
return filteredTemplates.map((template) => ({
label:
template.display_name !== "" ? template.display_name : template.name,
value: template.name,
icon: template.icon,
}))
},
onChange,
})
}

export type TemplatesAutocomplete = ReturnType<typeof useTemplatesAutocomplete>

export const useStatusAutocomplete = (
initialOptionValue: string | undefined,
onChange: (option: StatusOption | undefined) => void,
) => {
const statusOptions = WorkspaceStatuses.map((status) => {
const display = getDisplayWorkspaceStatus(status)
return {
label: display.text,
value: status,
color: display.type ?? "warning",
} as StatusOption
})
return useAutocomplete({
id: "status",
getInitialOption: async () =>
statusOptions.find((option) => option.value === initialOptionValue) ??
null,
getOptions: async () => statusOptions,
onChange,
})
}

export type StatusAutocomplete = ReturnType<typeof useStatusAutocomplete>
Loading