Allow supervisors to write a note associated with their projects #8

Merged
ansv7779 merged 10 commits from 3399-supervisor-project-note into develop 2024-07-10 13:43:27 +02:00
19 changed files with 318 additions and 1 deletions

View File

@ -48,6 +48,7 @@ import se.su.dsv.scipro.notifications.settings.service.ReceiverConfigurationServ
import se.su.dsv.scipro.notifications.settings.service.ReceiverConfigurationServiceImpl;
import se.su.dsv.scipro.peer.*;
import se.su.dsv.scipro.plagiarism.*;
import se.su.dsv.scipro.project.ProjectNoteService;
import se.su.dsv.scipro.project.ProjectPeopleStatisticsService;
import se.su.dsv.scipro.project.ProjectPeopleStatisticsServiceImpl;
import se.su.dsv.scipro.project.ProjectService;
@ -139,6 +140,7 @@ public class CoreModule extends AbstractModule {
bind(FirstMeetingService.class).to(FirstMeetingServiceImpl.class);
bind(FinalSeminarCreationSubscribers.class).asEagerSingleton();
bind(ProjectPartnerRepository.class).to(ProjectPartnerRepositoryImpl.class);
bind(ProjectNoteService.class).to(ProjectServiceImpl.class);
install(new PlagiarismModule());
install(new NotificationModule());

View File

@ -85,6 +85,13 @@ public class Project extends DomainObject {
@Enumerated(EnumType.STRING)
private Language language;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "project_user_note", joinColumns = @JoinColumn(name = "project_id"))
@Column(name = "note")
@SuppressWarnings("JpaDataSourceORMInspection") // false warning from IntelliJ for the @MapKeyJoinColumn
@MapKeyJoinColumn(name = "user_id")
private Map<User, String> userNotes = new HashMap<>();
@PrePersist
@PreUpdate
void cleanTitle() {
@ -94,6 +101,14 @@ public class Project extends DomainObject {
title = title.trim();
}
public Map<User, String> getUserNotes() {
return userNotes;
}
public void setUserNotes(Map<User, String> userNotes) {
this.userNotes = userNotes;
}
public boolean isFinalSeminarRuleExempted() {
return finalSeminarRuleExempted;
}

View File

@ -0,0 +1,9 @@
package se.su.dsv.scipro.project;
import se.su.dsv.scipro.system.User;
public interface ProjectNoteService {
String getUserNote(Project project, User user);
void setUserNote(Project project, User user, String note);
}

View File

@ -11,4 +11,8 @@ import java.util.List;
@Transactional
public interface ProjectRepo extends JpaRepository<Project, Long>, QueryDslPredicateExecutor<Project> {
List<User> findMultipleAuthors(Collection<Project> projects);
String getUserNoteForProject(Project project, User user);
void setUserNoteForProject(Project project, User user, String note);
}

View File

@ -1,5 +1,6 @@
package se.su.dsv.scipro.project;
import com.google.inject.persist.Transactional;
import se.su.dsv.scipro.system.GenericRepo;
import se.su.dsv.scipro.system.User;
@ -30,4 +31,16 @@ public class ProjectRepoImpl extends GenericRepo<Project, Long> implements Proje
query.setParameter("projects", projects);
return query.getResultList();
}
@Override
public String getUserNoteForProject(Project project, User user) {
return project.getUserNotes().get(user);
}
@Override
@Transactional
public void setUserNoteForProject(Project project, User user, String note) {
project.getUserNotes().put(user, note);
save(project);
}
}

View File

