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
18 changes: 10 additions & 8 deletions apps/staged/src/lib/features/board/BoardCard.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<!--
BoardCard.svelte — a project on the board: its status icon and name, the
activity indicator (running, unread, …), chips for the tags its column and
icon don't already show, and its repos. Clicking opens the project; the
context menu is the grid card's, plus "Move to" for the board's group.
activity indicator (running, unread, …), and its repos with chips for the
tags its column and icon don't already show. A column stands for the card's
tag in the board's group, so that group's chip is the one left out; a card
under "No ‹group›" has no such tag and shows every chip. Clicking opens the
project; the context menu is the grid card's, plus "Move to" for the
board's group.

With drag and drop on (a fine pointer, desktop layout) the card drags to
another column. A search hides the card rather than unmounting it.
Expand Down Expand Up @@ -34,8 +37,7 @@
import { workspaceLifecycle } from '../projects/workspaceLifecycle.svelte';
import ProjectCardMenuContent from '../projects/ProjectCardMenuContent.svelte';
import ProjectCardStatusIndicator from '../projects/ProjectCardStatusIndicator.svelte';
import ProjectCardTagChips from '../projects/ProjectCardTagChips.svelte';
import ProjectRepoBadges from '../projects/ProjectRepoBadges.svelte';
import ProjectCardLabels from '../projects/ProjectCardLabels.svelte';
import ProjectStatusIcon from '../projects/ProjectStatusIcon.svelte';
import RemoteProjectIcon from '../projects/RemoteProjectIcon.svelte';

Expand Down Expand Up @@ -84,14 +86,14 @@
/>
<span class="board-card-name">{projectDisplayName(project)}</span>
</div>
<ProjectCardTagChips projectId={project.id} excludeGroupId={group.id} />
<div class="board-card-footer">
{#if project.location === 'remote'}
<RemoteProjectIcon size={12} />
{/if}
<ProjectRepoBadges
<ProjectCardLabels
{project}
repos={projectsDataStore.reposByProject.get(project.id) ?? []}
excludeTagGroupId={group.id}
/>
</div>
</button>
Expand Down Expand Up @@ -178,7 +180,7 @@
font-size: var(--size-xs);
}

.board-card-footer :global(.repo) {
.board-card-footer :global(.card-labels) {
font-size: var(--size-xs);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ vi.mock(
() => import('../sessions/testStubs/EmptyStub.svelte')
);
vi.mock(
'../projects/ProjectRepoBadges.svelte',
'../projects/ProjectCardLabels.svelte',
() => import('../sessions/testStubs/EmptyStub.svelte')
);
vi.mock(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Mounts the real board over the real tagsStore, tagActions and board
// preferences, with the backend commands and the preference store mocked: a
// drop or a "Move to" ends in `set_project_group_tag` and moves the card at
// once, and a collapsed column is saved to `projects-board-columns`. The last
// block puts the real projectFiltersStore in front of it, as ProjectsList
// does, so the tag chips decide which cards it shows.
// once, and a collapsed column is saved to `projects-board-columns`. A card's
// labels are the real ones, so its chips show which tags its column leaves
// out. The last block puts the real projectFiltersStore in front of it, as
// ProjectsList does, so the tag chips decide which cards it shows.
import { flushSync, mount, tick, unmount } from 'svelte';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Project, TagState } from '../../types';
Expand Down Expand Up @@ -85,10 +86,7 @@ vi.mock(
'../projects/ProjectTransferMenuItem.svelte',
() => import('../sessions/testStubs/EmptyStub.svelte')
);
vi.mock(
'../projects/ProjectRepoBadges.svelte',
() => import('../sessions/testStubs/EmptyStub.svelte')
);
vi.mock('../../stores/repoBadges.svelte', () => ({ repoBadgeStore: { lookup: () => null } }));
vi.mock(
'../projects/ProjectCardStatusIndicator.svelte',
() => import('../sessions/testStubs/EmptyStub.svelte')
Expand All @@ -115,23 +113,26 @@ async function settle() {
}

/**
* Mount the Status board (idea, doing, done) over p1 (Done), p2 (no status)
* and p3 (Idea).
* Mount a group's board, by default the Status board (idea, doing, done)
* over p1 (Done), p2 (no status) and p3 (Idea).
*/
async function mountBoard(
state: TagState = tagState([
{ projectId: 'p1', tagId: 'done' },
{ projectId: 'p3', tagId: 'idea' },
]),
{ searchRank = null }: { searchRank?: Set<string> | null } = {}
{
searchRank = null,
groupId = 'status',
}: { searchRank?: Set<string> | null; groupId?: string } = {}
) {
backend.state = state;
await tagsStore.load({ force: true });
projects.splice(0, projects.length, project('p1'), project('p2'), project('p3'));
const target = document.createElement('div');
document.body.appendChild(target);
const props = reactiveProps({
group: tagsStore.catalog.groupsById.get('status') ?? null,
group: tagsStore.catalog.groupsById.get(groupId) ?? null,
projects: [...projects],
searchRank,
onOpen: openProject,
Expand Down Expand Up @@ -170,6 +171,13 @@ function card(root: ParentNode, projectId: string): HTMLElement {
return found;
}

/** The names on a card's tag chips, in order. */
function chipsOn(root: ParentNode, projectId: string): string[] {
return [...card(root, projectId).querySelectorAll('.tag-chip-name')].map((el) =>
el.textContent!.trim()
);
}

/** A drag event carrying a stub DataTransfer (jsdom has neither). */
function dragEvent(type: string, data: Record<string, string>): Event {
const store = new Map(Object.entries(data));
Expand Down Expand Up @@ -282,6 +290,63 @@ describe('ProjectsBoard', () => {
expect(column(root, 'idea').querySelector('.column-count')?.textContent).toBe('0');
});

it('leaves the tag a card’s column stands for off the card, and keeps its other tags', async () => {
// The Priority board: p1 under P0, p2 under P1, p3 under "No Priority".
const root = await mountBoard(
tagState([
{ projectId: 'p1', tagId: 'p0' },
{ projectId: 'p1', tagId: 'q4' },
{ projectId: 'p1', tagId: 'infra' },
{ projectId: 'p2', tagId: 'p1' },
{ projectId: 'p3', tagId: 'q4' },
]),
{ groupId: 'priority' }
);

expect(cardsIn(root, 'p0')).toEqual(['p1']);
expect(chipsOn(root, 'p1')).toEqual(['q4', 'infra']);
// Nothing but the column's tag: no chips at all.
expect(cardsIn(root, 'p1')).toEqual(['p2']);
expect(chipsOn(root, 'p2')).toEqual([]);
// "No ‹group›" stands for no tag, so its cards show every chip.
expect(cardsIn(root, NO_TAG_COLUMN_KEY)).toEqual(['p3']);
expect(chipsOn(root, 'p3')).toEqual(['q4']);
});

it('leaves out the new column’s tag once a card moves, not the old one’s', async () => {
const root = await mountBoard(
tagState([
{ projectId: 'p1', tagId: 'p0' },
{ projectId: 'p1', tagId: 'q4' },
]),
{ groupId: 'priority' }
);

expect(chipsOn(root, 'p1')).toEqual(['q4']);

await moveWithMenu(root, 'p1', 'P1');

expect(cardsIn(root, 'p1')).toEqual(['p1']);
expect(chipsOn(root, 'p1')).toEqual(['q4']);
});

it('keeps another group’s tag on the card: only the board’s own group is implied', async () => {
// On the Status board a card's Priority tag is not what its column says.
const root = await mountBoard(
tagState([
{ projectId: 'p1', tagId: 'done' },
{ projectId: 'p1', tagId: 'p0' },
{ projectId: 'p2', tagId: 'p1' },
])
);

expect(cardsIn(root, 'done')).toEqual(['p1']);
expect(chipsOn(root, 'p1')).toEqual(['P0']);
expect(cardsIn(root, NO_TAG_COLUMN_KEY)).toEqual(['p2', 'p3']);
expect(chipsOn(root, 'p2')).toEqual(['P1']);
expect(chipsOn(root, 'p3')).toEqual([]);
});

it('opens a project when its card is clicked', async () => {
const root = await mountBoard();
card(root, 'p3').click();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// 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.
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: new Map<string, { shortName: string; hue: number }>(),
}));

const commandsMock = vi.hoisted(() => ({
getTagState: vi.fn(async () => ({ data: structuredClone(backend.state), revalidating: null })),
}));

vi.mock('../../commands', () => commandsMock);
vi.mock('../../api/commands', () => commandsMock);
vi.mock('../../transport', async (importOriginal) => ({
...(await importOriginal<typeof import('../../transport')>()),
listenToEvent: () => () => {},
}));
vi.mock('../../stores/projectsData.svelte', () => ({
projectsDataStore: { liveProjects: [] },
}));
vi.mock('../../stores/repoBadges.svelte', () => ({
repoBadgeStore: { lookup: (githubRepo: string) => badges.get(githubRepo) ?? null },
}));

import { tagsStore } from '../../stores/tags.svelte';
import ProjectCardLabels from './ProjectCardLabels.svelte';

let component: ReturnType<typeof mount> | null = null;

const PROJECT: Project = {
id: 'p1',
name: 'Project',
githubRepo: null,
location: 'local',
subpath: null,
createdAt: 1,
updatedAt: 1,
};

function repo(githubRepo: string): ProjectRepo {
return {
id: githubRepo,
projectId: 'p1',
githubRepo,
branchName: 'main',
subpath: null,
isPrimary: false,
reason: null,
headRepo: null,
createdAt: 1,
updatedAt: 1,
};
}

/** One repo with a badge, one without. */
const REPOS = [repo('block/goose'), repo('block/builderbot')];

/** Mount p1's labels with `tagIds` assigned to it in `state`'s catalog. */
async function mountLabels(
tagIds: string[],
{
repos = REPOS,
excludeTagGroupId = null,
state = tagState(),
}: { repos?: ProjectRepo[]; excludeTagGroupId?: string | null; state?: TagState } = {}
) {
badges.clear();
badges.set('block/builderbot', { shortName: 'bb', hue: 200 });
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 },
});
flushSync();
return target;
}

/** What the cluster holds, in order. */
function clusterTexts(root: ParentNode): string[] {
return [...root.querySelector('.card-labels')!.children].map((el) => el.textContent!.trim());
}

function chipNames(root: ParentNode): string[] {
return [...root.querySelectorAll('.tag-chip-name')].map((el) => el.textContent!.trim());
}

afterEach(() => {
if (component) unmount(component);
component = null;
document.body.innerHTML = '';
});

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']);
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);
});

