diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts index 224f8b0d12815..9be370271c74d 100644 --- a/site/src/api/queries/audits.ts +++ b/site/src/api/queries/audits.ts @@ -1,6 +1,6 @@ import { API } from "api/api"; import type { AuditLogResponse } from "api/typesGenerated"; -import { useFilterParamsKey } from "components/Filter/filter"; +import { useFilterParamsKey } from "components/Filter/Filter"; import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; export function paginatedAudits( diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/Filter.tsx similarity index 94% rename from site/src/components/Filter/filter.tsx rename to site/src/components/Filter/Filter.tsx index c8636c3267e49..7129351db2f58 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/Filter.tsx @@ -125,17 +125,13 @@ const BaseSkeleton: FC = ({ children, ...skeletonProps }) => { ); }; -export const SearchFieldSkeleton: FC = () => { - return ; -}; - export const MenuSkeleton: FC = () => { return ; }; type FilterProps = { filter: ReturnType; - skeleton: ReactNode; + optionsSkeleton: ReactNode; isLoading: boolean; learnMoreLink?: string; learnMoreLabel2?: string; @@ -143,20 +139,26 @@ type FilterProps = { error?: unknown; options?: ReactNode; presets: PresetFilter[]; - breakpoint?: Breakpoint; + + /** + * The CSS media query breakpoint that defines when the UI will try + * displaying all options on one row, regardless of the number of options + * present + */ + singleRowBreakpoint?: Breakpoint; }; export const Filter: FC = ({ filter, isLoading, error, - skeleton, + optionsSkeleton, options, learnMoreLink, learnMoreLabel2, learnMoreLink2, presets, - breakpoint = "md", + singleRowBreakpoint = "lg", }) => { const theme = useTheme(); // Storing local copy of the filter query so that it can be updated more @@ -187,15 +189,18 @@ export const Filter: FC = ({ display: "flex", gap: 8, marginBottom: 16, - flexWrap: "nowrap", + flexWrap: "wrap", - [theme.breakpoints.down(breakpoint)]: { - flexWrap: "wrap", + [theme.breakpoints.up(singleRowBreakpoint)]: { + flexWrap: "nowrap", }, }} > {isLoading ? ( - skeleton + <> + + {optionsSkeleton} + ) : ( <> diff --git a/site/src/components/Filter/SelectFilter.tsx b/site/src/components/Filter/SelectFilter.tsx index a06730993530b..1b55cf2585806 100644 --- a/site/src/components/Filter/SelectFilter.tsx +++ b/site/src/components/Filter/SelectFilter.tsx @@ -52,7 +52,7 @@ export const SelectFilter: FC = ({ {selectedOption?.label ?? placeholder} diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 9ef7ccf6a3377..3bd5936bfcb7a 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -19,8 +19,8 @@ export const useUserFilterMenu = ({ >) => { const { user: me } = useAuthenticated(); - const addMeAsFirstOption = (options: SelectFilterOption[]) => { - options = options.filter((option) => option.value !== me.username); + const addMeAsFirstOption = (options: readonly SelectFilterOption[]) => { + const filtered = options.filter((o) => o.value !== me.username); return [ { label: me.username, @@ -33,7 +33,7 @@ export const useUserFilterMenu = ({ /> ), }, - ...options, + ...filtered, ]; }; diff --git a/site/src/components/Filter/storyHelpers.ts b/site/src/components/Filter/storyHelpers.ts index fc820fc27caf0..92285b41e48ee 100644 --- a/site/src/components/Filter/storyHelpers.ts +++ b/site/src/components/Filter/storyHelpers.ts @@ -1,5 +1,5 @@ import { action } from "@storybook/addon-actions"; -import type { UseFilterResult } from "./filter"; +import type { UseFilterResult } from "./Filter"; import type { UseFilterMenuResult } from "./menu"; export const MockMenu: UseFilterMenuResult = { diff --git a/site/src/components/SearchField/SearchField.tsx b/site/src/components/SearchField/SearchField.tsx index 0c5414cdc82c1..16e0c064d8386 100644 --- a/site/src/components/SearchField/SearchField.tsx +++ b/site/src/components/SearchField/SearchField.tsx @@ -21,6 +21,9 @@ export const SearchField: FC = ({ const theme = useTheme(); return ( onChange(e.target.value)} diff --git a/site/src/contexts/auth/RequireAuth.tsx b/site/src/contexts/auth/RequireAuth.tsx index 6d66045b9756a..e558b66c802de 100644 --- a/site/src/contexts/auth/RequireAuth.tsx +++ b/site/src/contexts/auth/RequireAuth.tsx @@ -1,14 +1,30 @@ import { API } from "api/api"; import { isApiError } from "api/errors"; import { Loader } from "components/Loader/Loader"; -import { ProxyProvider } from "contexts/ProxyContext"; -import { DashboardProvider } from "modules/dashboard/DashboardProvider"; +import { ProxyProvider as ProductionProxyProvider } from "contexts/ProxyContext"; +import { DashboardProvider as ProductionDashboardProvider } from "modules/dashboard/DashboardProvider"; import { type FC, useEffect } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; import { embedRedirect } from "utils/redirect"; import { type AuthContextValue, useAuthContext } from "./AuthProvider"; -export const RequireAuth: FC = () => { +type RequireAuthProps = Readonly<{ + ProxyProvider?: typeof ProductionProxyProvider; + DashboardProvider?: typeof ProductionDashboardProvider; +}>; + +/** + * Wraps any component and ensures that the user has been authenticated before + * they can access the component's contents. + * + * In production, it is assumed that this component will not be called with any + * props at all. But to make testing easier, you can call this component with + * specific providers to mock them out. + */ +export const RequireAuth: FC = ({ + DashboardProvider = ProductionDashboardProvider, + ProxyProvider = ProductionProxyProvider, +}) => { const location = useLocation(); const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } = useAuthContext(); diff --git a/site/src/modules/tableFiltering/options.tsx b/site/src/modules/tableFiltering/options.tsx new file mode 100644 index 0000000000000..3eac5b92a184a --- /dev/null +++ b/site/src/modules/tableFiltering/options.tsx @@ -0,0 +1,121 @@ +/** + * @file Defines a centralized place for filter dropdown groups that are + * relevant across multiple pages within the Coder UI. + * + * @todo 2024-09-06 - Figure out how to move the user dropdown group into this + * file (or whether there are enough subtle differences that it's not worth + * centralizing the logic). We currently have two separate implementations for + * the workspaces and audits page that have a risk of getting out of sync. + */ +import { API } from "api/api"; +import { + SelectFilter, + type SelectFilterOption, + SelectFilterSearch, +} from "components/Filter/SelectFilter"; +import { + type UseFilterMenuOptions, + useFilterMenu, +} from "components/Filter/menu"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import type { FC } from "react"; + +// Organization helpers //////////////////////////////////////////////////////// + +export const useOrganizationsFilterMenu = ({ + value, + onChange, +}: Pick, "value" | "onChange">) => { + return useFilterMenu({ + onChange, + value, + id: "organizations", + getSelectedOption: async () => { + if (value) { + const organizations = await API.getOrganizations(); + const organization = organizations.find((o) => o.name === value); + if (organization) { + return { + label: organization.display_name || organization.name, + value: organization.name, + startIcon: ( + + ), + }; + } + } + return null; + }, + getOptions: async () => { + // Only show the organizations for which you can view audit logs. + const organizations = await API.getOrganizations(); + const permissions = await API.checkAuthorization({ + checks: Object.fromEntries( + organizations.map((organization) => [ + organization.id, + { + object: { + resource_type: "audit_log", + organization_id: organization.id, + }, + action: "read", + }, + ]), + ), + }); + return organizations + .filter((organization) => permissions[organization.id]) + .map((organization) => ({ + label: organization.display_name || organization.name, + value: organization.name, + startIcon: ( + + ), + })); + }, + }); +}; + +export type OrganizationsFilterMenu = ReturnType< + typeof useOrganizationsFilterMenu +>; + +interface OrganizationsMenuProps { + menu: OrganizationsFilterMenu; + width?: number; +} + +export const OrganizationsMenu: FC = ({ + menu, + width, +}) => { + return ( + + } + width={width} + /> + ); +}; diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index 9b7bf0bb6ba39..448a59e88dc8d 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -1,23 +1,19 @@ -import { API } from "api/api"; import { AuditActions, ResourceTypes } from "api/typesGenerated"; +import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; import { SelectFilter, type SelectFilterOption, - SelectFilterSearch, } from "components/Filter/SelectFilter"; import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; -import { - Filter, - MenuSkeleton, - SearchFieldSkeleton, - type useFilter, -} from "components/Filter/filter"; import { type UseFilterMenuOptions, useFilterMenu, } from "components/Filter/menu"; -import { UserAvatar } from "components/UserAvatar/UserAvatar"; import capitalize from "lodash/capitalize"; +import { + type OrganizationsFilterMenu, + OrganizationsMenu, +} from "modules/tableFiltering/options"; import type { FC } from "react"; import { docs } from "utils/docs"; @@ -51,8 +47,8 @@ interface AuditFilterProps { } export const AuditFilter: FC = ({ filter, error, menus }) => { - // Use a smaller width if including the organization filter. - const width = menus.organization && 175; + const width = menus.organization ? 175 : undefined; + return ( = ({ filter, error, menus }) => { isLoading={menus.user.isInitializing} filter={filter} error={error} - breakpoint={menus.organization && "lg"} options={ <> @@ -71,12 +66,12 @@ export const AuditFilter: FC = ({ filter, error, menus }) => { )} } - skeleton={ + optionsSkeleton={ <> - + {menus.organization && } } /> @@ -180,101 +175,3 @@ const ResourceTypeMenu: FC = ({ menu, width }) => { /> ); }; - -export const useOrganizationsFilterMenu = ({ - value, - onChange, -}: Pick, "value" | "onChange">) => { - return useFilterMenu({ - onChange, - value, - id: "organizations", - getSelectedOption: async () => { - if (value) { - const organizations = await API.getOrganizations(); - const organization = organizations.find((o) => o.name === value); - if (organization) { - return { - label: organization.display_name || organization.name, - value: organization.name, - startIcon: ( - - ), - }; - } - } - return null; - }, - getOptions: async () => { - // Only show the organizations for which you can view audit logs. - const organizations = await API.getOrganizations(); - const permissions = await API.checkAuthorization({ - checks: Object.fromEntries( - organizations.map((organization) => [ - organization.id, - { - object: { - resource_type: "audit_log", - organization_id: organization.id, - }, - action: "read", - }, - ]), - ), - }); - return organizations - .filter((organization) => permissions[organization.id]) - .map((organization) => ({ - label: organization.display_name || organization.name, - value: organization.name, - startIcon: ( - - ), - })); - }, - }); -}; - -export type OrganizationsFilterMenu = ReturnType< - typeof useOrganizationsFilterMenu ->; - -interface OrganizationsMenuProps { - menu: OrganizationsFilterMenu; - width?: number; -} - -export const OrganizationsMenu: FC = ({ - menu, - width, -}) => { - return ( - - } - width={width} - /> - ); -}; diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx index b950c0ecd1716..68f566b4bf054 100644 --- a/site/src/pages/AuditPage/AuditPage.tsx +++ b/site/src/pages/AuditPage/AuditPage.tsx @@ -1,24 +1,21 @@ import { paginatedAudits } from "api/queries/audits"; +import { useFilter } from "components/Filter/Filter"; import { useUserFilterMenu } from "components/Filter/UserFilter"; -import { useFilter } from "components/Filter/filter"; import { isNonInitialPage } from "components/PaginationWidget/utils"; import { usePaginatedQuery } from "hooks/usePaginatedQuery"; import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { useOrganizationsFilterMenu } from "modules/tableFiltering/options"; import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; -import { - useActionFilterMenu, - useOrganizationsFilterMenu, - useResourceTypeFilterMenu, -} from "./AuditFilter"; +import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter"; import { AuditPageView } from "./AuditPageView"; const AuditPage: FC = () => { const feats = useFeatureVisibility(); - const { experiments } = useDashboard(); + const { showOrganizations } = useDashboard(); /** * There is an implicit link between auditsQuery and filter via the @@ -70,10 +67,6 @@ const AuditPage: FC = () => { }), }); - // With the multi-organization experiment enabled, show extra organization - // info and the organization filter dropdon. - const canViewOrganizations = experiments.includes("multi-organization"); - return ( <> @@ -86,7 +79,7 @@ const AuditPage: FC = () => { isAuditLogVisible={feats.audit_log} auditsQuery={auditsQuery} error={auditsQuery.error} - showOrgDetails={canViewOrganizations} + showOrgDetails={showOrganizations} filterProps={{ filter, error: auditsQuery.error, @@ -94,7 +87,7 @@ const AuditPage: FC = () => { user: userMenu, action: actionMenu, resourceType: resourceTypeMenu, - organization: canViewOrganizations ? organizationsMenu : undefined, + organization: showOrganizations ? organizationsMenu : undefined, }, }} /> diff --git a/site/src/pages/TemplatesPage/TemplatesFilter.tsx b/site/src/pages/TemplatesPage/TemplatesFilter.tsx index f463a35ff5261..40de4c5532054 100644 --- a/site/src/pages/TemplatesPage/TemplatesFilter.tsx +++ b/site/src/pages/TemplatesPage/TemplatesFilter.tsx @@ -1,15 +1,10 @@ import { API } from "api/api"; import type { Organization } from "api/typesGenerated"; +import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; import { SelectFilter, type SelectFilterOption, } from "components/Filter/SelectFilter"; -import { - Filter, - MenuSkeleton, - SearchFieldSkeleton, - type useFilter, -} from "components/Filter/filter"; import { useFilterMenu } from "components/Filter/menu"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import type { FC } from "react"; @@ -64,12 +59,7 @@ export const TemplatesFilter: FC = ({ /> } - skeleton={ - <> - - - - } + optionsSkeleton={} /> ); }; diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 95691e8b7b189..de09956d44d1d 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,5 +1,5 @@ import { templateExamples, templates } from "api/queries/templates"; -import { useFilter } from "components/Filter/filter"; +import { useFilter } from "components/Filter/Filter"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 600fdb21488c3..e0c96b4b675ca 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -16,7 +16,7 @@ import { ExternalAvatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/AvatarData/AvatarData"; import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; import { DeprecatedBadge } from "components/Badges/Badges"; -import type { useFilter } from "components/Filter/filter"; +import type { useFilter } from "components/Filter/Filter"; import { HelpTooltip, HelpTooltipContent, diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index 9cdabe27c8ff1..dd6083652d56b 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/pages/UsersPage/UsersFilter.tsx @@ -1,13 +1,8 @@ +import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; import { SelectFilter, type SelectFilterOption, } from "components/Filter/SelectFilter"; -import { - Filter, - MenuSkeleton, - SearchFieldSkeleton, - type useFilter, -} from "components/Filter/filter"; import { type UseFilterMenuOptions, useFilterMenu, @@ -78,12 +73,7 @@ export const UsersFilter: FC = ({ filter, error, menus }) => { filter={filter} error={error} options={} - skeleton={ - <> - - - - } + optionsSkeleton={} /> ); }; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 95717909ac205..3e3461adea8ed 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -14,7 +14,7 @@ import { import type { User } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; -import { useFilter } from "components/Filter/filter"; +import { useFilter } from "components/Filter/Filter"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { isNonInitialPage } from "components/PaginationWidget/utils"; import { useAuthenticated } from "contexts/auth/RequireAuth"; diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index c6b69348e1a8d..2c113b933483e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -3,10 +3,19 @@ import userEvent from "@testing-library/user-event"; import * as apiModule from "api/api"; import type { TemplateVersionParameter, Workspace } from "api/typesGenerated"; import EventSourceMock from "eventsourcemock"; +import { + DashboardContext, + type DashboardProvider, +} from "modules/dashboard/DashboardProvider"; import { http, HttpResponse } from "msw"; +import type { FC } from "react"; +import { type Location, useLocation } from "react-router-dom"; import { + MockAppearanceConfig, MockDeploymentConfig, + MockEntitlements, MockFailedWorkspace, + MockOrganization, MockOutdatedWorkspace, MockStartingWorkspace, MockStoppedWorkspace, @@ -18,14 +27,22 @@ import { MockWorkspaceBuild, MockWorkspaceBuildDelete, } from "testHelpers/entities"; -import { renderWithAuth } from "testHelpers/renderHelpers"; +import { + type RenderWithAuthOptions, + renderWithAuth, +} from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; import { WorkspacePage } from "./WorkspacePage"; const { API, MissingBuildParameters } = apiModule; +type RenderWorkspacePageOptions = Omit; + // Renders the workspace page and waits for it be loaded -const renderWorkspacePage = async (workspace: Workspace) => { +const renderWorkspacePage = async ( + workspace: Workspace, + options: RenderWorkspacePageOptions = {}, +) => { jest.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue(workspace); jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([]); @@ -40,6 +57,7 @@ const renderWorkspacePage = async (workspace: Workspace) => { }); renderWithAuth(, { + ...options, route: `/@${workspace.owner_name}/${workspace.name}`, path: "/:username/:workspace", }); @@ -527,4 +545,69 @@ describe("WorkspacePage", () => { ); }); }); + + describe("Navigation to other pages", () => { + it("Shows a quota link when quota budget is greater than 0. Link navigates user to /workspaces route with the URL params populated with the corresponding organization", async () => { + jest.spyOn(API, "getWorkspaceQuota").mockResolvedValueOnce({ + budget: 25, + credits_consumed: 2, + }); + + const MockDashboardProvider: typeof DashboardProvider = ({ + children, + }) => ( + + {children} + + ); + + let destinationLocation!: Location; + const MockWorkspacesPage: FC = () => { + destinationLocation = useLocation(); + return null; + }; + + const workspace: Workspace = { + ...MockWorkspace, + organization_name: MockOrganization.name, + }; + + await renderWorkspacePage(workspace, { + mockAuthProviders: { + DashboardProvider: MockDashboardProvider, + }, + extraRoutes: [ + { + path: "/workspaces", + element: , + }, + ], + }); + + const quotaLink = await screen.findByRole("link", { + name: /\d+ credits of \d+/i, + }); + + const orgName = encodeURIComponent(MockOrganization.name); + expect( + quotaLink.href.endsWith(`/workspaces?filter=organization:${orgName}`), + ).toBe(true); + + const user = userEvent.setup(); + await user.click(quotaLink); + + expect(destinationLocation.pathname).toBe("/workspaces"); + expect(destinationLocation.search).toBe( + `?filter=organization:${orgName}`, + ); + }); + }); }); diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index c4a8ae30c0239..7ab39b6df0cba 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -147,11 +147,7 @@ export const WorkspaceTopbar: FC = ({ )} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index cb9db0a1d8127..abade141d5183 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,11 +1,12 @@ import { templates } from "api/queries/templates"; import type { Workspace } from "api/typesGenerated"; +import { useFilter } from "components/Filter/Filter"; import { useUserFilterMenu } from "components/Filter/UserFilter"; -import { useFilter } from "components/Filter/filter"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useEffectEvent } from "hooks/hookPolyfills"; import { usePagination } from "hooks/usePagination"; import { useDashboard } from "modules/dashboard/useDashboard"; +import { useOrganizationsFilterMenu } from "modules/tableFiltering/options"; import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; @@ -175,12 +176,24 @@ const useWorkspacesFilter = ({ filter.update({ ...filter.values, status: option?.value }), }); + const { showOrganizations } = useDashboard(); + const organizationsMenu = useOrganizationsFilterMenu({ + value: filter.values.organization, + onChange: (option) => { + filter.update({ + ...filter.values, + organization: option?.value, + }); + }, + }); + return { filter, menus: { user: canFilterByUser ? userMenu : undefined, template: templateMenu, status: statusMenu, + organizations: showOrganizations ? organizationsMenu : undefined, }, }; }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 02f34d0189691..ef639d087fb5a 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -98,6 +98,7 @@ const defaultFilterProps = getDefaultFilterProps({ user: MockMenu, template: MockMenu, status: MockMenu, + organizations: MockMenu, }, values: { owner: MockUser.username, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 4e4501854b185..bc25572c23578 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -23,12 +23,15 @@ import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidg import { Stack } from "components/Stack/Stack"; import { TableToolbar } from "components/TableToolbar/TableToolbar"; import { WorkspacesTable } from "pages/WorkspacesPage/WorkspacesTable"; -import type { ComponentProps, FC } from "react"; +import type { FC } from "react"; import type { UseQueryResult } from "react-query"; import { mustUpdateWorkspace } from "utils/workspace"; import { WorkspaceHelpTooltip } from "./WorkspaceHelpTooltip"; import { WorkspacesButton } from "./WorkspacesButton"; -import { WorkspacesFilter } from "./filter/filter"; +import { + type WorkspaceFilterProps, + WorkspacesFilter, +} from "./filter/WorkspacesFilter"; export const Language = { pageTitle: "Workspaces", @@ -47,7 +50,7 @@ export interface WorkspacesPageViewProps { workspaces?: readonly Workspace[]; checkedWorkspaces: readonly Workspace[]; count?: number; - filterProps: ComponentProps; + filterProps: WorkspaceFilterProps; page: number; limit: number; onPageChange: (page: number) => void; @@ -116,7 +119,11 @@ export const WorkspacesPageView: FC = ({ {hasError(error) && !isApiValidationError(error) && ( )} - + diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx similarity index 72% rename from site/src/pages/WorkspacesPage/filter/filter.tsx rename to site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx index 249deec05d292..c695f92647699 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx @@ -1,11 +1,10 @@ +import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter"; import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; -import { - Filter, - MenuSkeleton, - SearchFieldSkeleton, - type useFilter, -} from "components/Filter/filter"; import { useDashboard } from "modules/dashboard/useDashboard"; +import { + type OrganizationsFilterMenu, + OrganizationsMenu, +} from "modules/tableFiltering/options"; import type { FC } from "react"; import { docs } from "utils/docs"; import { @@ -63,13 +62,14 @@ const PRESETS_WITH_DORMANT: FilterPreset[] = [ }, ]; -type WorkspaceFilterProps = { +export type WorkspaceFilterProps = { filter: ReturnType; error?: unknown; menus: { user?: UserFilterMenu; template: TemplateFilterMenu; status: StatusFilterMenu; + organizations?: OrganizationsFilterMenu; }; }; @@ -78,7 +78,8 @@ export const WorkspacesFilter: FC = ({ error, menus, }) => { - const { entitlements } = useDashboard(); + const { entitlements, showOrganizations } = useDashboard(); + const width = showOrganizations ? 175 : undefined; const presets = entitlements.features.advanced_template_scheduling.enabled ? PRESETS_WITH_DORMANT : PRESET_FILTERS; @@ -92,17 +93,20 @@ export const WorkspacesFilter: FC = ({ learnMoreLink={docs("/workspaces#workspace-filtering")} options={ <> - {menus.user && } - - + {menus.user && } + + + {showOrganizations && menus.organizations !== undefined && ( + + )} } - skeleton={ + optionsSkeleton={ <> - {menus.user && } + {showOrganizations && } } /> diff --git a/site/src/pages/WorkspacesPage/filter/menus.tsx b/site/src/pages/WorkspacesPage/filter/menus.tsx index 841162422c9db..50a3b5ff3cea2 100644 --- a/site/src/pages/WorkspacesPage/filter/menus.tsx +++ b/site/src/pages/WorkspacesPage/filter/menus.tsx @@ -11,6 +11,7 @@ import { } from "components/Filter/menu"; import { StatusIndicator } from "components/StatusIndicator/StatusIndicator"; import { TemplateAvatar } from "components/TemplateAvatar/TemplateAvatar"; +import type { FC } from "react"; import { getDisplayWorkspaceStatus } from "utils/workspace"; export const useTemplateFilterMenu = ({ @@ -53,9 +54,15 @@ export const useTemplateFilterMenu = ({ export type TemplateFilterMenu = ReturnType; -export const TemplateMenu = (menu: TemplateFilterMenu) => { +type TemplateMenuProps = Readonly<{ + width?: number; + menu: TemplateFilterMenu; +}>; + +export const TemplateMenu: FC = ({ width, menu }) => { return ( ; -export const StatusMenu = (menu: StatusFilterMenu) => { +type StatusMenuProps = Readonly<{ + width?: number; + menu: StatusFilterMenu; +}>; + +export const StatusMenu: FC = ({ width, menu }) => { return ( ; }; export function renderWithAuth( @@ -92,12 +99,13 @@ export function renderWithAuth( route = "/", extraRoutes = [], nonAuthenticatedRoutes = [], + mockAuthProviders = {}, children, }: RenderWithAuthOptions = {}, ) { const routes: RouteObject[] = [ { - element: , + element: , children: [{ path, element, children }, ...extraRoutes], }, ...nonAuthenticatedRoutes, @@ -108,8 +116,8 @@ export function renderWithAuth( ); return { - user: MockUser, ...renderResult, + user: MockUser, }; }