Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
Parkreiner merged 17 commits intomainfrommes/batch-update-01
Aug 14, 2025
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
17 commits
Select commitHold shift + click to select a range
bb05088
refactor: centralize storybook queries key
ParkreinerJul 16, 2025
a119d70
refactor: centralize template version key prefix
ParkreinerJul 16, 2025
5dc67cf
refactor: remove JSX extension from batch actions
ParkreinerJul 16, 2025
4c77bff
fix: update API design of various utility hooks
ParkreinerJul 16, 2025
4833ae1
fix: remove promise race conditions for batch utilities
ParkreinerJul 16, 2025
b8d92fa
fix: decouple component props from one another
ParkreinerJul 16, 2025
1662a55
fix: apply biome fixes
ParkreinerJul 16, 2025
e326414
refactor: remove more bad coupling
ParkreinerJul 16, 2025
cc00106
fix: format
ParkreinerJul 16, 2025
7304644
fix: update call site mismatch
ParkreinerJul 16, 2025
b512e18
fix: update import
ParkreinerJul 16, 2025
cb92b79
fix: update Filter logic to account for fallback filter
ParkreinerJul 16, 2025
86d19b8
docs: add comment about wonky code
ParkreinerJul 16, 2025
330d91d
fix: get workspace tests passing
ParkreinerJul 16, 2025
fb9d8f2
fix: update biome again
ParkreinerJul 16, 2025
de02ad1
Merge branch 'main' into mes/batch-update-01
ParkreinerAug 14, 2025
ebd8b1c
chore: apply feedback
ParkreinerAug 14, 2025
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 deletionssite/.storybook/preview.jsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 thiscan 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 deletionssite/src/api/queries/templates.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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
MemberAuthor

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

aslilac reacted with thumbs up emoji

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

export const templateVersionRoot: string = "templateVersion";
Copy link
MemberAuthor

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
MemberAuthor

@ParkreinerParkreinerJul 16, 2025
edited
Loading

Choose a reason for hiding this comment

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

Also added an explicitstring 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
MemberAuthor

Choose a reason for hiding this comment

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

For point 1: React Query is entirely based around Query Keys. This all serves two purposes:

  1. The keys themselves are hashed deterministically so that RQ knows where to put the results of promises
  2. RQ also uses the keys to create "bucket groups" for the promise results

So, if you cache result A in["cat", "color", 1], and cache result B in["cat", "name", 2], A and B will be put in separate cache pockets, but you also get the ability to do this when invalidating:

  1. If you invalidate all["cat"] queries, you'll hit both A and B with a single function call
  2. If you invalidate["cat", "color"], you'll hit A only (as well as any other query keys that start with["cat", "color"]. If you do it for["cat", "name"], you'll only hit B and queries with similar prefixes

To be honest, I think our query key setup is a bit of a mess. It feels like we've kind of just throw a bunch of values in arrays because that's what made the API happy, but because we weren't deliberate with what those values were, the groups all over the place. We don't have super easy ways to invalidate specific groups of queries, and there are a few parts of the codebase where we've basically treated RQ as free real estate, and thrown a bunch of component-specific state int our global cache. And it feels like, the more we add to the cache, the higher risk we have of creating key conflicts. We already have a few risks for the template query keys/option factories, because we're reusing similar prefixes for different resource types

So basically, this is an idea for what we could do:

  1. Remove all component-scoped query keys
  2. Redefine how all the keys are formatted, so that they all follow a predictable order of general to specific, and make sure that different resources don't have similar keys
  3. Update the fetch logic so that if we get an API response for A, and part of it can also be used for B, we use A to pre-seed/refresh the cache for B

Basically, treating RQ as the basis for a much more formal boundary between the API and the rest of the frontend

Copy link
MemberAuthor

Choose a reason for hiding this comment

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

And for 2, I just don't like that TypeScript tries to be "helpful" and infer const types as the most specific form possible. From a contract standpoint, a consumer really shouldn't have to care about exactly what string a part of a query key is – just that it'sa string at all, and has guarantees of not conflicting with other parts of the cache

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 usingcamelCase orUPPER_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
MemberAuthor

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...

constaQueryKey=["a"];constbQueryKey=(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.

Parkreiner reacted with thumbs up emoji

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 DownExpand Up@@ -216,7 +203,7 @@ export const templaceACLAvailable = (
};

const templateVersionExternalAuthKey = (versionId: string) => [
"templateVersion",
templateVersionRoot,
versionId,
"externalAuth",
];
Expand DownExpand 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 deletionssite/src/components/Filter/Filter.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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";

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
MemberAuthor

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

aslilac reacted with thumbs up emoji
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
MemberAuthor

@ParkreinerParkreinerJul 16, 2025
edited
Loading

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

aslilac reacted with heart emoji

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 via immutable state updates 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
52 changes: 35 additions & 17 deletionssite/src/hooks/usePagination.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,43 @@
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/utils";
import type { useSearchParams } from "react-router";

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
MemberAuthor

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(newPage) ||
!Number.isInteger(newPage);
if (abortNavigation) {
return;
}

const copy = new URLSearchParams(searchParams);
copy.set("page", newPage.toString());
onSearchParamsChange(copy);
},
};
};
}
3 changes: 2 additions & 1 deletionsite/src/pages/AuditPage/AuditPage.test.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 deletionsite/src/pages/AuditPage/AuditPage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsite/src/pages/ConnectionLogPage/ConnectionLogPage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,9 +104,7 @@ export const CreateWorkspacePageViewExperimental: FC<
owner,
setOwner,
}) => {
const [suggestedName, setSuggestedName] = useState(() =>
generateWorkspaceName(),
);
const [suggestedName, setSuggestedName] = useState(generateWorkspaceName);
Copy link
MemberAuthor

@ParkreinerParkreinerJul 16, 2025
edited
Loading

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

aslilac reacted with heart emoji
const [showPresetParameters, setShowPresetParameters] = useState(false);
const id = useId();
const workspaceNameInputRef = useRef<HTMLInputElement>(null);
Expand All@@ -120,14 +118,8 @@ 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(
(touched, parameter) => {
if (autofillByName[parameter.name] !== undefined) {
touched[parameter.name] = true;
}
return touched;
},
{} as Record<string, boolean>,
const initialTouched = Object.fromEntries(
parameters.filter((p) => autofillByName[p.name]).map((p) => [p, 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 deletionssite/src/pages/TemplatesPage/TemplatesPage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,11 @@ const TemplatesPage: FC = () => {
const { permissions, user: me } = useAuthenticated();
const { showOrganizations } = useDashboard();

constsearchParamsResult = 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 deletionssite/src/pages/UsersPage/UsersPage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,9 +39,8 @@ type UserPageProps = {
const UsersPage: FC<UserPageProps> = ({ defaultNewPassword }) => {
const queryClient = useQueryClient();
const navigate = useNavigate();
constsearchParamsResult = 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

[8]ページ先頭

©2009-2025 Movatter.jp