it('adds nothing for a project without tags', async () => {
const target = await mountLabels([]);

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

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

expect(target.querySelector('.card-labels')!.textContent).toContain('No repo attached');
expect(chipNames(target)).toEqual(['q4']);
});

it('leaves out the icon group’s tag, which the status icon shows', async () => {
const target = await mountLabels(['doing', 'p1', 'q4']);

expect(chipNames(target)).toEqual(['P1', 'q4']);
});

it('shows three chips, then "+N" naming the rest', async () => {
const base = tagState();
const state = {
...base,
tags: [...base.tags, tag('web', null, 2), tag('mobile', null, 3)],
};
const target = await mountLabels(['p0', 'q4', 'infra', 'web', 'mobile'], { state });

expect(chipNames(target)).toEqual(['P0', 'q4', 'infra']);
const more = target.querySelector<HTMLElement>('.card-labels > .tag-chips-more')!;
expect(more.textContent).toBe('+2');
expect(more.title).toBe('web, mobile');
});

it('leaves out the excluded group’s tag and keeps the rest (a board card)', async () => {
const target = await mountLabels(['p0', 'q4', 'infra'], { excludeTagGroupId: 'priority' });

expect(chipNames(target)).toEqual(['q4', 'infra']);
expect(target.querySelector('.tag-chips-more')).toBeNull();
});
});
Loading
Loading