Skip to content

Commit e7bf412

Browse files
committed
feat: enrol an organization and its projects in one dialog
Granting an organization access used to be only half the job — somebody still had to open every project's settings and switch the app on there. The availability dialog now pairs the organization single-select with a project picker, so one action grants access and enables the app for the chosen projects (availability first, since enablement is gated on it). Selecting projects stays optional, so availability alone still works. Enablement runs per project and reports what happened: a partial failure says the organization does have access, which projects could not be enabled, and leaves those selected so the admin can retry.
1 parent 72a38f3 commit e7bf412

4 files changed

Lines changed: 416 additions & 49 deletions

File tree

e2e/cypress/support/dataCyType.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,17 @@ declare namespace DataCy {
3434
"administration-apps-organizations-close": true;
3535
"administration-apps-organizations-dialog": true;
3636
"administration-apps-organizations-empty": true;
37+
"administration-apps-organizations-enroll-result": true;
38+
"administration-apps-organizations-enroll-submit": true;
3739
"administration-apps-organizations-error": true;
3840
"administration-apps-organizations-item": true;
3941
"administration-apps-organizations-item-remove": true;
42+
"administration-apps-projects-empty": true;
43+
"administration-apps-projects-error": true;
44+
"administration-apps-projects-item": true;
45+
"administration-apps-projects-select": true;
46+
"administration-apps-projects-select-all": true;
47+
"administration-apps-projects-truncated": true;
4048
"administration-apps-register-back": true;
4149
"administration-apps-register-button": true;
4250
"administration-apps-register-consent": true;
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {
2+
Box,
3+
Checkbox,
4+
CircularProgress,
5+
FormControlLabel,
6+
styled,
7+
Typography,
8+
} from '@mui/material';
9+
import { T } from '@tolgee/react';
10+
11+
export type SelectableProject = {
12+
id: number;
13+
name: string;
14+
};
15+
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+
`;
24+
25+
const StyledEmpty = styled('div')`
26+
padding: ${({ theme }) => theme.spacing(2)};
27+
color: ${({ theme }) => theme.palette.text.secondary};
28+
border-radius: ${({ theme }) => theme.shape.borderRadius}px;
29+
border: 1px solid ${({ theme }) => theme.palette.divider};
30+
`;
31+
32+
type Props = {
33+
projects: SelectableProject[];
34+
loading: boolean;
35+
truncated: boolean;
36+
selectedIds: number[];
37+
disabled?: boolean;
38+
onChange: (ids: number[]) => void;
39+
};
40+
41+
export const AppOrganizationProjectsSelect = ({
42+
projects,
43+
loading,
44+
truncated,
45+
selectedIds,
46+
disabled,
47+
onChange,
48+
}: Props) => {
49+
if (loading) {
50+
return (
51+
<Box display="flex" justifyContent="center" py={2}>
52+
<CircularProgress size={20} />
53+
</Box>
54+
);
55+
}
56+
57+
if (projects.length === 0) {
58+
return (
59+
<StyledEmpty data-cy="administration-apps-projects-empty">
60+
<Typography variant="body2">
61+
<T
62+
keyName="administration_apps_projects_empty"
63+
defaultValue="This organization has no projects yet. You can still grant it access — its projects can enable the app later."
64+
/>
65+
</Typography>
66+
</StyledEmpty>
67+
);
68+
}
69+
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+
89+
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+
}
111+
/>
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>
141+
)}
142+
</>
143+
);
144+
};

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

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,18 @@ type Option =
1919
| ({ kind: 'organization' } & SelectableOrganization);
2020

2121
type Props = {
22-
excludedIds: number[];
22+
value: SelectableOrganization | null;
2323
disabled?: boolean;
2424
allOptionVisible?: boolean;
25-
onSelect: (organization: SelectableOrganization) => void;
25+
onChange: (organization: SelectableOrganization | null) => void;
2626
onSelectAll: () => void;
2727
};
2828

2929
export const AppOrganizationSelect = ({
30-
excludedIds,
30+
value,
3131
disabled,
3232
allOptionVisible,
33-
onSelect,
33+
onChange,
3434
onSelectAll,
3535
}: Props) => {
3636
const { t } = useTranslate();
@@ -51,20 +51,31 @@ export const AppOrganizationSelect = ({
5151
},
5252
});
5353

54-
const excluded = new Set(excludedIds);
5554
const organizationOptions: Option[] = (
5655
organizationsLoadable.data?._embedded?.organizations ?? []
57-
)
58-
.filter((organization) => !excluded.has(organization.id))
59-
.map((organization) => ({
60-
kind: 'organization',
61-
id: organization.id,
62-
name: organization.name,
63-
}));
56+
).map((organization) => ({
57+
kind: 'organization',
58+
id: organization.id,
59+
name: organization.name,
60+
}));
6461

65-
const options: Option[] = allOptionVisible
66-
? [ALL_ORGANIZATIONS_OPTION, ...organizationOptions]
67-
: organizationOptions;
62+
const selectedOption: Option | null = value
63+
? { kind: 'organization', ...value }
64+
: null;
65+
66+
// The selected organization has to stay among the options, otherwise the search
67+
// query narrowing the list would make Autocomplete drop the current value.
68+
const selectedMissing =
69+
value !== null &&
70+
!organizationOptions.some(
71+
(option) => option.kind === 'organization' && option.id === value.id
72+
);
73+
74+
const options: Option[] = [
75+
...(allOptionVisible ? [ALL_ORGANIZATIONS_OPTION] : []),
76+
...(selectedMissing && selectedOption ? [selectedOption] : []),
77+
...organizationOptions,
78+
];
6879

6980
const getLabel = (option: Option) =>
7081
option.kind === 'all'
@@ -82,20 +93,33 @@ export const AppOrganizationSelect = ({
8293
loading={organizationsLoadable.isFetching}
8394
getOptionLabel={getLabel}
8495
filterOptions={(items) => items}
85-
value={null}
96+
isOptionEqualToValue={(option, selected) =>
97+
option.kind === selected.kind &&
98+
(option.kind !== 'organization' ||
99+
selected.kind !== 'organization' ||
100+
option.id === selected.id)
101+
}
102+
value={selectedOption}
86103
onChange={(_, newValue) => {
87104
if (!newValue) {
105+
onChange(null);
88106
return;
89107
}
90108
if (newValue.kind === 'all') {
91109
onSelectAll();
92-
} else {
93-
onSelect({ id: newValue.id, name: newValue.name });
110+
return;
111+
}
112+
onChange({ id: newValue.id, name: newValue.name });
113+
}}
114+
onInputChange={(_, newValue, reason) => {
115+
if (reason === 'input') {
116+
setSearch(newValue);
117+
return;
118+
}
119+
if (reason === 'clear') {
120+
setSearch('');
94121
}
95-
setSearch('');
96122
}}
97-
inputValue={search}
98-
onInputChange={(_, value) => setSearch(value)}
99123
renderOption={(props, option) =>
100124
option.kind === 'all' ? (
101125
<li
@@ -119,9 +143,13 @@ export const AppOrganizationSelect = ({
119143
renderInput={(params) => (
120144
<TextField
121145
{...params}
146+
label={t(
147+
'administration_apps_organization_select_label',
148+
'Organization'
149+
)}
122150
placeholder={t(
123151
'administration_apps_organization_select_placeholder',
124-
'Add organization…'
152+
'Search organizations…'
125153
)}
126154
data-cy="administration-apps-organization-select"
127155
/>
@@ -130,8 +158,6 @@ export const AppOrganizationSelect = ({
130158
'administration_apps_organization_select_empty',
131159
'No matching organizations'
132160
)}
133-
blurOnSelect
134-
clearOnBlur
135161
/>
136162
);
137163
};

0 commit comments

Comments
 (0)