Skip to content

Commit 3d6e52d

Browse files
committed
Replace legacy admin group forms with Vue GroupForm component
Replace FormGeneric-based group create/edit views with a new GroupForm Vue component that uses the API directly. Add search/pagination support to roles and users API endpoints for the multiselect dropdowns. Remove legacy controller methods (create_group, manage_users_and_roles_for_group) that loaded all users/roles upfront causing slow page loads. Also add auto_create_role option to group creation API, fix selenium test selector for the new component's submit button, and enable the admin_user_display test for Playwright.
1 parent cc72559 commit 3d6e52d

19 files changed

Lines changed: 472 additions & 181 deletions

File tree

client/src/api/schema/schema.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13294,6 +13294,12 @@ export interface components {
1329413294
* @description Payload schema for creating a group.
1329513295
*/
1329613296
GroupCreatePayload: {
13297+
/**
13298+
* auto-create role
13299+
* @description If true, create a new role with the same name as the group and associate it.
13300+
* @default false
13301+
*/
13302+
auto_create_role: boolean;
1329713303
/** name of the group */
1329813304
name: string;
1329913305
/**
@@ -40586,7 +40592,14 @@ export interface operations {
4058640592
};
4058740593
index_api_roles_get: {
4058840594
parameters: {
40589-
query?: never;
40595+
query?: {
40596+
/** @description Search by role name or user email (for private roles). */
40597+
search?: string | null;
40598+
/** @description The maximum number of roles to return. */
40599+
limit?: number | null;
40600+
/** @description Number of roles to skip. */
40601+
offset?: number | null;
40602+
};
4059040603
header?: {
4059140604
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
4059240605
"run-as"?: string | null;
@@ -43021,6 +43034,10 @@ export interface operations {
4302143034
f_name?: string | null;
4302243035
/** @description Filter on username OR email */
4302343036
f_any?: string | null;
43037+
/** @description Maximum number of users to return. */
43038+
limit?: number | null;
43039+
/** @description Number of users to skip. */
43040+
offset?: number | null;
4302443041
};
4302543042
header?: {
4302643043
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<script setup lang="ts">
2+
import "vue-multiselect/dist/vue-multiselect.min.css";
3+
4+
import { faSave } from "@fortawesome/free-solid-svg-icons";
5+
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
6+
import { BAlert, BButton, BFormCheckbox } from "bootstrap-vue";
7+
import { ref } from "vue";
8+
import Multiselect from "vue-multiselect";
9+
import { useRouter } from "vue-router/composables";
10+
11+
import { GalaxyApi } from "@/api";
12+
import { errorMessageAsString } from "@/utils/simple-error";
13+
14+
import FormInput from "@/components/Form/Elements/FormInput.vue";
15+
import FormCard from "@/components/Form/FormCard.vue";
16+
import FormElementLabel from "@/components/Form/FormElementLabel.vue";
17+
import LoadingSpan from "@/components/LoadingSpan.vue";
18+
19+
interface UserOption {
20+
id: string;
21+
email: string;
22+
}
23+
24+
interface RoleOption {
25+
id: string;
26+
name: string;
27+
}
28+
29+
const props = defineProps<{
30+
groupId?: string;
31+
}>();
32+
33+
const isEditMode = !!props.groupId;
34+
35+
const errorMessage = ref("");
36+
const loading = ref(false);
37+
const groupName = ref("");
38+
const selectedUsers = ref<UserOption[]>([]);
39+
const selectedRoles = ref<RoleOption[]>([]);
40+
const userOptions = ref<UserOption[]>([]);
41+
const roleOptions = ref<RoleOption[]>([]);
42+
const userSearch = ref("");
43+
const roleSearch = ref("");
44+
const autoCreateRole = ref(false);
45+
46+
const router = useRouter();
47+
48+
async function onUserSearch(searchValue: string) {
49+
userSearch.value = searchValue;
50+
if (searchValue.length < 3) {
51+
userOptions.value = [...selectedUsers.value];
52+
return;
53+
}
54+
const { data, error } = await GalaxyApi().GET("/api/users", {
55+
params: { query: { f_email: searchValue, limit: 50 } },
56+
});
57+
if (error) {
58+
errorMessage.value = errorMessageAsString(error);
59+
return;
60+
}
61+
const selectedIds = new Set(selectedUsers.value.map((u) => u.id));
62+
const filtered = data.filter((u) => u.email && !selectedIds.has(u.id)).map((u) => ({ id: u.id, email: u.email! }));
63+
userOptions.value = [...selectedUsers.value, ...filtered];
64+
}
65+
66+
async function onRoleSearch(searchValue: string) {
67+
roleSearch.value = searchValue;
68+
if (searchValue.length < 3) {
69+
roleOptions.value = [...selectedRoles.value];
70+
return;
71+
}
72+
const { data, error } = await GalaxyApi().GET("/api/roles", {
73+
params: { query: { search: searchValue, limit: 50 } },
74+
});
75+
if (error) {
76+
errorMessage.value = errorMessageAsString(error);
77+
return;
78+
}
79+
const selectedIds = new Set(selectedRoles.value.map((r) => r.id));
80+
const filtered = data.filter((r) => !selectedIds.has(r.id)).map((r) => ({ id: r.id, name: r.name }));
81+
roleOptions.value = [...selectedRoles.value, ...filtered];
82+
}
83+
84+
async function loadGroupData() {
85+
if (!props.groupId) {
86+
return;
87+
}
88+
loading.value = true;
89+
try {
90+
const { data: group, error: groupError } = await GalaxyApi().GET("/api/groups/{group_id}", {
91+
params: { path: { group_id: props.groupId } },
92+
});
93+
if (groupError) {
94+
errorMessage.value = errorMessageAsString(groupError);
95+
loading.value = false;
96+
return;
97+
}
98+
groupName.value = group.name;
99+
100+
const { data: users, error: usersError } = await GalaxyApi().GET("/api/groups/{group_id}/users", {
101+
params: { path: { group_id: props.groupId } },
102+
});
103+
if (usersError) {
104+
errorMessage.value = errorMessageAsString(usersError);
105+
loading.value = false;
106+
return;
107+
}
108+
selectedUsers.value = users.map((u) => ({
109+
id: u.id,
110+
email: u.email,
111+
}));
112+
userOptions.value = [...selectedUsers.value];
113+
114+
const { data: roles, error: rolesError } = await GalaxyApi().GET("/api/groups/{group_id}/roles", {
115+
params: { path: { group_id: props.groupId } },
116+
});
117+
if (rolesError) {
118+
errorMessage.value = errorMessageAsString(rolesError);
119+
loading.value = false;
120+
return;
121+
}
122+
selectedRoles.value = roles.map((r) => ({
123+
id: r.id,
124+
name: r.name,
125+
}));
126+
roleOptions.value = [...selectedRoles.value];
127+
} catch (e) {
128+
errorMessage.value = errorMessageAsString(e);
129+
}
130+
loading.value = false;
131+
}
132+
133+
async function onSubmit() {
134+
const userIds = selectedUsers.value.map((u) => u.id);
135+
const roleIds = selectedRoles.value.map((r) => r.id);
136+
137+
if (isEditMode) {
138+
const { error } = await GalaxyApi().PUT("/api/groups/{group_id}", {
139+
params: { path: { group_id: props.groupId! } },
140+
body: {
141+
user_ids: userIds,
142+
role_ids: roleIds,
143+
},
144+
});
145+
if (error) {
146+
errorMessage.value = errorMessageAsString(error);
147+
return;
148+
}
149+
} else {
150+
if (!groupName.value) {
151+
errorMessage.value = "Please enter a group name.";
152+
return;
153+
}
154+
const { error } = await GalaxyApi().POST("/api/groups", {
155+
body: {
156+
name: groupName.value,
157+
user_ids: userIds,
158+
role_ids: roleIds,
159+
auto_create_role: autoCreateRole.value,
160+
},
161+
});
162+
if (error) {
163+
errorMessage.value = errorMessageAsString(error);
164+
return;
165+
}
166+
}
167+
router.push("/admin/groups");
168+
}
169+
170+
loadGroupData();
171+
</script>
172+
173+
<template>
174+
<div>
175+
<LoadingSpan v-if="loading" />
176+
<div v-else>
177+
<BAlert v-if="errorMessage" variant="danger" show>{{ errorMessage }}</BAlert>
178+
<FormCard :title="isEditMode ? `Group '${groupName}'` : 'Create a new Group'" icon="fa-users">
179+
<template v-slot:body>
180+
<FormElementLabel title="Name" :required="!isEditMode" :condition="!!groupName">
181+
<FormInput v-if="!isEditMode" id="admin-group-name-input" v-model="groupName" />
182+
<span v-else>{{ groupName }}</span>
183+
</FormElementLabel>
184+
185+
<FormElementLabel title="Users">
186+
<Multiselect
187+
id="admin-group-users-select"
188+
v-model="selectedUsers"
189+
:options="userOptions"
190+
:clear-on-select="true"
191+
:multiple="true"
192+
:internal-search="false"
193+
:max-height="300"
194+
label="email"
195+
track-by="id"
196+
placeholder="Search users by email..."
197+
@search-change="onUserSearch">
198+
<template slot="noResult">
199+
<div v-if="userSearch.length < 3">Enter at least 3 characters to search</div>
200+
<div v-else>No users found</div>
201+
</template>
202+
<template slot="noOptions">
203+
<div>Enter at least 3 characters to search</div>
204+
</template>
205+
</Multiselect>
206+
</FormElementLabel>
207+
208+
<FormElementLabel title="Roles">
209+
<Multiselect
210+
id="admin-group-roles-select"
211+
v-model="selectedRoles"
212+
:options="roleOptions"
213+
:clear-on-select="true"
214+
:multiple="true"
215+
:internal-search="false"
216+
:max-height="300"
217+
label="name"
218+
track-by="id"
219+
placeholder="Search roles by name..."
220+
@search-change="onRoleSearch">
221+
<template slot="noResult">
222+
<div v-if="roleSearch.length < 3">Enter at least 3 characters to search</div>
223+
<div v-else>No roles found</div>
224+
</template>
225+
<template slot="noOptions">
226+
<div>Enter at least 3 characters to search</div>
227+
</template>
228+
</Multiselect>
229+
</FormElementLabel>
230+
231+
<FormElementLabel v-if="!isEditMode" title="Auto-create role">
232+
<BFormCheckbox v-model="autoCreateRole">
233+
Create a new role with the same name as this group
234+
</BFormCheckbox>
235+
</FormElementLabel>
236+
</template>
237+
</FormCard>
238+
<BButton id="admin-group-submit" class="my-2" variant="primary" @click="onSubmit">
239+
<FontAwesomeIcon :icon="faSave" class="mr-1" />
240+
<span v-localize>{{ isEditMode ? "Save" : "Create" }}</span>
241+
</BButton>
242+
</div>
243+
</div>
244+
</template>

client/src/entry/analysis/routes/admin-routes.js

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import DataTypes from "@/components/admin/DataTypes.vue";
1616
import ToolboxDependencies from "@/components/admin/Dependencies/Landing.vue";
1717
import DisplayApplications from "@/components/admin/DisplayApplications.vue";
1818
import ErrorStack from "@/components/admin/ErrorStack.vue";
19+
import GroupForm from "@/components/admin/GroupForm.vue";
1920
import JobsList from "@/components/admin/JobsList.vue";
2021
import BroadcastForm from "@/components/admin/Notifications/BroadcastForm.vue";
2122
import NotificationForm from "@/components/admin/Notifications/NotificationForm.vue";
@@ -206,10 +207,9 @@ export default [
206207
},
207208
{
208209
path: "form/manage_users_and_roles_for_group",
209-
component: FormGeneric,
210+
component: GroupForm,
210211
props: (route) => ({
211-
url: `/admin/manage_users_and_roles_for_group?id=${route.query.id}`,
212-
redirect: "/admin/groups",
212+
groupId: route.query.id,
213213
}),
214214
},
215215
{
@@ -226,11 +226,7 @@ export default [
226226
},
227227
{
228228
path: "form/create_group",
229-
component: FormGeneric,
230-
props: {
231-
url: "/admin/create_group",
232-
redirect: "/admin/groups",
233-
},
229+
component: GroupForm,
234230
},
235231
{
236232
path: "form/create_quota",

client/src/utils/navigation/navigation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,7 +1218,7 @@ admin:
12181218
registration_form: 'form#registration'
12191219
groups_grid: '#groups-grid'
12201220
roles_grid: '#roles-grid'
1221-
groups_create_view: '#submit:not([aria-disabled])'
1221+
groups_create_view: '#admin-group-submit'
12221222

12231223
libraries:
12241224

lib/galaxy/managers/groups.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,19 @@ def create(self, trans: ProvidesAppContext, payload: GroupCreatePayload):
5252
group = model.Group(name=name)
5353
sa_session.add(group)
5454

55+
role_ids = list(payload.role_ids)
56+
if payload.auto_create_role:
57+
existing_role = sa_session.scalars(select(model.Role).where(model.Role.name == name).limit(1)).first()
58+
if existing_role:
59+
raise Conflict(f"A role with name '{name}' already exists")
60+
role = model.Role(name=name, description=f"Role for group {name}")
61+
sa_session.add(role)
62+
sa_session.flush()
63+
gra = model.GroupRoleAssociation(group, role)
64+
sa_session.add(gra)
65+
5566
trans.app.security_agent.set_group_user_and_role_associations(
56-
group, user_ids=payload.user_ids, role_ids=payload.role_ids
67+
group, user_ids=payload.user_ids, role_ids=role_ids
5768
)
5869
sa_session.commit()
5970

lib/galaxy/managers/roles.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,21 @@ def get(self, trans: ProvidesUserContext, role_id: int) -> model.Role:
6666

6767
return role
6868

69-
def list_displayable_roles(self, trans: ProvidesUserContext) -> list[Role]:
70-
return get_displayable_roles(trans.sa_session, trans.user, trans.user_is_admin, trans.app.security_agent)
69+
def list_displayable_roles(
70+
self,
71+
trans: ProvidesUserContext,
72+
search: str | None = None,
73+
limit: int | None = None,
74+
offset: int = 0,
75+
) -> list[Role]:
76+
return get_displayable_roles(
77+
trans.sa_session,
78+
trans.user,
79+
trans.user_is_admin,
80+
search=search,
81+
limit=limit,
82+
offset=offset,
83+
)
7184

7285
def create_role(self, trans: ProvidesUserContext, role_definition_model: RoleDefinitionModel) -> model.Role:
7386
name = role_definition_model.name

0 commit comments

Comments
 (0)