Improve the UX when creating groups as a supervisor ()

The main problem was that the supervisor did not get enough information about each project, mainly who the authors were, when selecting them in the dropdown.

To remedy this, the dropdown has been completely replaced with a checkbox based approach showing the title as well as project type, authors, and start date for each project. The projects are sorted first by start date (descending) and then title, based on the assumptions that newly created projects are the most relevant when setting up groups.

In addition extra "quick buttons" have been added in an effort to reduce the number of clicks required to accomplish varying tasks.

Fixes 

## How to test
1. Log in as `evan@example.com`
2. Go to "My groups"
3. Click "Create new group"

Co-authored-by: Nico Athanassiadis <nico@dsv.su.se>
Reviewed-on: 
Reviewed-by: Nico Athanassiadis <nico@dsv.su.se>
Co-authored-by: Andreas Svanberg <andreass@dsv.su.se>
Co-committed-by: Andreas Svanberg <andreass@dsv.su.se>
This commit is contained in:
Andreas Svanberg 2025-03-05 11:01:37 +01:00 committed by Nico Athanassiadis
parent d008bec815
commit 1aa0a4e3ef
5 changed files with 201 additions and 184 deletions
test-data/src/main/java/se/su/dsv/scipro/testdata/populators
view/src
main
java/se/su/dsv/scipro/group
webapp/css
test/java/se/su/dsv/scipro/group

@ -0,0 +1,65 @@
package se.su.dsv.scipro.testdata.populators;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Service;
import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.system.ProjectType;
import se.su.dsv.scipro.system.User;
import se.su.dsv.scipro.testdata.BaseData;
import se.su.dsv.scipro.testdata.Factory;
import se.su.dsv.scipro.testdata.TestDataPopulator;
@Service
public class GroupCreationUXImprovement implements TestDataPopulator {
private static final String[] STUDENT_NAMES = { "Alice", "Bob", "Charlie", "David", "Emma" };
private final ProjectService projectService;
@Inject
public GroupCreationUXImprovement(ProjectService projectService) {
this.projectService = projectService;
}
@Override
public void populate(BaseData baseData, Factory factory) {
User supervisor = factory.createSupervisor("Evan");
List<User> students = createStudents(factory);
for (int i = 1; i <= 20; i++) {
projectService.save(createProject(baseData, i, supervisor, students));
}
}
private List<User> createStudents(Factory factory) {
return Arrays.stream(STUDENT_NAMES).map(factory::createAuthor).toList();
}
private Project createProject(BaseData baseData, int i, User supervisor, List<User> students) {
User author1 = students.get(i % students.size());
User author2 = students.get((i + 1) % students.size());
String title = "Test project " + i;
if (i % 6 == 0) {
title = title + " with a very long title that makes the project special";
}
ProjectType projectType =
switch (i % 3) {
case 1 -> baseData.magister();
case 2 -> baseData.master();
default -> baseData.bachelor();
};
return Project.builder()
.title(title)
.projectType(projectType)
.startDate(LocalDate.now())
.headSupervisor(supervisor)
.projectParticipants(Set.of(author1, author2))
.build();
}
}

@ -2,85 +2,50 @@
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:panel>
<div class="row">
<div class="col-lg-12">
<form wicket:id="form">
<div class="line-length-limit">
<form wicket:id="form">
<div class="row">
<div class="col-lg-12">
<div wicket:id="feedback"></div>
</div>
</div>
<div wicket:id="feedback"></div>
<div class="row">
<div class="col-lg-5 col-md-10">
<label wicket:for="title">Title: </label>
<input type="text" wicket:id="title" class="form-control">
</div>
</div>
<div class="mb-3">
<label wicket:for="title" class="form-label">Title</label>
<input type="text" wicket:id="title" class="form-control">
</div>
<div class="row">
<div class="col-lg-5 col-md-10">
<label wicket:for="description">Description: </label>
<textarea wicket:id="description" class="form-control"></textarea>
</div>
</div>
<div class="mb-3">
<label wicket:for="description" class="form-label">Description</label>
<textarea wicket:id="description" class="form-control"></textarea>
</div>
<div class="row">
<div class="col-lg-5 col-md-10">
<div class="form-check">
<input class="form-check-input" wicket:id="active" type="checkbox"/>
<label class="form-check-label" wicket:for="active">Active</label>
<div class="form-check mb-3">
<input class="form-check-input" wicket:id="active" type="checkbox"/>
<label class="form-check-label" wicket:for="active">Active</label>
</div>
<fieldset class="mb-3">
<legend>Projects</legend>
<div class="group-project-grid">
<label wicket:id="available_projects">
<div>
<input class="form-check-input mt-0" type="checkbox" wicket:id="selected">
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<div wicket:id="wmc">
<div class="row">
<div class="col-lg-5 col-md-10">
<strong>Add projects to group: </strong>
<div wicket:id="projectTypes"></div>
<select class="form-select" wicket:id="addProjects"></select>
</div>
<div>
<h4 wicket:id="title"></h4>
<span wicket:id="type"></span>
<br>
Started at <span wicket:id="start_date"></span>
<div wicket:id="authors">
<span wicket:id="author"></span>
</div>
<div class="row">
<div class="col-lg-12">
<strong>Projects in group: </strong>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Type</th>
<th>Title</th>
<th>Authors</th>
<th>Remove</th>
</tr>
</thead>
<tbody>
<tr wicket:id="projects">
<td><span wicket:id="type"></span></td>
<td><span wicket:id="title"></span></td>
<td><div wicket:id="authors">
<div wicket:id="author"></div>
</div></td>
<td><a wicket:id="remove"><span class="fa fa-times"></span></a></td>
</tr>
</tbody>
</table>
<div wicket:id="noProjects"></div>
</div>
</div>
</div>
</div>
</label>
</div>
<br>
<button type="submit" class="btn btn-success" >Save</button>
</form>
</div>
</fieldset>
<button type="submit" class="btn btn-success">Save</button>
<button type="submit" wicket:id="save_and_close" class="btn btn-success">Save and close</button>
<button type="submit" wicket:id="save_and_create" class="btn btn-success">Save and create another</button>
<a wicket:id="cancel" class="btn btn-outline-secondary">Cancel</a>
</form>
</div>
</wicket:panel>
</body>