@ -22,7 +22,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.*;
public class ProjectServiceImpl extends AbstractServiceImpl<Project, Long> implements ProjectService {
public class ProjectServiceImpl extends AbstractServiceImpl<Project, Long> implements ProjectService, ProjectNoteService {
public static final int MIN_TITLE_LENGTH = 3;
private final ProjectRepo projectRepo;
@ -175,6 +175,16 @@ public class ProjectServiceImpl extends AbstractServiceImpl<Project, Long> imple
return completed;
}
@Override
public String getUserNote(Project project, User user) {
return projectRepo.getUserNoteForProject(project, user);
}
@Override
public void setUserNote(Project project, User user, String note) {
projectRepo.setUserNoteForProject(project, user, note);
}
@Override
public List<Project> findAll(Filter filter, Pageable pageable) {
return findAll(toPredicate(filter), pageable);

View File

@ -0,0 +1,5 @@
package se.su.dsv.scipro.settings.dataobjects;
public enum SupervisorProjectNoteDisplay {
COMPACT, FULL
}

View File

@ -58,6 +58,11 @@ public class UserProfile extends DomainObject {
@Enumerated(EnumType.STRING)
private Roles selectedRole;
@Basic
@Enumerated(EnumType.STRING)
@Column(name = "supervisor_project_note_display")
private SupervisorProjectNoteDisplay supervisorProjectNoteDisplay = SupervisorProjectNoteDisplay.COMPACT;
@Override
public Long getId() {
return this.id;
@ -147,6 +152,14 @@ public class UserProfile extends DomainObject {
this.selectedRole = selectedRole;
}
public SupervisorProjectNoteDisplay getSupervisorProjectNoteDisplay() {
return supervisorProjectNoteDisplay;
}
public void setSupervisorProjectNoteDisplay(SupervisorProjectNoteDisplay supervisorProjectNoteDisplay) {
this.supervisorProjectNoteDisplay = supervisorProjectNoteDisplay;
}
@Override
public String toString() {
return "UserProfile(id=" + this.getId() + ", user=" + this.getUser() + ", skypeId=" + this.getSkypeId() + ", phoneNumber=" + this.getPhoneNumber() + ", otherInfo=" + this.getOtherInfo() + ", mailCompilation=" + this.isMailCompilation() + ", defaultProjectStatusFilter=" + this.getDefaultProjectStatusFilter() + ", defaultProjectTeamMemberRolesFilter=" + this.getDefaultProjectTeamMemberRolesFilter() + ", defaultSupervisorFilter=" + this.isDefaultSupervisorFilter() + ", defaultProjectTypeFilter=" + this.getDefaultProjectTypeFilter() + ", selectedRole=" + this.getSelectedRole() + ")";

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `project_user_note` (
`project_id` bigint NOT NULL,
`user_id` bigint NOT NULL,
`note` text NULL,
PRIMARY KEY (`project_id`, `user_id`),
CONSTRAINT `FK_project_user_note_project` FOREIGN KEY (`project_id`) REFERENCES `project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `FK_project_user_note_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
);
ALTER TABLE `user_profile`
ADD COLUMN `supervisor_project_note_display` VARCHAR(15) NOT NULL DEFAULT 'COMPACT';

View File

@ -0,0 +1,37 @@
package se.su.dsv.scipro.components;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.model.IModel;
import org.apache.wicket.util.convert.IConverter;
import java.util.Locale;
public class MaxLengthLabel extends Label {
private final IModel<Integer> maxLengthModel;
public MaxLengthLabel(String id, IModel<String> dataModel, IModel<Integer> maxLength) {
super(id, dataModel);
this.maxLengthModel = maxLength;
}
@Override
protected IConverter<?> createConverter(Class<?> type) {
return new MaxLengthConverter();
}
private class MaxLengthConverter implements IConverter<String> {
@Override
public String convertToObject(String s, Locale locale) {
return s;
}
@Override
public String convertToString(String o, Locale locale) {
Integer maxLength = maxLengthModel.getObject();
if (o.length() > maxLength) {
return o.substring(0, maxLength) + "...";
}
return o;
}
}
}

View File

@ -1,6 +1,7 @@
package se.su.dsv.scipro.components;
import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxEventBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.behavior.AttributeAppender;
import org.apache.wicket.markup.ComponentTag;
@ -9,6 +10,7 @@ import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.Model;
import org.danekja.java.util.function.serializable.SerializableConsumer;
import java.util.function.Function;
@ -83,4 +85,13 @@ public class ModalWindowPlus extends Panel {
component.setOutputMarkupPlaceholderTag(true);
replace(component);
}
public void onClose(SerializableConsumer<AjaxRequestTarget> onClose) {
add(new AjaxEventBehavior("hidden.bs.modal") {
@Override
protected void onEvent(AjaxRequestTarget target) {
onClose.accept(target);
}
});
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:panel>
<div wicket:id="edit_note_modal"></div>
<span wicket:id="shortened_note"></span>
<wicket:container wicket:id="full_note"></wicket:container>
<a wicket:id="view_note"><wicket:message key="note.view.edit"/></a>
</wicket:panel>
</body>
</html>

View File

@ -0,0 +1,2 @@
note.modal.title=View/edit note for ${title}
note.view.edit=View/edit note

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<body>
<wicket:panel>
<form wicket:id="form">
<div wicket:id="feedback"></div>
<div class="mb-3">
<label wicket:for="note" class="sr-only">Note</label>
<textarea wicket:id="note" rows="20" class="form-control"></textarea>
</div>
<button wicket:id="save" type="submit" class="btn btn-primary">Save</button>
</form>
</wicket:panel>
</body>
</html>

View File

@ -0,0 +1,129 @@
package se.su.dsv.scipro.supervisor.panels;
import jakarta.inject.Inject;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.ajax.markup.html.form.AjaxSubmitLink;
import org.apache.wicket.extensions.markup.html.repeater.data.grid.ICellPopulator;
import org.apache.wicket.extensions.markup.html.repeater.data.table.export.AbstractExportableColumn;
import org.apache.wicket.feedback.FencedFeedbackPanel;
import org.apache.wicket.injection.Injector;
import org.apache.wicket.markup.html.basic.MultiLineLabel;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.form.TextArea;
import org.apache.wicket.markup.html.panel.GenericPanel;
import org.apache.wicket.markup.repeater.Item;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.LoadableDetachableModel;
import org.apache.wicket.model.Model;
import org.apache.wicket.model.StringResourceModel;
import se.su.dsv.scipro.components.LargeModalWindow;
import se.su.dsv.scipro.components.MaxLengthLabel;
import se.su.dsv.scipro.components.ModalWindowPlus;
import se.su.dsv.scipro.project.Project;
import se.su.dsv.scipro.project.ProjectNoteService;
import se.su.dsv.scipro.settings.dataobjects.SupervisorProjectNoteDisplay;
import se.su.dsv.scipro.system.User;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class ProjectNoteColumn extends AbstractExportableColumn<Project, String> {
@Inject
private ProjectNoteService projectNoteService;
private final IModel<User> user;
private final IModel<SupervisorProjectNoteDisplay> supervisorProjectNoteDisplayModel;
public ProjectNoteColumn(IModel<String> displayModel, IModel<User> user,
IModel<SupervisorProjectNoteDisplay> supervisorProjectNoteDisplayModel) {
super(displayModel);
Injector.get().inject(this);
this.supervisorProjectNoteDisplayModel = supervisorProjectNoteDisplayModel;
this.user = user;
}
@Override
public IModel<String> getDataModel(IModel<Project> rowModel) {
return LoadableDetachableModel.of(() -> projectNoteService.getUserNote(
rowModel.getObject(),
user.getObject()));
}
@Override
public void populateItem(
Item<ICellPopulator<Project>> cellItem,
String componentId,
IModel<Project> rowModel)
{
cellItem.add(new ViewAndEditNoteCellPanel(componentId, rowModel));
}
private class ViewAndEditNoteCellPanel extends GenericPanel<Project> {
public ViewAndEditNoteCellPanel(String id, IModel<Project> model) {
super(id, model);
ansv7779 marked this conversation as resolved
Review

Use of String.format could work here?
Example
modal.setTitle(model.map(Project::getTitle).map(projectTitle -> String.format("View/Edit note for %s", projectTitle)));

What do you think?

Use of String.format could work here? Example ```modal.setTitle(model.map(Project::getTitle).map(projectTitle -> String.format("View/Edit note for %s", projectTitle)));``` What do you think?
Review

Replaced with proper i18n

Replaced with proper i18n
ModalWindowPlus modal = new LargeModalWindow("edit_note_modal");
modal.setTitle(new StringResourceModel("note.modal.title", this, model));
modal.setContent(componentId -> new ViewAndEditNoteForm(componentId, model));
add(modal);
setOutputMarkupId(true);
modal.onClose(target -> target.add(this));
add(new MaxLengthLabel("shortened_note", getDataModel(model), Model.of(100)) {
@Override
protected void onConfigure() {
super.onConfigure();
setVisibilityAllowed(supervisorProjectNoteDisplayModel.getObject() == SupervisorProjectNoteDisplay.COMPACT);
}
});
add(new MultiLineLabel("full_note", getDataModel(model)) {
@Override
protected void onConfigure() {
super.onConfigure();
setVisibilityAllowed(supervisorProjectNoteDisplayModel.getObject() == SupervisorProjectNoteDisplay.FULL);
}
});
AjaxLink<Object> noteLink = new AjaxLink<>("view_note") {
@Override
public void onClick(AjaxRequestTarget target) {
modal.show(target);
}
};
add(noteLink);
}
}
private class ViewAndEditNoteForm extends GenericPanel<Project> {
ansv7779 marked this conversation as resolved
Review

Rename model.getObject to projectModel.getObject ?

Would make it more readable and conform with the projectNoteService.setUserNote method.

Rename model.getObject to projectModel.getObject ? Would make it more readable and conform with the projectNoteService.setUserNote method.
public ViewAndEditNoteForm(String id, IModel<Project> project) {
super(id, project);
IModel<String> note = getDataModel(project);
Form<Project> form = new Form<>("form", project) {
@Override
protected void onSubmit() {
projectNoteService.setUserNote(
project.getObject(),
user.getObject(),
note.getObject()
);
success("Note saved at " + LocalTime.now().truncatedTo(ChronoUnit.SECONDS));
}
};
add(form);
form.add(new FencedFeedbackPanel("feedback", form));
form.add(new TextArea<>("note", note));
form.add(new AjaxSubmitLink("save") {
@Override
protected void onAfterSubmit(AjaxRequestTarget target) {
target.add(form);
}
});
}
}
}

View File

@ -31,6 +31,10 @@
</strong>
<div wicket:id="projectTypes"></div>
</div>
<fieldset class="col-6 col-md-3 col-lg-2">
<legend>Note</legend>
<div wicket:id="note_display"></div>
</fieldset>
</div>
</form>
</div>

View File

@ -1,6 +1,7 @@
package se.su.dsv.scipro.supervisor.panels;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.form.AjaxFormChoiceComponentUpdatingBehavior;
import org.apache.wicket.ajax.markup.html.form.AjaxCheckBox;
import org.apache.wicket.extensions.markup.html.repeater.data.grid.ICellPopulator;
import org.apache.wicket.extensions.markup.html.repeater.data.sort.SortOrder;
@ -15,6 +16,7 @@ import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.markup.repeater.Item;
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 se.su.dsv.scipro.components.*;
import se.su.dsv.scipro.components.datatables.MultipleUsersColumn;
@ -30,6 +32,7 @@ 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.settings.dataobjects.SupervisorProjectNoteDisplay;
import se.su.dsv.scipro.settings.dataobjects.UserProfile;
import se.su.dsv.scipro.springdata.services.UserProfileService;
import se.su.dsv.scipro.system.ProjectType;
@ -62,6 +65,7 @@ public class SupervisorMyProjectsPanel extends Panel {
private ExportableDataPanel dataPanel;
private ProjectService.Filter filter = new ProjectService.Filter();
private IModel<SupervisorProjectNoteDisplay> supervisorProjectNoteDisplayModel = new Model<>();
public SupervisorMyProjectsPanel(String id) {
super(id);
@ -90,6 +94,7 @@ public class SupervisorMyProjectsPanel extends Panel {
return new ListAdapterModel<>(rowModel.map(Project::getProjectParticipants));
}
});
columns.add(new ProjectNoteColumn(Model.of("Note"), LoadableDetachableModel.of(this::currentUser), supervisorProjectNoteDisplayModel));
columns.add(new UserColumn<>(Model.of("Head supervisor"), "headSupervisor.fullName", Project::getHeadSupervisor));
return columns;
}
@ -112,6 +117,7 @@ public class SupervisorMyProjectsPanel extends Panel {
filter.setRoles(userProfile.getDefaultProjectTeamMemberRolesFilter());
filter.setFilterSupervisor(userProfile.isDefaultSupervisorFilter());
filter.setProjectTypes(userProfile.getDefaultProjectTypeFilter());
supervisorProjectNoteDisplayModel.setObject(userProfile.getSupervisorProjectNoteDisplay());
}
private User currentUser() {
@ -155,6 +161,19 @@ public class SupervisorMyProjectsPanel extends Panel {
updateProfileWithCurrentFilter();
}
});
BootstrapRadioChoice<SupervisorProjectNoteDisplay> noteDisplay = new BootstrapRadioChoice<>(
"note_display",
supervisorProjectNoteDisplayModel,
List.of(SupervisorProjectNoteDisplay.values()),
new EnumChoiceRenderer<>(this));
noteDisplay.add(new AjaxFormChoiceComponentUpdatingBehavior() {
@Override
public void onUpdate(AjaxRequestTarget target) {
target.add(dataPanel);
updateProfileWithCurrentFilter();
}
});
add(noteDisplay);
}
private void updateProfileWithCurrentFilter() {
@ -163,6 +182,7 @@ public class SupervisorMyProjectsPanel extends Panel {
userProfile.setDefaultProjectTeamMemberRolesFilter(filter.getRoles());
userProfile.setDefaultSupervisorFilter(filter.isFilterSupervisor());
userProfile.setDefaultProjectTypeFilter(filter.getProjectTypes());
userProfile.setSupervisorProjectNoteDisplay(supervisorProjectNoteDisplayModel.getObject());
profileService.save(userProfile);
}
}

View File

@ -9,3 +9,6 @@ ProjectTeamMemberRoles.CO_SUPERVISOR= Co-Supervisor
ProjectStatus.ACTIVE= Active
ProjectStatus.INACTIVE= Inactive
ProjectStatus.COMPLETED= Completed
SupervisorProjectNoteDisplay.COMPACT=Compact
SupervisorProjectNoteDisplay.FULL=Full

View File

@ -87,6 +87,7 @@ import se.su.dsv.scipro.peer.PerformReviewService;
import se.su.dsv.scipro.plagiarism.PlagiarismControl;
import se.su.dsv.scipro.plagiarism.urkund.UrkundService;
import se.su.dsv.scipro.profiles.CurrentProfile;
import se.su.dsv.scipro.project.ProjectNoteService;
import se.su.dsv.scipro.project.ProjectPeopleStatisticsService;
import se.su.dsv.scipro.project.ProjectService;
import se.su.dsv.scipro.project.pages.ProjectStartPage;
@ -355,6 +356,8 @@ public abstract class SciProTest {
protected ExaminerTimelineService examinerTimelineService;
@Mock
protected NationalSubjectCategoryService nationalSubjectCategoryService;
@Mock
protected ProjectNoteService projectNoteService;
protected WicketTester tester;