Skip to content

Commit 608acd0

Browse files
committed
feat: search and page through all projects when enrolling an organization
The project picker listed only the first 100 projects and told the user the rest were missing, which put larger organizations out of reach from this dialog. It is now an infinite multi-search-select over the organization's projects, following the assigned-projects select used for glossaries: server-side search with load-more paging, so every project is reachable. Selection stays optional, the no-projects state and the partial-failure retry are unchanged.
1 parent aafc07f commit 608acd0

3 files changed

Lines changed: 122 additions & 158 deletions

File tree

e2e/cypress/support/dataCyType.d.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,7 @@ declare namespace DataCy {
4040
"administration-apps-organizations-item": true;
4141
"administration-apps-organizations-item-remove": true;
4242
"administration-apps-projects-empty": true;
43-
"administration-apps-projects-error": true;
4443
"administration-apps-projects-item": true;
45-
"administration-apps-projects-select": true;
46-
"administration-apps-projects-select-all": true;
47-
"administration-apps-projects-truncated": true;
4844
"administration-apps-register-back": true;
4945
"administration-apps-register-button": true;
5046
"administration-apps-register-consent": true;
Lines changed: 108 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
1-
import {
2-
Box,
3-
Checkbox,
4-
CircularProgress,
5-
FormControlLabel,
6-
styled,
7-
Typography,
8-
} from '@mui/material';
9-
import { T } from '@tolgee/react';
1+
import { useState } from 'react';
2+
import { styled, Typography } from '@mui/material';
3+
import { T, useTranslate } from '@tolgee/react';
4+
import { useDebounce } from 'use-debounce';
5+
6+
import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi';
7+
import { InfiniteMultiSearchSelect } from 'tg.component/searchSelect/InfiniteMultiSearchSelect';
8+
import { MultiselectItem } from 'tg.component/searchSelect/MultiselectItem';
9+
import { components } from 'tg.service/apiSchema.generated';
10+
import { TranslatedError } from 'tg.translationTools/TranslatedError';
11+
12+
type ProjectModel = components['schemas']['ProjectModel'];
1013

1114
export type SelectableProject = {
1215
id: number;
1316
name: string;
1417
};
1518

16-
const StyledList = styled('div')`
17-
display: grid;
18-
max-height: 240px;
19-
overflow-y: auto;
20-
border-radius: ${({ theme }) => theme.shape.borderRadius}px;
21-
border: 1px solid ${({ theme }) => theme.palette.divider};
22-
padding: ${({ theme }) => theme.spacing(0.5, 1)};
23-
`;
19+
const SEARCH_DEBOUNCE_MS = 500;
20+
const PAGE_SIZE = 30;
2421

2522
const StyledEmpty = styled('div')`
2623
padding: ${({ theme }) => theme.spacing(2)};
@@ -30,31 +27,82 @@ const StyledEmpty = styled('div')`
3027
`;
3128

3229
type Props = {
33-
projects: SelectableProject[];
34-
loading: boolean;
35-
truncated: boolean;
36-
selectedIds: number[];
30+
organizationId: number;
31+
selected: SelectableProject[];
3732
disabled?: boolean;
38-
onChange: (ids: number[]) => void;
33+
onChange: (projects: SelectableProject[]) => void;
3934
};
4035

4136
export const AppOrganizationProjectsSelect = ({
42-
projects,
43-
loading,
44-
truncated,
45-
selectedIds,
37+
organizationId,
38+
selected,
4639
disabled,
4740
onChange,
4841
}: Props) => {
49-
if (loading) {
50-
return (
51-
<Box display="flex" justifyContent="center" py={2}>
52-
<CircularProgress size={20} />
53-
</Box>
54-
);
55-
}
42+
const { t } = useTranslate();
43+
const [search, setSearch] = useState('');
44+
const [searchDebounced] = useDebounce(search, SEARCH_DEBOUNCE_MS);
45+
46+
const query = {
47+
search: searchDebounced,
48+
size: PAGE_SIZE,
49+
};
50+
51+
const projectsLoadable = useApiInfiniteQuery({
52+
url: '/v2/organizations/{id}/projects',
53+
method: 'get',
54+
path: { id: organizationId },
55+
query,
56+
options: {
57+
keepPreviousData: true,
58+
noGlobalLoading: true,
59+
// Reported inline on the select instead of as a global toast.
60+
onError: () => undefined,
61+
getNextPageParam: (lastPage) => {
62+
if (
63+
lastPage.page &&
64+
lastPage.page.number! < lastPage.page.totalPages! - 1
65+
) {
66+
return {
67+
path: { id: organizationId },
68+
query: { ...query, page: lastPage.page!.number! + 1 },
69+
};
70+
}
71+
return null;
72+
},
73+
},
74+
});
75+
76+
const items = projectsLoadable.data?.pages.flatMap(
77+
(page) => page._embedded?.projects ?? []
78+
);
79+
80+
const totalElements = projectsLoadable.data?.pages[0]?.page?.totalElements;
81+
const organizationHasNoProjects = !searchDebounced && totalElements === 0;
82+
83+
const handleFetchMore = () => {
84+
if (projectsLoadable.hasNextPage && !projectsLoadable.isFetching) {
85+
projectsLoadable.fetchNextPage();
86+
}
87+
};
88+
89+
const toggleSelected = (project: ProjectModel) => {
90+
if (selected.some((item) => item.id === project.id)) {
91+
onChange(selected.filter((item) => item.id !== project.id));
92+
return;
93+
}
94+
onChange([...selected, { id: project.id, name: project.name }]);
95+
};
96+
97+
const renderError = () => {
98+
if (!projectsLoadable.error) return undefined;
99+
if (typeof projectsLoadable.error.code === 'string') {
100+
return <TranslatedError code={projectsLoadable.error.code} />;
101+
}
102+
return <T keyName="simple_paginated_list_error_message" />;
103+
};
56104

57-
if (projects.length === 0) {
105+
if (organizationHasNoProjects) {
58106
return (
59107
<StyledEmpty data-cy="administration-apps-projects-empty">
60108
<Typography variant="body2">
@@ -67,78 +115,34 @@ export const AppOrganizationProjectsSelect = ({
67115
);
68116
}
69117

70-
const selected = new Set(selectedIds);
71-
const allSelected = projects.every((project) => selected.has(project.id));
72-
73-
const toggle = (projectId: number) => {
74-
if (selected.has(projectId)) {
75-
onChange(selectedIds.filter((id) => id !== projectId));
76-
return;
77-
}
78-
onChange([...selectedIds, projectId]);
79-
};
80-
81-
const toggleAll = () => {
82-
if (allSelected) {
83-
onChange([]);
84-
return;
85-
}
86-
onChange(projects.map((project) => project.id));
87-
};
88-
89118
return (
90-
<>
91-
<StyledList data-cy="administration-apps-projects-select">
92-
<FormControlLabel
93-
control={
94-
<Checkbox
95-
size="small"
96-
checked={allSelected}
97-
indeterminate={!allSelected && selectedIds.length > 0}
98-
disabled={disabled}
99-
onChange={toggleAll}
100-
data-cy="administration-apps-projects-select-all"
101-
/>
102-
}
103-
label={
104-
<Typography variant="body2" color="text.secondary">
105-
<T
106-
keyName="administration_apps_projects_select_all"
107-
defaultValue="Select all"
108-
/>
109-
</Typography>
110-
}
119+
<InfiniteMultiSearchSelect
120+
items={items}
121+
selected={selected}
122+
queryResult={projectsLoadable}
123+
itemKey={(project) => project.id}
124+
search={search}
125+
onSearchChange={setSearch}
126+
onFetchMore={handleFetchMore}
127+
onClearSelected={() => onChange([])}
128+
renderItem={(props, project) => (
129+
<MultiselectItem
130+
{...props}
131+
data-cy="administration-apps-projects-item"
132+
data-cy-project-id={project.id}
133+
selected={selected.some((item) => item.id === project.id)}
134+
label={project.name}
135+
onClick={() => toggleSelected(project)}
111136
/>
112-
{projects.map((project) => (
113-
<FormControlLabel
114-
key={project.id}
115-
control={
116-
<Checkbox
117-
size="small"
118-
checked={selected.has(project.id)}
119-
disabled={disabled}
120-
onChange={() => toggle(project.id)}
121-
data-cy="administration-apps-projects-item"
122-
data-cy-project-id={project.id}
123-
/>
124-
}
125-
label={<Typography variant="body2">{project.name}</Typography>}
126-
/>
127-
))}
128-
</StyledList>
129-
{truncated && (
130-
<Typography
131-
variant="caption"
132-
color="text.secondary"
133-
data-cy="administration-apps-projects-truncated"
134-
>
135-
<T
136-
keyName="administration_apps_projects_truncated"
137-
defaultValue="Only the first {count} projects are listed. Enable the app for the rest from their project settings."
138-
params={{ count: projects.length }}
139-
/>
140-
</Typography>
141137
)}
142-
</>
138+
labelItem={(project) => project.name}
139+
label={t('administration_apps_projects_select_label', 'Projects')}
140+
searchPlaceholder={t(
141+
'administration_apps_projects_search_placeholder',
142+
'Search projects…'
143+
)}
144+
error={renderError()}
145+
disabled={disabled}
146+
/>
143147
);
144148
};

webapp/src/views/administration/apps/AppOrganizationsDialog.tsx

Lines changed: 14 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ import {
3636

3737
type AppInstallModel = components['schemas']['AppInstallModel'];
3838

39-
const PROJECTS_PAGE_SIZE = 100;
40-
4139
const StyledList = styled('div')`
4240
display: grid;
4341
border-radius: ${({ theme }) => theme.shape.borderRadius}px;
@@ -83,7 +81,9 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
8381

8482
const [organization, setOrganization] =
8583
useState<SelectableOrganization | null>(null);
86-
const [selectedProjectIds, setSelectedProjectIds] = useState<number[]>([]);
84+
const [selectedProjects, setSelectedProjects] = useState<SelectableProject[]>(
85+
[]
86+
);
8787
const [result, setResult] = useState<EnrollResult | null>(null);
8888

8989
const organizationsLoadable = useApiQuery({
@@ -92,17 +92,6 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
9292
path: { installId: install.id },
9393
});
9494

95-
const projectsLoadable = useApiQuery({
96-
url: '/v2/organizations/{id}/projects',
97-
method: 'get',
98-
path: { id: organization?.id ?? 0 },
99-
query: { size: PROJECTS_PAGE_SIZE, sort: ['name,asc'] },
100-
options: {
101-
enabled: Boolean(organization),
102-
noGlobalLoading: true,
103-
},
104-
});
105-
10695
const grantMutation = useApiMutation({
10796
url: '/v2/administration/apps/{installId}/organizations/{organizationId}',
10897
method: 'put',
@@ -145,17 +134,11 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
145134
grantAllMutation.isLoading ||
146135
revokeAllMutation.isLoading;
147136

148-
const projects: SelectableProject[] = (
149-
projectsLoadable.data?._embedded?.projects ?? []
150-
).map((project) => ({ id: project.id, name: project.name }));
151-
const projectsTruncated =
152-
(projectsLoadable.data?.page?.totalElements ?? 0) > projects.length;
153-
154137
const handleSelectOrganization = (
155138
selected: SelectableOrganization | null
156139
) => {
157140
setOrganization(selected);
158-
setSelectedProjectIds([]);
141+
setSelectedProjects([]);
159142
setResult(null);
160143
};
161144

@@ -173,19 +156,14 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
173156

174157
const failedProjects: SelectableProject[] = [];
175158
let enabledCount = 0;
176-
for (const projectId of selectedProjectIds) {
159+
for (const project of selectedProjects) {
177160
try {
178161
await enableMutation.mutateAsync({
179-
path: { projectId, installId: install.id },
162+
path: { projectId: project.id, installId: install.id },
180163
});
181164
enabledCount += 1;
182165
} catch (e) {
183-
failedProjects.push(
184-
projects.find((project) => project.id === projectId) ?? {
185-
id: projectId,
186-
name: String(projectId),
187-
}
188-
);
166+
failedProjects.push(project);
189167
}
190168
}
191169

@@ -194,7 +172,7 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
194172
enabledCount,
195173
failedProjects,
196174
});
197-
setSelectedProjectIds(failedProjects.map((project) => project.id));
175+
setSelectedProjects(failedProjects);
198176
};
199177

200178
const handleAddAll = () => {
@@ -338,27 +316,13 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
338316
onSelectAll={handleAddAll}
339317
/>
340318

341-
{organization && projectsLoadable.error && (
342-
<Alert
343-
severity="error"
344-
data-cy="administration-apps-projects-error"
345-
>
346-
{typeof projectsLoadable.error.code === 'string' ? (
347-
<TranslatedError code={projectsLoadable.error.code} />
348-
) : (
349-
<T keyName="simple_paginated_list_error_message" />
350-
)}
351-
</Alert>
352-
)}
353-
354-
{organization && !projectsLoadable.error && (
319+
{organization && (
355320
<AppOrganizationProjectsSelect
356-
projects={projects}
357-
loading={projectsLoadable.isLoading}
358-
truncated={projectsTruncated}
359-
selectedIds={selectedProjectIds}
321+
key={organization.id}
322+
organizationId={organization.id}
323+
selected={selectedProjects}
360324
disabled={updating}
361-
onChange={setSelectedProjectIds}
325+
onChange={setSelectedProjects}
362326
/>
363327
)}
364328

@@ -408,7 +372,7 @@ export const AppOrganizationsDialog = ({ install, onClose }: Props) => {
408372
disabled={!organization || updating}
409373
onClick={handleEnroll}
410374
>
411-
{selectedProjectIds.length === 0 ? (
375+
{selectedProjects.length === 0 ? (
412376
<T
413377
keyName="administration_apps_organizations_enroll_submit"
414378
defaultValue="Grant access"

0 commit comments

Comments
 (0)