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";
Copy link
Member

Choose a reason for hiding this comment

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

actually it looks like this can be a tsx file in Storybook 9. I'm gonna upgrade us.


/** @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
Member

Choose a reason for hiding this comment

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

I am very interested to hear you elaborate on all of this because I am not sure I follow. 😅

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

Copy link
Member

Choose a reason for hiding this comment

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

I prefer to just name them like normal variables personally. an example of why in this context is that for an actual constant key vs a function that returns the key we get...

const aQueryKey = ["a"];
const bQueryKey = (id: string) => ["b"];

({ queryKey: aQueryKey });
({ queryKey: bQueryKey(bId) });

a small benefit of this aside from the visual consistency is that if a query key ever needs to go from truly constant to a function the amount of typing required is slightly smaller.

I think SCREAMING_CASE served more of a purpose in the days before LSPs. now it's just so much easier to tell what something is and where it's defined.


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.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* update state the "React way" causes our code to break.
* update state immutably 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

Copy link
Member

Choose a reason for hiding this comment

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

wdym edge cases with Object.is? the only "edge case" I'm aware of is Object.is(0, -0) === false


const copy = new URLSearchParams(searchParams);
copy.set("page", page.toString());
onSearchParamsChange(copy);
},
Comment on lines +29 to +39
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical bug: setting wrong page value

The goToPage function has a bug where it sets the current page instead of newPage. Also, the validation should check newPage instead of page.

Apply this fix:

 goToPage: (newPage) => {
   const abortNavigation =
-    page === newPage || !Number.isFinite(page) || !Number.isInteger(page);
+    page === newPage || !Number.isFinite(newPage) || !Number.isInteger(newPage);
   if (abortNavigation) {
     return;
   }

   const copy = new URLSearchParams(searchParams);
-  copy.set("page", page.toString());
+  copy.set("page", newPage.toString());
   onSearchParamsChange(copy);
 },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
goToPage: (newPage) => {
const abortNavigation =
page === newPage || !Number.isFinite(page) || !Number.isInteger(page);
if (abortNavigation) {
return;
}
const copy = new URLSearchParams(searchParams);
copy.set("page", page.toString());
onSearchParamsChange(copy);
},
goToPage: (newPage) => {
const abortNavigation =
page === newPage || !Number.isFinite(newPage) || !Number.isInteger(newPage);
if (abortNavigation) {
return;
}
const copy = new URLSearchParams(searchParams);
copy.set("page", newPage.toString());
onSearchParamsChange(copy);
},
🤖 Prompt for AI Agents
In site/src/hooks/usePagination.ts around lines 29 to 39, the goToPage function
incorrectly validates and sets the current page variable instead of the newPage
parameter. To fix this, update the validation conditions to check newPage for
finiteness and integer status, and set the "page" query parameter to
newPage.toString() instead of page.toString() before calling
onSearchParamsChange.

};
};
}
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

);
Comment on lines +125 to 133
Copy link
Member

Choose a reason for hiding this comment

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

better yet can we just get rid of the reduce here?

	const initialTouched = Object.fromEntries(
		parameters
			.filter((it) => autofillByName[it.name])
			.map((it) => [it.name, true]),
	);


// 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
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy