Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 41 additions & 41 deletions jhub_apps/static/js/index.js

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions ui/src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ export const serverApps = {
profile: 'small0',
env: null,
public: false,
share_with: {
users: ['user1'],
groups: ['analyst', 'viewer'],
},
keep_alive: false,
username: 'Test User',
},
Expand All @@ -255,6 +259,10 @@ export const serverApps = {
profile: 'small0',
env: null,
public: false,
share_with: {
users: [],
groups: ['superadmin'],
},
keep_alive: false,
username: 'Test User',
},
Expand Down
68 changes: 68 additions & 0 deletions ui/src/pages/home/apps-section/app-filters/app-filters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,74 @@ describe('AppFilters', () => {
});
});

test('should filter by groups', async () => {
const spy = vi.fn();
mock.onGet(new RegExp('/frameworks')).reply(200, frameworks);
queryClient.setQueryData(['app-frameworks'], frameworks);
const { baseElement } = render(
<RecoilRoot initializeState={({ set }) => set(defaultUser, currentUser)}>
<QueryClientProvider client={queryClient}>
<AppFilters
data={serverApps}
currentUser={userState}
setApps={spy}
isGridViewActive={false}
toggleView={function (): void {
throw new Error('Function not implemented.');
}}
/>
</QueryClientProvider>
</RecoilRoot>,
);

const btn = baseElement.querySelector('#filters-btn') as HTMLButtonElement;
await act(async () => {
btn.click();
});

await waitFor(async () => {
const form = baseElement.querySelector(
'#filters-form',
) as HTMLFormElement;
expect(form).toBeTruthy();

// Find the groups label to ensure groups section is rendered
const groupsLabel = Array.from(
baseElement.querySelectorAll('.MuiFormLabel-root'),
).find((label) => label.textContent === 'Groups');
expect(groupsLabel).toBeTruthy();

const filterItems = baseElement.querySelectorAll(
'.MuiFormControlLabel-root',
) as NodeListOf<HTMLLabelElement>;

// Find the checkbox for 'developer' group
// The groups checkboxes come after frameworks and server statuses
const developerCheckbox = Array.from(filterItems).find((item) =>
item.textContent?.includes('developer'),
);

if (developerCheckbox) {
await act(async () => {
developerCheckbox.click();
});
expect(developerCheckbox).toBeTruthy();

const applyButton = baseElement.querySelector(
'#apply-filters-btn',
) as HTMLButtonElement;
await act(async () => {
applyButton.click();
});

expect(spy).toHaveBeenCalled();
// Verify that the filter function was called with the groups parameter
const callArgs = spy.mock.calls[0][0];
expect(callArgs).toBeDefined();
}
});
});

