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
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// Mounts a card's labels (the home grid's and the board's) over the real
// tagsStore, with the backend commands and the badge store mocked: the
// project's tags sit as chips in the same cluster as its repos, and a board
// card leaves out the chip its column already stands for.
// project's repos show under their badges' short names (the full path in the
// tooltip, and in place of a repo with no badge), its tags sit as chips in
// the same cluster, and a board card leaves out the chip its column already
// stands for.
import { flushSync, mount, tick, unmount } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Project, ProjectRepo, TagState } from '../../types';
import { tag, tagState } from '../tags/testStubs/tagFixtures';

const { backend, badges } = vi.hoisted(() => ({
backend: { state: null as unknown as TagState },
/** Badges by `githubRepo`. */
/** Badges by `githubRepo`, or `githubRepo:subpath` for a subpath's. */
badges: new Map<string, { shortName: string; hue: number }>(),
}));

Expand All @@ -27,7 +29,10 @@ vi.mock('../../stores/projectsData.svelte', () => ({
projectsDataStore: { liveProjects: [] },
}));
vi.mock('../../stores/repoBadges.svelte', () => ({
repoBadgeStore: { lookup: (githubRepo: string) => badges.get(githubRepo) ?? null },
repoBadgeStore: {
lookup: (githubRepo: string, subpath: string | null | undefined) =>
badges.get(subpath ? `${githubRepo}:${subpath}` : githubRepo) ?? null,
},
}));

import { tagsStore } from '../../stores/tags.svelte';
Expand All @@ -45,9 +50,9 @@ const PROJECT: Project = {
updatedAt: 1,
};

