mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): add manual removal for captured Pod components (#1951)
* feat(web): add manual removal for captured Pod components Adds `removePodMember` helper and a per-chip × in `BoardComposerPopover`; leaves `comments.ts` untouched (avoid-zone from #1127). Closes #802 Contract: runs/2026-05-16T08-08-52_open-design_issue-feat/contract.md * style(web): hide Pod chip × until chip hover Swaps the unicode × for the existing `Icon name="close"` SVG so the hit target stays centered, and fades the button in only on chip hover / keyboard focus for a quieter resting state. * fix(web): auto-close Pod composer when last chip is removed Removing the last chip leaves a stale anchor; close so Send cannot attach to elements no longer visible. * refactor(web): extract BoardComposerPopover and Pod-member reducer Moves the popover to its own module and lifts the chip-removal reducer into a pure `applyPodMemberRemoval` so unit tests exercise the real code path and the popover's export is no longer test-only. * fix(web): rebuild Pod anchor when a member is removed Without this, the popover keeps the original union bbox / selector / label after each chip removal, so a subsequent Send to chat anchors the comment to elements no longer in the Pod. * fix(web): render every captured chip and scroll on overflow The previous slice(0, 6) cap left chips beyond the sixth invisible and undeletable. Render the full list inside a 132px-tall scrollable strip.
This commit is contained in:
parent
10c62bf6e4
commit
324eca27ea
6 changed files with 637 additions and 160 deletions
180
apps/web/src/components/BoardComposerPopover.tsx
Normal file
180
apps/web/src/components/BoardComposerPopover.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { useId } from 'react';
|
||||
|
||||
import { selectionKindLabel, type PreviewCommentSnapshot } from '../comments';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import type { PreviewComment, PreviewCommentMember } from '../types';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
||||
function summarizeMember(member: PreviewCommentMember): string {
|
||||
const text = String(member.text || '').trim();
|
||||
if (text) {
|
||||
const trimmed = text.length > 24 ? `${text.slice(0, 21)}...` : text;
|
||||
return `${member.label || member.elementId} · ${trimmed}`;
|
||||
}
|
||||
return member.label || member.elementId;
|
||||
}
|
||||
|
||||
export function BoardComposerPopover({
|
||||
target,
|
||||
existing,
|
||||
draft,
|
||||
notes,
|
||||
onDraft,
|
||||
onAddDraft,
|
||||
onRemoveQueuedNote,
|
||||
onClose,
|
||||
onSaveComment,
|
||||
onSendBatch,
|
||||
onRemove,
|
||||
onRemoveMember,
|
||||
sending,
|
||||
t,
|
||||
}: {
|
||||
target: PreviewCommentSnapshot;
|
||||
existing: PreviewComment | null;
|
||||
draft: string;
|
||||
notes: string[];
|
||||
onDraft: (value: string) => void;
|
||||
onAddDraft: () => void;
|
||||
onRemoveQueuedNote: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onSaveComment: () => void | Promise<void>;
|
||||
onSendBatch: () => void | Promise<void>;
|
||||
onRemove: (commentId: string) => void | Promise<void>;
|
||||
onRemoveMember: (elementId: string) => void;
|
||||
sending: boolean;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
|
||||
const podMembers = target.podMembers ?? [];
|
||||
const titleId = useId();
|
||||
const isFreePin = target.elementId.startsWith('pin-');
|
||||
return (
|
||||
<div
|
||||
className="comment-popover"
|
||||
data-testid="comment-popover"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby={titleId}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="comment-popover-head">
|
||||
<div title={target.elementId}>
|
||||
{isFreePin ? (
|
||||
<>
|
||||
<strong id={titleId}>{t('chat.comments.pin')}</strong>
|
||||
<span>{t('chat.comments.pinAtCoords', { x: target.position.x + 12, y: target.position.y + 12 })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong id={titleId}>{target.label || target.elementId}</strong>
|
||||
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-popover-close"
|
||||
onClick={onClose}
|
||||
title={t('common.close')}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{podMembers.length > 0 ? (
|
||||
<div className="board-pod-summary">
|
||||
<strong>{t('chat.comments.capturedItems', { n: target.memberCount || podMembers.length })}</strong>
|
||||
<div className="board-pod-members">
|
||||
{podMembers.map((member) => (
|
||||
<span key={member.elementId} className="board-pod-chip">
|
||||
{summarizeMember(member)}
|
||||
<button
|
||||
type="button"
|
||||
className="board-pod-chip-remove"
|
||||
onClick={() => onRemoveMember(member.elementId)}
|
||||
aria-label={t('chat.comments.remove')}
|
||||
title={t('chat.comments.remove')}
|
||||
>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{notes.length > 0 ? (
|
||||
<div className="board-note-list">
|
||||
{notes.map((note, index) => (
|
||||
<div key={`${target.elementId}-${index}`} className="board-note-item">
|
||||
<span>{note}</span>
|
||||
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
|
||||
{t('chat.comments.remove')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
data-testid="comment-popover-input"
|
||||
value={draft}
|
||||
autoFocus
|
||||
aria-label={t('chat.comments.placeholder')}
|
||||
placeholder={t('chat.comments.placeholder')}
|
||||
onChange={(event) => onDraft(event.target.value)}
|
||||
/>
|
||||
<div className="comment-popover-actions">
|
||||
{existing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-popover-remove"
|
||||
onClick={() => onRemove(existing.id)}
|
||||
title={t('chat.comments.remove')}
|
||||
>
|
||||
{t('chat.comments.remove')}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="comment-popover-actions-end">
|
||||
{target.selectionKind === 'pod' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
data-testid="comment-popover-add-note"
|
||||
disabled={!draft.trim()}
|
||||
onClick={onAddDraft}
|
||||
>
|
||||
{t('chat.comments.addNote')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
data-testid="comment-popover-save"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void onSaveComment()}
|
||||
>
|
||||
{t('chat.comments.comment')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
data-testid="comment-add-send"
|
||||
disabled={pendingCount === 0 || sending}
|
||||
onClick={() => void onSendBatch()}
|
||||
>
|
||||
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,6 +87,8 @@ import {
|
|||
targetFromSnapshot,
|
||||
type PreviewCommentSnapshot,
|
||||
} from '../comments';
|
||||
import { applyPodMemberRemoval } from '../lib/pod-members';
|
||||
import { BoardComposerPopover } from './BoardComposerPopover';
|
||||
import type {
|
||||
ChatCommentAttachment,
|
||||
PreviewComment,
|
||||
|
|
@ -1826,157 +1828,6 @@ function FileActions({
|
|||
);
|
||||
}
|
||||
|
||||
function BoardComposerPopover({
|
||||
target,
|
||||
existing,
|
||||
draft,
|
||||
notes,
|
||||
onDraft,
|
||||
onAddDraft,
|
||||
onRemoveQueuedNote,
|
||||
onClose,
|
||||
onSaveComment,
|
||||
onSendBatch,
|
||||
onRemove,
|
||||
sending,
|
||||
t,
|
||||
}: {
|
||||
target: PreviewCommentSnapshot;
|
||||
existing: PreviewComment | null;
|
||||
draft: string;
|
||||
notes: string[];
|
||||
onDraft: (value: string) => void;
|
||||
onAddDraft: () => void;
|
||||
onRemoveQueuedNote: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onSaveComment: () => void | Promise<void>;
|
||||
onSendBatch: () => void | Promise<void>;
|
||||
onRemove: (commentId: string) => void | Promise<void>;
|
||||
sending: boolean;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
|
||||
const podMembers = target.podMembers ?? [];
|
||||
const titleId = useId();
|
||||
const isFreePin = target.elementId.startsWith('pin-');
|
||||
return (
|
||||
<div
|
||||
className="comment-popover"
|
||||
data-testid="comment-popover"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby={titleId}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="comment-popover-head">
|
||||
<div title={target.elementId}>
|
||||
{isFreePin ? (
|
||||
<>
|
||||
<strong id={titleId}>{t('chat.comments.pin')}</strong>
|
||||
<span>{t('chat.comments.pinAtCoords', { x: target.position.x + 12, y: target.position.y + 12 })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong id={titleId}>{target.label || target.elementId}</strong>
|
||||
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-popover-close"
|
||||
onClick={onClose}
|
||||
title={t('common.close')}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{podMembers.length > 0 ? (
|
||||
<div className="board-pod-summary">
|
||||
<strong>{t('chat.comments.capturedItems', { n: target.memberCount || podMembers.length })}</strong>
|
||||
<div className="board-pod-members">
|
||||
{podMembers.slice(0, 6).map((member) => (
|
||||
<span key={member.elementId} className="board-pod-chip">
|
||||
{summarizeMember(member)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{notes.length > 0 ? (
|
||||
<div className="board-note-list">
|
||||
{notes.map((note, index) => (
|
||||
<div key={`${target.elementId}-${index}`} className="board-note-item">
|
||||
<span>{note}</span>
|
||||
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
|
||||
{t('chat.comments.remove')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
data-testid="comment-popover-input"
|
||||
value={draft}
|
||||
autoFocus
|
||||
aria-label={t('chat.comments.placeholder')}
|
||||
placeholder={t('chat.comments.placeholder')}
|
||||
onChange={(event) => onDraft(event.target.value)}
|
||||
/>
|
||||
<div className="comment-popover-actions">
|
||||
{existing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-popover-remove"
|
||||
onClick={() => onRemove(existing.id)}
|
||||
title={t('chat.comments.remove')}
|
||||
>
|
||||
{t('chat.comments.remove')}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="comment-popover-actions-end">
|
||||
{target.selectionKind === 'pod' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
data-testid="comment-popover-add-note"
|
||||
disabled={!draft.trim()}
|
||||
onClick={onAddDraft}
|
||||
>
|
||||
{t('chat.comments.addNote')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
data-testid="comment-popover-save"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void onSaveComment()}
|
||||
>
|
||||
{t('chat.comments.comment')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
data-testid="comment-add-send"
|
||||
disabled={pendingCount === 0 || sending}
|
||||
onClick={() => void onSendBatch()}
|
||||
>
|
||||
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCommentTime(ts: number, t: TranslateFn): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60_000) return t('common.justNow');
|
||||
|
|
@ -2815,15 +2666,6 @@ export function applyInspectOverridesToSource(source: string, css: string): stri
|
|||
return block + out;
|
||||
}
|
||||
|
||||
function summarizeMember(member: PreviewCommentMember): string {
|
||||
const text = String(member.text || '').trim();
|
||||
if (text) {
|
||||
const trimmed = text.length > 24 ? `${text.slice(0, 21)}...` : text;
|
||||
return `${member.label || member.elementId} · ${trimmed}`;
|
||||
}
|
||||
return member.label || member.elementId;
|
||||
}
|
||||
|
||||
function CommentPreviewOverlays({
|
||||
comments,
|
||||
liveTargets,
|
||||
|
|
@ -6100,6 +5942,13 @@ function HtmlViewer({
|
|||
await onRemovePreviewComment(commentId);
|
||||
clearBoardComposer();
|
||||
}}
|
||||
onRemoveMember={(elementId) => {
|
||||
setActiveCommentTarget((current) => {
|
||||
const { next, shouldClose } = applyPodMemberRemoval(current, elementId);
|
||||
if (shouldClose) clearBoardComposer();
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
sending={sendingBoardBatch || streaming}
|
||||
t={t}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -10901,8 +10901,13 @@ button.connector-action.is-loading {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 132px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.board-pod-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-pill);
|
||||
|
|
@ -10910,6 +10915,29 @@ button.connector-action.is-loading {
|
|||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.board-pod-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms cubic-bezier(0.23, 1, 0.32, 1), color 200ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.board-pod-chip:hover .board-pod-chip-remove,
|
||||
.board-pod-chip-remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.board-pod-chip-remove:hover,
|
||||
.board-pod-chip-remove:focus-visible {
|
||||
color: var(--text-default, var(--text-strong));
|
||||
}
|
||||
.board-note-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
|
|
|||
117
apps/web/src/lib/pod-members.ts
Normal file
117
apps/web/src/lib/pod-members.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { PreviewCommentMember } from '@open-design/contracts';
|
||||
import type { PreviewCommentSnapshot } from '../comments';
|
||||
|
||||
export function removePodMember(
|
||||
members: PreviewCommentMember[],
|
||||
elementId: string,
|
||||
): PreviewCommentMember[] {
|
||||
return members.filter((member) => member.elementId !== elementId);
|
||||
}
|
||||
|
||||
export type PodMemberRemovalResult = {
|
||||
next: PreviewCommentSnapshot | null;
|
||||
shouldClose: boolean;
|
||||
};
|
||||
|
||||
export function applyPodMemberRemoval(
|
||||
current: PreviewCommentSnapshot | null,
|
||||
elementId: string,
|
||||
): PodMemberRemovalResult {
|
||||
if (!current || current.selectionKind !== 'pod' || !current.podMembers) {
|
||||
return { next: current, shouldClose: false };
|
||||
}
|
||||
const nextMembers = removePodMember(current.podMembers, elementId);
|
||||
if (nextMembers.length === 0) {
|
||||
return { next: null, shouldClose: true };
|
||||
}
|
||||
const anchor = recomputePodAnchor(nextMembers);
|
||||
if (!anchor) {
|
||||
return { next: null, shouldClose: true };
|
||||
}
|
||||
return {
|
||||
next: {
|
||||
...current,
|
||||
...anchor,
|
||||
podMembers: nextMembers,
|
||||
memberCount: nextMembers.length,
|
||||
},
|
||||
shouldClose: false,
|
||||
};
|
||||
}
|
||||
|
||||
type PodMemberLike = Pick<
|
||||
PreviewCommentMember,
|
||||
'elementId' | 'selector' | 'label' | 'text' | 'position' | 'htmlHint'
|
||||
>;
|
||||
|
||||
export type PodAnchorFields = Pick<
|
||||
PreviewCommentSnapshot,
|
||||
'selector' | 'label' | 'text' | 'position' | 'htmlHint'
|
||||
>;
|
||||
|
||||
// Recomputes the snapshot-level fields that address a multi-member Pod as
|
||||
// one region. Output shape (slice limits, htmlHint cap, position rounding)
|
||||
// mirrors `buildPodSnapshot` so an anchor rebuilt after removal stays
|
||||
// structurally identical to the one created at capture time.
|
||||
export function recomputePodAnchor(
|
||||
members: readonly PodMemberLike[],
|
||||
): PodAnchorFields | null {
|
||||
if (members.length === 0) return null;
|
||||
|
||||
const xs = members.map((m) => m.position.x);
|
||||
const ys = members.map((m) => m.position.y);
|
||||
const rights = members.map((m) => m.position.x + m.position.width);
|
||||
const bottoms = members.map((m) => m.position.y + m.position.height);
|
||||
const left = Math.min(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const right = Math.max(...rights);
|
||||
const bottom = Math.max(...bottoms);
|
||||
|
||||
const selector =
|
||||
members
|
||||
.slice(0, 8)
|
||||
.map((m) => m.selector)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
.join(', ') || 'body *';
|
||||
|
||||
const summaryParts = members
|
||||
.slice(0, 3)
|
||||
.map(summarizeAnchorMember)
|
||||
.filter((s) => s.length > 0);
|
||||
const label = summaryParts.join(' · ') || `Pod of ${members.length} items`;
|
||||
|
||||
const text = members
|
||||
.slice(0, 4)
|
||||
.map((m) => m.text)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
.join(' · ');
|
||||
|
||||
const htmlHint = members
|
||||
.slice(0, 4)
|
||||
.map((m) => m.htmlHint)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
.join(' ')
|
||||
.slice(0, 180);
|
||||
|
||||
return {
|
||||
selector,
|
||||
label,
|
||||
text,
|
||||
position: {
|
||||
x: Math.round(left),
|
||||
y: Math.round(top),
|
||||
width: Math.max(1, Math.round(right - left)),
|
||||
height: Math.max(1, Math.round(bottom - top)),
|
||||
},
|
||||
htmlHint,
|
||||
};
|
||||
}
|
||||
|
||||
// 28-char truncation matches `buildPodSnapshot`'s label-summary rule; the
|
||||
// chip-level summary lives in BoardComposerPopover with a tighter 24-char cap.
|
||||
function summarizeAnchorMember(member: PodMemberLike): string {
|
||||
const raw = String(member.text || '').trim();
|
||||
if (!raw) return member.label || member.elementId;
|
||||
const trimmed = raw.length > 28 ? `${raw.slice(0, 25)}...` : raw;
|
||||
return `${member.label || member.elementId} · ${trimmed}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BoardComposerPopover } from '../../src/components/BoardComposerPopover';
|
||||
import type { PreviewCommentSnapshot } from '../../src/comments';
|
||||
import type { PreviewCommentMember } from '../../src/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function member(elementId: string, label = elementId): PreviewCommentMember {
|
||||
return {
|
||||
elementId,
|
||||
selector: `#${elementId}`,
|
||||
label,
|
||||
text: '',
|
||||
position: { x: 0, y: 0, width: 10, height: 10 },
|
||||
htmlHint: '',
|
||||
};
|
||||
}
|
||||
|
||||
function podTarget(members: PreviewCommentMember[]): PreviewCommentSnapshot {
|
||||
return {
|
||||
filePath: 'index.html',
|
||||
elementId: 'pod-1',
|
||||
selector: '',
|
||||
label: 'Pod',
|
||||
text: '',
|
||||
position: { x: 0, y: 0, width: 100, height: 60 },
|
||||
htmlHint: '',
|
||||
selectionKind: 'pod',
|
||||
memberCount: members.length,
|
||||
podMembers: members,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPopover(overrides: {
|
||||
target: PreviewCommentSnapshot;
|
||||
onRemoveMember: (elementId: string) => void;
|
||||
}) {
|
||||
return render(
|
||||
<BoardComposerPopover
|
||||
target={overrides.target}
|
||||
existing={null}
|
||||
draft=""
|
||||
notes={[]}
|
||||
onDraft={() => {}}
|
||||
onAddDraft={() => {}}
|
||||
onRemoveQueuedNote={() => {}}
|
||||
onClose={() => {}}
|
||||
onSaveComment={() => {}}
|
||||
onSendBatch={() => {}}
|
||||
onRemove={() => {}}
|
||||
onRemoveMember={overrides.onRemoveMember}
|
||||
sending={false}
|
||||
t={((key: string) => String(key)) as never}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('BoardComposerPopover captured-component removal', () => {
|
||||
it('calls onRemoveMember with the chip elementId when the chip × is clicked', () => {
|
||||
const onRemoveMember = vi.fn();
|
||||
renderPopover({
|
||||
target: podTarget([member('alpha', 'Alpha'), member('beta', 'Beta')]),
|
||||
onRemoveMember,
|
||||
});
|
||||
|
||||
const alphaChip = screen.getByText('Alpha').closest('.board-pod-chip');
|
||||
if (!alphaChip) throw new Error('Alpha chip not rendered');
|
||||
fireEvent.click(within(alphaChip as HTMLElement).getByRole('button'));
|
||||
|
||||
expect(onRemoveMember).toHaveBeenCalledTimes(1);
|
||||
expect(onRemoveMember).toHaveBeenCalledWith('alpha');
|
||||
});
|
||||
|
||||
it('renders every captured chip so members beyond the sixth stay reachable', () => {
|
||||
const members = Array.from({ length: 10 }, (_, i) => member(`m${i}`, `Member ${i}`));
|
||||
renderPopover({ target: podTarget(members), onRemoveMember: () => {} });
|
||||
|
||||
expect(document.querySelectorAll('.board-pod-chip')).toHaveLength(10);
|
||||
expect(screen.queryByText('Member 9')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
216
apps/web/tests/lib/pod-members.test.ts
Normal file
216
apps/web/tests/lib/pod-members.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PreviewCommentMember } from '@open-design/contracts';
|
||||
|
||||
import type { PreviewCommentSnapshot } from '../../src/comments';
|
||||
import { applyPodMemberRemoval, recomputePodAnchor, removePodMember } from '../../src/lib/pod-members';
|
||||
|
||||
function member(
|
||||
elementId: string,
|
||||
label = elementId,
|
||||
overrides: Partial<PreviewCommentMember> = {},
|
||||
): PreviewCommentMember {
|
||||
return {
|
||||
elementId,
|
||||
selector: `#${elementId}`,
|
||||
label,
|
||||
text: '',
|
||||
position: { x: 0, y: 0, width: 10, height: 10 },
|
||||
htmlHint: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('removePodMember', () => {
|
||||
it('removes the matching member while preserving order of the remaining items', () => {
|
||||
const a = member('a');
|
||||
const b = member('b');
|
||||
const c = member('c');
|
||||
|
||||
const result = removePodMember([a, b, c], 'b');
|
||||
|
||||
expect(result).toEqual([a, c]);
|
||||
});
|
||||
|
||||
it('returns an equivalent array when the elementId is absent', () => {
|
||||
const a = member('a');
|
||||
const b = member('b');
|
||||
const input = [a, b];
|
||||
|
||||
const result = removePodMember(input, 'missing');
|
||||
|
||||
expect(result).toEqual([a, b]);
|
||||
expect(result).not.toBe(input);
|
||||
});
|
||||
|
||||
it('returns an empty array for empty input', () => {
|
||||
expect(removePodMember([], 'anything')).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mutate the caller\'s array', () => {
|
||||
const a = member('a');
|
||||
const b = member('b');
|
||||
const input = [a, b];
|
||||
|
||||
removePodMember(input, 'a');
|
||||
|
||||
expect(input).toEqual([a, b]);
|
||||
expect(input).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('removes every entry when the same elementId appears more than once', () => {
|
||||
const a1 = member('a', 'first');
|
||||
const a2 = member('a', 'second');
|
||||
const b = member('b');
|
||||
|
||||
const result = removePodMember([a1, b, a2], 'a');
|
||||
|
||||
expect(result).toEqual([b]);
|
||||
});
|
||||
});
|
||||
|
||||
function podSnapshot(members: PreviewCommentMember[]): PreviewCommentSnapshot {
|
||||
return {
|
||||
filePath: 'index.html',
|
||||
elementId: 'pod-1',
|
||||
selector: 'stale, stale, stale',
|
||||
label: 'stale label',
|
||||
text: 'stale text',
|
||||
position: { x: 999, y: 999, width: 999, height: 999 },
|
||||
htmlHint: '<stale>',
|
||||
selectionKind: 'pod',
|
||||
memberCount: members.length,
|
||||
podMembers: members,
|
||||
};
|
||||
}
|
||||
|
||||
describe('recomputePodAnchor', () => {
|
||||
it('returns null for an empty member list', () => {
|
||||
expect(recomputePodAnchor([])).toBeNull();
|
||||
});
|
||||
|
||||
it('joins selectors from the first 8 members and falls back to body * when all are empty', () => {
|
||||
const empty = recomputePodAnchor([member('a', 'a', { selector: '' })]);
|
||||
expect(empty?.selector).toBe('body *');
|
||||
|
||||
const many = Array.from({ length: 10 }, (_, i) => member(`m${i}`));
|
||||
const joined = recomputePodAnchor(many);
|
||||
expect(joined?.selector.split(', ')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('computes the tightest bounding rect across every member position', () => {
|
||||
const result = recomputePodAnchor([
|
||||
member('a', 'a', { position: { x: 10, y: 20, width: 30, height: 40 } }),
|
||||
member('b', 'b', { position: { x: 100, y: 200, width: 50, height: 60 } }),
|
||||
]);
|
||||
|
||||
expect(result?.position).toEqual({ x: 10, y: 20, width: 140, height: 240 });
|
||||
});
|
||||
|
||||
it('rounds position fields and enforces minimum 1x1 dimensions', () => {
|
||||
const result = recomputePodAnchor([
|
||||
member('a', 'a', { position: { x: 0.49, y: 0.49, width: 0.49, height: 0.49 } }),
|
||||
]);
|
||||
|
||||
expect(result?.position).toEqual({ x: 0, y: 0, width: 1, height: 1 });
|
||||
});
|
||||
|
||||
it('renders a "Pod of N items" label when every member summary collapses to empty', () => {
|
||||
const result = recomputePodAnchor([
|
||||
member('', '', { elementId: '', label: '' }),
|
||||
member('', '', { elementId: '', label: '' }),
|
||||
]);
|
||||
|
||||
expect(result?.label).toBe('Pod of 2 items');
|
||||
});
|
||||
|
||||
it('caps htmlHint at 180 chars to match the buildPodSnapshot creation path', () => {
|
||||
const longHint = 'x'.repeat(300);
|
||||
const result = recomputePodAnchor([member('a', 'a', { htmlHint: longHint })]);
|
||||
|
||||
expect(result?.htmlHint).toHaveLength(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPodMemberRemoval', () => {
|
||||
it('signals shouldClose when the last member is removed', () => {
|
||||
const result = applyPodMemberRemoval(podSnapshot([member('only')]), 'only');
|
||||
|
||||
expect(result.shouldClose).toBe(true);
|
||||
expect(result.next).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the trimmed snapshot when other members remain', () => {
|
||||
const a = member('a');
|
||||
const b = member('b');
|
||||
|
||||
const result = applyPodMemberRemoval(podSnapshot([a, b]), 'a');
|
||||
|
||||
expect(result.shouldClose).toBe(false);
|
||||
expect(result.next?.podMembers).toEqual([b]);
|
||||
expect(result.next?.memberCount).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps memberCount in sync with podMembers.length', () => {
|
||||
const result = applyPodMemberRemoval(podSnapshot([member('a'), member('b'), member('c')]), 'b');
|
||||
|
||||
expect(result.next?.memberCount).toBe(2);
|
||||
expect(result.next?.podMembers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('is a no-op when current is null', () => {
|
||||
expect(applyPodMemberRemoval(null, 'a')).toEqual({ next: null, shouldClose: false });
|
||||
});
|
||||
|
||||
it('is a no-op when the target is not a pod', () => {
|
||||
const elementTarget: PreviewCommentSnapshot = {
|
||||
...podSnapshot([member('a')]),
|
||||
selectionKind: 'element',
|
||||
};
|
||||
|
||||
const result = applyPodMemberRemoval(elementTarget, 'a');
|
||||
|
||||
expect(result.shouldClose).toBe(false);
|
||||
expect(result.next).toBe(elementTarget);
|
||||
});
|
||||
|
||||
it('is a no-op when the elementId is absent', () => {
|
||||
const a = member('a');
|
||||
const input = podSnapshot([a]);
|
||||
|
||||
const result = applyPodMemberRemoval(input, 'missing');
|
||||
|
||||
expect(result.shouldClose).toBe(false);
|
||||
expect(result.next?.podMembers).toEqual([a]);
|
||||
});
|
||||
|
||||
it('rebuilds selector, label, position, text, htmlHint from the remaining members', () => {
|
||||
const hero = member('hero', 'hero', {
|
||||
selector: '[data-od-id="hero"]',
|
||||
label: 'section.hero',
|
||||
text: 'Hero title',
|
||||
position: { x: 10, y: 20, width: 200, height: 100 },
|
||||
htmlHint: '<section data-od-id="hero">',
|
||||
});
|
||||
const chart = member('chart', 'chart', {
|
||||
selector: '[data-od-id="chart"]',
|
||||
label: 'section.chart',
|
||||
text: 'Chart value',
|
||||
position: { x: 120, y: 80, width: 190, height: 120 },
|
||||
htmlHint: '<section data-od-id="chart">',
|
||||
});
|
||||
|
||||
const result = applyPodMemberRemoval(podSnapshot([hero, chart]), 'hero');
|
||||
|
||||
expect(result.next).toMatchObject({
|
||||
selector: '[data-od-id="chart"]',
|
||||
label: 'section.chart · Chart value',
|
||||
text: 'Chart value',
|
||||
position: { x: 120, y: 80, width: 190, height: 120 },
|
||||
htmlHint: '<section data-od-id="chart">',
|
||||
memberCount: 1,
|
||||
});
|
||||
expect(JSON.stringify(result.next)).not.toContain('hero');
|
||||
expect(JSON.stringify(result.next)).not.toContain('stale');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue