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 './hooks/useMemberWorkSyncStatus';
|
||||||
export * from './ui/MemberWorkSyncBadge';
|
export * from './ui/MemberWorkSyncBadge';
|
||||||
export * from './ui/MemberWorkSyncDetails';
|
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 { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||||
|
import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
|
||||||
import {
|
import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
getMemberLaunchDiagnosticsErrorMessage,
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
|
|
@ -291,7 +292,14 @@ export const MemberDetailDialog = ({
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="tasks">
|
<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>
|
||||||
<TabsContent value="activity">
|
<TabsContent value="activity">
|
||||||
<MemberMessagesTab
|
<MemberMessagesTab
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
MemberWorkSyncBadge,
|
MemberWorkSyncBadge,
|
||||||
MemberWorkSyncDetails,
|
MemberWorkSyncDetails,
|
||||||
|
MemberWorkSyncStatusPanel,
|
||||||
useMemberWorkSyncStatus,
|
useMemberWorkSyncStatus,
|
||||||
} from '@features/member-work-sync/renderer';
|
} from '@features/member-work-sync/renderer';
|
||||||
|
|
||||||
|
|
@ -110,4 +111,26 @@ describe('member work sync renderer', () => {
|
||||||
expect(host.textContent).toContain('11111111');
|
expect(host.textContent).toContain('11111111');
|
||||||
expect(host.textContent).not.toContain('developer_only');
|
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 { useStore } from '@renderer/store';
|
||||||
|
|
||||||
|
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
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', () => ({
|
vi.mock('@renderer/hooks/useMemberStats', () => ({
|
||||||
useMemberStats: () => ({
|
useMemberStats: () => ({
|
||||||
stats: null,
|
stats: null,
|
||||||
|
|
@ -115,6 +166,7 @@ import { MemberDetailDialog } from '@renderer/components/team/members/MemberDeta
|
||||||
describe('MemberDetailDialog activity count', () => {
|
describe('MemberDetailDialog activity count', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
apiMocks.getMemberWorkSyncStatus.mockResolvedValue(makeMemberWorkSyncStatus());
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
'demo-team': {
|
'demo-team': {
|
||||||
|
|
@ -134,6 +186,7 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
useStore.setState({ teamMessagesByName: {} } as never);
|
useStore.setState({ teamMessagesByName: {} } as never);
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
@ -202,6 +255,12 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
|
|
||||||
expect(host.textContent).toContain('activity-count:1');
|
expect(host.textContent).toContain('activity-count:1');
|
||||||
expect(host.textContent).toContain('Activity1');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue