chore: update workspaces page filter to include organization controls (#14597)

* chore: move schedule controls to the right side of the screen

* chore: add org display to workspace topbar

* fix: force organizations to be readonly array

* fix update type mismatch for organizations again

* refactor: tuck main loading skeleton for filter into base definition

* refactor: give filter files different names to reduce confusion

* refactor: remove separate base filter skeleton

* fix: update responsive logic for audit table filter

* chore: add organizations option group to workspaces table

* refactor: make prop contracts more explicit

* refactor: centralize the organizations dropdown logic

* fix: update imports and formatting

* fix: update quota querying logic to use new endpoint

* fix: add logic for handling long workspace or org names

* chore: add links for workspaces by org

* chore: expand tooltip styling for org

* chore: expand tooltip styling for owner

* refactor: split off breadcrumbs for readability

* fix: display correct template version name in dropdown

* fix: update overflow styling for breadcrumb segments

* fix: favor org display name

* fix: centralize org display name logic

* fix: make sure skeletons stay synced with org feature toggles

* fix: ensure that mock query cache key and component key are properly synced for storybook

* docs: clean up wording on SearchField comment

* fix: shrink mix width threshold for search field

* chore: add navigation test for workspace details page (#14629)

* chore: add tests for WorkspacePage cross-page navigation

* fix: update story to use mock organizations menu
This commit is contained in:
Michael Smith
2024-09-16 20:45:49 +00:00
committed by GitHub
parent 910225698e
commit 5aa54be6ca
23 changed files with 343 additions and 203 deletions
+1 -1
View File
@@ -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(
@@ -125,17 +125,13 @@ const BaseSkeleton: FC<SkeletonProps> = ({ children, ...skeletonProps }) => {
);
};
export const SearchFieldSkeleton: FC = () => {
return <BaseSkeleton width="100%" />;
};
export const MenuSkeleton: FC = () => {
return <BaseSkeleton css={{ minWidth: 200, flexShrink: 0 }} />;
};
type FilterProps = {
filter: ReturnType<typeof useFilter>;
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<FilterProps> = ({
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<FilterProps> = ({
display: "flex",
gap: 8,
marginBottom: 16,
flexWrap: "nowrap",
flexWrap: "wrap",
[theme.breakpoints.down(breakpoint)]: {
flexWrap: "wrap",
[theme.breakpoints.up(singleRowBreakpoint)]: {
flexWrap: "nowrap",
},
}}
>
{isLoading ? (
skeleton
<>
<BaseSkeleton width="100%" />
{optionsSkeleton}
</>
) : (
<>
<InputGroup css={{ width: "100%" }}>
+1 -1
View File
@@ -52,7 +52,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
<SelectMenuTrigger>
<SelectMenuButton
startIcon={selectedOption?.startIcon}
css={{ width, flexGrow: 1 }}
css={{ flexBasis: width, flexGrow: 1 }}
aria-label={label}
>
{selectedOption?.label ?? placeholder}
+3 -3
View File
@@ -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,
];
};
+1 -1
View File
@@ -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 = {
@@ -21,6 +21,9 @@ export const SearchField: FC<SearchFieldProps> = ({
const theme = useTheme();
return (
<TextField
// Specifying `minWidth` so that the text box can't shrink so much
// that it becomes un-clickable as we add more filter controls
css={{ minWidth: "280px" }}
size="small"
value={value}
onChange={(e) => onChange(e.target.value)}
+19 -3
View File
@@ -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<RequireAuthProps> = ({
DashboardProvider = ProductionDashboardProvider,
ProxyProvider = ProductionProxyProvider,
}) => {
const location = useLocation();
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
useAuthContext();
+121
View File
@@ -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<UseFilterMenuOptions<SelectFilterOption>, "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: (
<UserAvatar
key={organization.id}
size="xs"
username={organization.display_name || organization.name}
avatarURL={organization.icon}
/>
),
};
}
}
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<SelectFilterOption>((organization) => ({
label: organization.display_name || organization.name,
value: organization.name,
startIcon: (
<UserAvatar
key={organization.id}
size="xs"
username={organization.display_name || organization.name}
avatarURL={organization.icon}
/>
),
}));
},
});
};
export type OrganizationsFilterMenu = ReturnType<
typeof useOrganizationsFilterMenu
>;
interface OrganizationsMenuProps {
menu: OrganizationsFilterMenu;
width?: number;
}
export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
menu,
width,
}) => {
return (
<SelectFilter
label="Select an organization"
placeholder="All organizations"
emptyText="No organizations found"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
inputProps={{ "aria-label": "Search organization" }}
placeholder="Search organization..."
value={menu.query}
onChange={menu.setQuery}
/>
}
width={width}
/>
);
};
+9 -112
View File
@@ -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<AuditFilterProps> = ({ 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
learnMoreLink={docs("/admin/audit-logs#filtering-logs")}
@@ -60,7 +56,6 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
isLoading={menus.user.isInitializing}
filter={filter}
error={error}
breakpoint={menus.organization && "lg"}
options={
<>
<ResourceTypeMenu width={width} menu={menus.resourceType} />
@@ -71,12 +66,12 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
)}
</>
}
skeleton={
optionsSkeleton={
<>
<SearchFieldSkeleton />
<MenuSkeleton />
<MenuSkeleton />
<MenuSkeleton />
{menus.organization && <MenuSkeleton />}
</>
}
/>
@@ -180,101 +175,3 @@ const ResourceTypeMenu: FC<ResourceTypeMenuProps> = ({ menu, width }) => {
/>
);
};
export const useOrganizationsFilterMenu = ({
value,
onChange,
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "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: (
<UserAvatar
key={organization.id}
size="xs"
username={organization.display_name || organization.name}
avatarURL={organization.icon}
/>
),
};
}
}
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<SelectFilterOption>((organization) => ({
label: organization.display_name || organization.name,
value: organization.name,
startIcon: (
<UserAvatar
key={organization.id}
size="xs"
username={organization.display_name || organization.name}
avatarURL={organization.icon}
/>
),
}));
},
});
};
export type OrganizationsFilterMenu = ReturnType<
typeof useOrganizationsFilterMenu
>;
interface OrganizationsMenuProps {
menu: OrganizationsFilterMenu;
width?: number;
}
export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
menu,
width,
}) => {
return (
<SelectFilter
label="Select an organization"
placeholder="All organizations"
emptyText="No organizations found"
options={menu.searchOptions}
onSelect={menu.selectOption}
selectedOption={menu.selectedOption ?? undefined}
selectFilterSearch={
<SelectFilterSearch
inputProps={{ "aria-label": "Search organization" }}
placeholder="Search organization..."
value={menu.query}
onChange={menu.setQuery}
/>
}
width={width}
/>
);
};
+6 -13
View File
@@ -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 (
<>
<Helmet>
@@ -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,
},
}}
/>
@@ -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<TemplatesFilterProps> = ({
/>
</>
}
skeleton={
<>
<SearchFieldSkeleton />
<MenuSkeleton />
</>
}
optionsSkeleton={<MenuSkeleton />}
/>
);
};
@@ -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";
@@ -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,
+2 -12
View File
@@ -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<UsersFilterProps> = ({ filter, error, menus }) => {
filter={filter}
error={error}
options={<StatusMenu {...menus.status} />}
skeleton={
<>
<SearchFieldSkeleton />
<MenuSkeleton />
</>
}
optionsSkeleton={<MenuSkeleton />}
/>
);
};
+1 -1
View File
@@ -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";
@@ -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 { http, HttpResponse } from "msw";
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<RenderWithAuthOptions, "route" | "path">;
// 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(<WorkspacePage />, {
...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,
}) => (
<DashboardContext.Provider
value={{
appearance: MockAppearanceConfig,
entitlements: MockEntitlements,
experiments: [],
organizations: [MockOrganization],
showOrganizations: true,
}}
>
{children}
</DashboardContext.Provider>
);
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: <MockWorkspacesPage />,
},
],
});
const quotaLink = await screen.findByRole<HTMLAnchorElement>("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}`,
);
});
});
});
@@ -147,11 +147,7 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
<OrganizationBreadcrumb
orgName={orgDisplayName}
orgIconUrl={activeOrg?.icon}
orgPageUrl={
showOrganizations
? `/organizations/${encodeURIComponent(workspace.organization_name)}`
: undefined
}
orgPageUrl={`/organizations/${encodeURIComponent(workspace.organization_name)}`}
/>
</>
)}
@@ -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,
},
};
};
@@ -98,6 +98,7 @@ const defaultFilterProps = getDefaultFilterProps<FilterProps>({
user: MockMenu,
template: MockMenu,
status: MockMenu,
organizations: MockMenu,
},
values: {
owner: MockUser.username,
@@ -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<typeof WorkspacesFilter>;
filterProps: WorkspaceFilterProps;
page: number;
limit: number;
onPageChange: (page: number) => void;
@@ -116,7 +119,11 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
{hasError(error) && !isApiValidationError(error) && (
<ErrorAlert error={error} />
)}
<WorkspacesFilter error={error} {...filterProps} />
<WorkspacesFilter
filter={filterProps.filter}
menus={filterProps.menus}
error={error}
/>
</Stack>
<TableToolbar>
@@ -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<typeof useFilter>;
error?: unknown;
menus: {
user?: UserFilterMenu;
template: TemplateFilterMenu;
status: StatusFilterMenu;
organizations?: OrganizationsFilterMenu;
};
};
@@ -78,7 +78,8 @@ export const WorkspacesFilter: FC<WorkspaceFilterProps> = ({
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<WorkspaceFilterProps> = ({
learnMoreLink={docs("/workspaces#workspace-filtering")}
options={
<>
{menus.user && <UserMenu menu={menus.user} />}
<TemplateMenu {...menus.template} />
<StatusMenu {...menus.status} />
{menus.user && <UserMenu width={width} menu={menus.user} />}
<TemplateMenu width={width} menu={menus.template} />
<StatusMenu width={width} menu={menus.status} />
{showOrganizations && menus.organizations !== undefined && (
<OrganizationsMenu width={width} menu={menus.organizations} />
)}
</>
}
skeleton={
optionsSkeleton={
<>
<SearchFieldSkeleton />
{menus.user && <MenuSkeleton />}
<MenuSkeleton />
<MenuSkeleton />
{showOrganizations && <MenuSkeleton />}
</>
}
/>
+15 -2
View File
@@ -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<typeof useTemplateFilterMenu>;
export const TemplateMenu = (menu: TemplateFilterMenu) => {
type TemplateMenuProps = Readonly<{
width?: number;
menu: TemplateFilterMenu;
}>;
export const TemplateMenu: FC<TemplateMenuProps> = ({ width, menu }) => {
return (
<SelectFilter
width={width}
label="Select a template"
emptyText="No templates found"
placeholder="All templates"
@@ -106,9 +113,15 @@ export const useStatusFilterMenu = ({
export type StatusFilterMenu = ReturnType<typeof useStatusFilterMenu>;
export const StatusMenu = (menu: StatusFilterMenu) => {
type StatusMenuProps = Readonly<{
width?: number;
menu: StatusFilterMenu;
}>;
export const StatusMenu: FC<StatusMenuProps> = ({ width, menu }) => {
return (
<SelectFilter
width={width}
placeholder="All statuses"
label="Select a status"
options={menu.searchOptions}
+10 -2
View File
@@ -4,9 +4,11 @@ import {
waitFor,
} from "@testing-library/react";
import { AppProviders } from "App";
import type { ProxyProvider } from "contexts/ProxyContext";
import { ThemeProvider } from "contexts/ThemeProvider";
import { RequireAuth } from "contexts/auth/RequireAuth";
import { DashboardLayout } from "modules/dashboard/DashboardLayout";
import type { DashboardProvider } from "modules/dashboard/DashboardProvider";
import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout";
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
@@ -83,6 +85,11 @@ export type RenderWithAuthOptions = {
nonAuthenticatedRoutes?: RouteObject[];
// In case you want to render a layout inside of it
children?: RouteObject["children"];
mockAuthProviders?: Readonly<{
DashboardProvider?: typeof DashboardProvider;
ProxyProvider?: typeof ProxyProvider;
}>;
};
export function renderWithAuth(
@@ -92,12 +99,13 @@ export function renderWithAuth(
route = "/",
extraRoutes = [],
nonAuthenticatedRoutes = [],
mockAuthProviders = {},
children,
}: RenderWithAuthOptions = {},
) {
const routes: RouteObject[] = [
{
element: <RequireAuth />,
element: <RequireAuth {...mockAuthProviders} />,
children: [{ path, element, children }, ...extraRoutes],
},
...nonAuthenticatedRoutes,
@@ -108,8 +116,8 @@ export function renderWithAuth(
);
return {
user: MockUser,
...renderResult,
user: MockUser,
};
}