Skip to content
10 changes: 9 additions & 1 deletion scripts/check-edit-route-parity.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,15 @@ const LOCAL_ONLY_ALLOWLIST = [
];

const ADMIN_ONLY_ALLOWLIST = [
// none today
// We have a layout at the root on rill-dev, not under subpath like (application)/(workspace)/ or (viz)/
"/+layout.ts",

// Welcome is under (misc) in local. There will be a future PR that moves them to root.
"/welcome/+layout.svelte",
"/welcome/+layout.ts",
"/welcome/+page.svelte",
"/welcome/add-data/+page.svelte",
"/welcome/add-data/+page.ts",
];

function walkRoutes(absRoot) {
Expand Down
265 changes: 265 additions & 0 deletions web-admin/src/features/branches/deployment-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { isRedirect } from "@sveltejs/kit";
import {
V1DeploymentStatus,
type V1Deployment,
type V1ListDeploymentsResponse,
} from "@rilldata/web-admin/client";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import {
isActiveDeployment,
isProdDeployment,
maybeRedirectToEditableDeployment,
} from "./deployment-utils";

const { listDeploymentsMock } = vi.hoisted(() => ({
listDeploymentsMock: vi.fn<() => Promise<V1ListDeploymentsResponse>>(),
}));

vi.mock("@rilldata/web-admin/client", async () => {
// Import the rest of the client. Mainly needed for type definitions.
const actual = await vi.importActual<
typeof import("@rilldata/web-admin/client")
>("@rilldata/web-admin/client");
return {
...actual,
adminServiceListDeployments: (...args: unknown[]) =>
listDeploymentsMock(...(args as [])),
};
});

const ORG = "rilldata";
const PROJECT = "openrtb";

function makeDeployment(overrides: Partial<V1Deployment>): V1Deployment {
return {
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
...overrides,
};
}

describe("deployment-utils", () => {
describe("isActiveDeployment", () => {
it.each([
V1DeploymentStatus.DEPLOYMENT_STATUS_PENDING,
V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
V1DeploymentStatus.DEPLOYMENT_STATUS_UPDATING,
])("returns true for %s", (status) => {
expect(isActiveDeployment({ status })).toBe(true);
});

it.each([
V1DeploymentStatus.DEPLOYMENT_STATUS_UNSPECIFIED,
V1DeploymentStatus.DEPLOYMENT_STATUS_ERRORED,
V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED,
V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPING,
V1DeploymentStatus.DEPLOYMENT_STATUS_DELETING,
V1DeploymentStatus.DEPLOYMENT_STATUS_DELETED,
])("returns false for %s", (status) => {
expect(isActiveDeployment({ status })).toBe(false);
});
});

describe("isProdDeployment", () => {
it("returns true when environment is 'prod'", () => {
expect(isProdDeployment({ environment: "prod" })).toBe(true);
});

it("returns false for any other environment", () => {
expect(isProdDeployment({ environment: "dev" })).toBe(false);
expect(isProdDeployment({ environment: "staging" })).toBe(false);
expect(isProdDeployment({})).toBe(false);
});
});

describe("maybeRedirectToEditableDeployment", () => {
beforeEach(() => {
listDeploymentsMock.mockReset();
queryClient.clear();
});

async function call(pathname: string): Promise<Error | undefined> {
try {
await maybeRedirectToEditableDeployment(
ORG,
PROJECT,
new URL(`http://localhost${pathname}`),
);
return undefined;
} catch (e) {
return e as Error;
}
}

it("does not redirect when an active prod deployment exists", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "prod",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}),
makeDeployment({
environment: "dev",
branch: "edit-branch",
editable: true,
}),
],
});

const result = await call("/rilldata/openrtb");
expect(result).toBeUndefined();
});

it("does not redirect when there is no editable deployment", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "prod",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED,
}),
],
});

const result = await call("/rilldata/openrtb");
expect(result).toBeUndefined();
});

it("does not redirect when the editable deployment has no branch", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "dev",
editable: true,
branch: undefined,
}),
],
});

const result = await call("/rilldata/openrtb");
expect(result).toBeUndefined();
});

it("does not redirect when the editable deployment is inactive (hibernating)", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "prod",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED,
}),
makeDeployment({
environment: "dev",
editable: true,
branch: "edit-branch",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED,
}),
],
});

const result = await call("/rilldata/openrtb");
expect(result).toBeUndefined();
});

it("does not redirect when the user is already on the editable branch", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "dev",
editable: true,
branch: "edit-branch",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}),
],
});

const result = await call(
"/rilldata/openrtb/@edit-branch/explore/revenue",
);
expect(result).toBeUndefined();
});

it("does not redirect when the user is on a different branch than the editable one", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "dev",
editable: true,
branch: "edit-branch",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}),
],
});

const result = await call("/rilldata/openrtb/@some-other-branch");
expect(result).toBeUndefined();
});

it("redirects to the editable branch when prod is inactive and editable is active", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "prod",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED,
}),
makeDeployment({
environment: "dev",
editable: true,
branch: "edit-branch",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}),
],
});

const result = await call("/rilldata/openrtb");
expect(isRedirect(result)).toBe(true);
if (!isRedirect(result)) return; // type-safety
expect(result.status).toBe(307);
expect(result.location).toBe("/rilldata/openrtb/@edit-branch");
});

