Skip to content

fix(site): tighten interface design for various frontend utility functions #18894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions site/.storybook/preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ function withHelmet(Story) {
);
}

/**
* This JSX file isn't part of the main project, so it doesn't get to use the
* ambient types defined in `storybook.d.ts` to provide extra type-safety.
* Extracting main key to avoid typos.
*/
const queryParametersKey = "queries";

/** @type {Decorator} */
function withQuery(Story, { parameters }) {
const queryClient = new QueryClient({
Expand All @@ -112,8 +119,8 @@ function withQuery(Story, { parameters }) {
},
});

if (parameters.queries) {
for (const query of parameters.queries) {
if (parameters[queryParametersKey]) {
for (const query of parameters[queryParametersKey]) {
queryClient.setQueryData(query.key, query.data);
}
}
Expand Down
35 changes: 11 additions & 24 deletions site/src/api/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,6 @@ export const templates = (
};
};

const getTemplatesByOrganizationQueryKey = (
organization: string,
options?: GetTemplatesOptions,
) => [organization, "templates", options?.deprecated];

const templatesByOrganization = (
organization: string,
options: GetTemplatesOptions = {},
) => {
return {
queryKey: getTemplatesByOrganizationQueryKey(organization, options),
queryFn: () => API.getTemplatesByOrganization(organization, options),
};
};
Comment on lines -51 to -64
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weren't used at all


export const templateACL = (templateId: string) => {
return {
queryKey: ["templateAcl", templateId],
Expand Down Expand Up @@ -121,9 +106,11 @@ export const templateExamples = () => {
};
};

export const templateVersionRoot: string = "templateVersion";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ends up being more important for the upcoming PR, but I really, really think that we need to find the time to formalize our entire data layer, and be way more deliberate with our React Query caching

I fully expect our caching to create a super, super hard-to-recreate bug sometime in the future, because it's a bunch of semi-structured global mutable state right now

Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also added an explicit string type annotation so that TypeScript doesn't over-infer the value and treat it as a literal. We shouldn't know what exact combination of characters it is – just what purpose the string serves in the greater codebase as an opaque value

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there seems to be inconsistencies within the code base between using camelCase or UPPER_SNAKE_CASE for file-wide constants so while out of scope for this PR I think deciding on one and sticking to it would be good

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's fair. The JS/TS way seems to be SCREAMING_CASE for constants, while the Go way seems to be to treat them as any other package value

I'm leaning towards switching this to SCREAMING_CASE, but I'll wait for Kayla to chime in


export const templateVersion = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId],
queryKey: [templateVersionRoot, versionId],
queryFn: () => API.getTemplateVersion(versionId),
};
};
Expand All @@ -134,7 +121,7 @@ export const templateVersionByName = (
versionName: string,
) => {
return {
queryKey: ["templateVersion", organizationId, templateName, versionName],
queryKey: [templateVersionRoot, organizationId, templateName, versionName],
queryFn: () =>
API.getTemplateVersionByName(organizationId, templateName, versionName),
};
Expand All @@ -153,7 +140,7 @@ export const templateVersions = (templateId: string) => {
};

export const templateVersionVariablesKey = (versionId: string) => [
"templateVersion",
templateVersionRoot,
versionId,
"variables",
];
Expand Down Expand Up @@ -216,7 +203,7 @@ export const templaceACLAvailable = (
};

const templateVersionExternalAuthKey = (versionId: string) => [
"templateVersion",
templateVersionRoot,
versionId,
"externalAuth",
];
Expand Down Expand Up @@ -257,21 +244,21 @@ const createTemplateFn = async (options: CreateTemplateOptions) => {

export const templateVersionLogs = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId, "logs"],
queryKey: [templateVersionRoot, versionId, "logs"],
queryFn: () => API.getTemplateVersionLogs(versionId),
};
};

export const richParameters = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId, "richParameters"],
queryKey: [templateVersionRoot, versionId, "richParameters"],
queryFn: () => API.getTemplateVersionRichParameters(versionId),
};
};

