Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/dashboard/app/new/hooks/use-workspace-step.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { setSessionCookie } from "@/lib/auth/cookies";
import { reset } from "@/lib/collections";
import { trpc } from "@/lib/trpc/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { StackPerspective2 } from "@unkey/icons";
Expand Down Expand Up @@ -33,6 +34,7 @@ export const useWorkspaceStep = (): OnboardingStep => {
const [workspaceCreated, setWorkspaceCreated] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const router = useRouter();
const trpcUtils = trpc.useUtils();

const form = useForm<WorkspaceFormData>({
resolver: zodResolver(workspaceSchema),
Expand Down Expand Up @@ -61,6 +63,8 @@ export const useWorkspaceStep = (): OnboardingStep => {
onSuccess: async ({ organizationId }) => {
setWorkspaceCreated(true);
switchOrgMutation.mutate(organizationId);
trpcUtils.user.listMemberships.invalidate();
await reset();
},
onError: (error) => {
if (error.data?.code === "METHOD_NOT_SUPPORTED") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { useMemo } from "react";
export const useProjectNavigation = (baseNavItems: NavItem[]) => {
const segments = useSelectedLayoutSegments() ?? [];

const { data, isLoading } = useLiveQuery((q) =>
q.from({ project: collection.projects }).orderBy(({ project }) => project.id, "desc"),
const { data, isLoading } = useLiveQuery(
(q) => q.from({ project: collection.projects }).orderBy(({ project }) => project.id, "desc"),
// Deps are required here otherwise it won't get rerendered
[collection.projects],
);

const projectNavItems = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ import { useMemo } from "react";
export const useRatelimitNavigation = (baseNavItems: NavItem[]) => {
const segments = useSelectedLayoutSegments() ?? [];

const { data } = useLiveQuery((q) =>
q
.from({ namespace: collection.ratelimitNamespaces })
.orderBy(({ namespace }) => namespace.id, "desc"),
const { data } = useLiveQuery(
(q) =>
q
.from({ namespace: collection.ratelimitNamespaces })
.orderBy(({ namespace }) => namespace.id, "desc"),
// Deps are required here otherwise it won't get rerendered
[collection.ratelimitNamespaces],
);

// Convert ratelimit namespaces data to navigation items with sub-items
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export const WorkspaceSwitcher: React.FC<Props> = (props): JSX.Element => {
toast.error("Failed to switch workspace. Contact support if error persists.");
},
});
const handleWorkspaceSwitch = async (targetOrgId: string) => {
// Prevent switch if already on the target workspace
if (targetOrgId === currentOrgMembership?.organization.id) {
return;
}

await changeWorkspace.mutateAsync(targetOrgId);
};

const [search, _setSearch] = useState("");
const filteredOrgs = useMemo(() => {
Expand Down Expand Up @@ -146,7 +154,7 @@ export const WorkspaceSwitcher: React.FC<Props> = (props): JSX.Element => {
<DropdownMenuItem
key={membership.id}
className="flex items-center justify-between"
onClick={async () => changeWorkspace.mutateAsync(membership.organization.id)}
onClick={() => handleWorkspaceSwitch(membership.organization.id)}
>
<span
className={
Expand Down
131 changes: 68 additions & 63 deletions apps/dashboard/lib/collections/deploy/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,69 +38,74 @@ export const createProjectRequestSchema = z.object({
export type Project = z.infer<typeof schema>;
export type CreateProjectRequestSchema = z.infer<typeof createProjectRequestSchema>;

export const projects = createCollection<Project>(
queryCollectionOptions({
queryClient,
queryKey: ["projects"],
retry: 3,
queryFn: async () => {
return await trpcClient.deploy.project.list.query();
},
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const { changes } = transaction.mutations[0];
const ERROR_MESSAGES = {
CONFLICT: {
message: "Project Already Exists",
description: "A project with this slug already exists in your workspace.",
},
FORBIDDEN: {
message: "Permission Denied",
description: "You don't have permission to create projects in this workspace.",
},
BAD_REQUEST: {
message: "Invalid Configuration",
description: "Please check your project settings.",
},
INTERNAL_SERVER_ERROR: {
message: "Server Error",
description:
"We encountered an issue while creating your project. Please try again later or contact support at support@unkey.dev",
},
NOT_FOUND: {
message: "Project Creation Failed",
description: "Unable to find the workspace. Please refresh and try again.",
},
DEFAULT: {
message: "Failed to Create Project",
description: "An unexpected error occurred. Please try again later.",
},
} as const;

const createInput = createProjectRequestSchema.parse({
name: changes.name,
slug: changes.slug,
gitRepositoryUrl: changes.gitRepositoryUrl,
});
const mutation = trpcClient.deploy.project.create.mutate(createInput);
export function createProjectsCollection() {
return createCollection<Project>(
queryCollectionOptions({
queryClient,
queryKey: ["projects"],
retry: 3,
queryFn: async () => {
return await trpcClient.deploy.project.list.query();
},
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const { changes } = transaction.mutations[0];

toast.promise(mutation, {
loading: "Creating project...",
success: "Project created successfully",
error: (err) => {
console.error("Failed to create project", err);
const createInput = createProjectRequestSchema.parse({
name: changes.name,
slug: changes.slug,
gitRepositoryUrl: changes.gitRepositoryUrl,
});

switch (err.data?.code) {
case "CONFLICT":
return {
message: "Project Already Exists",
description:
err.message || "A project with this slug already exists in your workspace.",
};
case "FORBIDDEN":
return {
message: "Permission Denied",
description:
err.message || "You don't have permission to create projects in this workspace.",
};
case "BAD_REQUEST":
return {
message: "Invalid Configuration",
description: `Please check your project settings. ${err.message || ""}`,
};
case "INTERNAL_SERVER_ERROR":
return {
message: "Server Error",
description:
"We encountered an issue while creating your project. Please try again later or contact support at support@unkey.dev",
};
case "NOT_FOUND":
return {
message: "Project Creation Failed",
description: "Unable to find the workspace. Please refresh and try again.",
};
default:
return {
message: "Failed to Create Project",
description: err.message || "An unexpected error occurred. Please try again later.",
};
}
},
});
await mutation;
},
}),
);
const mutation = trpcClient.deploy.project.create.mutate(createInput);

toast.promise(mutation, {
loading: "Creating project...",
success: "Project created successfully",
error: (err) => {
console.error("Failed to create project", err);

const errorConfig =
ERROR_MESSAGES[err.data?.code as keyof typeof ERROR_MESSAGES] ||
ERROR_MESSAGES.DEFAULT;

return {
message: errorConfig.message,
description: err.message || errorConfig.description,
};
},
});

await mutation;
},
}),
);
}
101 changes: 65 additions & 36 deletions apps/dashboard/lib/collections/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import { createDeploymentsCollection } from "./deploy/deployments";
import { createDomainsCollection } from "./deploy/domains";
import { createEnvironmentsCollection } from "./deploy/environments";
import { projects } from "./deploy/projects";
import { ratelimitNamespaces } from "./ratelimit/namespaces";
import { ratelimitOverrides } from "./ratelimit/overrides";
import { createProjectsCollection } from "./deploy/projects";
import { createRatelimitNamespacesCollection } from "./ratelimit/namespaces";
import { createRatelimitOverridesCollection } from "./ratelimit/overrides";

// Export types
export type { Deployment } from "./deploy/deployments";
Expand All @@ -14,27 +14,48 @@ export type { RatelimitNamespace } from "./ratelimit/namespaces";
export type { RatelimitOverride } from "./ratelimit/overrides";
export type { Environment } from "./deploy/environments";

// Collection factory definitions - only project-scoped collections
const PROJECT_COLLECTION_FACTORIES = {
environments: createEnvironmentsCollection,
domains: createDomainsCollection,
deployments: createDeploymentsCollection,
} as const;

const GLOBAL_COLLECTION_FACTORIES = {
projects: createProjectsCollection,
ratelimitNamespaces: createRatelimitNamespacesCollection,
ratelimitOverrides: createRatelimitOverridesCollection,
} as const;

// ProjectCollections only contains project-scoped collections
type ProjectCollections = {
environments: ReturnType<typeof createEnvironmentsCollection>;
domains: ReturnType<typeof createDomainsCollection>;
deployments: ReturnType<typeof createDeploymentsCollection>;
projects: typeof projects;
[K in keyof typeof PROJECT_COLLECTION_FACTORIES]: ReturnType<
(typeof PROJECT_COLLECTION_FACTORIES)[K]
>;
};

async function cleanupCollections(collections: Record<string, { cleanup(): Promise<void> }>) {
await Promise.all(Object.values(collections).map((c) => c.cleanup()));
}

class CollectionManager {
private projectCollections = new Map<string, ProjectCollections>();

getProjectCollections(projectId: string): ProjectCollections {
if (!projectId) {
throw new Error("projectId is required");
}

if (!this.projectCollections.has(projectId)) {
this.projectCollections.set(projectId, {
environments: createEnvironmentsCollection(projectId),
domains: createDomainsCollection(projectId),
deployments: createDeploymentsCollection(projectId),
projects,
});
// Create collections using factories - only project-scoped ones
const newCollections = Object.fromEntries(
Object.entries(PROJECT_COLLECTION_FACTORIES).map(([key, factory]) => [
key,
factory(projectId),
]),
) as ProjectCollections;

this.projectCollections.set(projectId, newCollections);
}
// biome-ignore lint/style/noNonNullAssertion: Its okay
return this.projectCollections.get(projectId)!;
Expand All @@ -43,44 +64,52 @@ class CollectionManager {
async cleanup(projectId: string) {
const collections = this.projectCollections.get(projectId);
if (collections) {
await Promise.all([
collections.environments.cleanup(),
collections.domains.cleanup(),
collections.deployments.cleanup(),
// Note: projects is shared, don't clean it up per project
]);
// All collections in ProjectCollections are cleanupable
await cleanupCollections(collections);
this.projectCollections.delete(projectId);
}
}

async cleanupAll() {
// Clean up all project collections
const projectCleanupPromises = Array.from(this.projectCollections.keys()).map((projectId) =>
this.cleanup(projectId),
const projectPromises = Array.from(this.projectCollections.entries()).map(
async ([_, collections]) => {
await cleanupCollections(collections);
},
);
// Clean up global collections, this has to run sequentially
for (const c of Object.values(collection)) {
await c.cleanup();
}

// Clean up global collections
const globalCleanupPromises = Object.values(collection).map((c) => c.cleanup());

await Promise.all([...projectCleanupPromises, ...globalCleanupPromises]);
await Promise.all([...projectPromises]);
this.projectCollections.clear();
}
}

export const collectionManager = new CollectionManager();

// Global collections
export const collection = {
projects,
ratelimitNamespaces,
ratelimitOverrides,
} as const;
// Global collections, create using factories
export const collection = Object.fromEntries(
Object.entries(GLOBAL_COLLECTION_FACTORIES).map(([key, factory]) => [key, factory()]),
) as {
[K in keyof typeof GLOBAL_COLLECTION_FACTORIES]: ReturnType<
(typeof GLOBAL_COLLECTION_FACTORIES)[K]
>;
};

export async function reset() {
// This is GC cleanup only useful for better memory management
await collectionManager.cleanupAll();
// Preload global collections after cleanup
await Promise.all(
Object.values(collection).map(async (c) => {
await c.preload();
}),
// Without these components still subscribed to old collections, so create new instances for each reset. Mostly used when switching workspaces
Object.assign(
collection,
Object.fromEntries(
Object.entries(GLOBAL_COLLECTION_FACTORIES).map(([key, factory]) => [key, factory()]),
),
);
// Preload all collections, please keep this sequential. Otherwise UI acts weird. react-query already takes care of batching.
for (const c of Object.values(collection)) {
await c.preload();
}
}
Loading