Skip to content

Commit 5aa54be

Browse files
authored
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
1 parent 9102256 commit 5aa54be

23 files changed

+342
-202
lines changed

site/src/api/queries/audits.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { API } from "api/api";
22
import type { AuditLogResponse } from "api/typesGenerated";
3-
import { useFilterParamsKey } from "components/Filter/filter";
3+
import { useFilterParamsKey } from "components/Filter/Filter";
44
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
55

66
export function paginatedAudits(

site/src/components/Filter/filter.tsx renamed to site/src/components/Filter/Filter.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,38 +125,40 @@ const BaseSkeleton: FC<SkeletonProps> = ({ children, ...skeletonProps }) => {
125125
);
126126
};
127127

128-
export const SearchFieldSkeleton: FC = () => {
129-
return <BaseSkeleton width="100%" />;
130-
};
131-
132128
export const MenuSkeleton: FC = () => {
133129
return <BaseSkeleton css={{ minWidth: 200, flexShrink: 0 }} />;
134130
};
135131

136132
type FilterProps = {
137133
filter: ReturnType<typeof useFilter>;
138-
skeleton: ReactNode;
134+
optionsSkeleton: ReactNode;
139135
isLoading: boolean;
140136
learnMoreLink?: string;
141137
learnMoreLabel2?: string;
142138
learnMoreLink2?: string;
143139
error?: unknown;
144140
options?: ReactNode;
145141
presets: PresetFilter[];
146-
breakpoint?: Breakpoint;
142+
143+
/**
144+
* The CSS media query breakpoint that defines when the UI will try
145+
* displaying all options on one row, regardless of the number of options
146+
* present
147+
*/
148+
singleRowBreakpoint?: Breakpoint;
147149
};
148150

