Skip to content

Commit 5bb677b

Browse files
gtryusGreg Trihus
andauthored
TT-6891b conditionally display Add New Project... button based on admin status (#176)
- Updated ProjectsScreenInner to include isAdmin check for showing the `Add New Project...` button. - Refactored mountProjectsScreen helper to support mock TeamContext for testing. - Added tests to verify button visibility based on admin status. These changes improve user experience by ensuring that only authorized users can add new projects. Co-authored-by: Greg Trihus <[email protected]>
1 parent 8f6bab3 commit 5bb677b

File tree

3 files changed

+159
-21
lines changed

3 files changed

+159
-21
lines changed

src/renderer/src/context/TeamContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ const TeamProvider = (props: IProps) => {
286286
};
287287

288288
const isAdmin = (org: OrganizationD) => {
289+
if (isPersonalTeam(org.id, organizations)) return true;
289290
const role = getMyOrgRole(org.id);
290291
return role === RoleNames.Admin;
291292
};

src/renderer/src/routes/ProjectsScreen.cy.tsx

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="cypress" />
22
import React from 'react';
3-
import ProjectsScreen from './ProjectsScreen';
3+
import ProjectsScreen, { ProjectsScreenInner } from './ProjectsScreen';
44
import { GlobalProvider } from '../context/GlobalContext';
55
import { Provider } from 'react-redux';
66
import {
@@ -19,6 +19,7 @@ import localizationReducer from '../store/localization/reducers';
1919
import DataProvider from '../hoc/DataProvider';
2020
import { UnsavedProvider } from '../context/UnsavedContext';
2121
import { TokenContext } from '../context/TokenProvider';
22+
import { TeamContext } from '../context/TeamContext';
2223

2324
// Create a mock liveQuery object with subscribe and query methods
2425
const createMockLiveQuery = () => ({
@@ -147,10 +148,22 @@ describe('ProjectsScreen', () => {
147148
// Helper function to mount ProjectsScreen with all required providers
148149
const mountProjectsScreen = (
149150
initialState: ReturnType<typeof createInitialState>,
150-
initialEntries: string[] = ['/projects']
151+
initialEntries: string[] = ['/projects'],
152+
options?: {
153+
isAdmin?: (team: any) => boolean;
154+
personalTeam?: string;
155+
teams?: any[];
156+
}
151157
) => {
152158
const memory = createMockMemory();
153159

160+
// Default options
161+
const {
162+
isAdmin = () => false,
163+
personalTeam = 'personal-team-id',
164+
teams = [],
165+
} = options || {};
166+
154167
// Create mock TokenContext value
155168
const mockTokenContextValue = {
156169
state: {
@@ -166,19 +179,80 @@ describe('ProjectsScreen', () => {
166179
setState: cy.stub(),
167180
};
168181

182+
// Create mock TeamContext value
183+
const mockTeamContextValue = {
184+
state: {
185+
lang: 'en',
186+
ts: {} as any,
187+
resetOrbitError: cy.stub(),
188+
bookSuggestions: [],
189+
bookMap: {} as any,
190+
allBookData: [],
191+
planTypes: [],
192+
isDeleting: false,
193+
teams: teams,
194+
personalTeam: personalTeam,
195+
personalProjects: [],
196+
teamProjects: () => [],
197+
teamMembers: () => 0,
198+
loadProject: () => {},
199+
setProjectParams: () => ['', ''],
200+
projectType: () => '',
201+
projectSections: () => '',
202+
projectDescription: () => '',
203+
projectLanguage: () => '',
204+
projectCreate: async () => '',
205+
projectUpdate: () => {},
206+
projectDelete: () => {},
207+
teamCreate: () => {},
208+
teamUpdate: () => {},
209+
teamDelete: async () => {},
210+
isAdmin: isAdmin,
211+
isProjectAdmin: () => false,
212+
flatAdd: async () => {},
213+
cardStrings: mockCardStrings,
214+
sharedStrings: {} as any,
215+
vProjectStrings: {} as any,
216+
pickerStrings: {} as any,
217+
projButtonStrings: {} as any,
218+
newProjectStrings: {} as any,
219+
importOpen: false,
220+
setImportOpen: () => {},
221+
importProject: undefined,
222+
doImport: () => {},
223+
resetProjectPermissions: async () => {},
224+
generalBook: () => '000',
225+
updateGeneralBooks: async () => {},
226+
checkScriptureBooks: () => {},
227+
tab: 0,
228+
setTab: () => {},
229+
},
230+
setState: cy.stub(),
231+
};
232+
169233
const stateWithMemory = {
170234
...initialState,
171235
memory,
172236
};
173237

238+
// If options are provided, use ProjectsScreenInner with mock TeamContext
239+
// Otherwise, use ProjectsScreen with real TeamProvider
240+
const screenElement = options ? (
241+
<TeamContext.Provider value={mockTeamContextValue as any}>
242+
<ProjectsScreenInner />
243+
</TeamContext.Provider>
244+
) : (
245+
<ProjectsScreen />
246+
);
247+
174248
cy.mount(
175249
<MemoryRouter initialEntries={initialEntries}>
176250
<Provider store={mockStore}>
177251
<GlobalProvider init={stateWithMemory}>
178252
<DataProvider dataStore={memory}>
179253
<UnsavedProvider>
180254
<TokenContext.Provider value={mockTokenContextValue as any}>
181-
<ProjectsScreen />
255+
{screenElement}
182256
</TokenContext.Provider>
183257
</UnsavedProvider>
184258
</DataProvider>
@@ -203,22 +277,73 @@ describe('ProjectsScreen', () => {
203277
cy.contains('No projects yet.').should('be.visible');
204278
});
205279

206-
it('should show "Add New Project..." button', () => {
207-
mountProjectsScreen(createInitialState());
280+
it('should show "Add New Project..." button when isAdmin returns true', () => {
281+
const teamId = 'test-team-id';
282+
const mockTeam = {
283+
id: teamId,
284+
type: 'organization',
285+
attributes: { name: 'Test Team' },
286+
};
287+
288+
cy.window().then((win) => {
289+
win.localStorage.setItem(localUserKey(LocalKey.team), teamId);
290+
});
291+
292+
mountProjectsScreen(createInitialState(), ['/projects'], {
293+
isAdmin: (team: any) => team?.id === teamId,
294+
personalTeam: 'personal-team-id',
295+
teams: [mockTeam],
296+
});
208297

209298
cy.get('#ProjectActAdd').should('be.visible');
210299
cy.contains('Add New Project...').should('be.visible');
211300
});
212301

213302
it('should open ProjectDialog when "Add New Project..." button is clicked', () => {
214-
mountProjectsScreen(createInitialState());
303+
const teamId = 'test-team-id';
304+
const mockTeam = {
305+
id: teamId,
306+
type: 'organization',
307+
attributes: { name: 'Test Team' },
308+
};
309+
310+
cy.window().then((win) => {
311+
win.localStorage.setItem(localUserKey(LocalKey.team), teamId);
312+
});
313+
314+
mountProjectsScreen(createInitialState(), ['/projects'], {
315+
isAdmin: (team: any) => team?.id === teamId,
316+
personalTeam: 'personal-team-id',
317+
teams: [mockTeam],
318+
});
215319

216320
cy.get('#ProjectActAdd').click();
217321

218322
// ProjectDialog should open (check for dialog role or form elements)
219323
cy.get('[role="dialog"]').should('be.visible');
220324
});
221325

326+
it('should not show "Add New Project..." button when isAdmin returns false', () => {
327+
const teamId = 'test-team-id';
328+
const mockTeam = {
329+
id: teamId,
330+
type: 'organization',
331+
attributes: { name: 'Test Team' },
332+
};
333+
334+
cy.window().then((win) => {
335+
win.localStorage.setItem(localUserKey(LocalKey.team), teamId);
336+
});
337+
338+
mountProjectsScreen(createInitialState(), ['/projects'], {
339+
isAdmin: () => false, // Not admin
340+
personalTeam: 'personal-team-id',
341+
teams: [mockTeam],
342+
});
343+
344+
cy.get('#ProjectActAdd').should('not.exist');
345+
});
346+
222347
it('should show "Switch Teams" button', () => {
223348
mountProjectsScreen(createInitialState());
224349

src/renderer/src/routes/ProjectsScreen.tsx

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,18 @@ const ProjectsBox = styled(Box)<ProjectBoxProps>(({ theme, isMobile }) => ({
3737
}),
3838
}));
3939

40-
const ProjectsScreenInner: React.FC = () => {
40+
export const ProjectsScreenInner: React.FC = () => {
4141
const navigate = useMyNavigate();
4242
const teamId = localStorage.getItem(localUserKey(LocalKey.team));
4343
const ctx = React.useContext(TeamContext);
44-
const { teamProjects, personalProjects, personalTeam, cardStrings, teams } =
45-
ctx.state;
44+
const {
45+
teamProjects,
46+
personalProjects,
47+
personalTeam,
48+
cardStrings,
49+
teams,
50+
isAdmin,
51+
} = ctx.state;
4652
const t = cardStrings;
4753
const { pathname } = useLocation();
4854
const [plan] = useGlobal('plan');
@@ -181,6 +187,10 @@ const ProjectsScreenInner: React.FC = () => {
181187
// eslint-disable-next-line react-hooks/exhaustive-deps
182188
}, [plan, pathname, home]);
183189

190+
const showAddButton = React.useMemo(() => {
191+
return thisTeam && isAdmin(thisTeam);
192+
}, [thisTeam, isAdmin]);
193+
184194
return (
185195
<Box sx={{ width: '100%' }}>
186196
<AppHead />
@@ -227,18 +237,20 @@ const ProjectsScreenInner: React.FC = () => {
227237
spacing={2}
228238
sx={{ pointerEvents: 'auto', alignItems: 'center' }}
229239
>
230-
<Button
231-
id="ProjectActAdd"
232-
data-testid="add-project-button"
233-
variant="outlined"
234-
onClick={handleAddProject}
235-
sx={(theme) => ({
236-
minWidth: 160,
237-
bgcolor: theme.palette.common.white,
238-
})}
239-
>
240-
{t.addNewProject || 'Add New Project...'}
241-
</Button>
240+
{showAddButton && (
241+
<Button
242+
id="ProjectActAdd"
243+
data-testid="add-project-button"
244+
variant="outlined"
245+
onClick={handleAddProject}
246+
sx={(theme) => ({
247+
minWidth: 160,
248+
bgcolor: theme.palette.common.white,
249+
})}
250+
>
251+
{t.addNewProject || 'Add New Project...'}
252+
</Button>
253+
)}
242254
<Button
243255
id="ProjectActSwitch"
244256
variant="outlined"

0 commit comments

Comments
 (0)