mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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%" }}>
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
+17
-13
@@ -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 />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user