Sat, 01 Feb 2025 18:52:08 +0100
implement adding and filtering for variants
relates to #491
--- a/setup/postgres/psql_create_tables.sql Thu Jan 30 21:20:27 2025 +0100 +++ b/setup/postgres/psql_create_tables.sql Sat Feb 01 18:52:08 2025 +0100 @@ -192,3 +192,11 @@ ); create unique index lpit_commit_ref_unique on lpit_commit_ref (issueid, commit_hash); + +create table lpit_issue_variant_status +( + issueid integer not null references lpit_issue (issueid), + variant integer not null references lpit_variant (id), + status issue_status not null default 'InSpecification', + primary key (issueid, variant) +);
--- a/setup/postgres/psql_patch_1.5.0.sql Thu Jan 30 21:20:27 2025 +0100 +++ b/setup/postgres/psql_patch_1.5.0.sql Sat Feb 01 18:52:08 2025 +0100 @@ -16,3 +16,11 @@ ); create unique index lpit_variant_node_unique on lpit_variant (project, node); + +create table lpit_issue_variant_status +( + issueid integer not null references lpit_issue (issueid), + variant integer not null references lpit_variant (id), + status issue_status not null default 'InSpecification', + primary key (issueid, variant) +);
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Sat Feb 01 18:52:08 2025 +0100 @@ -29,6 +29,7 @@ import de.uapcore.lightpit.types.CommitRef import de.uapcore.lightpit.viewmodel.ComponentSummary import de.uapcore.lightpit.viewmodel.IssueSummary +import de.uapcore.lightpit.viewmodel.VariantSummary import de.uapcore.lightpit.viewmodel.VersionSummary interface DataAccessObject { @@ -60,7 +61,7 @@ fun updateComponent(component: Component) fun listVariants(project: Project): List<Variant> - //fun listVariantSummaries(project: Project): List<VariantSummary> + fun listVariantSummaries(project: Project): List<VariantSummary> fun findVariant(id: Int): Variant? fun findVariantByNode(project: Project, node: String): Variant? fun insertVariant(variant: Variant) @@ -95,9 +96,9 @@ /** * Lists all issues for the specified [project]. * The result will only [includeDone] issues, if requested. - * When a [specificVersion] or a [specificComponent] is requested, - * the result is filtered for [version] or [component] respectively. - * In both cases null means that only issues without version or component shall be returned. + * When a [specificVersion], [specificComponent], or [specificVariant] is requested, + * the result is filtered for [version], [component], or [variant] respectively. + * In those cases null means that only issues without version/component/variant shall be returned. */ fun listIssues( project: Project, @@ -105,7 +106,9 @@ specificVersion: Boolean, version: Version?, specificComponent: Boolean, - component: Component? + component: Component?, + specificVariant: Boolean, + variant: Variant? ): List<Issue> fun findIssue(id: Int): Issue? fun insertIssue(issue: Issue): Int
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Sat Feb 01 18:52:08 2025 +0100 @@ -32,6 +32,7 @@ import de.uapcore.lightpit.types.WebColor import de.uapcore.lightpit.viewmodel.ComponentSummary import de.uapcore.lightpit.viewmodel.IssueSummary +import de.uapcore.lightpit.viewmodel.VariantSummary import de.uapcore.lightpit.viewmodel.VersionSummary import org.intellij.lang.annotations.Language import java.sql.Connection @@ -394,14 +395,15 @@ setInt(1, project.id) queryAll { it.extractVariant() } } -/* + override fun listVariantSummaries(project: Project): List<VariantSummary> = withStatement( """ with issues as ( - select variant, phase, count(issueid) as total - from lpit_issue - join lpit_issue_phases using (status) + select variant, phase, count(i.issueid) as total + from lpit_issue i + join lpit_issue_variant_status vs on i.issueid = vs.issueid + join lpit_issue_phases p on p.status = vs.status group by variant, phase ), summary as ( @@ -409,28 +411,26 @@ from lpit_variant v left join issues i on v.id = i.variant ) - select c.id, project, name, node, color, ordinal, description, active, - userid, username, givenname, lastname, mail, + select v.id, project, name, node, color, ordinal, description, active, open.total as open, wip.total as wip, done.total as done - from lpit_component c - left join lpit_user on lead = userid - left join summary open on c.id = open.id and open.phase = 0 - left join summary wip on c.id = wip.id and wip.phase = 1 - left join summary done on c.id = done.id and done.phase = 2 - where c.project = ? + from lpit_variant v + left join summary open on v.id = open.id and open.phase = 0 + left join summary wip on v.id = wip.id and wip.phase = 1 + left join summary done on v.id = done.id and done.phase = 2 + where v.project = ? order by ordinal, name """.trimIndent() ) { setInt(1, project.id) queryAll { rs -> - ComponentSummary(rs.extractComponent()).apply { + VariantSummary(rs.extractVariant()).apply { issueSummary.open = rs.getInt("open") issueSummary.active = rs.getInt("wip") issueSummary.done = rs.getInt("done") } } } -*/ + override fun findVariant(id: Int): Variant? = withStatement("$variantQuery where id = ?") { setInt(1, id) @@ -619,6 +619,29 @@ left join lpit_user on userid = assignee """.trimIndent() + //language=SQL + private val issueQueryForVariant = + """ + select i.issueid, i.project, + p.projectid as project_projectid, + p.name as project_name, + p.node as project_node, + p.ordinal as project_ordinal, + p.description as project_description, + p.vcs as project_vcs, + p.repourl as project_repourl, + component, c.name as componentname, c.node as componentnode, c.color as componentcolor, + vs.status, phase, category, subject, i.description, + userid, username, givenname, lastname, mail, + created, updated, eta, affected, resolved + from lpit_issue i + join lpit_project p on i.project = projectid + join lpit_issue_variant_status vs on vs.issueid = i.issueid + join lpit_issue_phases ph on ph.status = vs.status + left join lpit_component c on component = c.id + left join lpit_user on userid = assignee + """.trimIndent() + private fun ResultSet.extractIssue(): Issue { val proj = extractProject("project_") val comp = getInt("component").let { @@ -681,15 +704,46 @@ specificVersion: Boolean, version: Version?, specificComponent: Boolean, - component: Component? - ): List<Issue> = - withStatement( - """$issueQuery where i.project = ? and + component: Component?, + specificVariant: Boolean, + variant: Variant? + ): List<Issue> { + // base query + var sql = if (variant != null) issueQueryForVariant else issueQuery + + // prepare a filter when only issues without a variant are requested + if (specificVariant && variant == null) { + // language=SQL + sql = """with variants_per_issue(issueid, variants) as ( + select issueid, count(variant) + from lpit_issue_variant_status + right join lpit_issue using (issueid) + group by issueid + ) $sql join variants_per_issue using (issueid) + """.trimIndent() + } + + // add component and version queries + // language=SQL + sql = """$sql where i.project = ? and (? or phase < 2) and (not ? or ? in (resolved, affected)) and (not ? or (resolved is null and affected is null)) and (not ? or component = ?) and (not ? or component is null) """.trimIndent() - ) { + + // if specific variant requested, add respective clause + if (variant != null) { + // language=SQL + sql = "$sql and vs.variant = ?" + } + + // if only issues without variants are requested, use the prepared filter + if (specificVariant && variant == null) { + // language=SQL + sql ="$sql and variants = 0" + } + + return withStatement(sql) { setInt(1, project.id) setBoolean(2, includeDone) @@ -701,8 +755,13 @@ setInt(7, component?.id ?: 0) setBoolean(8, specificComponent && component == null) + if (variant != null) { + setInt(9, variant.id) + } + queryAll { it.extractIssue() } } + } override fun findIssue(id: Int): Issue? = withStatement("$issueQuery where issueid = ?") {
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt Sat Feb 01 18:52:08 2025 +0100 @@ -27,10 +27,7 @@ import de.uapcore.lightpit.* import de.uapcore.lightpit.dao.DataAccessObject -import de.uapcore.lightpit.entities.Component -import de.uapcore.lightpit.entities.Issue -import de.uapcore.lightpit.entities.Project -import de.uapcore.lightpit.entities.Version +import de.uapcore.lightpit.entities.* import de.uapcore.lightpit.logic.* import de.uapcore.lightpit.types.VcsType import de.uapcore.lightpit.types.VersionStatus @@ -62,6 +59,11 @@ get("/%project/components/-/create", this::componentForm) post("/%project/components/-/commit", this::componentCommit) + get("/%project/variants/", this::variants) + get("/%project/variants/%variant/edit", this::variantForm) + get("/%project/variants/-/create", this::variantForm) + post("/%project/variants/-/commit", this::variantCommit) + get("/%project/issues/%version/%component/%variant/%issue", this::issue) get("/%project/issues/%version/%component/%variant/%issue/edit", this::issueForm) post("/%project/issues/%version/%component/%variant/%issue/comment", this::issueComment) @@ -114,8 +116,14 @@ val version = if (path.versionInfo is OptionalPathInfo.Specific) path.versionInfo.elem else null val specificComponent = path.componentInfo !is OptionalPathInfo.All val component = if (path.componentInfo is OptionalPathInfo.Specific) path.componentInfo.elem else null + val specificVariant = path.variantInfo !is OptionalPathInfo.All + val variant = if (path.variantInfo is OptionalPathInfo.Specific) path.variantInfo.elem else null - val issues = dao.listIssues(project, filter.includeDone, specificVersion, version, specificComponent, component) + val issues = dao.listIssues(project, filter.includeDone, + specificVersion, version, + specificComponent, component, + specificVariant, variant + ) .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary)) .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "<Anonymous>")) @@ -323,6 +331,62 @@ http.renderCommit("projects/${project.node}/components/") } + private fun variants(http: HttpRequest, dao: DataAccessObject) { + withPathInfo(http, dao)?.let { path -> + with(http) { + pageTitle = "${path.project.name} - ${i18n("navmenu.variants")}" + view = VariantsView( + path.projectInfo, + dao.listVariantSummaries(path.project) + ) + navigationMenu = projectNavMenu(dao.listProjects(), path) + styleSheets = listOf("projects") + javascript = "issue-overview" + render("variants") + } + } + } + + private fun variantForm(http: HttpRequest, dao: DataAccessObject) { + withPathInfo(http, dao)?.let { path -> + val variant = if (path.variantInfo is OptionalPathInfo.Specific) + path.variantInfo.elem else Variant(-1, path.project.id) + + with(http) { + view = VariantEditView(path.projectInfo, variant) + navigationMenu = projectNavMenu(dao.listProjects(), path) + styleSheets = listOf("projects") + render("variant-form") + } + } + } + + private fun variantCommit(http: HttpRequest, dao: DataAccessObject) { + val idParams = obtainIdAndProject(http, dao) ?: return + val (id, project) = idParams + + val variant = Variant(id, project.id).apply { + name = http.param("name") ?: "" + node = http.param("node") ?: "" + ordinal = http.param("ordinal")?.toIntOrNull() ?: 0 + color = WebColor(http.param("color") ?: "#000000") + description = http.param("description") + active = http.param("active", ::boolValidator, true, mutableListOf()) + // intentional defaults + if (node.isBlank()) node = name + // sanitizing + node = sanitizeNode(node) + } + + if (id < 0) { + dao.insertVariant(variant) + } else { + dao.updateVariant(variant) + } + + http.renderCommit("projects/${project.node}/variants/") + } + private fun issue(http: HttpRequest, dao: DataAccessObject) { val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) if (issue == null) {
--- a/src/main/resources/localization/strings.properties Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/resources/localization/strings.properties Sat Feb 01 18:52:08 2025 +0100 @@ -43,6 +43,7 @@ button.remove=Remove button.save=Save button.user.create=Add Developer +button.variant.create=New Variant button.version.create=New Version button.version.edit=Edit Version commit.redirect-link=If redirection does not work, click the following link: @@ -175,6 +176,9 @@ validation.date.format=Illegal date format. validation.username.null=Username is mandatory. validation.username.unique=Username is already taken. +variant.active=Active +variant.color=Color +variant=Variant version.eol=End of Life version.latest=Latest Version version.next=Next Version
--- a/src/main/resources/localization/strings_de.properties Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/resources/localization/strings_de.properties Sat Feb 01 18:52:08 2025 +0100 @@ -43,6 +43,7 @@ button.remove=Entfernen button.save=Speichern button.user.create=Neuer Entwickler +button.variant.create=Neue Variante button.version.create=Neue Version button.version.edit=Version Bearbeiten commit.redirect-link=Falls die Weiterleitung nicht klappt, klicken Sie bitte hier: @@ -175,6 +176,9 @@ validation.date.format=Datumsformat wird nicht unterst\u00fctzt. validation.username.null=Benutzername ist ein Pflichtfeld. validation.username.unique=Der Benutzername wird bereits verwendet. +variant.active=Aktiv +variant.color=Farbe +variant=Variante version.eol=Supportende version.latest=Neuste Version version.next=N\u00e4chste Version
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp Sat Feb 01 18:52:08 2025 +0100 @@ -32,6 +32,7 @@ <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/> <c:set var="component" scope="page" value="${viewmodel.componentDetails}"/> +<c:set var="variant" scope="page" value="${viewmodel.variantDetails}"/> <c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/> <%@include file="../jspf/project-header.jspf"%>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/webapp/WEB-INF/jsp/variant-form.jsp Sat Feb 01 18:52:08 2025 +0100 @@ -0,0 +1,92 @@ +<%-- +DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. + +Copyright 2021 Mike Becker. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--%> +<%@page pageEncoding="UTF-8" %> +<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> + +<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.VariantEditView" scope="request" /> +<c:set var="variant" scope="page" value="${viewmodel.variant}"/> +<c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/> + +<form action="./projects/${project.node}/variants/-/commit" method="post"> + <table class="formtable" style="width: 70ch"> + <colgroup> + <col> + <col style="width: 100%"> + </colgroup> + <tbody> + <tr> + <th><fmt:message key="project"/></th> + <td> + <c:out value="${project.name}" /> + <input type="hidden" name="projectid" value="${project.id}" /> + </td> + </tr> + <tr> + <th><label for="variant-name"><fmt:message key="variant"/></label></th> + <td><input id="variant-name" name="name" type="text" maxlength="20" required value="<c:out value="${variant.name}"/>" /></td> + </tr> + <tr title="<fmt:message key="node.tooltip"/>"> + <th><label for="variant-node"><fmt:message key="node"/></label></th> + <td><input id="variant-node" name="node" type="text" maxlength="20" value="<c:out value="${variant.node}"/>" /></td> + </tr> + <tr> + <th><label for="variant-color"><fmt:message key="variant.color"/></label></th> + <td><input id="variant-color" name="color" type="color" required value="${variant.color}" /></td> + </tr> + <tr title="<fmt:message key="ordinal.tooltip" />"> + <th><label for="variant-ordinal"><fmt:message key="ordinal"/></label></th> + <td> + <input id="variant-ordinal" name="ordinal" type="number" value="${variant.ordinal}"/> + </td> + </tr> + <tr> + <th class="vtop"><label for="variant-description"><fmt:message key="description"/></label></th> + <td> + <textarea id="variant-description" name="description" rows="5"><c:out value="${variant.description}"/></textarea> + </td> + </tr> + <tr> + <th><label for="variant-active"><fmt:message key="variant.active"/></label></th> + <td> + <input type="checkbox" id="variant-active" name="active" <c:if test="${variant.active}">checked</c:if> > + </td> + </tr> + </tbody> + <tfoot> + <tr> + <td colspan="2"> + <input type="hidden" name="id" value="${variant.id}"/> + <a href="./projects/${project.node}/variants/" class="button"> + <fmt:message key="button.cancel"/> + </a> + <button type="submit"><fmt:message key="button.okay"/></button> + </td> + </tr> + </tfoot> + </table> +</form>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/webapp/WEB-INF/jsp/variants.jsp Sat Feb 01 18:52:08 2025 +0100 @@ -0,0 +1,98 @@ +<%-- +DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. + +Copyright 2021 Mike Becker. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--%> +<%@page pageEncoding="UTF-8" %> +<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> + +<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.VariantsView" scope="request" /> + +<c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/> + +<%@include file="../jspf/project-header.jspf"%> + +<div> + <a href="./projects/${project.node}/variants/-/create" class="button"><fmt:message key="button.variant.create"/></a> + <button onclick="toggleProjectDetails()" id="toggle-details-button"><fmt:message key="button.project.details"/></button> + <a href="./projects/${project.node}/issues/-/-/-/-/create" class="button"><fmt:message key="button.issue.create"/></a> +</div> + +<h2><fmt:message key="progress" /></h2> + +<c:set var="summary" value="${viewmodel.projectInfo.issueSummary}" /> +<%@include file="../jspf/issue-summary.jspf"%> + +<table id="version-list" class="datatable medskip fullwidth"> + <colgroup> + <col> + <col style="width: 20%"> + <col style="width: 44%"> + <col style="width: 12%"> + <col style="width: 12%"> + <col style="width: 12%"> + </colgroup> + <thead> + <tr> + <th colspan="3"></th> + <th colspan="3" class="hcenter"> + <fmt:message key="issues"/> + </th> + </tr> + <tr> + <th></th> + <th><fmt:message key="variant"/></th> + <th><fmt:message key="description"/></th> + <th class="hcenter"><fmt:message key="issues.open" /></th> + <th class="hcenter"><fmt:message key="issues.active" /></th> + <th class="hcenter"><fmt:message key="issues.done" /></th> + </tr> + </thead> + <tbody> + <c:forEach var="variantInfo" items="${viewmodel.variantInfos}" > + <tr> + <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/variants/${variantInfo.variant.node}/edit">✎</a></td> + <td rowspan="2"> + <div class="navmenu-icon" style="background-color: ${variantInfo.variant.color}"></div> + <a href="./projects/${project.node}/issues/-/-/${variantInfo.variant.node}/" + <c:if test="${not variantInfo.variant.active}">style="text-decoration: line-through;"</c:if> + > + <c:out value="${variantInfo.variant.name}"/> + </a> + </td> + <td rowspan="2"><c:out value="${variantInfo.variant.description}"/> </td> + <td class="hright">${variantInfo.issueSummary.open}</td> + <td class="hright">${variantInfo.issueSummary.active}</td> + <td class="hright">${variantInfo.issueSummary.done}</td> + </tr> + <tr> + <td colspan="3"> + <c:set var="summary" value="${variantInfo.issueSummary}"/> + <%@include file="../jspf/issue-progress.jspf" %> + </td> + </tr> + </c:forEach> + </tbody> +</table> \ No newline at end of file
--- a/src/main/webapp/WEB-INF/jspf/project-header.jspf Thu Jan 30 21:20:27 2025 +0100 +++ b/src/main/webapp/WEB-INF/jspf/project-header.jspf Sat Feb 01 18:52:08 2025 +0100 @@ -1,6 +1,7 @@ <%-- project: Project component: Component (optional) +variant: Variant (optional) --%> <div id="project-details-header-reduced" style="display:none" class="table project-attributes"> <div class="row"> @@ -64,4 +65,12 @@ </div> </div> </c:if> + <c:if test="${not empty variant}"> + <div class="row"> + <div class="caption"><fmt:message key="variant"/>:</div> + <div><c:out value="${variant.name}"/></div> + <div class="caption"><fmt:message key="description"/>:</div> + <div><c:out value="${variant.description}"/></div> + </div> + </c:if> </div>