mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary Part 1 of 4 stacked PRs continuing the Projects work from #6003. - The Projects overview now follows the selected section: the right-hand context pod stays visible across the Projects / Repositories / Reviews / Tasks / Channels tabs and shows section-specific people, stats, and contribution graphs, so it reads as live context rather than a detached summary. - Entity list rows are consolidated onto one compact line (title first, icons after, aligned dates/counts), shared across projects, repositories, PRs, issues, and channel lists via `ProjectEntityListRow`. - New `projectRelatedChannels` helper resolves the channels a project is discussed in for the overview. Follow-ups in this stack: part 2 (selectable workspaces), part 3 (context-aware collaboration), part 4 (navigation & detail-page polish). ## Test plan - [x] Desktop unit tests (`pnpm test`) — pass - [x] TypeScript (`tsc --noEmit`), Biome, clippy — clean via pre-push gate - [x] Playwright specs updated for the new overview behavior (`projects-v3-screenshots.spec.ts`, `sidebar.spec.ts`) — pass locally in the smoke project --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com>
248 lines
6.5 KiB
JavaScript
248 lines
6.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
|
|
import {
|
|
isProjectAccessibleToViewer,
|
|
isProjectMine,
|
|
isRepositoryAccessibleToViewer,
|
|
listRowDescription,
|
|
nextRepositoryEntryLimit,
|
|
relativeTime,
|
|
} from "./projectsViewHelpers.ts";
|
|
|
|
const DAY_SECONDS = 24 * 60 * 60;
|
|
|
|
function localSeconds(year, month, day) {
|
|
return Math.floor(new Date(year, month, day, 12).getTime() / 1_000);
|
|
}
|
|
|
|
const REPO_OWNER = "a".repeat(64);
|
|
const VIEWER = "b".repeat(64);
|
|
const BOUND_CHANNEL = "11111111-1111-4111-8111-111111111111";
|
|
const RELAY_ORIGIN = "https://relay.example";
|
|
|
|
function makeRepository(overrides = {}) {
|
|
return {
|
|
channelId: BOUND_CHANNEL,
|
|
cloneUrls: [`${RELAY_ORIGIN}/git/${REPO_OWNER}/repo-a`],
|
|
contributors: [],
|
|
createdAt: 0,
|
|
defaultBranch: "main",
|
|
dtag: "repo-a",
|
|
id: `${REPO_OWNER}:repo-a`,
|
|
name: "repo-a",
|
|
owner: REPO_OWNER,
|
|
repoAddress: `30617:${REPO_OWNER}:repo-a`,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeAccessInput(overrides = {}) {
|
|
return {
|
|
currentPubkey: VIEWER,
|
|
localRepoNames: new Set(),
|
|
memberChannelIds: [],
|
|
relayOrigin: RELAY_ORIGIN,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeProject(overrides = {}) {
|
|
return {
|
|
createdAt: 0,
|
|
description: "",
|
|
dtag: "sprout",
|
|
id: "30621:owner:sprout",
|
|
name: "sprout",
|
|
owner: REPO_OWNER,
|
|
primaryRepositoryAddress: null,
|
|
projectAddress: "30621:owner:sprout",
|
|
projectChannelId: null,
|
|
repositories: [],
|
|
repositoryAddresses: [],
|
|
status: "open",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test("isProjectMine is true for projects the viewer owns or contributes to", () => {
|
|
assert.equal(isProjectMine(makeProject(), undefined), false);
|
|
assert.equal(isProjectMine(makeProject(), VIEWER), false);
|
|
assert.equal(isProjectMine(makeProject(), REPO_OWNER), true);
|
|
assert.equal(
|
|
isProjectMine(
|
|
makeProject({
|
|
owner: "c".repeat(64),
|
|
repositories: [makeRepository({ owner: VIEWER })],
|
|
}),
|
|
VIEWER,
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isProjectMine(
|
|
makeProject({
|
|
owner: "c".repeat(64),
|
|
repositories: [makeRepository({ contributors: [VIEWER] })],
|
|
}),
|
|
VIEWER,
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("a channel-bound repository is accessible only to channel members", () => {
|
|
const repository = makeRepository();
|
|
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(
|
|
repository,
|
|
makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] }),
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(
|
|
repository,
|
|
makeAccessInput({ memberChannelIds: [] }),
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("an unbound repository stays accessible to its owner", () => {
|
|
const repository = makeRepository({ channelId: null });
|
|
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(repository, makeAccessInput()),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(
|
|
repository,
|
|
makeAccessInput({ currentPubkey: REPO_OWNER }),
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("external hosting and local checkouts bypass the channel gate", () => {
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(
|
|
makeRepository({ cloneUrls: ["https://github.com/acme/site.git"] }),
|
|
makeAccessInput(),
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(
|
|
makeRepository(),
|
|
makeAccessInput({ localRepoNames: new Set(["repo-a"]) }),
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("channel-bound repositories stay visible while memberships load", () => {
|
|
assert.equal(
|
|
isRepositoryAccessibleToViewer(
|
|
makeRepository(),
|
|
makeAccessInput({ memberChannelIds: null }),
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("a project is accessible when any repository is, or when owned", () => {
|
|
const accessible = makeRepository({ channelId: BOUND_CHANNEL });
|
|
const restricted = makeRepository({
|
|
channelId: "22222222-2222-4222-8222-222222222222",
|
|
dtag: "repo-b",
|
|
id: `${REPO_OWNER}:repo-b`,
|
|
name: "repo-b",
|
|
repoAddress: `30617:${REPO_OWNER}:repo-b`,
|
|
});
|
|
const input = makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] });
|
|
|
|
assert.equal(
|
|
isProjectAccessibleToViewer(
|
|
{ owner: REPO_OWNER, repositories: [restricted, accessible] },
|
|
input,
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isProjectAccessibleToViewer(
|
|
{ owner: REPO_OWNER, repositories: [restricted] },
|
|
input,
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
isProjectAccessibleToViewer(
|
|
{ owner: VIEWER, repositories: [restricted] },
|
|
input,
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("relativeTime switches to an absolute date at seven days", () => {
|
|
const now = localSeconds(2025, 5, 15);
|
|
|
|
assert.equal(relativeTime(now - 7 * DAY_SECONDS + 1, now), "6 days ago");
|
|
|
|
const createdAt = now - 7 * DAY_SECONDS;
|
|
const expected = new Date(createdAt * 1_000).toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
assert.equal(relativeTime(createdAt, now), expected);
|
|
});
|
|
|
|
test("relativeTime includes the year only across a year boundary", () => {
|
|
const sameYearNow = localSeconds(2025, 5, 15);
|
|
const sameYearCreatedAt = sameYearNow - 7 * DAY_SECONDS;
|
|
const sameYearExpected = new Intl.DateTimeFormat(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
}).format(new Date(sameYearCreatedAt * 1_000));
|
|
assert.equal(relativeTime(sameYearCreatedAt, sameYearNow), sameYearExpected);
|
|
|
|
const crossYearNow = localSeconds(2025, 0, 8);
|
|
const crossYearCreatedAt = localSeconds(2024, 11, 31);
|
|
const crossYearExpected = new Intl.DateTimeFormat(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
}).format(new Date(crossYearCreatedAt * 1_000));
|
|
assert.equal(
|
|
relativeTime(crossYearCreatedAt, crossYearNow),
|
|
crossYearExpected,
|
|
);
|
|
});
|
|
|
|
test("repository entry pagination advances and clamps to the total", () => {
|
|
assert.equal(nextRepositoryEntryLimit(200, 450), 400);
|
|
assert.equal(nextRepositoryEntryLimit(400, 450), 450);
|
|
assert.equal(nextRepositoryEntryLimit(450, 450), 450);
|
|
});
|
|
|
|
test("listRowDescription keeps real copy and drops empty or title-duplicate text", () => {
|
|
assert.equal(
|
|
listRowDescription("The complete Buzz community platform."),
|
|
"The complete Buzz community platform.",
|
|
);
|
|
assert.equal(listRowDescription(" "), undefined);
|
|
assert.equal(listRowDescription(""), undefined);
|
|
assert.equal(listRowDescription(null), undefined);
|
|
assert.equal(
|
|
listRowDescription(
|
|
"Fix reconnect backoff jitter",
|
|
"Fix reconnect backoff jitter",
|
|
),
|
|
undefined,
|
|
);
|
|
assert.equal(listRowDescription("**Hello** world"), "Hello world");
|
|
});
|