feat(member-work-sync): show status in member details
This commit is contained in:
parent
98a7c915ed
commit
ab459fbae0
5 changed files with 143 additions and 1 deletions
|
|
@ -2,3 +2,4 @@ export * from './adapters/memberWorkSyncStatusViewModel';
|
|||
export * from './hooks/useMemberWorkSyncStatus';
|
||||
export * from './ui/MemberWorkSyncBadge';
|
||||
export * from './ui/MemberWorkSyncDetails';
|
||||
export * from './ui/MemberWorkSyncStatusPanel';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type React from 'react';
|
||||
|
||||
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
|
||||
import { MemberWorkSyncDetails } from './MemberWorkSyncDetails';
|
||||
import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus';
|
||||
|
||||
interface MemberWorkSyncStatusPanelProps {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
enabled?: boolean;
|
||||
showDiagnostics?: boolean;
|
||||
}
|
||||
|
||||
export function MemberWorkSyncStatusPanel({
|
||||
teamName,
|
||||
memberName,
|
||||
enabled = true,
|
||||
showDiagnostics = false,
|
||||
}: MemberWorkSyncStatusPanelProps): React.ReactElement | null {
|
||||
const { status, viewModel, loading, error } = useMemberWorkSyncStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
enabled,
|
||||
});
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
return <MemberWorkSyncDetails status={status} showDiagnostics={showDiagnostics} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
{loading
|
||||
? 'Loading member work sync diagnostics.'
|
||||
: error
|
||||
? 'Member work sync diagnostics are unavailable.'
|
||||
: viewModel.tooltip}
|
||||
</p>
|
||||
</div>
|
||||
<MemberWorkSyncBadge viewModel={viewModel} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
|||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||
import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
|
|
@ -291,7 +292,14 @@ export const MemberDetailDialog = ({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="tasks">
|
||||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||
<div className="space-y-3">
|
||||
<MemberWorkSyncStatusPanel
|
||||
teamName={teamName}
|
||||
memberName={member.name}
|
||||
enabled={open && !member.removedAt}
|
||||
/>
|
||||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<MemberMessagesTab
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
MemberWorkSyncBadge,
|
||||
MemberWorkSyncDetails,
|
||||
MemberWorkSyncStatusPanel,
|
||||
useMemberWorkSyncStatus,
|
||||
} from '@features/member-work-sync/renderer';
|
||||
|
||||
|
|
@ -110,4 +111,26 @@ describe('member work sync renderer', () => {
|
|||
expect(host.textContent).toContain('11111111');
|
||||
expect(host.textContent).not.toContain('developer_only');
|
||||
});
|
||||
|
||||
it('renders the status panel through the read-only API hook', async () => {
|
||||
apiMocks.getStatus.mockResolvedValue(makeStatus());
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberWorkSyncStatusPanel, {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Member work sync');
|
||||
expect(host.textContent).toContain('Needs sync');
|
||||
expect(host.textContent).toContain('Shadow would nudge');
|
||||
expect(apiMocks.getStatus).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'bob' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,59 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { useStore } from '@renderer/store';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getMemberWorkSyncStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeMemberWorkSyncStatus(
|
||||
overrides: Partial<MemberWorkSyncStatus> = {}
|
||||
): MemberWorkSyncStatus {
|
||||
return {
|
||||
teamName: 'demo-team',
|
||||
memberName: 'jack',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'demo-team',
|
||||
memberName: 'jack',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:abcdef123456',
|
||||
items: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
displayId: '11111111',
|
||||
subject: 'Review patch',
|
||||
kind: 'work',
|
||||
assignee: 'jack',
|
||||
priority: 'normal',
|
||||
reason: 'owned_pending_task',
|
||||
evidence: { status: 'in_progress', owner: 'jack' },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
},
|
||||
shadow: {
|
||||
reconciledBy: 'request',
|
||||
wouldNudge: true,
|
||||
fingerprintChanged: false,
|
||||
},
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
memberWorkSync: {
|
||||
getStatus: apiMocks.getMemberWorkSyncStatus,
|
||||
},
|
||||
},
|
||||
isElectronMode: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useMemberStats', () => ({
|
||||
useMemberStats: () => ({
|
||||
stats: null,
|
||||
|
|
@ -115,6 +166,7 @@ import { MemberDetailDialog } from '@renderer/components/team/members/MemberDeta
|
|||
describe('MemberDetailDialog activity count', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiMocks.getMemberWorkSyncStatus.mockResolvedValue(makeMemberWorkSyncStatus());
|
||||
useStore.setState({
|
||||
teamMessagesByName: {
|
||||
'demo-team': {
|
||||
|
|
@ -134,6 +186,7 @@ describe('MemberDetailDialog activity count', () => {
|
|||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
useStore.setState({ teamMessagesByName: {} } as never);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
|
@ -202,6 +255,12 @@ describe('MemberDetailDialog activity count', () => {
|
|||
|
||||
expect(host.textContent).toContain('activity-count:1');
|
||||
expect(host.textContent).toContain('Activity1');
|
||||
expect(host.textContent).toContain('Member work sync');
|
||||
expect(host.textContent).toContain('Needs sync');
|
||||
expect(apiMocks.getMemberWorkSyncStatus).toHaveBeenCalledWith({
|
||||
teamName: 'demo-team',
|
||||
memberName: 'jack',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
Loading…
Reference in a new issue