export const resources = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId, "resources"],
queryKey: [templateVersionRoot, versionId, "resources"],
queryFn: () => API.getTemplateVersionResources(versionId),
};
};
Expand All @@ -293,7 +280,7 @@ export const previousTemplateVersion = (
) => {
return {
queryKey: [
"templateVersion",
templateVersionRoot,
organizationId,
templateName,
versionName,
Expand All @@ -313,7 +300,7 @@ export const previousTemplateVersion = (

export const templateVersionPresets = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId, "presets"],
queryKey: [templateVersionRoot, versionId, "presets"],
queryFn: () => API.getTemplateVersionPresets(versionId),
};
};
Expand Down
47 changes: 32 additions & 15 deletions site/src/components/Filter/Filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { useDebouncedFunction } from "hooks/debounce";
import { ExternalLinkIcon } from "lucide-react";
import { ChevronDownIcon } from "lucide-react";
import { type FC, type ReactNode, useEffect, useRef, useState } from "react";
import type { useSearchParams } from "react-router-dom";

type PresetFilter = {
name: string;
Expand All @@ -27,35 +26,55 @@ type FilterValues = Record<string, string | undefined>;

type UseFilterConfig = {
/**
* The fallback value to use in the event that no filter params can be parsed
* from the search params object. This value is allowed to change on
* re-renders.
* The fallback value to use in the event that no filter params can be
* parsed from the search params object.
*/
fallbackFilter?: string;
searchParamsResult: ReturnType<typeof useSearchParams>;
searchParams: URLSearchParams;
onSearchParamsChange: (newParams: URLSearchParams) => void;
Comment on lines +33 to +34
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decoupled type signature from React Router. Splitting this up gives us a lot more freedom, which comes up later in this PR

onUpdate?: (newValue: string) => void;
};

export type UseFilterResult = Readonly<{
query: string;
values: FilterValues;
used: boolean;
update: (newValues: string | FilterValues) => void;
debounceUpdate: (newValues: string | FilterValues) => void;
cancelDebounce: () => void;
}>;
Comment on lines +38 to +45
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the previous type was super, super risky, because it was derived from a function that didn't have much type-safety

With this, the contract is way more explicit and simple, and there's basically no chance of the TypeScript LSP getting confused and dropping properties for tooltips


export const useFilterParamsKey = "filter";

export const useFilter = ({
fallbackFilter = "",
searchParamsResult,
searchParams,
onSearchParamsChange,
onUpdate,
}: UseFilterConfig) => {
const [searchParams, setSearchParams] = searchParamsResult;
}: UseFilterConfig): UseFilterResult => {
const query = searchParams.get(useFilterParamsKey) ?? fallbackFilter;

const update = (newValues: string | FilterValues) => {
const serialized =
typeof newValues === "string" ? newValues : stringifyFilter(newValues);
const noUpdateNeeded = query === serialized;
if (noUpdateNeeded) {
return;
}

/**
* @todo 2025-07-15 - We have a slightly nasty bug here, where trying to
* update state the "React way" causes our code to break.
*
* In theory, it would be better to make a copy of the search params. We
* can then mutate and dispatch the copy instead of the original. Doing
* that causes other parts of our existing logic to break, though.
* That's a sign that our other code is slightly broken, and only just
* happens to work by chance right now.
*/
searchParams.set(useFilterParamsKey, serialized);
setSearchParams(searchParams);

if (onUpdate !== undefined) {
onUpdate(serialized);
}
onSearchParamsChange(searchParams);
onUpdate?.(serialized);
};

const { debounced: debounceUpdate, cancelDebounce } = useDebouncedFunction(
Expand All @@ -73,8 +92,6 @@ export const useFilter = ({
};
};

export type UseFilterResult = ReturnType<typeof useFilter>;

const parseFilterQuery = (filterQuery: string): FilterValues => {
if (filterQuery === "") {
return {};
Expand Down
50 changes: 33 additions & 17 deletions site/src/hooks/usePagination.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils";
import type { useSearchParams } from "react-router-dom";

export const usePagination = ({
searchParamsResult,
}: {
searchParamsResult: ReturnType<typeof useSearchParams>;
}) => {
const [searchParams, setSearchParams] = searchParamsResult;
const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1;
const limit = DEFAULT_RECORDS_PER_PAGE;
const offset = page <= 0 ? 0 : (page - 1) * limit;
const paginationKey = "page";

const goToPage = (page: number) => {
searchParams.set("page", page.toString());
setSearchParams(searchParams);
};
type UsePaginationOptions = Readonly<{
searchParams: URLSearchParams;
onSearchParamsChange: (newParams: URLSearchParams) => void;
}>;

type UsePaginationResult = Readonly<{
page: number;
limit: number;
offset: number;
goToPage: (page: number) => void;
}>;
Comment on lines +10 to +15
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were missing a return type altogether before


export function usePagination(
options: UsePaginationOptions,
): UsePaginationResult {
const { searchParams, onSearchParamsChange } = options;
const limit = DEFAULT_RECORDS_PER_PAGE;
const rawPage = Number.parseInt(searchParams.get(paginationKey) || "1", 10);
const page = Number.isNaN(rawPage) || rawPage <= 0 ? 1 : rawPage;

return {
page,
limit,
goToPage,
offset,
offset: Math.max(0, (page - 1) * limit),
goToPage: (newPage) => {
const abortNavigation =
page === newPage || !Number.isFinite(page) || !Number.isInteger(page);
if (abortNavigation) {
return;
}
Comment on lines +30 to +34
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another render optimization

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this render optimization? Not re-rendering the page if it's the same page?

Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there's two parts:

  • The basic input validation from the Number static methods
  • The equality check

I could maybe add a comment, but React only has Object.is for change detection, so in general, a lot of render optimizations boil down to "only dispatch a new value (especially a non-primitive value) when you know the contents have changed". Most of the time, you don't have to worry about over-dispatching primitive values (as long as they're done outside a render, React will detect that nothing changed and do a no-op for the state update). But because JS is wacky, there are edge cases with Object.is and numbers, which is why there's the extra === check


const copy = new URLSearchParams(searchParams);
copy.set("page", page.toString());
onSearchParamsChange(copy);
},
};
};
}
3 changes: 2 additions & 1 deletion site/src/pages/AuditPage/AuditPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "api/api";
import type { AuditLogsRequest } from "api/typesGenerated";
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils";
import { http, HttpResponse } from "msw";
import {
Expand Down Expand Up @@ -106,7 +107,7 @@ describe("AuditPage", () => {
await userEvent.type(filterField, query);

await waitFor(() =>
expect(getAuditLogsSpy).toBeCalledWith({
expect(getAuditLogsSpy).toHaveBeenCalledWith<[AuditLogsRequest]>({
limit: DEFAULT_RECORDS_PER_PAGE,
offset: 0,
q: query,
Expand Down
3 changes: 2 additions & 1 deletion site/src/pages/AuditPage/AuditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const AuditPage: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const auditsQuery = usePaginatedQuery(paginatedAudits(searchParams));
const filter = useFilter({
searchParamsResult: [searchParams, setSearchParams],
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: auditsQuery.goToFirstPage,
});

Expand Down
3 changes: 2 additions & 1 deletion site/src/pages/ConnectionLogPage/ConnectionLogPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const ConnectionLogPage: FC = () => {
paginatedConnectionLogs(searchParams),
);
const filter = useFilter({
searchParamsResult: [searchParams, setSearchParams],
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: connectionlogsQuery.goToFirstPage,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ export const CreateWorkspacePageViewExperimental: FC<
owner,
setOwner,
}) => {
const [suggestedName, setSuggestedName] = useState(() =>
generateWorkspaceName(),
);
const [suggestedName, setSuggestedName] = useState(generateWorkspaceName);
Copy link
Member Author

@Parkreiner Parkreiner Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extra wrapper function was redundant

const [showPresetParameters, setShowPresetParameters] = useState(false);
const id = useId();
const workspaceNameInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -124,14 +122,14 @@ export const CreateWorkspacePageViewExperimental: FC<

// Only touched fields are sent to the websocket
// Autofilled parameters are marked as touched since they have been modified
const initialTouched = parameters.reduce(
const initialTouched = parameters.reduce<Record<string, boolean>>(
(touched, parameter) => {
if (autofillByName[parameter.name] !== undefined) {
touched[parameter.name] = true;
}
return touched;
},
{} as Record<string, boolean>,
{},
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed type assertion to remove risk of accidentally silencing the compiler

);

// The form parameters values hold the working state of the parameters that will be submitted when creating a workspace
Expand Down
5 changes: 3 additions & 2 deletions site/src/pages/TemplatesPage/TemplatesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ const TemplatesPage: FC = () => {
const { permissions, user: me } = useAuthenticated();
const { showOrganizations } = useDashboard();

const searchParamsResult = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const filter = useFilter({
fallbackFilter: "deprecated:false",
searchParamsResult,
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: () => {}, // reset pagination
});

Expand Down
8 changes: 4 additions & 4 deletions site/src/pages/UsersPage/UsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ type UserPageProps = {
const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParamsResult = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { entitlements } = useDashboard();
const [searchParams] = searchParamsResult;

const groupsByUserIdQuery = useQuery(groupsByUserId());
const authMethodsQuery = useQuery(authMethods());
Expand All @@ -58,9 +57,10 @@ const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
enabled: viewDeploymentConfig,
});

const usersQuery = usePaginatedQuery(paginatedUsers(searchParamsResult[0]));
const usersQuery = usePaginatedQuery(paginatedUsers(searchParams));
const useFilterResult = useFilter({
searchParamsResult,
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: usersQuery.goToFirstPage,
});

Expand Down
Loading
Loading