@ -2,11 +2,10 @@ package se.su.dsv.scipro.group;
import jakarta.inject.Inject;
import java.util.*;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.extensions.model.AbstractCheckBoxModel;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.*;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.markup.html.list.ListItem;
import org.apache.wicket.markup.html.list.ListView;
import org.apache.wicket.markup.html.panel.FeedbackPanel;
@ -14,10 +13,6 @@ import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LambdaModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.model.Model;
import org.apache.wicket.model.util.ListModel;
import se.su.dsv.scipro.components.AjaxCheckBoxMultipleChoice;
import se.su.dsv.scipro.components.AjaxDropDownChoice;
import se.su.dsv.scipro.components.ListAdapterModel;
import se.su.dsv.scipro.profile.UserLinkPanel;
import se.su.dsv.scipro.project.Project;
@ -25,8 +20,8 @@ import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.project.ProjectStatus;
import se.su.dsv.scipro.project.ProjectTeamMemberRoles;
import se.su.dsv.scipro.session.SciProSession;
import se.su.dsv.scipro.system.ProjectType;
import se.su.dsv.scipro.system.ProjectTypeService;
import se.su.dsv.scipro.supervisor.pages.SupervisorEditGroupPage;
import se.su.dsv.scipro.supervisor.pages.SupervisorMyGroupsPage;
import se.su.dsv.scipro.system.User;
public class EditGroupPanel extends Panel {
@ -37,9 +32,6 @@ public class EditGroupPanel extends Panel {
@Inject
private GroupService groupService;
@Inject
private ProjectTypeService projectTypeService;
public EditGroupPanel(String id, final IModel<Group> model) {
super(id, model);
add(new GroupForm("form", model));
@ -47,18 +39,46 @@ public class EditGroupPanel extends Panel {
private class GroupForm extends Form<Group> {
private final AjaxDropDownChoice<Project> addProjects;
private final ListView<Project> projects;
private final List<Project> currentProjects;
private final AjaxCheckBoxMultipleChoice<ProjectType> projectTypes;
public GroupForm(String form, final IModel<Group> model) {
super(form, model);
final FeedbackPanel feedbackPanel = new FeedbackPanel("feedback");
feedbackPanel.setOutputMarkupId(true);
add(feedbackPanel);
currentProjects = new ArrayList<>(getModelObject().getProjects());
IModel<List<Project>> availableProjects = LoadableDetachableModel.of(() -> {
Set<Project> projects = new HashSet<>();
projects.addAll(getAllRelevantProjects());
// Have to add the projects that are already in the group to the list of available projects
// since they may not be included in the relevant projects if they're inactive or completed.
// To allow them to be removed from the group, it will not be possible to add them again.
projects.addAll(model.getObject().getProjects());
return projects
.stream()
.sorted(Comparator.comparing(Project::getStartDate).reversed().thenComparing(Project::getTitle))
.toList();
});
add(
new ListView<>("available_projects", availableProjects) {
@Override
protected void populateItem(ListItem<Project> item) {
CheckBox checkbox = new CheckBox("selected", new SelectProjectModel(model, item.getModel()));
checkbox.setOutputMarkupId(true);
item.add(checkbox);
item.add(new Label("title", item.getModel().map(Project::getTitle)));
item.add(new Label("type", item.getModel().map(Project::getProjectTypeName)));
item.add(new Label("start_date", item.getModel().map(Project::getStartDate)));
IModel<SortedSet<User>> authors = item.getModel().map(Project::getProjectParticipants);
item.add(
new ListView<>("authors", new ListAdapterModel<>(authors)) {
@Override
protected void populateItem(ListItem<User> item) {
item.add(new UserLinkPanel("author", item.getModel()));
}
}
);
}
}
);
add(new RequiredTextField<>("title", LambdaModel.of(model, Group::getTitle, Group::setTitle)));
add(new TextArea<>("description", LambdaModel.of(model, Group::getDescription, Group::setDescription)));
@ -66,120 +86,64 @@ public class EditGroupPanel extends Panel {
new CheckBox("active", LambdaModel.of(model, Group::isActive, Group::setActive)).setOutputMarkupId(true)
);
final WebMarkupContainer wmc = new WebMarkupContainer("wmc");
wmc.setOutputMarkupId(true);
projectTypes = projectTypeSelection(wmc);
wmc.add(projectTypes);
addProjects = new AjaxDropDownChoice<>(
"addProjects",
new Model<>(),
getSelectableProjects(currentProjects),
new LambdaChoiceRenderer<>(Project::getTitle, Project::getId)
) {
@Override
public void onNewSelection(AjaxRequestTarget target, Project objectSelected) {
if (objectSelected != null && !currentProjects.contains(objectSelected)) {
currentProjects.add(objectSelected);
projects.setList(currentProjects);
addProjects.setChoices(getSelectableProjects(currentProjects));
target.add(wmc);
}
}
};
addProjects.setRequired(false);
addProjects.setNullValid(true);
wmc.add(addProjects);
projects = new ListView<>("projects", new ArrayList<>(currentProjects)) {
@Override
protected void populateItem(final ListItem<Project> item) {
item.add(new Label("type", item.getModel().map(Project::getProjectTypeName)));
item.add(new Label("title", item.getModel().map(Project::getTitle)));
item.add(
new ListView<>(
"authors",
new ListAdapterModel<>(
getLoaded(item.getModelObject()).map(Project::getProjectParticipants)
)
) {
@Override
public void populateItem(ListItem<User> item) {
item.add(new UserLinkPanel("author", item.getModel()));
}
}
);
item.add(
new AjaxLink<>("remove", item.getModel()) {
@Override
public void onClick(AjaxRequestTarget target) {
currentProjects.remove(item.getModelObject());
projects.setList(currentProjects);
addProjects.setChoices(getSelectableProjects(currentProjects));
target.add(wmc);
}
}
);
}
};
wmc.add(projects);
wmc.add(
new Label("noProjects", "None") {
add(
new SubmitLink("save_and_close") {
@Override
protected void onConfigure() {
super.onConfigure();
setVisibilityAllowed(currentProjects.isEmpty());
public void onAfterSubmit() {
setResponsePage(SupervisorMyGroupsPage.class);
}
}
);
add(wmc);
}
private AjaxCheckBoxMultipleChoice<ProjectType> projectTypeSelection(final WebMarkupContainer wmc) {
return new AjaxCheckBoxMultipleChoice<>(
"projectTypes",
projectTypeService.findAllActive(),
projectTypeService.findAllActive(),
new LambdaChoiceRenderer<>(ProjectType::getName, ProjectType::getId)
) {
@Override
public void onUpdate(AjaxRequestTarget target) {
addProjects.setChoices(getSelectableProjects(currentProjects));
target.add(wmc);
add(
new SubmitLink("save_and_create") {
@Override
public void onAfterSubmit() {
setResponsePage(SupervisorEditGroupPage.class);
}
}
};
);
add(new BookmarkablePageLink<>("cancel", SupervisorMyGroupsPage.class));
}
@Override
protected void onSubmit() {
Group group = getModelObject();
group.setProjects(new HashSet<>(currentProjects));
groupService.save(group);
info(getString("saved"));
}
private ListModel<Project> getSelectableProjects(List<Project> currentProjects) {
private List<Project> getAllRelevantProjects() {
final ProjectService.Filter filter = new ProjectService.Filter();
filter.setSupervisor(SciProSession.get().getUser());
filter.setRoles(Collections.singleton(ProjectTeamMemberRoles.CO_SUPERVISOR));
filter.setStatuses(Collections.singletonList(ProjectStatus.ACTIVE));
filter.setProjectTypes(projectTypes.getModelObject());
List<Project> all = projectService.findAll(filter);
all.removeAll(currentProjects);
all.remove(null);
return new ListModel<>(all);
return projectService.findAll(filter);
}
private LoadableDetachableModel<Project> getLoaded(final Project project) {
return new LoadableDetachableModel<>() {
@Override
protected Project load() {
return projectService.findOne(project.getId());
}
};
private static final class SelectProjectModel extends AbstractCheckBoxModel {
private final IModel<Group> groupModel;
private final IModel<Project> projectModel;
public SelectProjectModel(IModel<Group> groupModel, IModel<Project> projectModel) {
this.groupModel = groupModel;
this.projectModel = projectModel;
}
@Override
public boolean isSelected() {
return groupModel.getObject().getProjects().contains(projectModel.getObject());
}
@Override
public void select() {
groupModel.getObject().getProjects().add(projectModel.getObject());
}
@Override
public void unselect() {
groupModel.getObject().getProjects().remove(projectModel.getObject());
}
}
}
}

@ -607,3 +607,27 @@ th.wicket_orderUp, th.sorting_asc {
.line-length-limit {
max-width: 80em;
}
.group-project-grid {
display: flex;
flex-wrap: wrap;
gap: 1em;
grid-template-columns: repeat(auto-fill, minmax(30em, 1fr));
}
.group-project-grid > * {
background: linear-gradient(to left, white 40%, var(--bs-success-bg-subtle) 60%) right;
background-size: 250% 100%;
transition: background 0.4s ease;
cursor: pointer;
border: 1px solid black;
border-radius: 0.25em;
display: flex;
padding: 0.5em;
align-items: center;
flex-grow: 1;
}
.group-project-grid > *:has(:checked) {
background-position: left;
}
.group-project-grid label {
font-weight: normal;
}

@ -27,7 +27,6 @@ public class EditGroupPanelTest extends SciProTest {
group.setId(1L);
Project project = createProject();
group.setProjects(new HashSet<>(Collections.singletonList(project)));
when(projectService.findOne(anyLong())).thenReturn(project);
startPanel();
}