Skip to content

Commit bed575c

Browse files
Merge pull request #1369 from Chiamaka489/feature/retain-phase-states
Retain Phase State on Feature View Across Page Reloads
2 parents 3765641 + 7f22a6c commit bed575c

File tree

4 files changed

+261
-23
lines changed

4 files changed

+261
-23
lines changed

src/people/widgetViews/workspace/HiveFeaturesView/HiveFeaturesView.tsx

+5-22
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,6 @@ const HiveFeaturesView = observer<HiveFeaturesViewProps>(() => {
304304
const [phaseNames, setPhaseNames] = useState<{ [key: string]: string }>({});
305305
const [collapsed, setCollapsed] = useState(false);
306306
const [data, setData] = useState<QuickBountyTicket[]>([]);
307-
const [expandedPhases, setExpandedPhases] = useState<{ [key: string]: boolean }>({});
308307
const { ui, main } = useStores();
309308
const [draftTexts, setDraftTexts] = useState<{ [phaseID: string]: string }>({});
310309
const [toasts, setToasts] = React.useState<any[]>([]);
@@ -452,11 +451,8 @@ const HiveFeaturesView = observer<HiveFeaturesViewProps>(() => {
452451
for (const phase of phases) {
453452
names[phase.uuid] = phase.name || 'Untitled Phase';
454453

455-
if (!(phase.uuid in expandedPhases)) {
456-
setExpandedPhases((prev) => ({
457-
...prev,
458-
[phase.uuid]: true
459-
}));
454+
if (!(phase.uuid in quickBountyTicketStore.expandedPhases)) {
455+
quickBountyTicketStore.setPhaseExpanded(phase.uuid, true);
460456
}
461457
}
462458
setPhaseNames(names);
@@ -465,17 +461,6 @@ const HiveFeaturesView = observer<HiveFeaturesViewProps>(() => {
465461
fetchAllPhases();
466462
}, [featureUuid, main]);
467463

468-
useEffect(() => {
469-
const savedState = localStorage.getItem(`expandedPhases_${featureUuid}`);
470-
if (savedState) {
471-
setExpandedPhases(JSON.parse(savedState));
472-
}
473-
}, [featureUuid]);
474-
475-
useEffect(() => {
476-
localStorage.setItem(`expandedPhases_${featureUuid}`, JSON.stringify(expandedPhases));
477-
}, [expandedPhases, featureUuid]);
478-
479464
useEffect(() => {
480465
const socket = createSocketInstance();
481466

@@ -536,10 +521,8 @@ const HiveFeaturesView = observer<HiveFeaturesViewProps>(() => {
536521
}, [workspaceUuid, main]);
537522

538523
const togglePhase = (phaseID: string) => {
539-
setExpandedPhases((prev) => ({
540-
...prev,
541-
[phaseID]: !prev[phaseID]
542-
}));
524+
const currentState = quickBountyTicketStore.expandedPhases[phaseID] !== false;
525+
quickBountyTicketStore.setPhaseExpanded(phaseID, !currentState);
543526
};
544527

545528
const getNavigationURL = (item: QuickBountyTicket) => {
@@ -916,7 +899,7 @@ const HiveFeaturesView = observer<HiveFeaturesViewProps>(() => {
916899
) : (
917900
Object.entries(phaseNames).map(([phaseID, phaseName], index) => {
918901
const items = groupedData[phaseID] || [];
919-
const isExpanded = expandedPhases[phaseID] !== false;
902+
const isExpanded = quickBountyTicketStore.expandedPhases[phaseID] !== false;
920903
const draftText = draftTexts[phaseID] || '';
921904

922905
return (

src/people/widgetViews/workspace/HiveFeaturesView/__tests__/HiveFeaturesView.spec.tsx

+78-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import '@testing-library/jest-dom';
2-
import { render, screen } from '@testing-library/react';
2+
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
33
import React from 'react';
44
import { MemoryRouter } from 'react-router-dom';
55
import HiveFeaturesView from '../HiveFeaturesView';
6+
import { quickBountyTicketStore } from '../../../../../store/quickBountyTicketStore';
67

78
jest.mock('../HiveFeaturesView', () => ({
89
__esModule: true,
@@ -93,4 +94,80 @@ describe('HiveFeaturesView', () => {
9394
expect(screen.getByTestId('mock-component')).toBeInTheDocument();
9495
});
9596
});
97+
98+
describe('Phase Expansion State', () => {
99+
beforeEach(() => {
100+
jest.clearAllMocks();
101+
});
102+
103+
it('should initialize phases with stored expansion states', async () => {
104+
const mockExpandedStates = {
105+
'phase-1': true,
106+
'phase-2': false
107+
};
108+
109+
quickBountyTicketStore.expandedPhases = mockExpandedStates;
110+
111+
renderComponent();
112+
113+
waitFor(() => {
114+
const phase1Header = screen.getByText('Phase 1').closest('div');
115+
const phase2Header = screen.getByText('Phase 2').closest('div');
116+
117+
expect(phase1Header).toHaveAttribute('aria-expanded', 'true');
118+
expect(phase2Header).toHaveAttribute('aria-expanded', 'false');
119+
});
120+
});
121+
122+
it('should default new phases to expanded state', async () => {
123+
quickBountyTicketStore.expandedPhases = {};
124+
125+
renderComponent();
126+
127+
waitFor(() => {
128+
const phaseHeaders = screen.getAllByRole('button', { name: /Phase \d/ });
129+
phaseHeaders.forEach((header) => {
130+
expect(header).toHaveAttribute('aria-expanded', 'true');
131+
});
132+
});
133+
});
134+
135+
it('should persist phase state changes', async () => {
136+
renderComponent();
137+
138+
waitFor(() => {
139+
const phaseHeader = screen.getByText('Phase 1').closest('div');
140+
if (phaseHeader) {
141+
fireEvent.click(phaseHeader);
142+
expect(quickBountyTicketStore.setPhaseExpanded).toHaveBeenCalledWith('phase-1', false);
143+
}
144+
});
145+
});
146+
147+
it('should maintain phase states after data refresh', async () => {
148+
const mockExpandedStates = {
149+
'phase-1': false,
150+
'phase-2': true
151+
};
152+
153+
quickBountyTicketStore.expandedPhases = mockExpandedStates;
154+
155+
const { rerender } = renderComponent();
156+
157+
quickBountyTicketStore.fetchAndSetQuickData('test-feature');
158+
rerender(
159+
<MemoryRouter>
160+
<HiveFeaturesView />
161+
</MemoryRouter>
162+
);
163+
164+
waitFor(() => {
165+
const phase1Header = screen.getByText('Phase 1').closest('div');
166+
const phase2Header = screen.getByText('Phase 2').closest('div');
167+
168+
expect(phase1Header).toHaveAttribute('aria-expanded', 'false');
169+
expect(phase2Header).toHaveAttribute('aria-expanded', 'true');
170+
});
171+
});
172+
});
96173
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { waitFor } from '@testing-library/react';
2+
import { quickBountyTicketStore } from '../quickBountyTicketStore';
3+
4+
describe('QuickBountyTicketStore', () => {
5+
let localStorageMock: { [key: string]: string };
6+
7+
beforeEach(() => {
8+
localStorageMock = {};
9+
10+
Object.defineProperty(window, 'localStorage', {
11+
value: {
12+
getItem: jest.fn((key) => localStorageMock[key]),
13+
setItem: jest.fn((key, value) => {
14+
localStorageMock[key] = value;
15+
}),
16+
removeItem: jest.fn((key) => delete localStorageMock[key]),
17+
clear: jest.fn(() => {
18+
localStorageMock = {};
19+
})
20+
},
21+
writable: true
22+
});
23+
24+
quickBountyTicketStore.quickBountyTickets = [];
25+
quickBountyTicketStore.expandedPhases = {};
26+
});
27+
28+
describe('initialization', () => {
29+
it('should initialize with empty quickBountyTickets', () => {
30+
expect(quickBountyTicketStore.quickBountyTickets).toEqual([]);
31+
});
32+
33+
it('should initialize with empty expandedPhases', () => {
34+
expect(quickBountyTicketStore.expandedPhases).toEqual({});
35+
});
36+
37+
it('should load expanded states from localStorage if available', () => {
38+
const savedState = { 'phase-1': true, 'phase-2': false };
39+
localStorageMock['expandedPhases'] = JSON.stringify(savedState);
40+
41+
(quickBountyTicketStore as any).loadExpandedStates();
42+
43+
expect(quickBountyTicketStore.expandedPhases).toEqual(savedState);
44+
});
45+
46+
it('should handle invalid JSON in localStorage', () => {
47+
localStorageMock['expandedPhases'] = 'invalid-json';
48+
49+
(quickBountyTicketStore as any).loadExpandedStates();
50+
51+
expect(quickBountyTicketStore.expandedPhases).toEqual({});
52+
});
53+
});
54+
55+
describe('setPhaseExpanded', () => {
56+
it('should update expanded state for a phase', () => {
57+
quickBountyTicketStore.setPhaseExpanded('phase-1', true);
58+
59+
expect(quickBountyTicketStore.expandedPhases['phase-1']).toBe(true);
60+
});
61+
62+
it('should save to localStorage when updating state', () => {
63+
quickBountyTicketStore.setPhaseExpanded('phase-1', true);
64+
65+
expect(localStorage.setItem).toHaveBeenCalledWith(
66+
'expandedPhases',
67+
JSON.stringify({ 'phase-1': true })
68+
);
69+
});
70+
71+
it('should handle multiple phase states', () => {
72+
quickBountyTicketStore.setPhaseExpanded('phase-1', true);
73+
quickBountyTicketStore.setPhaseExpanded('phase-2', false);
74+
75+
expect(quickBountyTicketStore.expandedPhases).toEqual({
76+
'phase-1': true,
77+
'phase-2': false
78+
});
79+
});
80+
});
81+
82+
describe('fetchAndSetQuickData', () => {
83+
const mockBounties = {
84+
featureID: 'feature-1',
85+
phases: {
86+
'phase-1': [
87+
{
88+
bountyID: 1,
89+
phaseID: 'phase-1',
90+
bountyTitle: 'Test Bounty',
91+
status: 'TODO',
92+
assignedAlias: 'tester'
93+
}
94+
]
95+
}
96+
};
97+
98+
const mockTickets = {
99+
featureID: 'feature-1',
100+
phases: {
101+
'phase-1': [
102+
{
103+
ticketUUID: 'ticket-1',
104+
phaseID: 'phase-1',
105+
ticketTitle: 'Test Ticket',
106+
status: 'TODO',
107+
assignedAlias: 'tester'
108+
}
109+
]
110+
}
111+
};
112+
113+
beforeEach(() => {
114+
const mainStore = {
115+
fetchQuickBounties: jest.fn().mockResolvedValue(mockBounties),
116+
fetchQuickTickets: jest.fn().mockResolvedValue(mockTickets)
117+
};
118+
quickBountyTicketStore['main'] = mainStore;
119+
});
120+
121+
it('should fetch and process bounties and tickets', async () => {
122+
const result = await quickBountyTicketStore.fetchAndSetQuickData('feature-1');
123+
124+
waitFor(() => {
125+
expect(result).toHaveLength(2);
126+
expect(result?.find((item) => item.bountyTicket === 'bounty')).toBeTruthy();
127+
expect(result?.find((item) => item.bountyTicket === 'ticket')).toBeTruthy();
128+
});
129+
});
130+
131+
it('should handle empty response', async () => {
132+
quickBountyTicketStore['main'].fetchQuickBounties.mockResolvedValue(null);
133+
quickBountyTicketStore['main'].fetchQuickTickets.mockResolvedValue(null);
134+
135+
const result = await quickBountyTicketStore.fetchAndSetQuickData('feature-1');
136+
137+
expect(result).toEqual([]);
138+
});
139+
140+
it('should handle fetch errors', async () => {
141+
quickBountyTicketStore['main'].fetchQuickBounties.mockRejectedValue(
142+
new Error('Fetch failed')
143+
);
144+
145+
const result = await quickBountyTicketStore.fetchAndSetQuickData('feature-1');
146+
147+
waitFor(() => {
148+
expect(result).toBeUndefined();
149+
});
150+
});
151+
});
152+
});

src/store/quickBountyTicketStore.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,35 @@ export interface QuickBountyTicket {
1818

1919
class QuickBountyTicketStore {
2020
quickBountyTickets: QuickBountyTicket[] = [];
21+
expandedPhases: { [key: string]: boolean } = {};
2122

2223
constructor() {
2324
makeAutoObservable(this);
25+
this.loadExpandedStates();
26+
}
27+
28+
private loadExpandedStates() {
29+
try {
30+
const savedState = localStorage.getItem('expandedPhases');
31+
if (savedState) {
32+
this.expandedPhases = JSON.parse(savedState);
33+
}
34+
} catch (error) {
35+
console.error('Error loading expanded states:', error);
36+
}
37+
}
38+
39+
setPhaseExpanded(phaseId: string, expanded: boolean) {
40+
this.expandedPhases[phaseId] = expanded;
41+
this.saveExpandedStates();
42+
}
43+
44+
private saveExpandedStates() {
45+
try {
46+
localStorage.setItem('expandedPhases', JSON.stringify(this.expandedPhases));
47+
} catch (error) {
48+
console.error('Error saving expanded states:', error);
49+
}
2450
}
2551

2652
async fetchAndSetQuickData(featureUUID: string) {

0 commit comments

Comments
 (0)