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
5 changes: 5 additions & 0 deletions apps/code/src/renderer/platform-adapters/auth-side-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@posthog/ui/features/auth/authQueries";
import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore";
import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers";
import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore";
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
import { resetSessionService } from "@posthog/ui/features/sessions/sessionServiceHost";
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
Expand All @@ -30,6 +31,9 @@ export class RendererAuthSideEffects implements IAuthSideEffects {
onProjectSelected(): void {
clearAuthScopedQueries();
void refreshAuthStateQuery();
// Before openTaskInput, which files a new task into the scoped channel —
// a channel id from the project we just left.
resetCurrentChannel();
openTaskInput();
}

Expand All @@ -40,6 +44,7 @@ export class RendererAuthSideEffects implements IAuthSideEffects {
if (previousRegion) {
useAuthUiStateStore.getState().setStaleRegion(previousRegion);
}
resetCurrentChannel();
openTaskInput();
useOnboardingStore.getState().resetSelections();
}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/web-auth-side-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@posthog/ui/features/auth/authQueries";
import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore";
import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers";
import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore";
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
import { injectable } from "inversify";
Expand All @@ -24,6 +25,9 @@ export class WebAuthSideEffects implements IAuthSideEffects {
onProjectSelected(): void {
clearAuthScopedQueries();
void refreshAuthStateQuery();
// Before openTaskInput, which files a new task into the scoped channel —
// a channel id from the project we just left.
resetCurrentChannel();
openTaskInput();
}

Expand All @@ -32,6 +36,7 @@ export class WebAuthSideEffects implements IAuthSideEffects {
if (previousRegion) {
useAuthUiStateStore.getState().setStaleRegion(previousRegion);
}
resetCurrentChannel();
openTaskInput();
useOnboardingStore.getState().resetSelections();
}
Expand Down
211 changes: 211 additions & 0 deletions packages/core/src/canvas/channelItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import type { Task, UserBasic } from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import {
buildChannelItems,
type ChannelItemModel,
filterChannelItems,
} from "./channelItems";
import type { DashboardSummary } from "./dashboardSchemas";

const ME: UserBasic = {
id: 1,
uuid: "me-uuid",
distinct_id: "me",
first_name: "Ada",
last_name: "Lovelace",
email: "ada@posthog.com",
};

const OTHER: UserBasic = {
id: 2,
uuid: "other-uuid",
distinct_id: "other",
first_name: "Grace",
last_name: "Hopper",
email: "grace@posthog.com",
};

function canvas(over: Partial<DashboardSummary> = {}): DashboardSummary {
return {
id: "d1",
channelId: "c1",
name: "Canvas",
templateId: "freeform",
createdAt: 0,
updatedAt: 1_000,
...over,
} as DashboardSummary;
}

function task(over: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
updated_at: new Date(2_000).toISOString(),
created_by: ME,
...over,
} as Task;
}

const NONE: ReadonlySet<string> = new Set();

function build(options: Partial<Parameters<typeof buildChannelItems>[0]> = {}) {
return buildChannelItems({
dashboards: [],
feedTasks: [],
archivedTaskIds: NONE,
pinnedTaskIds: NONE,
ownedBy: null,
...options,
});
}

describe("buildChannelItems", () => {
it("merges canvases and tasks newest-first", () => {
const items = build({
dashboards: [canvas({ id: "old", updatedAt: 1_000 })],
feedTasks: [
task({ id: "new", updated_at: new Date(5_000).toISOString() }),
],
});
expect(items.map((i) => i.key)).toEqual(["task:new", "canvas:old"]);
});

it("drops archived tasks but keeps canvases", () => {
const items = build({
dashboards: [canvas()],
feedTasks: [task({ id: "gone" })],
archivedTaskIds: new Set(["gone"]),
});
expect(items.map((i) => i.kind)).toEqual(["canvas"]);
});

it("marks pinned state from each source's own signal", () => {
const items = build({
dashboards: [canvas({ id: "pinned-canvas", pinnedAt: 42 })],
feedTasks: [task({ id: "pinned-task" })],
pinnedTaskIds: new Set(["pinned-task"]),
});
expect(items.every((i) => i.pinned)).toBe(true);
});

it("falls back to a placeholder title for untitled tasks", () => {
const [item] = build({
feedTasks: [task({ title: "" })],
});
expect(item.title).toBe("Untitled task");
});

it("treats an unparseable updated_at as epoch rather than NaN", () => {
const [item] = build({
feedTasks: [task({ updated_at: "not a date" })],
});
expect(item.ts).toBe(0);
});

it("returns everything when the owner is unknown", () => {
const items = build({
dashboards: [canvas({ createdBy: "Grace Hopper" })],
feedTasks: [task({ created_by: OTHER })],
});
expect(items).toHaveLength(2);
});

it("filters to the owner for the personal channel", () => {
const items = build({
dashboards: [
canvas({ id: "mine", createdBy: "Ada Lovelace" }),
canvas({ id: "theirs", createdBy: "Grace Hopper" }),
],
feedTasks: [
task({ id: "mine-task", created_by: ME }),
task({ id: "their-task", created_by: OTHER }),
],
ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" },
});
expect(items.map((i) => i.id).sort()).toEqual(["mine", "mine-task"]);
});

it("keeps items whose author is unknown", () => {
const items = build({
dashboards: [canvas({ id: "orphan", createdBy: undefined })],
feedTasks: [task({ id: "orphan-task", created_by: null })],
ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" },
});
expect(items).toHaveLength(2);
});
});

function model(over: Partial<ChannelItemModel> = {}): ChannelItemModel {
return {
key: "task:t1",
kind: "task",
id: "t1",
title: "Ship the thing",
ts: 0,
pinned: false,
rawStatus: null,
authorUser: ME,
authorName: null,
templateId: null,
...over,
};
}

describe("filterChannelItems", () => {
const me = { uuid: ME.uuid, name: "Ada Lovelace" };

it("matches titles case-insensitively", () => {
const items = [model({ title: "Ship IT" }), model({ title: "Other" })];
const result = filterChannelItems(items, {
query: " ship ",
createdBy: "anyone",
status: null,
me,
});
expect(result.map((i) => i.title)).toEqual(["Ship IT"]);
});

it.each([
["me", ["mine"]],
["others", ["theirs"]],
["anyone", ["mine", "theirs"]],
] as const)("filters createdBy=%s", (createdBy, expected) => {
const items = [
model({ id: "mine", authorUser: ME }),
model({ id: "theirs", authorUser: OTHER }),
];
const result = filterChannelItems(items, {
query: "",
createdBy,
status: null,
me,
});
expect(result.map((i) => i.id)).toEqual(expected);
});

it("filters by run status, including not_started", () => {
const items = [
model({ id: "fresh", rawStatus: "not_started" }),
model({ id: "done", rawStatus: "completed" }),
];
const result = filterChannelItems(items, {
query: "",
createdBy: "anyone",
status: "not_started",
me,
});
expect(result.map((i) => i.id)).toEqual(["fresh"]);
});

it("excludes canvases when a run status is selected", () => {
const items = [model({ kind: "canvas", rawStatus: null })];
const result = filterChannelItems(items, {
query: "",
createdBy: "anyone",
status: "completed",
me,
});
expect(result).toEqual([]);
});
});
115 changes: 115 additions & 0 deletions packages/core/src/canvas/channelItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type {
Task,
TaskRunStatus,
UserBasic,
} from "@posthog/shared/domain-types";
import type { DashboardSummary } from "./dashboardSchemas";

export interface ChannelItemModel {
key: string;
kind: "task" | "canvas";
id: string;
title: string;
ts: number;
pinned: boolean;
rawStatus: TaskRunStatus | null;
authorUser: UserBasic | null;
authorName: string | null;
templateId: string | null;
}

export interface ChannelItemOwner {
uuid: string | null;
name: string | null;
}

function isOwnedBy(
item: Pick<ChannelItemModel, "authorUser" | "authorName">,
owner: ChannelItemOwner,
): boolean {
if (item.authorUser) return item.authorUser.uuid === owner.uuid;
if (item.authorName && owner.name) return item.authorName === owner.name;
return true;
}

export function buildChannelItems({
dashboards,
feedTasks,
archivedTaskIds,
pinnedTaskIds,
ownedBy,
}: {
dashboards: readonly DashboardSummary[];
feedTasks: readonly Task[];
archivedTaskIds: ReadonlySet<string>;
pinnedTaskIds: ReadonlySet<string>;
ownedBy: ChannelItemOwner | null;
}): ChannelItemModel[] {
const canvasItems: ChannelItemModel[] = dashboards.map((d) => ({
key: `canvas:${d.id}`,
kind: "canvas",
id: d.id,
title: d.name,
ts: d.updatedAt,
pinned: d.pinnedAt != null,
rawStatus: null,
authorUser: null,
authorName: d.createdBy ?? null,
templateId: d.templateId,
}));

const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) =>
archivedTaskIds.has(task.id)
? []
: [
{
key: `task:${task.id}`,
kind: "task" as const,
id: task.id,
title: task.title || "Untitled task",
ts: Date.parse(task.updated_at) || 0,
pinned: pinnedTaskIds.has(task.id),
rawStatus: task.latest_run?.status ?? null,
authorUser: task.created_by ?? null,
authorName: null,
templateId: null,
},
],
);

const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts);
return ownedBy ? all.filter((item) => isOwnedBy(item, ownedBy)) : all;
}

export type CreatedByFilter = "anyone" | "me" | "others";

export function filterChannelItems(
items: readonly ChannelItemModel[],
{
query,
createdBy,
status,
me,
}: {
query: string;
createdBy: CreatedByFilter;
status: TaskRunStatus | null;
me: ChannelItemOwner;
},
): ChannelItemModel[] {
const normalizedQuery = query.trim().toLowerCase();
return items.filter((item) => {
if (
normalizedQuery &&
!item.title.toLowerCase().includes(normalizedQuery)
) {
return false;
}
if (createdBy !== "anyone") {
const mine = isOwnedBy(item, me);
if (createdBy === "me" ? !mine : mine) return false;
}
if (status && item.rawStatus !== status) return false;
return true;
});
}
Loading
Loading