Improve the UX when creating groups as a supervisor #123
65
test-data/src/main/java/se/su/dsv/scipro/testdata/populators/GroupCreationUXImprovement.java
vendored
Normal file
65
test-data/src/main/java/se/su/dsv/scipro/testdata/populators/GroupCreationUXImprovement.java
vendored
Normal file
@ -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();
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user