refactor(team): extract team data selectors
This commit is contained in:
parent
8589391ccf
commit
1d2f61ad86
3 changed files with 149 additions and 45 deletions
|
|
@ -29,6 +29,7 @@ import {
|
||||||
isTeamTaskNeedsFixActionable,
|
isTeamTaskNeedsFixActionable,
|
||||||
} from '@shared/utils/teamTaskState';
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
|
import { selectTeamDataForName } from '../team/teamDataSelectors';
|
||||||
import {
|
import {
|
||||||
areInboxMessageArraysEquivalent,
|
areInboxMessageArraysEquivalent,
|
||||||
clearTeamMessageSelectorCaches,
|
clearTeamMessageSelectorCaches,
|
||||||
|
|
@ -95,6 +96,12 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
|
export {
|
||||||
|
selectTeamDataForName,
|
||||||
|
selectTeamIsAliveForName,
|
||||||
|
selectTeamMemberSnapshotsForName,
|
||||||
|
selectTeamTasksForName,
|
||||||
|
} from '../team/teamDataSelectors';
|
||||||
export type {
|
export type {
|
||||||
RefreshTeamMessagesHeadResult,
|
RefreshTeamMessagesHeadResult,
|
||||||
TeamMessagesCacheEntry,
|
TeamMessagesCacheEntry,
|
||||||
|
|
@ -1763,9 +1770,6 @@ const resolvedMemberSelectorCache = new Map<
|
||||||
result: ResolvedTeamMember | null;
|
result: ResolvedTeamMember | null;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = [];
|
|
||||||
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
|
|
||||||
|
|
||||||
function resolveMemberStatus(
|
function resolveMemberStatus(
|
||||||
snapshot: TeamMemberSnapshot,
|
snapshot: TeamMemberSnapshot,
|
||||||
activity: MemberActivityMetaEntry | undefined
|
activity: MemberActivityMetaEntry | undefined
|
||||||
|
|
@ -2187,27 +2191,6 @@ function structurallyShareMemberActivityFacts(
|
||||||
return changed ? shared : previous;
|
return changed ? shared : previous;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamDataSelectorState = Pick<
|
|
||||||
TeamSlice,
|
|
||||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function selectTeamDataForName(
|
|
||||||
state: TeamDataSelectorState,
|
|
||||||
teamName: string | null | undefined
|
|
||||||
): TeamViewSnapshot | null {
|
|
||||||
if (!teamName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (state.selectedTeamName === teamName && state.selectedTeamData) {
|
|
||||||
return state.selectedTeamData;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
state.teamDataCacheByName[teamName] ??
|
|
||||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResolvedMemberSelectorState = Pick<
|
type ResolvedMemberSelectorState = Pick<
|
||||||
TeamSlice,
|
TeamSlice,
|
||||||
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam'
|
||||||
|
|
@ -2316,27 +2299,6 @@ export function selectResolvedMemberForTeamName(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectTeamMemberSnapshotsForName(
|
|
||||||
state: TeamDataSelectorState,
|
|
||||||
teamName: string | null | undefined
|
|
||||||
): TeamViewSnapshot['members'] {
|
|
||||||
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectTeamTasksForName(
|
|
||||||
state: TeamDataSelectorState,
|
|
||||||
teamName: string | null | undefined
|
|
||||||
): TeamViewSnapshot['tasks'] {
|
|
||||||
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectTeamIsAliveForName(
|
|
||||||
state: TeamDataSelectorState,
|
|
||||||
teamName: string | null | undefined
|
|
||||||
): boolean | undefined {
|
|
||||||
return selectTeamDataForName(state, teamName)?.isAlive;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMemberActivityMetaStale(
|
function isMemberActivityMetaStale(
|
||||||
state: Pick<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
|
state: Pick<TeamSlice, 'memberActivityMetaByTeam' | 'teamMessagesByName'>,
|
||||||
teamName: string
|
teamName: string
|
||||||
|
|
|
||||||
47
src/renderer/store/team/teamDataSelectors.ts
Normal file
47
src/renderer/store/team/teamDataSelectors.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { TeamViewSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
export interface TeamDataSelectorState {
|
||||||
|
teamDataCacheByName: Record<string, TeamViewSnapshot>;
|
||||||
|
selectedTeamName: string | null;
|
||||||
|
selectedTeamData: TeamViewSnapshot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamViewSnapshot['members'] = [];
|
||||||
|
const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = [];
|
||||||
|
|
||||||
|
export function selectTeamDataForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamViewSnapshot | null {
|
||||||
|
if (!teamName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.selectedTeamName === teamName && state.selectedTeamData) {
|
||||||
|
return state.selectedTeamData;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
state.teamDataCacheByName[teamName] ??
|
||||||
|
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamMemberSnapshotsForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamViewSnapshot['members'] {
|
||||||
|
return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamTasksForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamViewSnapshot['tasks'] {
|
||||||
|
return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTeamIsAliveForName(
|
||||||
|
state: TeamDataSelectorState,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
return selectTeamDataForName(state, teamName)?.isAlive;
|
||||||
|
}
|
||||||
95
test/renderer/store/teamDataSelectors.test.ts
Normal file
95
test/renderer/store/teamDataSelectors.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
selectTeamDataForName,
|
||||||
|
selectTeamIsAliveForName,
|
||||||
|
selectTeamMemberSnapshotsForName,
|
||||||
|
selectTeamTasksForName,
|
||||||
|
type TeamDataSelectorState,
|
||||||
|
} from '../../../src/renderer/store/team/teamDataSelectors';
|
||||||
|
|
||||||
|
import type { TeamViewSnapshot } from '../../../src/shared/types';
|
||||||
|
|
||||||
|
function createSnapshot(overrides: Partial<TeamViewSnapshot> = {}): TeamViewSnapshot {
|
||||||
|
return {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(overrides: Partial<TeamDataSelectorState> = {}): TeamDataSelectorState {
|
||||||
|
return {
|
||||||
|
teamDataCacheByName: {},
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('teamDataSelectors', () => {
|
||||||
|
it('returns null when no team name is selected or cached', () => {
|
||||||
|
const state = createState();
|
||||||
|
|
||||||
|
expect(selectTeamDataForName(state, null)).toBeNull();
|
||||||
|
expect(selectTeamDataForName(state, undefined)).toBeNull();
|
||||||
|
expect(selectTeamDataForName(state, 'missing-team')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers selected team data over cached data for the active team', () => {
|
||||||
|
const cachedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: false });
|
||||||
|
const selectedSnapshot = createSnapshot({ teamName: 'my-team', isAlive: true });
|
||||||
|
const state = createState({
|
||||||
|
selectedTeamName: 'my-team',
|
||||||
|
selectedTeamData: selectedSnapshot,
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': cachedSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectTeamDataForName(state, 'my-team')).toBe(selectedSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to cached team data outside the selected snapshot', () => {
|
||||||
|
const cachedSnapshot = createSnapshot({ teamName: 'cached-team', isAlive: true });
|
||||||
|
const state = createState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: createSnapshot({ teamName: 'other-team' }),
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'cached-team': cachedSnapshot,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectTeamDataForName(state, 'cached-team')).toBe(cachedSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns stable empty arrays and scalar fields from team snapshots', () => {
|
||||||
|
const task = { id: 'task-1', subject: 'Build', status: 'pending' as const };
|
||||||
|
const member = { name: 'alice', role: 'developer', currentTaskId: null, taskCount: 0 };
|
||||||
|
const state = createState({
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': createSnapshot({
|
||||||
|
members: [member],
|
||||||
|
tasks: [task],
|
||||||
|
isAlive: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selectTeamMemberSnapshotsForName(state, 'my-team')).toEqual([member]);
|
||||||
|
expect(selectTeamTasksForName(state, 'my-team')).toEqual([task]);
|
||||||
|
expect(selectTeamIsAliveForName(state, 'my-team')).toBe(true);
|
||||||
|
|
||||||
|
expect(selectTeamMemberSnapshotsForName(state, 'missing-team')).toBe(
|
||||||
|
selectTeamMemberSnapshotsForName(state, 'missing-team')
|
||||||
|
);
|
||||||
|
expect(selectTeamTasksForName(state, 'missing-team')).toBe(
|
||||||
|
selectTeamTasksForName(state, 'missing-team')
|
||||||
|
);
|
||||||
|
expect(selectTeamIsAliveForName(state, 'missing-team')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue