From 8a95c559f88613b29feac942563e71a17c85eb75 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 19 May 2023 19:03:04 +0000 Subject: [PATCH 01/21] Add base filter --- site/.eslintrc.yaml | 1 + site/package.json | 18 +- site/{ => src/@types}/emoji-mart.d.ts | 0 site/src/@types/i18n.d.ts | 10 + site/{ => src/@types}/mui.d.ts | 0 .../components/BuildsTable/BuildAvatar.tsx | 4 +- site/src/components/UserAvatar/UserAvatar.tsx | 14 +- .../TokensPage/TokensPage.tsx | 16 +- .../pages/WorkspacesPage/WorkspacesPage.tsx | 4 +- .../WorkspacesPageView.stories.tsx | 4 +- .../WorkspacesPage/WorkspacesPageView.tsx | 531 +++++++++++++++++- site/src/theme/theme.ts | 11 +- site/src/utils/workspace.ts | 32 +- site/yarn.lock | 30 +- 14 files changed, 583 insertions(+), 92 deletions(-) rename site/{ => src/@types}/emoji-mart.d.ts (100%) create mode 100644 site/src/@types/i18n.d.ts rename site/{ => src/@types}/mui.d.ts (100%) diff --git a/site/.eslintrc.yaml b/site/.eslintrc.yaml index da75dba53d0a0..f7eb30901c5a0 100644 --- a/site/.eslintrc.yaml +++ b/site/.eslintrc.yaml @@ -136,6 +136,7 @@ rules: "object-curly-spacing": "off" react-hooks/exhaustive-deps: warn react-hooks/rules-of-hooks: error + react/display-name: "off" react/jsx-no-script-url: - error - - name: Link diff --git a/site/package.json b/site/package.json index 319dae3e3be22..1272fff9ee572 100644 --- a/site/package.json +++ b/site/package.json @@ -30,16 +30,16 @@ "dependencies": { "@emoji-mart/data": "1.0.5", "@emoji-mart/react": "1.0.1", - "@fastly/performance-observer-polyfill": "^2.0.0", - "@emotion/react": "^11.10.8", - "@emotion/styled": "^11.10.8", + "@emotion/react": "11.10.8", + "@emotion/styled": "11.10.8", + "@fastly/performance-observer-polyfill": "2.0.0", "@fontsource/ibm-plex-mono": "4.5.10", "@fontsource/inter": "4.5.11", "@monaco-editor/react": "4.5.0", - "@mui/icons-material": "^5.11.16", - "@mui/lab": "^5.0.0-alpha.129", - "@mui/material": "^5.12.3", - "@mui/styles": "^5.12.3", + "@mui/icons-material": "5.11.16", + "@mui/lab": "5.0.0-alpha.129", + "@mui/material": "5.12.3", + "@mui/styles": "5.12.3", "@tanstack/react-query": "4.22.4", "@testing-library/react-hooks": "8.0.1", "@types/color-convert": "2.0.0", @@ -111,8 +111,8 @@ "@testing-library/user-event": "14.4.3", "@types/jest": "29.4.0", "@types/node": "14.18.22", - "@types/react": "18.0.15", - "@types/react-dom": "18.0.6", + "@types/react": "18.2.6", + "@types/react-dom": "18.2.4", "@types/react-helmet": "6.1.5", "@types/react-syntax-highlighter": "15.5.5", "@types/react-virtualized-auto-sizer": "1.0.1", diff --git a/site/emoji-mart.d.ts b/site/src/@types/emoji-mart.d.ts similarity index 100% rename from site/emoji-mart.d.ts rename to site/src/@types/emoji-mart.d.ts diff --git a/site/src/@types/i18n.d.ts b/site/src/@types/i18n.d.ts new file mode 100644 index 0000000000000..bee7fa78b1c06 --- /dev/null +++ b/site/src/@types/i18n.d.ts @@ -0,0 +1,10 @@ +import "i18next" + +// https://github.com/i18next/react-i18next/issues/1543#issuecomment-1528679591 +declare module "i18next" { + interface TypeOptions { + returnNull: false + allowObjectInHTMLChildren: false + } + export function t(s: string): T +} diff --git a/site/mui.d.ts b/site/src/@types/mui.d.ts similarity index 100% rename from site/mui.d.ts rename to site/src/@types/mui.d.ts diff --git a/site/src/components/BuildsTable/BuildAvatar.tsx b/site/src/components/BuildsTable/BuildAvatar.tsx index e095bda76a32e..b5f80b19164e1 100644 --- a/site/src/components/BuildsTable/BuildAvatar.tsx +++ b/site/src/components/BuildsTable/BuildAvatar.tsx @@ -5,7 +5,7 @@ import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined" import PauseOutlined from "@mui/icons-material/PauseOutlined" import DeleteOutlined from "@mui/icons-material/DeleteOutlined" import { WorkspaceBuild, WorkspaceTransition } from "api/typesGenerated" -import { getDisplayWorkspaceBuildStatus } from "utils/workspace" +import { getDisplayJobStatus } from "utils/workspace" import { Avatar, AvatarProps } from "components/Avatar/Avatar" import { PaletteIndex } from "theme/theme" import { Theme } from "@mui/material/styles" @@ -39,7 +39,7 @@ const iconByTransition: Record = { export const BuildAvatar: FC = ({ build, size }) => { const theme = useTheme() - const displayBuildStatus = getDisplayWorkspaceBuildStatus(theme, build) + const displayBuildStatus = getDisplayJobStatus(theme, build.job.status) return ( = ({ username, avatarURL, - className, + + ...avatarProps }) => { return ( - + {username} ) diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index f2ffa61e7e12a..fc7505a5f3ee5 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -2,7 +2,7 @@ import { FC, PropsWithChildren, useState } from "react" import { Section } from "components/SettingsLayout/Section" import { TokensPageView } from "./TokensPageView" import makeStyles from "@mui/styles/makeStyles" -import { useTranslation, Trans } from "react-i18next" +import { useTranslation } from "react-i18next" import { useTokensData } from "./hooks" import { ConfirmDeleteDialog } from "./components" import { Stack } from "components/Stack/Stack" @@ -16,12 +16,6 @@ export const TokensPage: FC> = () => { const { t } = useTranslation("tokensPage") const cliCreateCommand = "coder tokens create" - const description = ( - - Tokens are used to authenticate with the Coder API. You can create a token - with the Coder CLI using the {{ cliCreateCommand }} command. - - ) const TokenActions = () => ( @@ -52,7 +46,13 @@ export const TokensPage: FC> = () => {
+ Tokens are used to authenticate with the Coder API. You can create a + token with the Coder CLI using the {cliCreateCommand}{" "} + command. + + } layout="fluid" > diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index f69baa7d8fc20..837dc660a677c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -33,8 +33,8 @@ const WorkspacesPage: FC = () => { void - onFilter: (query: string) => void + onFilterQueryChange: (query: string) => void onUpdateWorkspace: (workspace: Workspace) => void allowAdvancedScheduling: boolean allowWorkspaceActions: boolean @@ -61,11 +64,11 @@ export const WorkspacesPageView: FC< > = ({ workspaces, error, - filter, + filterQuery, page, limit, count, - onFilter, + onFilterQueryChange, onPageChange, onUpdateWorkspace, allowAdvancedScheduling, @@ -144,16 +147,15 @@ export const WorkspacesPageView: FC< - @@ -168,3 +170,478 @@ export const WorkspacesPageView: FC< ) } + +type UserOption = { + label: string + value: string + avatarUrl?: string +} +type WorkspaceStatusOption = { + label: string + value: string + color: string +} +type TemplateOption = { + label: string + value: string + icon?: string +} +// There are null values because of the Autocomplete onChange API +export type FilterValue = { + owner?: UserOption["value"] | null + status?: WorkspaceStatusOption["value"] | null + template?: TemplateOption["value"] | null +} + +const Filter: FC<{ + filterQuery: string + onFilterQueryChange: (filterQuery: string) => void +}> = ({ filterQuery, onFilterQueryChange }) => { + const hasFilterQuery = filterQuery && filterQuery !== "" + const filter = parseFilterQuery(filterQuery) + + return ( + + onFilterQueryChange(e.target.value), + sx: { + borderRadius: "6px", + "& input::placeholder": { + color: (theme) => theme.palette.text.secondary, + }, + }, + startAdornment: ( + + theme.palette.text.secondary, + }} + /> + + ), + endAdornment: hasFilterQuery && ( + + + onFilterQueryChange("")} + > + + + + + ), + }} + /> + + onFilterQueryChange( + stringifyFilter({ ...filter, owner: newOwnerOption }), + ) + } + /> + + onFilterQueryChange( + stringifyFilter({ ...filter, template: newTemplateOption }), + ) + } + /> + + onFilterQueryChange( + stringifyFilter({ ...filter, status: newStatusOption }), + ) + } + /> + + ) +} + +const OwnerFilter: FC<{ + value: FilterValue["owner"] + onChange: (value: FilterValue["owner"]) => void +}> = ({ value, onChange }) => { + const userOptions = [MockUser, MockUser2].map( + (user) => + ({ + label: user.username, + value: user.username, + avatarUrl: user.avatar_url, + } as UserOption), + ) + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const selectedOption = userOptions.find((option) => option.value === value) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ + ) : undefined + } + onClick={() => setIsMenuOpen(true)} + sx={{ width: 220 }} + > + {selectedOption ? selectedOption.label : "All users"} + + + + theme.palette.text.secondary, + }} + /> + { + e.stopPropagation() + }} + sx={{ + height: "100%", + border: 0, + background: "none", + width: "100%", + marginLeft: 2, + outline: 0, + "&::placeholder": { + color: (theme) => theme.palette.text.secondary, + }, + }} + /> + + + {userOptions.map((option) => ( + { + onChange(option.value) + handleClose() + }} + > + + + {option.label} + + + ))} + + { + onChange(null) + handleClose() + }} + sx={{ fontSize: 14 }} + > + All users + + +
+ ) +} + +const TemplateFilter: FC<{ + value: FilterValue["template"] + onChange: (value: FilterValue["template"]) => void +}> = ({ value, onChange }) => { + const templateOptions = [MockTemplate].map( + (template) => + ({ + label: template.display_name ?? template.name, + value: template.name, + icon: template.icon, + } as TemplateOption), + ) + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const selectedOption = templateOptions.find( + (option) => option.value === value, + ) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ + ) : undefined + } + onClick={() => setIsMenuOpen(true)} + sx={{ width: 220 }} + > + {selectedOption ? selectedOption.label : "All templates"} + + + + theme.palette.text.secondary, + }} + /> + { + e.stopPropagation() + }} + sx={{ + height: "100%", + border: 0, + background: "none", + width: "100%", + marginLeft: 2, + outline: 0, + "&::placeholder": { + color: (theme) => theme.palette.text.secondary, + }, + }} + /> + + + {templateOptions.map((option) => ( + { + onChange(option.value) + handleClose() + }} + > + + + {option.label} + + + ))} + + { + onChange(null) + handleClose() + }} + sx={{ fontSize: 14 }} + > + All templates + + +
+ ) +} + +const TemplateAvatar: FC< + AvatarProps & { templateName: string; icon?: string } +> = ({ templateName, icon, ...avatarProps }) => { + return icon ? ( + + ) : ( + {templateName} + ) +} + +const StatusFilter: FC<{ + value: FilterValue["status"] + onChange: (value: FilterValue["status"]) => void +}> = ({ value, onChange }) => { + const theme = useTheme() + const buttonRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const workspaceStatusOptions: WorkspaceStatusOption[] = Object.keys( + jobStatuses, + ).map((status) => { + const display = getDisplayJobStatus(theme, status as ProvisionerJobStatus) + return { + label: display.status, + value: status, + color: display.type, + } + }) + const selectedOption = workspaceStatusOptions.find( + (option) => option.value === value, + ) + + const handleClose = () => { + setIsMenuOpen(false) + } + + return ( +
+ + ) : undefined + } + onClick={() => setIsMenuOpen(true)} + sx={{ width: 140 }} + > + {selectedOption ? selectedOption.label : "All statuses"} + + + {workspaceStatusOptions.map((option) => ( + { + onChange(option.value) + handleClose() + }} + > + + + {option.label} + + + ))} + + { + onChange(null) + handleClose() + }} + sx={{ fontSize: 14 }} + > + All statuses + + +
+ ) +} + +const StatusIndicator: FC<{ option: WorkspaceStatusOption }> = ({ option }) => { + return ( + + (theme.palette[option.color as keyof Palette] as PaletteColor).light, + }} + /> + ) +} + +const MenuButton = forwardRef((props, ref) => { + return ( +