test('should clear filters', async () => {
const spy = vi.fn();
mock.onGet(new RegExp('/frameworks')).reply(200, frameworks);
Expand Down
63 changes: 63 additions & 0 deletions ui/src/pages/home/apps-section/app-filters/app-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import React, { SyntheticEvent, useCallback, useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import {
currentFrameworks as defaultFrameworks,
currentGroups as defaultGroups,
currentOwnershipValue as defaultOwnershipValue,
currentSearchValue as defaultSearchValue,
currentServerStatuses as defaultServerStatuses,
Expand Down Expand Up @@ -78,6 +79,9 @@ export const AppFilters = ({
const [currentServerStatuses, setCurrentServerStatuses] = useRecoilState<
string[]
>(defaultServerStatuses);
const [currentGroups, setCurrentGroups] =
useRecoilState<string[]>(defaultGroups);
const [availableGroups, setAvailableGroups] = useState<string[]>([]);
const [filteredCount, setFilteredCount] = useState(0);
const { data: frameworks, isLoading: frameworksLoading } = useQuery<
AppFrameworkProps[],
Expand Down Expand Up @@ -115,6 +119,7 @@ export const AppFilters = ({
currentFrameworks,
value,
currentServerStatuses,
currentGroups,
),
);
setSortByAnchorEl(null);
Expand All @@ -130,6 +135,16 @@ export const AppFilters = ({
}
};

const handleGroupsChange = (event: SyntheticEvent) => {
const target = event.target as HTMLInputElement;
const value = target.value;
if (currentGroups.includes(value)) {
setCurrentGroups((prev) => prev.filter((item) => item !== value));
} else {
setCurrentGroups((prev) => [...prev, value]);
}
};

const handleApplyFilters = () => {
setFiltersAnchorEl(null);
setApps(
Expand All @@ -141,13 +156,15 @@ export const AppFilters = ({
currentFrameworks,
currentSortValue,
currentServerStatuses,
currentGroups,
),
);
};
const handleClearFilters = () => {
setCurrentFrameworks([]);
setCurrentOwnershipValue('Any');
setCurrentServerStatuses([]);
setCurrentGroups([]);
};

const calculateFilteredCount = useCallback(() => {
Expand All @@ -159,6 +176,7 @@ export const AppFilters = ({
currentFrameworks,
currentSortValue,
currentServerStatuses,
currentGroups,
);
return filteredApps.length;
}, [
Expand All @@ -169,8 +187,25 @@ export const AppFilters = ({
currentFrameworks,
currentSortValue,
currentServerStatuses,
currentGroups,
]);

// Extract unique groups from all apps
useEffect(() => {
if (data) {
const allGroups = new Set<string>();
const allApps = [...(data.user_apps || []), ...(data.shared_apps || [])];
allApps.forEach((app: any) => {
if (app.user_options?.share_with?.groups) {
app.user_options.share_with.groups.forEach((group: string) => {
allGroups.add(group);
});
}
});
setAvailableGroups(Array.from(allGroups).sort());
}
}, [data]);

useEffect(() => {
setFilteredCount(calculateFilteredCount());
}, [calculateFilteredCount]);
Expand Down Expand Up @@ -277,6 +312,34 @@ export const AppFilters = ({
))}
</Box>
<Divider sx={{ mt: '24px', mb: '16px' }} />
<FormLabel
id="groups-label"
sx={{
pb: '16px',
fontSize: '14px',
fontWeight: 600,
}}
>
Groups
</FormLabel>
<Box>
{availableGroups.length > 0 ? (
availableGroups.map((group) => (
<FormControlLabel
key={group}
control={<Checkbox value={group} />}
label={group}
onClick={handleGroupsChange}
checked={currentGroups.includes(group)}
/>
))
) : (
<FormLabel sx={{ fontSize: '12px', color: '#666' }}>
No groups available
</FormLabel>
)}
</Box>
<Divider sx={{ mt: '24px', mb: '16px' }} />
<FormLabel
id="ownership-label"
sx={{
Expand Down
3 changes: 3 additions & 0 deletions ui/src/pages/home/apps-section/apps-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
currentNotification,
currentSearchValue,
currentFrameworks as defaultFrameworks,
currentGroups as defaultGroups,
currentOwnershipValue as defaultOwnershipValue,
currentServerStatuses as defaultServerStatuses,
currentSortValue as defaultSortValue,
Expand All @@ -49,6 +50,7 @@ export const AppsSection = (): React.ReactElement => {
const [isGridViewActive, setIsGridViewActive] = useState<boolean>(true);
const [, setCurrentSearchValue] = useRecoilState<string>(currentSearchValue);
const [currentFrameworks] = useRecoilState<string[]>(defaultFrameworks);
const [currentGroups] = useRecoilState<string[]>(defaultGroups);
const [currentOwnershipValue] = useRecoilState<string>(defaultOwnershipValue);
const [, setNotification] = useRecoilState<string | undefined>(
currentNotification,
Expand Down Expand Up @@ -90,6 +92,7 @@ export const AppsSection = (): React.ReactElement => {
currentFrameworks,
currentSortValue,
currentServerStatuses,
currentGroups,
),
);
}
Expand Down
6 changes: 6 additions & 0 deletions ui/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ const currentServerStatuses = atom<string[]>({
default: [],
});

const currentGroups = atom<string[]>({
key: 'currentGroups',
default: [],
});

const isStartOpen = atom<boolean>({
key: 'isStartOpen',
default: false,
Expand Down Expand Up @@ -111,6 +116,7 @@ export {
currentFile,
currentFormInput,
currentFrameworks,
currentGroups,
currentImage,
currentJhData,
currentNotification,
Expand Down
3 changes: 3 additions & 0 deletions ui/src/utils/jupyterhub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ describe('JupyterHub utils', () => {
[],
'Recently modified',
[],
[],
);
expect(apps[0].name).toBe('Test App');
});
Expand All @@ -264,6 +265,7 @@ describe('JupyterHub utils', () => {
[],
'Name: A-Z',
[],
[],
);
expect(apps[0].name).toBe('App with a long name that should be truncated');
});
Expand All @@ -277,6 +279,7 @@ describe('JupyterHub utils', () => {
[],
'Name: Z-A',
[],
[],
);
expect(apps[0].name).toBe('TEST App 3');
});
Expand Down
8 changes: 8 additions & 0 deletions ui/src/utils/jupyterhub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export const filterAndSortApps = (
frameworkValues: string[],
sortByValue: string,
currentServerStatuses: string[],
groupValues: string[],
) => {
const searchToLower = searchValue.toLowerCase();
const ownershipType =
Expand Down Expand Up @@ -301,6 +302,13 @@ export const filterAndSortApps = (
return currentServerStatuses.includes(app.status);
}
return true;
})
.filter((app: any) => {
if (groupValues.length > 0) {
const appGroups = app.share_with?.groups || [];
return groupValues.some((group) => appGroups.includes(group));
}
return true;
});

// Sort Apps based on sort value
Expand Down
Loading