149151
export const Filter: FC<FilterProps> = ({
150152
filter,
151153
isLoading,
152154
error,
153-
skeleton,
155+
optionsSkeleton,
154156
options,
155157
learnMoreLink,
156158
learnMoreLabel2,
157159
learnMoreLink2,
158160
presets,
159-
breakpoint = "md",
161+
singleRowBreakpoint = "lg",
160162
}) => {
161163
const theme = useTheme();
162164
// Storing local copy of the filter query so that it can be updated more
@@ -187,15 +189,18 @@ export const Filter: FC<FilterProps> = ({
187189
display: "flex",
188190
gap: 8,
189191
marginBottom: 16,
190-
flexWrap: "nowrap",
192+
flexWrap: "wrap",
191193

192-
[theme.breakpoints.down(breakpoint)]: {
193-
flexWrap: "wrap",
194+
[theme.breakpoints.up(singleRowBreakpoint)]: {
195+
flexWrap: "nowrap",
194196
},
195197
}}
196198
>
197199
{isLoading ? (
198-
skeleton
200+
<>
201+
<BaseSkeleton width="100%" />
202+
{optionsSkeleton}
203+
</>
199204
) : (
200205
<>
201206
<InputGroup css={{ width: "100%" }}>

site/src/components/Filter/SelectFilter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
5252
<SelectMenuTrigger>
5353
<SelectMenuButton
5454
startIcon={selectedOption?.startIcon}
55-
css={{ width, flexGrow: 1 }}
55+
css={{ flexBasis: width, flexGrow: 1 }}
5656
aria-label={label}
5757
>
5858
{selectedOption?.label ?? placeholder}

site/src/components/Filter/UserFilter.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export const useUserFilterMenu = ({
1919
>) => {
2020
const { user: me } = useAuthenticated();
2121

22-
const addMeAsFirstOption = (options: SelectFilterOption[]) => {
23-
options = options.filter((option) => option.value !== me.username);
22+
const addMeAsFirstOption = (options: readonly SelectFilterOption[]) => {
23+
const filtered = options.filter((o) => o.value !== me.username);
2424
return [
2525
{
2626
label: me.username,
@@ -33,7 +33,7 @@ export const useUserFilterMenu = ({
3333
/>
3434
),
3535
},
36-
...options,
36+
...filtered,
3737
];
3838
};
3939

site/src/components/Filter/storyHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { action } from "@storybook/addon-actions";
2-
import type { UseFilterResult } from "./filter";
2+
import type { UseFilterResult } from "./Filter";
33
import type { UseFilterMenuResult } from "./menu";
44

55
export const MockMenu: UseFilterMenuResult = {

site/src/components/SearchField/SearchField.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const SearchField: FC<SearchFieldProps> = ({
2121
const theme = useTheme();
2222
return (
2323
<TextField
24+
// Specifying `minWidth` so that the text box can't shrink so much
25+
// that it becomes un-clickable as we add more filter controls
26+
css={{ minWidth: "280px" }}
2427
size="small"
2528
value={value}
2629
onChange={(e) => onChange(e.target.value)}

site/src/contexts/auth/RequireAuth.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
import { API } from "api/api";
22
import { isApiError } from "api/errors";
33
import { Loader } from "components/Loader/Loader";
4-
import { ProxyProvider } from "contexts/ProxyContext";
5-
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
4+
import { ProxyProvider as ProductionProxyProvider } from "contexts/ProxyContext";
5+
import { DashboardProvider as ProductionDashboardProvider } from "modules/dashboard/DashboardProvider";
66
import { type FC, useEffect } from "react";
77
import { Navigate, Outlet, useLocation } from "react-router-dom";
88
import { embedRedirect } from "utils/redirect";
99
import { type AuthContextValue, useAuthContext } from "./AuthProvider";
1010

11-
export const RequireAuth: FC = () => {
11+
type RequireAuthProps = Readonly<{
12+
ProxyProvider?: typeof ProductionProxyProvider;
13+
DashboardProvider?: typeof ProductionDashboardProvider;
14+
}>;
15+
16+
/**
17+
* Wraps any component and ensures that the user has been authenticated before
18+
* they can access the component's contents.
19+
*
20+
* In production, it is assumed that this component will not be called with any
21+
* props at all. But to make testing easier, you can call this component with
22+
* specific providers to mock them out.
23+
*/
24+
export const RequireAuth: FC<RequireAuthProps> = ({
25+
DashboardProvider = ProductionDashboardProvider,
26+
ProxyProvider = ProductionProxyProvider,
27+
}) => {
1228
const location = useLocation();
1329
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
1430
useAuthContext();
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @file Defines a centralized place for filter dropdown groups that are
3+
* relevant across multiple pages within the Coder UI.
4+
*
5+
* @todo 2024-09-06 - Figure out how to move the user dropdown group into this
6+
* file (or whether there are enough subtle differences that it's not worth
7+
* centralizing the logic). We currently have two separate implementations for
8+
* the workspaces and audits page that have a risk of getting out of sync.
9+
*/
10+
import { API } from "api/api";
11+
import {
12+
SelectFilter,
13+
type SelectFilterOption,
14+
SelectFilterSearch,
15+
} from "components/Filter/SelectFilter";
16+
import {
17+
type UseFilterMenuOptions,
18+
useFilterMenu,
19+
} from "components/Filter/menu";
20+
import { UserAvatar } from "components/UserAvatar/UserAvatar";
21+
import type { FC } from "react";
22+
23+
// Organization helpers ////////////////////////////////////////////////////////
24+
25+
export const useOrganizationsFilterMenu = ({
26+
value,
27+
onChange,
28+
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
29+
return useFilterMenu({
30+
onChange,
31+
value,
32+
id: "organizations",
33+
getSelectedOption: async () => {
34+
if (value) {
35+
const organizations = await API.getOrganizations();
36+
const organization = organizations.find((o) => o.name === value);
37+
if (organization) {
38+
return {
39+
label: organization.display_name || organization.name,
40+
value: organization.name,
41+
startIcon: (
42+
<UserAvatar
43+
key={organization.id}
44+
size="xs"
45+
username={organization.display_name || organization.name}
46+
avatarURL={organization.icon}
47+
/>
48+
),
49+
};
50+
}
51+
}
52+
return null;
53+
},
54+
getOptions: async () => {
55+
// Only show the organizations for which you can view audit logs.
56+
const organizations = await API.getOrganizations();
57+
const permissions = await API.checkAuthorization({
58+
checks: Object.fromEntries(
59+
organizations.map((organization) => [
60+
organization.id,
61+
{
62+
object: {
63+
resource_type: "audit_log",
64+
organization_id: organization.id,
65+
},
66+
action: "read",
67+
},
68+
]),
69+
),
70+
});
71+
return organizations
72+
.filter((organization) => permissions[organization.id])
73+
.map<SelectFilterOption>((organization) => ({
74+
label: organization.display_name || organization.name,
75+
value: organization.name,
76+
startIcon: (
77+
<UserAvatar
78+
key={organization.id}
79+
size="xs"
80+
username={organization.display_name || organization.name}
81+
avatarURL={organization.icon}
82+
/>
83+
),
84+
}));
85+
},
86+
});
87+
};
88+
89+
export type OrganizationsFilterMenu = ReturnType<
90+
typeof useOrganizationsFilterMenu
91+
>;
92+
93+
interface OrganizationsMenuProps {
94+
menu: OrganizationsFilterMenu;
95+
width?: number;
96+
}
97+
98+
export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
99+
menu,
100+
width,
101+
}) => {
102+
return (
103+
<SelectFilter
104+
label="Select an organization"
105+
placeholder="All organizations"
106+
emptyText="No organizations found"
107+
options={menu.searchOptions}
108+
onSelect={menu.selectOption}
109+
selectedOption={menu.selectedOption ?? undefined}
110+
selectFilterSearch={
111+
<SelectFilterSearch
112+
inputProps={{ "aria-label": "Search organization" }}
113+
placeholder="Search organization..."
114+
value={menu.query}
115+
onChange={menu.setQuery}
116+
/>
117+
}
118+
width={width}
119+
/>
120+
);
121+
};

0 commit comments

Comments
 (0)