MVC based UI for Content Section management

Jens Pelzetter 2021-01-18 21:38:52 +01:00
parent 7a630d0258
commit dd8d8d1aac
10 changed files with 339 additions and 35 deletions

View File

@ -62,12 +62,14 @@ import static org.librecms.CmsConstants.*;
@NamedQueries({ @NamedQueries({
@NamedQuery( @NamedQuery(
name = "ContentSection.findById", name = "ContentSection.findById",
query = "SELECT S FROM ContentSection s WHERE s.objectId = :objectId") query = "SELECT s FROM ContentSection s WHERE s.objectId = :objectId"),
, @NamedQuery(
name = "ContentSection.findByUuid",
query = "SELECT s FROM ContentSection s WHERE s.uuid = :uuid"
),
@NamedQuery( @NamedQuery(
name = "ContentSection.findByLabel", name = "ContentSection.findByLabel",
query = "SELECT s FROM ContentSection s WHERE s.label = :label") query = "SELECT s FROM ContentSection s WHERE s.label = :label"),
,
@NamedQuery( @NamedQuery(
name = "ContentSection.findUsableContentTypes", name = "ContentSection.findUsableContentTypes",
query = "SELECT t FROM ContentType t " query = "SELECT t FROM ContentType t "
@ -77,8 +79,7 @@ import static org.librecms.CmsConstants.*;
+ "WHERE p.grantedPrivilege = '" + "WHERE p.grantedPrivilege = '"
+ TypePrivileges.USE_TYPE + "' " + TypePrivileges.USE_TYPE + "' "
+ "AND p.grantee in :roles) " + "AND p.grantee in :roles) "
+ "OR true = :isSysAdmin)") + "OR true = :isSysAdmin)"),
,
@NamedQuery( @NamedQuery(
name = "ContentSection.countUsableContentTypes", name = "ContentSection.countUsableContentTypes",
query = "SELECT COUNT(t) FROM ContentType t " query = "SELECT COUNT(t) FROM ContentType t "
@ -88,8 +89,7 @@ import static org.librecms.CmsConstants.*;
+ "WHERE p.grantedPrivilege = '" + "WHERE p.grantedPrivilege = '"
+ TypePrivileges.USE_TYPE + "' " + TypePrivileges.USE_TYPE + "' "
+ "AND p.grantee IN :roles) " + "AND p.grantee IN :roles) "
+ "OR true = :isSysAdmin)") + "OR true = :isSysAdmin)"),
,
@NamedQuery( @NamedQuery(
name = "ContentSection.hasUsableContentTypes", name = "ContentSection.hasUsableContentTypes",
query = "SELECT (CASE WHEN COUNT(t) > 0 THEN true ELSE false END)" query = "SELECT (CASE WHEN COUNT(t) > 0 THEN true ELSE false END)"
@ -100,8 +100,7 @@ import static org.librecms.CmsConstants.*;
+ "WHERE p.grantedPrivilege = '" + "WHERE p.grantedPrivilege = '"
+ TypePrivileges.USE_TYPE + "' " + TypePrivileges.USE_TYPE + "' "
+ "AND p.grantee IN :roles) " + "AND p.grantee IN :roles) "
+ "OR true = :isSysAdmin)") + "OR true = :isSysAdmin)"),
,
@NamedQuery( @NamedQuery(
name = "ContentSection.findPermissions", name = "ContentSection.findPermissions",
query = "SELECT p FROM Permission p " query = "SELECT p FROM Permission p "
@ -117,19 +116,26 @@ import static org.librecms.CmsConstants.*;
// creator = ContentSectionCreator.class, // creator = ContentSectionCreator.class,
// servlet = ContentSectionServlet.class, // servlet = ContentSectionServlet.class,
// instanceForm = ApplicationInstanceForm.class) // instanceForm = ApplicationInstanceForm.class)
public class ContentSection public class ContentSection
extends CcmApplication extends CcmApplication
implements Serializable, Exportable { implements Serializable, Exportable {
private static final long serialVersionUID = -671718122153931727L; private static final long serialVersionUID = -671718122153931727L;
protected static final String ROOT = "root"; protected static final String ROOT = "root";
protected static final String ASSETS = "assets"; protected static final String ASSETS = "assets";
protected static final String ALERT_RECIPIENT = "alert_recipient"; protected static final String ALERT_RECIPIENT = "alert_recipient";
protected static final String AUTHOR = "author"; protected static final String AUTHOR = "author";
protected static final String EDITOR = "editor"; protected static final String EDITOR = "editor";
protected static final String MANAGER = "manager"; protected static final String MANAGER = "manager";
protected static final String PUBLISHER = "publisher"; protected static final String PUBLISHER = "publisher";
protected static final String CONTENT_READER = "content_reader"; protected static final String CONTENT_READER = "content_reader";
@Column(name = "LABEL", length = 512) @Column(name = "LABEL", length = 512)

View File

@ -55,6 +55,8 @@ import org.librecms.contentsection.privileges.TypePrivileges;
import org.librecms.dispatcher.ItemResolver; import org.librecms.dispatcher.ItemResolver;
import java.util.Objects; import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.enterprise.inject.Instance; import javax.enterprise.inject.Instance;
@ -77,6 +79,9 @@ public class ContentSectionManager {
@Inject @Inject
private CategoryRepository categoryRepo; private CategoryRepository categoryRepo;
@Inject
private FolderRepository folderRepository;
@Inject @Inject
private RoleRepository roleRepo; private RoleRepository roleRepo;
@ -462,7 +467,7 @@ public class ContentSectionManager {
try { try {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
final Class<ItemResolver> itemResolverClazz final Class<ItemResolver> itemResolverClazz
= (Class<ItemResolver>) Class. = (Class<ItemResolver>) Class.
forName(section.getItemResolverClass()); forName(section.getItemResolverClass());
final Instance<ItemResolver> instance = itemResolvers.select( final Instance<ItemResolver> instance = itemResolvers.select(
@ -664,4 +669,41 @@ public class ContentSectionManager {
typeRepo.delete(contentType.get()); typeRepo.delete(contentType.get());
} }
@AuthorizationRequired
@RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN)
@Transactional(Transactional.TxType.REQUIRED)
public void deleteContentSection(final ContentSection section) {
Objects.requireNonNull(section);
try {
for (final ContentType type : section.getContentTypes()) {
@SuppressWarnings("unchecked")
final Class<? extends ContentItem> clazz
= (Class<? extends ContentItem>) Class.forName(
type.getContentItemClass()
);
removeContentTypeFromSection(clazz, section);
}
} catch (ClassNotFoundException ex) {
throw new UnexpectedErrorException(ex);
}
for (final LifecycleDefinition lifecycle : section
.getLifecycleDefinitions()) {
removeLifecycleDefinitionFromContentSection(
lifecycle, section
);
}
for (final Role role : section.getRoles()) {
removeRoleFromContentSection(section, role);
}
for (final Workflow workflow : section.getWorkflowTemplates()) {
removeWorkflowTemplateFromContentSection(workflow, section);
}
folderRepository.delete(section.getRootAssetsFolder());
folderRepository.delete(section.getRootDocumentsFolder());
sectionRepo.delete(section);
}
} }

View File

@ -41,6 +41,22 @@ public class ContentSectionRepository
private static final long serialVersionUID = 4616599498399330865L; private static final long serialVersionUID = 4616599498399330865L;
public Optional<ContentSection> findByUuid(final String uuid) {
try {
return Optional.of(
getEntityManager()
.createNamedQuery(
"ContentSection.findByUuid",
ContentSection.class
)
.setParameter("uuid", uuid)
.getSingleResult()
);
} catch (NoResultException ex) {
return Optional.empty();
}
}
public Optional<ContentSection> findByLabel(final String label) { public Optional<ContentSection> findByLabel(final String label) {
if (label == null || label.isEmpty()) { if (label == null || label.isEmpty()) {
throw new IllegalArgumentException( throw new IllegalArgumentException(

View File

@ -5,20 +5,33 @@
*/ */
package org.librecms.ui; package org.librecms.ui;
import org.libreccm.api.Identifier;
import org.libreccm.api.IdentifierParser;
import org.libreccm.core.CoreConstants;
import org.libreccm.security.AuthorizationRequired; import org.libreccm.security.AuthorizationRequired;
import org.libreccm.security.PermissionChecker;
import org.libreccm.security.RequiresPrivilege;
import org.libreccm.security.Shiro;
import org.librecms.contentsection.ContentSection;
import org.librecms.contentsection.ContentSectionManager;
import org.librecms.contentsection.ContentSectionRepository; import org.librecms.contentsection.ContentSectionRepository;
import org.librecms.contentsection.Folder;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.util.Objects;
import javax.enterprise.context.RequestScoped; import javax.enterprise.context.RequestScoped;
import javax.inject.Inject; import javax.inject.Inject;
import javax.mvc.Controller; import javax.mvc.Controller;
import javax.servlet.ServletContext; import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.transaction.Transactional;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.WebApplicationException; import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@ -34,11 +47,17 @@ public class ContentSectionsController {
@Inject @Inject
private ContentSectionRepository sectionRepo; private ContentSectionRepository sectionRepo;
@Inject
private ContentSectionManager sectionManager;
@Inject @Inject
private HttpServletRequest request; private HttpServletRequest request;
@Inject @Inject
private ServletContext servletContext; private IdentifierParser identifierParser;
@Inject
private PermissionChecker permissionChecker;
@GET @GET
@Path("/") @Path("/")
@ -95,13 +114,126 @@ public class ContentSectionsController {
Response.status(Response.Status.NOT_FOUND).build() Response.status(Response.Status.NOT_FOUND).build()
); );
} }
@POST @POST
@Path("/new") @Path("/new")
public String createContentSection() { @AuthorizationRequired
throw new WebApplicationException( @RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN)
Response.status(Response.Status.NOT_FOUND).build() @Transactional(Transactional.TxType.REQUIRED)
public String createContentSection(
@FormParam("sectionName") final String sectionName
) {
sectionManager.createContentSection(sectionName);
return "redirect:/list";
}
@POST
@Path("/{sectionIdentifier}/rename")
@AuthorizationRequired
@RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN)
@Transactional(Transactional.TxType.REQUIRED)
public String renameContentSection(
@PathParam("sectionIdentifier") final String identifierParam,
@FormParam("sectionName") final String sectionName
) {
final ContentSection section = findContentSection(identifierParam);
sectionManager.renameContentSection(section, sectionName);
return "redirect:list";
}
@POST
@Path("/{sectionIdentifier}/delete")
@AuthorizationRequired
@RequiresPrivilege(CoreConstants.PRIVILEGE_ADMIN)
@Transactional(Transactional.TxType.REQUIRED)
public String deleteContentSection(
@PathParam("sectionIdentifier") final String identifierParam,
@FormParam("confirmed") final String confirmed
) {
if (Objects.equals(confirmed, "true")) {
final ContentSection section = findContentSection(identifierParam);
if (!canDelete(section)) {
throw new WebApplicationException(
String.format(
"ContentSection %s is not empty and can't be deleted.",
section.getLabel()
),
Response.Status.BAD_REQUEST
);
}
sectionManager.deleteContentSection(section);
}
return "redirect:/list";
}
private ContentSection findContentSection(final String identifierParam) {
final Identifier identifier = identifierParser.parseIdentifier(
identifierParam
); );
final ContentSection section;
switch (identifier.getType()) {
case ID:
section = sectionRepo.findById(
Long.parseLong(identifier.getIdentifier())
).orElseThrow(
() -> new WebApplicationException(
String.format(
"No ContentSection identified by ID %s "
+ "available.",
identifierParam
),
Response.Status.NOT_FOUND
)
);
break;
case UUID:
section = sectionRepo
.findByUuid(identifier.getIdentifier())
.orElseThrow(
() -> new WebApplicationException(
String.format(
"No ContentSection identifed UUID %s "
+ "available.",
identifierParam
),
Response.Status.NOT_FOUND
)
);
break;
default:
section = sectionRepo
.findByLabel(identifier.getIdentifier())
.orElseThrow(
() -> new WebApplicationException(
String.format(
"No ContentSection with name %s "
+ "available.",
identifierParam
),
Response.Status.NOT_FOUND
)
);
break;
}
return section;
}
protected boolean canDelete(final ContentSection section) {
final Folder rootAssetsFolder = section.getRootAssetsFolder();
final Folder rootDocumentsFolder = section.getRootDocumentsFolder();
return rootAssetsFolder.getSubFolders().isEmpty()
&& rootAssetsFolder.getObjects().isEmpty()
&& rootDocumentsFolder.getSubFolders().isEmpty()
&& rootDocumentsFolder.getObjects().isEmpty();
} }
} }

View File

@ -38,8 +38,13 @@ import javax.transaction.Transactional;
@Named("ContentSectionsTableModel") @Named("ContentSectionsTableModel")
public class ContentSectionsTableModel { public class ContentSectionsTableModel {
@Inject
private ContentSectionsController controller;
@Inject @Inject
private ContentSectionRepository sectionRepo; private ContentSectionRepository sectionRepo;
@AuthorizationRequired @AuthorizationRequired
@Transactional @Transactional
@ -59,6 +64,7 @@ public class ContentSectionsTableModel {
row.setSectionId(section.getObjectId()); row.setSectionId(section.getObjectId());
row.setLabel(section.getLabel()); row.setLabel(section.getLabel());
row.setDeletable(controller.canDelete(section));
return row; return row;
} }

View File

@ -31,6 +31,8 @@ public class ContentSectionsTableRow implements
private String label; private String label;
private boolean deletable;
public long getSectionId() { public long getSectionId() {
return sectionId; return sectionId;
} }
@ -47,13 +49,21 @@ public class ContentSectionsTableRow implements
this.label = label; this.label = label;
} }
public boolean isDeletable() {
return deletable;
}
public void setDeletable(boolean deletable) {
this.deletable = deletable;
}
@Override @Override
public int compareTo(final ContentSectionsTableRow other) { public int compareTo(final ContentSectionsTableRow other) {
int result; int result;
result = Objects.compare( result = Objects.compare(
label, other.getLabel(), String::compareTo label, other.getLabel(), String::compareTo
); );
if (result == 0) { if (result == 0) {
result = Objects.compare( result = Objects.compare(
sectionId, other.getSectionId(), Long::compareTo sectionId, other.getSectionId(), Long::compareTo

View File

@ -2,7 +2,7 @@
<html xmlns="http://www.w3.org/1999/xhtml" <html xmlns="http://www.w3.org/1999/xhtml"
xmlns:bootstrap="http://xmlns.jcp.org/jsf/composite/components/bootstrap" xmlns:bootstrap="http://xmlns.jcp.org/jsf/composite/components/bootstrap"
xmlns:c="http://xmlns.jcp.org/jsp/jstl/core" xmlns:c="http://xmlns.jcp.org/jsp/jstl/core"
xmlns:h="http://xmlns.jcp.org/jsf/html" xmlns:libreccm="http://xmlns.jcp.org/jsf/composite/components/libreccm"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"> xmlns:ui="http://xmlns.jcp.org/jsf/facelets">
<ui:composition template="/WEB-INF/views/org/librecms/ui/content-sections/content-sections.xhtml"> <ui:composition template="/WEB-INF/views/org/librecms/ui/content-sections/content-sections.xhtml">
@ -19,11 +19,6 @@
<c:if test="#{UserInfo.admin}"> <c:if test="#{UserInfo.admin}">
<div class="text-right mb-2"> <div class="text-right mb-2">
<!-- <a class="btn btn-secondary"
href="#{mvc.basePath}/new">
<bootstrap:svgIcon icon="plus-circle" />
<span>#{CmsAdminMessages['contentsections.list.add']}</span>
</a>-->
<button class="btn btn-secondary" <button class="btn btn-secondary"
data-toggle="modal" data-toggle="modal"
data-target="#new-content-section-dialog" data-target="#new-content-section-dialog"
@ -61,7 +56,7 @@
<input aria-describedby="contentsection-name-help" <input aria-describedby="contentsection-name-help"
class="form-control" class="form-control"
id="content-section-name" id="content-section-name"
name="contentSectionName" name="sectionName"
pattern="^([a-z0-9-_]*)$" pattern="^([a-z0-9-_]*)$"
type="text" type="text"
value="" /> value="" />
@ -86,13 +81,14 @@
</div> </div>
</div> </div>
</c:if> </c:if>
<table class="table table-hover"> <table class="table table-hover contentsections-table">
<thead class="thead-light"> <thead class="thead-light">
<tr> <tr>
<th> <th>
#{CmsAdminMessages['contentsections.list.table.headers.label']} #{CmsAdminMessages['contentsections.list.table.headers.label']}
</th> </th>
<th class="text-center">#{CmsAdminMessages['contentsections.list.table.headers.actions']}</th> <th class="action-col text-center"
colspan="2">#{CmsAdminMessages['contentsections.list.table.headers.actions']}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -100,16 +96,82 @@
var="section"> var="section">
<tr> <tr>
<td> <td>
<a href="#{mvc.basePath}/#{section.label}/contentitems"> <a href="#{mvc.basePath}/#{section.label}/items">
#{section.label} #{section.label}
</a> </a>
</td> </td>
<td> <td class="action-col">
<a class="btn btn-info" <button class="btn btn-secondary"
href="#{mvc.basePath}/#{section.label}/details"> data-toggle="modal"
<bootstrap:svgIcon icon="eye"/> data-target="#content-section-#{section.label}-edit-dialog"
#{CmsAdminMessages['contentsections.list.table.actions.show_details']} type="button">
</a> <bootstrap:svgIcon icon="plus-circle" />
<span>#{CmsAdminMessages['contentsections.list.table.actions.edit']}</span>
</button>
<div aria-hidden="true"
aria-labelledby="content-section-#{section.label}-edit-dialog-title"
class="modal fade"
id="content-section-#{section.label}-edit-dialog"
tabindex="-1">
<div class="modal-dialog">
<form action="#{mvc.basePath}/#{section.label}/rename"
class="modal-content"
method="post">
<div class="modal-header">
<h2 class="modal-title"
id="content-section-#{section.label}-edit-dialog-title">
#{CmsAdminMessages['contentsections.edit_dialog.title']}
</h2>
<button aria-label="#{CmsAdminMessages['contentsections.edit_dialog.close']}"
class="close"
data-dismiss="modal"
type="button">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="contentsection-name">
#{CmsAdminMessages['contentsections.edit_dialog.name.label']}
</label>
<input aria-describedby="contentsection-#{section.label}-name-help"
class="form-control"
id="content-section-#{section.label}-name"
name="sectionName"
pattern="^([a-z0-9-_]*)$"
type="text"
value="#{section.label}" />
<small class="form-text text-muted"
id="contentsection-name-help">
#{CmsAdminMessages['contentsections.edit_dialog.name.help']}
</small>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger"
data-dismiss="modal"
type="button">
#{CmsAdminMessages['contentsections.edit_dialog.close']}
</button>
<button class="btn btn-success"
type="submit">
#{CmsAdminMessages['contentsections.edit_dialog.save']}
</button>
</div>
</form>
</div>
</div>
</td>
<td class="action-col">
<c:if test="#{section.deletable}">
<libreccm:deleteDialog actionTarget="#{mvc.basePath}/#{section.label}/delete"
buttonText="#{CmsAdminMessages['contentsections.list.table.actions.delete']}"
cancelLabel="#{CmsAdminMessages['contentsections.delete_dialog.close']}"
confirmLabel="#{CmsAdminMessages['contentsections.delete_dialog.confirm']}"
dialogId="delete-content-section-#{section.label}-dialog"
dialogTitle="#{CmsAdminMessages.getMessage('contentsections.delete_dialog.title', [section.label])}"
message="#{CmsAdminMessages.getMessage('contentsections.delete_dialog.message', [section.label])}" />
</c:if>
</td> </td>
</tr> </tr>
</c:forEach> </c:forEach>

View File

@ -13,3 +13,14 @@ contentsections.new_dialog.close=Cancel
contentsections.new_dialog.save=Create new content section contentsections.new_dialog.save=Create new content section
contentsections.new_dialog.name.label=Name contentsections.new_dialog.name.label=Name
contentsections.new_dialog.name.help=The name of the new content section. Can only contain the letters a to z, the numbers 0-9, the hyphen and the underscore. contentsections.new_dialog.name.help=The name of the new content section. Can only contain the letters a to z, the numbers 0-9, the hyphen and the underscore.
contentsections.list.table.actions.edit=Rename
contentsections.list.table.actions.delete=Delete
contentsections.delete_dialog.close=Cancel
contentsections.delete_dialog.confirm=Delete
contentsections.delete_dialog.title=Delete Content Section {0}
contentsections.delete_dialog.message=Are you sure to delete content section {0}?
contentsections.edit_dialog.title=Rename content section
contentsections.edit_dialog.close=Cancel
contentsections.edit_dialog.name.label=Name
contentsections.edit_dialog.save=Rename content section
contentsections.edit_dialog.name.help=The name of the content section. Can only contain the letters a to z, the numbers 0-9, the hyphen and the underscore.

View File

@ -13,3 +13,14 @@ contentsections.new_dialog.close=Abbrechen
contentsections.new_dialog.save=Neue Content Section anlegen contentsections.new_dialog.save=Neue Content Section anlegen
contentsections.new_dialog.name.label=Name contentsections.new_dialog.name.label=Name
contentsections.new_dialog.name.help=Der Name der neuen Content Section. Darf nur die Zeichen a bis z, 0-9, the Bindestrich und den Unterstrich enthalten. contentsections.new_dialog.name.help=Der Name der neuen Content Section. Darf nur die Zeichen a bis z, 0-9, the Bindestrich und den Unterstrich enthalten.
contentsections.list.table.actions.edit=Umbenennen
contentsections.list.table.actions.delete=L\u00f6schen
contentsections.delete_dialog.close=Abbrechen
contentsections.delete_dialog.confirm=L\u00f6schen
contentsections.delete_dialog.title=Content Section {0} l\u00f6schen
contentsections.delete_dialog.message=Wollen Sie die Content Section {0} wirklich l\u00f6schen?
contentsections.edit_dialog.title=Content Section umbennen
contentsections.edit_dialog.close=Abbrechen
contentsections.edit_dialog.name.label=Name
contentsections.edit_dialog.save=Content Section umbenennen
contentsections.edit_dialog.name.help=Der Name der Content Section. Darf nur die Zeichen a bis z, 0-9, the Bindestrich und den Unterstrich enthalten.

View File

@ -37,3 +37,11 @@ $pre-scrollable-max-height: 21.25rem;
// Navbar default colors have insufficient contrast // Navbar default colors have insufficient contrast
$navbar-dark-color: #fff; $navbar-dark-color: #fff;
table.contentsections-table {
tbody {
td.action-col {
width: 11em;
}
}
}