Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .server-changes/paginate-environment-variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

The environment variables page now loads a page at a time, keeping it fast for projects with a large number of variables. Search matches variable names across every page.
145 changes: 86 additions & 59 deletions apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import type { SyncEnvVarsMapping, EnvSlug } from "~/v3/vercel/vercelProjectInteg
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import { loadEnvironmentVariablesEnvironments } from "./environmentVariablesEnvironments.server";

import { boundedIn } from "@trigger.dev/database";
import { boundedIn, type Prisma } from "@trigger.dev/database";
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number];

export const DEFAULT_ENV_VARS_PAGE_SIZE = 50;

export class EnvironmentVariablesPresenter {
#prismaClient: PrismaClient;
#replicaClient: PrismaReplicaClient;
Expand All @@ -21,7 +23,19 @@ export class EnvironmentVariablesPresenter {
this.#replicaClient = replicaClient;
}

public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) {
public async call({
userId,
projectSlug,
page = 1,
pageSize = DEFAULT_ENV_VARS_PAGE_SIZE,
search,
}: {
userId: User["id"];
projectSlug: Project["slug"];
page?: number;
pageSize?: number;
search?: string;
}) {
const project = await this.#replicaClient.project.findFirst({
select: {
id: true,
Expand Down Expand Up @@ -53,6 +67,18 @@ export class EnvironmentVariablesPresenter {
// values in archived branch environments, which would otherwise all be loaded here.
const environmentIds = sortedEnvironments.map((env) => env.id);

const variableWhere: Prisma.EnvironmentVariableWhereInput = {
projectId: project.id,
values: { some: { environmentId: { in: boundedIn(environmentIds) } } },
...(search ? { key: { contains: search, mode: "insensitive" } } : {}),
};

const totalCount = await this.#replicaClient.environmentVariable.count({
where: variableWhere,
});
Comment thread
ericallam marked this conversation as resolved.
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const currentPage = Math.min(Math.max(1, page), totalPages);
Comment thread
ericallam marked this conversation as resolved.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const environmentVariables = await this.#replicaClient.environmentVariable.findMany({
select: {
id: true,
Expand All @@ -64,11 +90,6 @@ export class EnvironmentVariablesPresenter {
version: true,
lastUpdatedBy: true,
updatedAt: true,
valueReference: {
select: {
key: true,
},
},
isSecret: true,
},
where: {
Expand All @@ -78,9 +99,12 @@ export class EnvironmentVariablesPresenter {
},
},
},
where: {
projectId: project.id,
where: variableWhere,
orderBy: {
key: "asc",
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
Comment thread
ericallam marked this conversation as resolved.
});

const userIds = new Set(
Expand Down Expand Up @@ -152,58 +176,61 @@ export class EnvironmentVariablesPresenter {
}

return {
environmentVariables: environmentVariables
.flatMap((environmentVariable) => {
return sortedEnvironments.flatMap((env) => {
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
const isSecret = valueRecord?.isSecret ?? false;

if (!valueRecord) {
return [];
}

const val = isSecret
? undefined
: variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`);

if (!isSecret && val === undefined) {
return [];
}

const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null;

const updatedByUser =
lastUpdatedBy?.type === "user"
? (() => {
const user = usersRecord[lastUpdatedBy.userId];
return user
? {
id: user.id,
name: user.displayName || user.name || "Unknown",
avatarUrl: user.avatarUrl,
}
: null;
})()
: null;

return [
{
id: environmentVariable.id,
key: environmentVariable.key,
environment: { type: env.type, id: env.id, branchName: env.branchName },
value: isSecret ? "" : val!,
isSecret,
version: valueRecord.version,
lastUpdatedBy,
updatedByUser,
updatedAt: valueRecord.updatedAt,
},
];
});
})
.sort((a, b) => a.key.localeCompare(b.key)),
environmentVariables: environmentVariables.flatMap((environmentVariable) => {
return sortedEnvironments.flatMap((env) => {
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
const isSecret = valueRecord?.isSecret ?? false;

if (!valueRecord) {
return [];
}

const val = isSecret
? undefined
: variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`);

if (!isSecret && val === undefined) {
return [];
}

const lastUpdatedBy = valueRecord.lastUpdatedBy as EnvironmentVariableUpdater | null;

const updatedByUser =
lastUpdatedBy?.type === "user"
? (() => {
const user = usersRecord[lastUpdatedBy.userId];
return user
? {
id: user.id,
name: user.displayName || user.name || "Unknown",
avatarUrl: user.avatarUrl,
}
: null;
})()
: null;

return [
{
id: environmentVariable.id,
key: environmentVariable.key,
environment: { type: env.type, id: env.id, branchName: env.branchName },
value: isSecret ? "" : val!,
isSecret,
version: valueRecord.version,
lastUpdatedBy,
updatedByUser,
updatedAt: valueRecord.updatedAt,
},
];
});
}),
environments: sortedEnvironments,
hasStaging,
pagination: {
currentPage,
totalPages,
totalCount,
},
// Vercel integration data
vercelIntegration: vercelIntegration
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { SearchInput } from "~/components/primitives/SearchInput";
import { Switch } from "~/components/primitives/Switch";
Expand All @@ -55,10 +56,8 @@ import {
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import {
Expand Down Expand Up @@ -117,6 +116,8 @@ export type EnvironmentVariablesPageLoaderData = {
accessibleEnvironmentIds: string[];
// Environment ids whose env vars the current role can write (create/edit/delete).
writableEnvironmentIds: string[];
pagination: { currentPage: number; totalPages: number; totalCount: number };
search?: string;
};

export const environmentVariablesRouteId =
Expand All @@ -125,22 +126,28 @@ export const environmentVariablesRouteId =
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
searchParams: z.object({
page: z.coerce.number().int().min(1).catch(1),
search: z.string().trim().min(1).optional().catch(undefined),
}),
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// No hard authorization: the page lists every environment. Values in
// environments the role can't read are masked per-tier below.
},
async ({ params, user, ability }) => {
async ({ params, searchParams, user, ability }) => {
const { projectParam } = params;

try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments, hasStaging, vercelIntegration } =
const { environmentVariables, environments, hasStaging, vercelIntegration, pagination } =
await presenter.call({
userId: user.id,
projectSlug: projectParam,
page: searchParams.page,
search: searchParams.search,
});

const accessibleEnvironmentIds = environments
Expand Down Expand Up @@ -176,6 +183,8 @@ export const loader = dashboardLoader(
vercelIntegration,
accessibleEnvironmentIds,
writableEnvironmentIds,
pagination,
search: searchParams.search,
});
} catch (error) {
console.error(error);
Expand Down Expand Up @@ -392,17 +401,12 @@ function EnvironmentVariablesListPage({
loaderData: EnvironmentVariablesPageLoaderData;
}) {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, vercelIntegration } = loaderData;
const { environmentVariables, vercelIntegration, pagination, search } = loaderData;
const hasSearch = Boolean(search);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { value } = useSearchParams();
const urlSearch = value("search") ?? "";
const { filteredItems } = useFuzzyFilter<EnvironmentVariableWithSetValues>({
items: environmentVariables,
keys: ["key", "value", "environment.type", "environment.branchName"],
filterText: urlSearch,
});
const filteredItems = environmentVariables;

const tableScrollRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -477,9 +481,9 @@ function EnvironmentVariablesListPage({
</NavBar>
<PageBody scrollable={false}>
<div className={cn("flex h-full min-h-0 flex-col")}>
{environmentVariables.length > 0 && (
{(environmentVariables.length > 0 || hasSearch) && (
<div className="flex items-center justify-between gap-2 px-2 py-2">
<SearchInput placeholder="Search variables…" autoFocus />
<SearchInput placeholder="Search variables…" resetParams={["page"]} autoFocus />
<div className="flex items-center justify-end gap-1.5">
<Switch
variant="secondary/small"
Expand Down Expand Up @@ -574,7 +578,7 @@ function EnvironmentVariablesListPage({
<TableBody>
<TableRow>
<TableCell colSpan={vercelColumnCount}>
{environmentVariables.length === 0 ? (
{!hasSearch ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Header2>You haven't set any environment variables yet.</Header2>
<LinkButton
Expand All @@ -597,6 +601,14 @@ function EnvironmentVariablesListPage({
)}
</Table>
</div>
{pagination.totalPages > 1 && (
<div className="flex items-center justify-end border-t border-grid-dimmed px-2 py-2">
<PaginationControls
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
/>
</div>
)}
</div>
</PageBody>
<Outlet />
Expand Down
Loading