it("redirects when there is no prod deployment at all", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "dev",
editable: true,
branch: "edit-branch",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}),
],
});

const result = await call("/rilldata/openrtb/explore/revenue");
expect(isRedirect(result)).toBe(true);
if (!isRedirect(result)) return; // type-safety
expect(result.status).toBe(307);
expect(result.location).toBe("/rilldata/openrtb/@edit-branch");
});

it("redirects when prod is in PENDING (still active) — sanity check on active statuses", async () => {
listDeploymentsMock.mockResolvedValue({
deployments: [
makeDeployment({
environment: "prod",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_PENDING,
}),
makeDeployment({
environment: "dev",
editable: true,
branch: "edit-branch",
status: V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
}),
],
});

const result = await call("/rilldata/openrtb");
expect(result).toBeUndefined();
});

it("returns undefined when deployments list is empty", async () => {
listDeploymentsMock.mockResolvedValue({ deployments: [] });

const result = await call("/rilldata/openrtb");
expect(result).toBeUndefined();
});
});
});
46 changes: 46 additions & 0 deletions web-admin/src/features/branches/deployment-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import {
getAdminServiceListDeploymentsQueryKey,
V1DeploymentStatus,
type V1Deployment,
adminServiceListDeployments,
} from "@rilldata/web-admin/client";
import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient";
import { redirect } from "@sveltejs/kit";
import {
extractBranchFromPath,
injectBranchIntoPath,
} from "@rilldata/web-admin/features/branches/branch-utils.ts";

/**
* Invalidates all deployment queries for a project, triggering a refetch.
Expand All @@ -28,3 +34,43 @@ export function isActiveDeployment(d: V1Deployment): boolean {
export function isProdDeployment(d: V1Deployment): boolean {
return d.environment === "prod";
}

export async function maybeRedirectToEditableDeployment(
organization: string,
project: string,
url: URL,
) {
const deploymentsResp = await queryClient.fetchQuery({
queryKey: getAdminServiceListDeploymentsQueryKey(organization, project, {}),
queryFn: () => adminServiceListDeployments(organization, project, {}),
// Do not refetch in this loader function. Refetch strategy is instead managed in BranchesSection.svelte
staleTime: Infinity,
});
const prodDeployment = deploymentsResp.deployments?.find(isProdDeployment);
const editableDeployment = deploymentsResp.deployments?.find(
(d) => d.editable,
);

// There is an active prod deployment, so no need for redirect.
const isActiveProdDeployment =
prodDeployment && isActiveDeployment(prodDeployment);
if (isActiveProdDeployment || !editableDeployment?.branch) return;

const isActiveEditableDeployment =
editableDeployment && isActiveDeployment(editableDeployment);
// Editable deployment is inactive as well, project is probably hibernating, skip redirect.
if (!isActiveEditableDeployment) return;

// If user is already in a specific deployment do not redirect.
// This method is meant as a convenience for direct links to unpublished project.
const currentBranch = extractBranchFromPath(url.pathname);
if (currentBranch) return;

throw redirect(
307,
injectBranchIntoPath(
`/${organization}/${project}`,
editableDeployment.branch,
),
);
}
6 changes: 3 additions & 3 deletions web-admin/src/features/navigation/nav-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export function isPublicAlertPage(page: Page): boolean {
);
}

export function isEditPage(page: Page): boolean {
return !!page.route?.id?.startsWith("/[organization]/[project]/-/edit");
export function isEditPage({ route }: Pick<Page, "route">): boolean {
return !!route?.id?.startsWith("/[organization]/[project]/-/edit");
}

export function isProjectRequestAccessPage(page: Page): boolean {
Expand All @@ -122,7 +122,7 @@ export function isWelcomePage({ route }: Pick<Page, "route">): boolean {
}

export function isProjectWelcomePage({ route }: Pick<Page, "route">): boolean {
return !!route.id?.startsWith("/[organization]/[project]/-/welcome");
return !!route.id?.startsWith("/[organization]/[project]/-/edit/welcome");
}

export function isAuthPage({ route }: Pick<Page, "route">): boolean {
Expand Down
15 changes: 2 additions & 13 deletions web-admin/src/features/projects/publish-project.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,18 @@
import { goto } from "$app/navigation";
import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
import { runtimeServiceGitPush } from "@rilldata/web-common/runtime-client";

export const CreateProjectBranchName = "dev";

/**
* Checkpoints the current project state and redirects to the project dashboard.
* Will be the cloud editing screen once it's available.
* Checkpoints the current project state. Redirect should already be handled through indiviual components.
*
* Note that publishing the project to prod deployment will be through explicit user action.
*/
export async function checkpointProjectAndRedirect(
runtimeClient: RuntimeClient,
organization: string,
project: string,
) {
export async function checkpointProject(runtimeClient: RuntimeClient) {
// Push the initial commit to the current branch.
await runtimeServiceGitPush(runtimeClient, {
commitMessage: "Initial project setup",
});

// TODO: land user to edit screen when that is available
const destinationPath = `/${organization}/${project}`;
// Without this delay, the navigation is getting cancelled.
setTimeout(() => void goto(destinationPath), 50);
}

// TODO: add the publish function that,
Expand Down
Loading
Loading