function repo(githubRepo: string): ProjectRepo {
function repo(githubRepo: string, overrides: Partial<ProjectRepo> = {}): ProjectRepo {
return {
id: githubRepo,
id: `${githubRepo}:${overrides.subpath ?? ''}`,
projectId: 'p1',
githubRepo,
branchName: 'main',
Expand All @@ -57,6 +62,7 @@ function repo(githubRepo: string): ProjectRepo {
headRepo: null,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

Expand All @@ -67,21 +73,28 @@ const REPOS = [repo('block/goose'), repo('block/builderbot')];
async function mountLabels(
tagIds: string[],
{
project = PROJECT,
repos = REPOS,
excludeTagGroupId = null,
state = tagState(),
}: { repos?: ProjectRepo[]; excludeTagGroupId?: string | null; state?: TagState } = {}
}: {
project?: Project;
repos?: ProjectRepo[];
excludeTagGroupId?: string | null;
state?: TagState;
} = {}
) {
badges.clear();
badges.set('block/builderbot', { shortName: 'bb', hue: 200 });
badges.set('block/builderbot:apps/staged', { shortName: 'staged', hue: 120 });
backend.state = { ...state, assignments: tagIds.map((tagId) => ({ projectId: 'p1', tagId })) };
await tagsStore.load({ force: true });
await tick();
const target = document.createElement('div');
document.body.appendChild(target);
component = mount(ProjectCardLabels, {
target,
props: { project: PROJECT, repos, excludeTagGroupId },
props: { project, repos, excludeTagGroupId },
});
flushSync();
return target;
Expand All @@ -92,6 +105,17 @@ function clusterTexts(root: ParentNode): string[] {
return [...root.querySelector('.card-labels')!.children].map((el) => el.textContent!.trim());
}

/** The repos' labels, in order: what each shows and its tooltip. */
function repoLabels(root: ParentNode): { text: string; title: string }[] {
return [
...root.querySelectorAll<HTMLElement>('.card-labels > .repo-badge-label, .repo-line'),
].map((el) => ({
text: el.textContent!.trim(),
// A badge carries the tooltip itself; a plain label's is its RepoLabel's.
title: el.title || el.querySelector<HTMLElement>('.repo-label')!.title,
}));
}

function chipNames(root: ParentNode): string[] {
return [...root.querySelectorAll('.tag-chip-name')].map((el) => el.textContent!.trim());
}
Expand All @@ -106,8 +130,9 @@ describe('ProjectCardLabels', () => {
it('shows the tags as chips after the repos, in the same cluster', async () => {
const target = await mountLabels(['infra', 'p0']);

// Repos sorted, then the chips: grouped tags, then ungrouped.
expect(clusterTexts(target)).toEqual(['block/builderbot', 'block/goose', 'P0', 'infra']);
// Repos sorted by full path (the badged one under its short name), then
// the chips: grouped tags, then ungrouped.
expect(clusterTexts(target)).toEqual(['bb', 'block/goose', 'P0', 'infra']);
expect(target.querySelectorAll('.card-labels > .repo-badge-label')).toHaveLength(1);
expect(target.querySelectorAll('.card-labels > .repo-line')).toHaveLength(1);
expect(target.querySelectorAll('.card-labels > .tag-chip')).toHaveLength(2);
Expand All @@ -116,11 +141,56 @@ describe('ProjectCardLabels', () => {
it('adds nothing for a project without tags', async () => {
const target = await mountLabels([]);

expect(clusterTexts(target)).toEqual(['block/builderbot', 'block/goose']);
expect(clusterTexts(target)).toEqual(['bb', 'block/goose']);
expect(target.querySelector('.tag-chip')).toBeNull();
expect(target.querySelector('.tag-chips-more')).toBeNull();
});

it('shows a badged repo’s short name, keeping the full path as its tooltip', async () => {
const target = await mountLabels([]);

expect(repoLabels(target)).toEqual([
{ text: 'bb', title: 'block/builderbot' },
// No badge, so no short name: the full path stays.
{ text: 'block/goose', title: 'block/goose' },
]);
});

it('shows a monorepo subpath under its own short name', async () => {
const target = await mountLabels([], {
repos: [
repo('block/builderbot', { subpath: 'apps/staged' }),
repo('block/builderbot'),
// A subpath without a badge keeps its full path.
repo('block/builderbot', { subpath: 'apps/web' }),
],
});

// Still ordered by full path, whatever the short names are.
expect(repoLabels(target)).toEqual([
{ text: 'bb', title: 'block/builderbot' },
{ text: 'staged', title: 'block/builderbot/apps/staged' },
{ text: 'block/builderbot/apps/web', title: 'block/builderbot/apps/web' },
]);
});

it('names a fork-backed repo by its badge, with the fork’s path as the tooltip', async () => {
const target = await mountLabels([], {
repos: [repo('block/builderbot', { headRepo: 'someone/builderbot' })],
});

expect(repoLabels(target)).toEqual([{ text: 'bb', title: 'someone/builderbot' }]);
});

it('shortens the project’s own repo while its repos haven’t loaded', async () => {
const target = await mountLabels([], {
project: { ...PROJECT, githubRepo: 'block/builderbot', subpath: 'apps/staged' },
repos: [],
});

expect(repoLabels(target)).toEqual([{ text: 'staged', title: 'block/builderbot/apps/staged' }]);
});

it('shows the chips beside "No repo attached" for a project with no repo', async () => {
const target = await mountLabels(['q4'], { repos: [] });

Expand Down
33 changes: 13 additions & 20 deletions apps/staged/src/lib/features/projects/ProjectCardLabels.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
<!--
ProjectCardLabels.svelte — what a project card is labelled with, as one
wrapping cluster: its repos, sorted, each as its badge (the repo's hue) or a
plain label, then its tags as chips (see ProjectTagChips). A project whose
repos haven't loaded falls back to its own repo, and one with none says so.
wrapping cluster: its repos, sorted, each as its badge (the repo's short
name in its hue, as the sidebar's rows show it; see repoShortLabel) with the
full path as its tooltip, then its tags as chips (see ProjectTagChips). A
repo with no badge yet has no short name and shows its full path instead. A
project whose repos haven't loaded falls back to its own repo, and one with
none says so.
Shared by the home grid's cards and the board's; a board card leaves out
the board's group (`excludeTagGroupId`), whose tag its column stands for.
-->
<script lang="ts">
import type { Project, ProjectRepo } from '../../types';
import { darkMode } from '../../stores/isDark.svelte';
import { repoBadgeStore } from '../../stores/repoBadges.svelte';
import { badgeBg, badgeFg } from '../../shared/badgeColors';
import RepoLabel from '../../shared/RepoLabel.svelte';
import { repoFullName, repoShortLabel } from '../../shared/repoShortLabel';
import ProjectTagChips from './ProjectTagChips.svelte';

interface Props {
Expand All @@ -23,14 +26,12 @@

let { project, repos, excludeTagGroupId = null }: Props = $props();

function repoKey(repo: Pick<ProjectRepo, 'githubRepo' | 'subpath'>): string {
return repo.subpath ? `${repo.githubRepo}/${repo.subpath}` : repo.githubRepo;
}

// Sorted by full path, as before the labels were shortened: the order of a
// card's repos doesn't move with their badges' names.
let labels = $derived(
repos.length > 0
? [...repos]
.sort((a, b) => repoKey(a).localeCompare(repoKey(b)))
.sort((a, b) => repoFullName(a).localeCompare(repoFullName(b)))
.map((repo) => ({
githubRepo: repo.githubRepo,
shownRepo: repo.headRepo ?? repo.githubRepo,
Expand All @@ -50,16 +51,17 @@

<div class="card-labels">
{#each labels as label, i (i)}
{@const badge = repoBadgeStore.lookup(label.githubRepo, label.subpath)}
{@const badge = repoShortLabel(label)}
{#if badge}
<span
class="repo-badge-label"
title={repoFullName({ githubRepo: label.shownRepo, subpath: label.subpath })}
style="background: {badgeBg(badge.hue, darkMode.value)}; color: {badgeFg(
badge.hue,
darkMode.value
)};"
>
<RepoLabel githubRepo={label.shownRepo} subpath={label.subpath} />
{badge.shortName}
</span>
{:else}
<span class="repo-line">
Expand Down Expand Up @@ -106,13 +108,4 @@
overflow: hidden;
text-overflow: ellipsis;
}

.repo-badge-label :global(.repo-label-prefix) {
color: inherit;
opacity: 0.6;
}

.repo-badge-label :global(.repo-label-emphasis) {
color: inherit;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
// Mounts the real chip bar over the real projectFiltersStore and tagsStore,
// with the backend commands and the other data stores mocked: tag chips list
// the tags in use, in catalog order, with counts; clicking them filters; and a
// deleted tag's filter neither filters nor keeps Clear up.
// with the backend commands and the other data stores mocked: repo chips show
// their badges' short names; tag chips list the tags in use, in catalog order,
// with counts; clicking them filters; and a deleted tag's filter neither
// filters nor keeps Clear up.
import { flushSync, mount, tick, unmount } from 'svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Project, TagState } from '../../types';
import { tagState, withTagAssigned } from '../tags/testStubs/tagFixtures';

const { backend, projects } = vi.hoisted(() => ({
const { backend, projects, badges } = vi.hoisted(() => ({
backend: { state: null as unknown as TagState },
projects: [] as Project[],
/** Badges by `githubRepo:subpath`. */
badges: new Map<string, { shortName: string; hue: number }>(),
}));

const commandsMock = vi.hoisted(() => ({
Expand All @@ -35,7 +38,12 @@ vi.mock('../../stores/projectsData.svelte', () => ({
vi.mock('../../stores/projectState.svelte', () => ({
projectStateStore: { isUnread: () => false },
}));
vi.mock('../../stores/repoBadges.svelte', () => ({ repoBadgeStore: { lookup: () => null } }));
vi.mock('../../stores/repoBadges.svelte', () => ({
repoBadgeStore: {
lookup: (githubRepo: string, subpath: string | null | undefined) =>
badges.get(`${githubRepo}:${subpath ?? ''}`) ?? null,
},
}));
vi.mock('./projectStatus', () => ({
getProjectStatus: () => ({ kind: 'idle', runningCount: 0, runActionPhase: null }),
}));
Expand All @@ -47,7 +55,7 @@ import { tagsStore } from '../../stores/tags.svelte';
import ProjectFilterChips from './ProjectFilterChips.svelte';
import { projectFiltersStore } from './projectFilters.svelte';

function project(id: string): Project {
function project(id: string, overrides: Partial<Project> = {}): Project {
return {
id,
name: `Project ${id}`,
Expand All @@ -56,6 +64,7 @@ function project(id: string): Project {
subpath: null,
createdAt: 1,
updatedAt: 1,
...overrides,
};
}

Expand Down Expand Up @@ -110,14 +119,32 @@ function tagChip(root: ParentNode, name: string): HTMLButtonElement {
return found;
}

function repoChips(root: ParentNode): HTMLButtonElement[] {
return [...root.querySelectorAll<HTMLButtonElement>('.repo-filter')];
}

function clearChip(root: ParentNode): HTMLButtonElement | null {
return root.querySelector<HTMLButtonElement>('.clear-chip');
}

const shown = () => projectFiltersStore.filteredProjects.map((p) => p.id);

/** The chips ahead of the tags': the statuses, then one per repo. */
const STATUS_AND_REPO_CHIPS = ['Unread 0', 'Running 0', 'bb 1', 'staged 1', 'block/goose 1'];

// The same projects and badges for every test: the mocked data store isn't
// reactive, so the filter store's repo list is whatever it first computed.
beforeEach(() => {
projects.splice(0, projects.length, project('p1'), project('p2'), project('p3'));
projects.splice(
0,
projects.length,
project('p1', { githubRepo: 'block/builderbot', subpath: 'apps/staged' }),
project('p2', { githubRepo: 'block/builderbot' }),
project('p3', { githubRepo: 'block/goose' })
);
badges.clear();
badges.set('block/builderbot:', { shortName: 'bb', hue: 200 });
badges.set('block/builderbot:apps/staged', { shortName: 'staged', hue: 120 });
});

afterEach(() => {
Expand All @@ -127,11 +154,37 @@ afterEach(() => {
document.body.innerHTML = '';
});

describe('ProjectFilterChips repo chips', () => {
it('shows a badged repo’s short name, with the full path as its tooltip', async () => {
const root = await mountChips(tagState());

// Ordered by full path; a subpath has a short name of its own, and a repo
// without a badge keeps its path.
expect(chips(root)).toEqual(STATUS_AND_REPO_CHIPS);
const [bb, staged, goose] = repoChips(root);
expect(bb.title).toBe('block/builderbot');
expect(staged.title).toBe('block/builderbot/apps/staged');
expect(goose.querySelector<HTMLElement>('.repo-label')!.title).toBe('block/goose');
});

it('still filters on the full repo and subpath', async () => {
const root = await mountChips(tagState());

repoChips(root)[1].click();
await settle();

expect(projectFiltersStore.activeFilters).toEqual(
new Set(['repo:block/builderbot:apps/staged'])
);
expect(shown()).toEqual(['p1']);
});
});

describe('ProjectFilterChips tag chips', () => {
it('lists the tags in use after the status chips, in catalog order, with counts', async () => {
it('lists the tags in use after the repo chips, in catalog order, with counts', async () => {
const root = await mountChips();

expect(chips(root)).toEqual(['Unread 0', 'Running 0', 'Doing 2', 'P0 1', 'q4 1']);
expect(chips(root)).toEqual([...STATUS_AND_REPO_CHIPS, 'Doing 2', 'P0 1', 'q4 1']);
// The tooltip names the group, since two groups can share a tag name.
expect(tagChip(root, 'Doing').title).toBe('Status: Doing');
expect(tagChip(root, 'q4').title).toBe('q4');
Expand Down Expand Up @@ -189,7 +242,7 @@ describe('ProjectFilterChips tag chips', () => {
deleted.assignments = deleted.assignments.filter((a) => a.tagId !== 'q4');
await backendChanged(deleted);

expect(chips(root)).toEqual(['Unread 0', 'Running 0', 'Doing 2', 'P0 1']);
expect(chips(root)).toEqual([...STATUS_AND_REPO_CHIPS, 'Doing 2', 'P0 1']);
expect(shown()).toEqual(['p1', 'p2', 'p3']);
expect(projectFiltersStore.hasActiveFilters).toBe(false);
expect(clearChip(root)).toBeNull();
Expand Down
Loading
Loading