feat(logs): add compact lead log source selector
- Add avatar trigger mode to MemberSelect for dense toolbar surfaces. - Render the lead log source selector beside compact sidebar log search and filters. - Cover toolbar accessory rendering, avatar trigger behavior and lead alias detection.
This commit is contained in:
parent
57931c0abd
commit
7e8f4b377d
7 changed files with 252 additions and 15 deletions
|
|
@ -29,6 +29,7 @@ interface ClaudeLogsPanelProps {
|
||||||
/** Extra className for the panel wrapper. */
|
/** Extra className for the panel wrapper. */
|
||||||
className?: string;
|
className?: string;
|
||||||
compactMetaInTooltip?: boolean;
|
compactMetaInTooltip?: boolean;
|
||||||
|
toolbarAccessory?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({
|
||||||
viewerMaxHeight,
|
viewerMaxHeight,
|
||||||
className,
|
className,
|
||||||
compactMetaInTooltip = false,
|
compactMetaInTooltip = false,
|
||||||
|
toolbarAccessory,
|
||||||
}: ClaudeLogsPanelProps): React.JSX.Element => {
|
}: ClaudeLogsPanelProps): React.JSX.Element => {
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
|
|
@ -86,10 +88,20 @@ export const ClaudeLogsPanel = ({
|
||||||
'Team is not running.'
|
'Team is not running.'
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2',
|
||||||
|
compactMetaInTooltip && 'min-w-0 flex-1 justify-end'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{data.total > 0 ? (
|
{data.total > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1',
|
||||||
|
compactMetaInTooltip ? 'min-w-0 max-w-48 flex-1' : 'w-48'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -109,6 +121,7 @@ export const ClaudeLogsPanel = ({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{toolbarAccessory}
|
||||||
<ClaudeLogsFilterPopover
|
<ClaudeLogsFilterPopover
|
||||||
filter={filter}
|
filter={filter}
|
||||||
open={filterOpen}
|
open={filterOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { memo, useMemo, useState } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react';
|
import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
import { ClaudeLogsDialog } from './ClaudeLogsDialog';
|
import { ClaudeLogsDialog } from './ClaudeLogsDialog';
|
||||||
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
|
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
|
||||||
|
import { isLeadLogSourceMember } from './claudeLogsSourceMember';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { useClaudeLogsController } from './useClaudeLogsController';
|
import { useClaudeLogsController } from './useClaudeLogsController';
|
||||||
|
|
||||||
|
|
@ -89,10 +93,26 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: ClaudeLogsSectionProps): React.JSX.Element {
|
}: ClaudeLogsSectionProps): React.JSX.Element {
|
||||||
const ctrl = useClaudeLogsController(teamName);
|
const ctrl = useClaudeLogsController(teamName);
|
||||||
|
const resolvedMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
const isSidebar = position === 'sidebar';
|
const isSidebar = position === 'sidebar';
|
||||||
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
|
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
|
||||||
|
const leadLogMember = useMemo(
|
||||||
|
() => resolvedMembers.find((member) => !member.removedAt && isLeadLogSourceMember(member)),
|
||||||
|
[resolvedMembers]
|
||||||
|
);
|
||||||
|
const sidebarLogSourceSelect =
|
||||||
|
isSidebar && leadLogMember ? (
|
||||||
|
<MemberSelect
|
||||||
|
members={[leadLogMember]}
|
||||||
|
value={leadLogMember.name}
|
||||||
|
onChange={() => undefined}
|
||||||
|
size="sm"
|
||||||
|
triggerVariant="avatar"
|
||||||
|
popoverAlign="end"
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const sectionHeaderExtra = useMemo(
|
const sectionHeaderExtra = useMemo(
|
||||||
() => (
|
() => (
|
||||||
|
|
@ -173,6 +193,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
||||||
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
|
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
|
||||||
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||||
compactMetaInTooltip={isSidebar}
|
compactMetaInTooltip={isSidebar}
|
||||||
|
toolbarAccessory={sidebarLogSourceSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
|
||||||
11
src/renderer/components/team/claudeLogsSourceMember.ts
Normal file
11
src/renderer/components/team/claudeLogsSourceMember.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
export function isLeadLogSourceMember(member: ResolvedTeamMember): boolean {
|
||||||
|
if (isLeadMember(member)) return true;
|
||||||
|
const normalizedName = member.name.trim().toLowerCase();
|
||||||
|
if (normalizedName === 'lead') return true;
|
||||||
|
const normalizedRole = member.role?.trim().toLowerCase();
|
||||||
|
return normalizedRole === 'lead' || normalizedRole === 'team lead';
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,10 @@ import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
buildMemberAvatarMap,
|
buildMemberAvatarMap,
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { Command as CommandPrimitive } from 'cmdk';
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown, UserRound } from 'lucide-react';
|
||||||
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,9 @@ interface MemberSelectProps {
|
||||||
allowUnassigned?: boolean;
|
allowUnassigned?: boolean;
|
||||||
/** Size variant */
|
/** Size variant */
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
|
/** Full select by default. Avatar mode is for dense toolbars/sidebar surfaces. */
|
||||||
|
triggerVariant?: 'default' | 'avatar';
|
||||||
|
popoverAlign?: 'start' | 'center' | 'end';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +42,8 @@ export const MemberSelect = ({
|
||||||
placeholder = 'Select member...',
|
placeholder = 'Select member...',
|
||||||
allowUnassigned = false,
|
allowUnassigned = false,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
|
triggerVariant = 'default',
|
||||||
|
popoverAlign,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
}: MemberSelectProps): React.JSX.Element => {
|
}: MemberSelectProps): React.JSX.Element => {
|
||||||
|
|
@ -57,6 +63,28 @@ export const MemberSelect = ({
|
||||||
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
|
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
|
||||||
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
|
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
|
||||||
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
|
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
|
||||||
|
const isAvatarTrigger = triggerVariant === 'avatar';
|
||||||
|
const effectivePopoverAlign = popoverAlign ?? (isAvatarTrigger ? 'end' : 'start');
|
||||||
|
const avatarTriggerSize = size === 'md' ? 'size-9' : 'size-8';
|
||||||
|
const selectedLabel =
|
||||||
|
selectedMember != null
|
||||||
|
? displayMemberName(selectedMember.name)
|
||||||
|
: value
|
||||||
|
? displayMemberName(value)
|
||||||
|
: allowUnassigned
|
||||||
|
? 'Unassigned'
|
||||||
|
: placeholder;
|
||||||
|
|
||||||
|
const renderAvatarByName = (name: string): React.ReactNode => (
|
||||||
|
<img
|
||||||
|
src={avatarMap.get(name) ?? agentAvatarUrl(name, avatarSize)}
|
||||||
|
alt=""
|
||||||
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const renderMemberAvatar = (member: ResolvedTeamMember): React.ReactNode =>
|
||||||
|
renderAvatarByName(member.name);
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
|
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
|
||||||
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
|
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
|
||||||
|
|
@ -90,29 +118,48 @@ export const MemberSelect = ({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
|
aria-haspopup="listbox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={listboxId}
|
aria-controls={listboxId}
|
||||||
|
aria-label={`Select member: ${selectedLabel}`}
|
||||||
|
title={isAvatarTrigger ? selectedLabel : undefined}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
|
isAvatarTrigger
|
||||||
|
? `inline-flex ${avatarTriggerSize} shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-transparent p-0 text-xs shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`
|
||||||
|
: `flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate text-left">
|
{isAvatarTrigger ? (
|
||||||
{selectedMember ? (
|
selectedMember ? (
|
||||||
renderMemberInline(selectedMember)
|
renderMemberAvatar(selectedMember)
|
||||||
) : value === null && allowUnassigned ? (
|
) : value ? (
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
|
renderAvatarByName(value)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
|
<UserRound className="size-4 text-[var(--color-text-muted)]" />
|
||||||
)}
|
)
|
||||||
</span>
|
) : (
|
||||||
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
|
<>
|
||||||
|
<span className="min-w-0 truncate text-left">
|
||||||
|
{selectedMember ? (
|
||||||
|
renderMemberInline(selectedMember)
|
||||||
|
) : value === null && allowUnassigned ? (
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--color-text-muted)]">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 size-3.5 shrink-0 opacity-50" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0"
|
className={cn(
|
||||||
align="start"
|
isAvatarTrigger ? 'w-56 p-0' : 'w-[var(--radix-popover-trigger-width)] min-w-[200px] p-0'
|
||||||
|
)}
|
||||||
|
align={effectivePopoverAlign}
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
avoidCollisions
|
avoidCollisions
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
|
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
|
||||||
|
|
@ -165,4 +166,55 @@ describe('ClaudeLogsPanel', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders toolbar accessory beside log search and filters', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const ctrl = createController({
|
||||||
|
isAlive: true,
|
||||||
|
data: {
|
||||||
|
lines: ['[stdout] ready'],
|
||||||
|
total: 1,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
filteredText: '[stdout]\nready',
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(ClaudeLogsPanel, {
|
||||||
|
ctrl,
|
||||||
|
compactMetaInTooltip: true,
|
||||||
|
toolbarAccessory: React.createElement(
|
||||||
|
'button',
|
||||||
|
{ type: 'button', 'data-testid': 'log-member-selector' },
|
||||||
|
'Lead'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = host.querySelector('input[placeholder="Search logs..."]');
|
||||||
|
const accessory = host.querySelector('[data-testid="log-member-selector"]');
|
||||||
|
const filter = host.querySelector('[data-testid="logs-filter"]');
|
||||||
|
|
||||||
|
expect(search).not.toBeNull();
|
||||||
|
expect(accessory).not.toBeNull();
|
||||||
|
expect(filter).not.toBeNull();
|
||||||
|
expect(search?.parentElement?.className).toContain('flex-1');
|
||||||
|
expect(search?.compareDocumentPosition(accessory as Node) ?? 0).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
expect(accessory?.compareDocumentPosition(filter as Node) ?? 0).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
29
test/renderer/components/team/ClaudeLogsSection.test.ts
Normal file
29
test/renderer/components/team/ClaudeLogsSection.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { isLeadLogSourceMember } from '@renderer/components/team/claudeLogsSourceMember';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
function member(overrides: Partial<ResolvedTeamMember>): ResolvedTeamMember {
|
||||||
|
return {
|
||||||
|
name: 'alice',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isLeadLogSourceMember', () => {
|
||||||
|
it('accepts canonical and cached lead aliases for compact log source UI', () => {
|
||||||
|
expect(isLeadLogSourceMember(member({ name: 'team-lead' }))).toBe(true);
|
||||||
|
expect(isLeadLogSourceMember(member({ name: 'Lead' }))).toBe(true);
|
||||||
|
expect(isLeadLogSourceMember(member({ name: 'current', role: 'Team Lead' }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat arbitrary leadership-like roles as the lead log source', () => {
|
||||||
|
expect(isLeadLogSourceMember(member({ name: 'alice', role: 'Tech Lead' }))).toBe(false);
|
||||||
|
expect(isLeadLogSourceMember(member({ name: 'lead-reviewer' }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
test/renderer/components/ui/MemberSelect.test.tsx
Normal file
64
test/renderer/components/ui/MemberSelect.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { MemberSelect } from '@renderer/components/ui/MemberSelect';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
function member(name: string): ResolvedTeamMember {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MemberSelect', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an avatar trigger for dense surfaces while keeping the full member list popover', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<MemberSelect
|
||||||
|
members={[member('Lead'), member('Alice')]}
|
||||||
|
value="Lead"
|
||||||
|
onChange={onChange}
|
||||||
|
triggerVariant="avatar"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement | null;
|
||||||
|
expect(trigger).not.toBeNull();
|
||||||
|
expect(trigger?.getAttribute('aria-label')).toBe('Select member: Lead');
|
||||||
|
expect(trigger?.getAttribute('title')).toBe('Lead');
|
||||||
|
expect(host.textContent).not.toContain('Lead');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Lead');
|
||||||
|
expect(document.body.textContent).toContain('Alice');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue