diff --git a/src/main/resources/update/gitbucket-core_4.39.xml b/src/main/resources/update/gitbucket-core_4.39.xml new file mode 100644 index 0000000..9691ce1 --- /dev/null +++ b/src/main/resources/update/gitbucket-core_4.39.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<changeSet> + <addColumn tableName="CUSTOM_FIELD"> + <column name="CONSTRAINTS" type="varchar(200)" nullable="true"/> + </addColumn> +</changeSet> diff --git a/src/main/scala/gitbucket/core/GitBucketCoreModule.scala b/src/main/scala/gitbucket/core/GitBucketCoreModule.scala index 08bdbf6..74f7159 100644 --- a/src/main/scala/gitbucket/core/GitBucketCoreModule.scala +++ b/src/main/scala/gitbucket/core/GitBucketCoreModule.scala @@ -115,5 +115,6 @@ new Version("4.38.1"), new Version("4.38.2"), new Version("4.38.3"), - new Version("4.38.4") + new Version("4.38.4"), + new Version("4.39.0", new LiquibaseMigration("update/gitbucket-core_4.39.xml")), ) diff --git a/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala b/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala index a949ba6..2a09fd1 100644 --- a/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala +++ b/src/main/scala/gitbucket/core/controller/RepositorySettingsController.scala @@ -126,6 +126,7 @@ case class CustomFieldForm( fieldName: String, fieldType: String, + constraints: Option[String], enableForIssues: Boolean, enableForPullRequests: Boolean ) @@ -133,6 +134,7 @@ val customFieldForm = mapping( "fieldName" -> trim(label("Field name", text(required, maxlength(100)))), "fieldType" -> trim(label("Field type", text(required))), + "constraints" -> trim(label("Constraints", optional(text()))), "enableForIssues" -> trim(label("Enable for issues", boolean(required))), "enableForPullRequests" -> trim(label("Enable for pull requests", boolean(required))), )(CustomFieldForm.apply) @@ -511,6 +513,7 @@ repository.name, form.fieldName, form.fieldType, + if (form.fieldType == "enum") form.constraints else None, form.enableForIssues, form.enableForPullRequests ) @@ -533,6 +536,7 @@ params("fieldId").toInt, form.fieldName, form.fieldType, + if (form.fieldType == "enum") form.constraints else None, form.enableForIssues, form.enableForPullRequests ) diff --git a/src/main/scala/gitbucket/core/model/CustomField.scala b/src/main/scala/gitbucket/core/model/CustomField.scala index 4d5a4f1..0eaac53 100644 --- a/src/main/scala/gitbucket/core/model/CustomField.scala +++ b/src/main/scala/gitbucket/core/model/CustomField.scala @@ -5,6 +5,7 @@ import gitbucket.core.util.StringUtil import gitbucket.core.view.helpers import org.scalatra.i18n.Messages +import play.twirl.api.Html trait CustomFieldComponent extends TemplateComponent { self: Profile => import profile.api._ @@ -15,10 +16,11 @@ val fieldId = column[Int]("FIELD_ID", O AutoInc) val fieldName = column[String]("FIELD_NAME") val fieldType = column[String]("FIELD_TYPE") + val constraints = column[Option[String]]("CONSTRAINTS") val enableForIssues = column[Boolean]("ENABLE_FOR_ISSUES") val enableForPullRequests = column[Boolean]("ENABLE_FOR_PULL_REQUESTS") def * = - (userName, repositoryName, fieldId, fieldName, fieldType, enableForIssues, enableForPullRequests) + (userName, repositoryName, fieldId, fieldName, fieldType, constraints, enableForIssues, enableForPullRequests) .<>(CustomField.tupled, CustomField.unapply) def byPrimaryKey(userName: String, repositoryName: String, fieldId: Int) = @@ -31,17 +33,28 @@ repositoryName: String, fieldId: Int = 0, fieldName: String, - fieldType: String, // long, double, string, or date + fieldType: String, // long, double, string, date, or enum + constraints: Option[String], enableForIssues: Boolean, enableForPullRequests: Boolean ) trait CustomFieldBehavior { - def createHtml(repository: RepositoryInfo, fieldId: Int)(implicit conext: Context): String - def fieldHtml(repository: RepositoryInfo, issueId: Int, fieldId: Int, value: String, editable: Boolean)( + def createHtml(repository: RepositoryInfo, fieldId: Int, fieldName: String, constraints: Option[String])( implicit context: Context ): String - def validate(name: String, value: String, messages: Messages): Option[String] + def fieldHtml( + repository: RepositoryInfo, + issueId: Int, + fieldId: Int, + fieldName: String, + constraints: Option[String], + value: String, + editable: Boolean + )( + implicit context: Context + ): String + def validate(name: String, constraints: Option[String], value: String, messages: Messages): Option[String] } object CustomFieldBehavior { @@ -49,7 +62,7 @@ if (value.isEmpty) None else { CustomFieldBehavior(field.fieldType).flatMap { behavior => - behavior.validate(field.fieldName, value, messages) + behavior.validate(field.fieldName, field.constraints, value, messages) } } } @@ -60,12 +73,18 @@ case "double" => Some(DoubleFieldBehavior) case "string" => Some(StringFieldBehavior) case "date" => Some(DateFieldBehavior) + case "enum" => Some(EnumFieldBehavior) case _ => None } } case object LongFieldBehavior extends TextFieldBehavior { - override def validate(name: String, value: String, messages: Messages): Option[String] = { + override def validate( + name: String, + constraints: Option[String], + value: String, + messages: Messages + ): Option[String] = { try { value.toLong None @@ -75,7 +94,12 @@ } } case object DoubleFieldBehavior extends TextFieldBehavior { - override def validate(name: String, value: String, messages: Messages): Option[String] = { + override def validate( + name: String, + constraints: Option[String], + value: String, + messages: Messages + ): Option[String] = { try { value.toDouble None @@ -89,7 +113,12 @@ private val pattern = "yyyy-MM-dd" override protected val fieldType: String = "date" - override def validate(name: String, value: String, messages: Messages): Option[String] = { + override def validate( + name: String, + constraints: Option[String], + value: String, + messages: Messages + ): Option[String] = { try { new java.text.SimpleDateFormat(pattern).parse(value) None @@ -100,10 +129,142 @@ } } + case object EnumFieldBehavior extends CustomFieldBehavior { + override def createHtml(repository: RepositoryInfo, fieldId: Int, fieldName: String, constraints: Option[String])( + implicit context: Context + ): String = { + createPulldownHtml(repository, fieldId, fieldName, constraints, None, None) + } + + override def fieldHtml( + repository: RepositoryInfo, + issueId: Int, + fieldId: Int, + fieldName: String, + constraints: Option[String], + value: String, + editable: Boolean + )(implicit context: Context): String = { + if (!editable) { + val sb = new StringBuilder + sb.append("""</div>""") + sb.append("""<div>""") + if (value == "") { + sb.append(s"""<span id="label-custom-field-$fieldId"><span class="muted small">No ${StringUtil.escapeHtml( + fieldName + )}</span></span>""") + } else { + sb.append(s"""<span id="label-custom-field-$fieldId"><span class="muted small">${StringUtil + .escapeHtml(value)}</span></span>""") + } + sb.toString() + } else { + createPulldownHtml(repository, fieldId, fieldName, constraints, Some(issueId), Some(value)) + } + } + + private def createPulldownHtml( + repository: RepositoryInfo, + fieldId: Int, + fieldName: String, + constraints: Option[String], + issueId: Option[Int], + value: Option[String] + )(implicit context: Context): String = { + val sb = new StringBuilder + sb.append("""<div class="pull-right">""") + sb.append( + gitbucket.core.helper.html + .dropdown("Edit", right = true, filter = (fieldName, s"Filter $fieldName")) { + val options = new StringBuilder() + options.append( + s"""<li><a href="javascript:void(0);" class="custom-field-option-$fieldId" data-value=""><i class="octicon octicon-x"></i> Clear ${StringUtil + .escapeHtml(fieldName)}</a></li>""" + ) + constraints.foreach { + x => + x.split(",").map(_.trim).foreach { + item => + options.append(s"""<li> + | <a href="javascript:void(0);" class="custom-field-option-$fieldId" data-value="${StringUtil + .escapeHtml(item)}"> + | ${gitbucket.core.helper.html.checkicon(value.contains(item))} + | ${StringUtil.escapeHtml(item)} + | </a> + |</li> + |""".stripMargin) + } + } + Html(options.toString()) + } + .toString() + ) + sb.append("""</div>""") + sb.append("""</div>""") + sb.append("""<div>""") + value match { + case None => + sb.append(s"""<span id="label-custom-field-$fieldId"><span class="muted small">No ${StringUtil.escapeHtml( + fieldName + )}</span></span>""") + case Some(value) => + sb.append(s"""<span id="label-custom-field-$fieldId"><span class="muted small">${StringUtil + .escapeHtml(value)}</span></span>""") + } + if (value.isEmpty || issueId.isEmpty) { + sb.append(s"""<input type="hidden" id="custom-field-$fieldId" name="custom-field-$fieldId" value=""/>""") + sb.append(s"""<script> + |$$('a.custom-field-option-$fieldId').click(function(){ + | const value = $$(this).data('value'); + | $$('a.custom-field-option-$fieldId i.octicon-check').removeClass('octicon-check'); + | $$('#custom-field-$fieldId').val(value); + | if (value == '') { + | $$('#label-custom-field-$fieldId').html($$('<span class="muted small">').text('No ${StringUtil + .escapeHtml(fieldName)}')); + | } else { + | $$('#label-custom-field-$fieldId').html($$('<span class="muted small">').text(value)); + | $$('a.custom-field-option-$fieldId[data-value=' + value + '] i').addClass('octicon-check'); + | } + |}); + |</script>""".stripMargin) + } else { + sb.append(s"""<script> + |$$('a.custom-field-option-$fieldId').click(function(){ + | const value = $$(this).data('value'); + | $$.post('${helpers.url(repository)}/issues/${issueId.get}/customfield/$fieldId', + | { value: value }, + | function(data){ + | $$('a.custom-field-option-$fieldId i.octicon-check').removeClass('octicon-check'); + | if (value == '') { + | $$('#label-custom-field-$fieldId').html($$('<span class="muted small">').text('No ${StringUtil + .escapeHtml(fieldName)}')); + | } else { + | $$('#label-custom-field-$fieldId').html($$('<span class="muted small">').text(value)); + | $$('a.custom-field-option-$fieldId[data-value=' + value + '] i').addClass('octicon-check'); + | } + | } + | ); + |}); + |</script> + |""".stripMargin) + } + sb.toString() + } + + override def validate( + name: String, + constraints: Option[String], + value: String, + messages: Messages + ): Option[String] = None + } + trait TextFieldBehavior extends CustomFieldBehavior { protected val fieldType = "text" - def createHtml(repository: RepositoryInfo, fieldId: Int)(implicit context: Context): String = { + override def createHtml(repository: RepositoryInfo, fieldId: Int, fieldName: String, constraints: Option[String])( + implicit context: Context + ): String = { val sb = new StringBuilder sb.append( s"""<input type="$fieldType" class="form-control input-sm" id="custom-field-$fieldId" name="custom-field-$fieldId" data-field-id="$fieldId" style="width: 120px;"/>""" @@ -111,8 +272,7 @@ sb.append(s"""<script> |$$('#custom-field-$fieldId').focusout(function(){ | const $$this = $$(this); - | const fieldId = $$this.data('field-id'); - | $$.post('${helpers.url(repository)}/issues/customfield_validation/' + fieldId, + | $$.post('${helpers.url(repository)}/issues/customfield_validation/$fieldId', | { value: $$this.val() }, | function(data){ | if (data != '') { @@ -128,7 +288,15 @@ sb.toString() } - def fieldHtml(repository: RepositoryInfo, issueId: Int, fieldId: Int, value: String, editable: Boolean)( + override def fieldHtml( + repository: RepositoryInfo, + issueId: Int, + fieldId: Int, + fieldName: String, + constraints: Option[String], + value: String, + editable: Boolean + )( implicit context: Context ): String = { val sb = new StringBuilder @@ -149,15 +317,14 @@ | |$$('#custom-field-$fieldId-editor').focusout(function(){ | const $$this = $$(this); - | const fieldId = $$this.data('field-id'); - | $$.post('${helpers.url(repository)}/issues/customfield_validation/' + fieldId, + | $$.post('${helpers.url(repository)}/issues/customfield_validation/$fieldId', | { value: $$this.val() }, | function(data){ | if (data != '') { | $$('#custom-field-$fieldId-error').text(data); | } else { | $$('#custom-field-$fieldId-error').text(''); - | $$.post('${helpers.url(repository)}/issues/$issueId/customfield/' + fieldId, + | $$.post('${helpers.url(repository)}/issues/$issueId/customfield/$fieldId', | { value: $$this.val() }, | function(data){ | $$this.hide(); @@ -186,6 +353,11 @@ sb.toString() } - def validate(name: String, value: String, messages: Messages): Option[String] = None + override def validate( + name: String, + constraints: Option[String], + value: String, + messages: Messages + ): Option[String] = None } } diff --git a/src/main/scala/gitbucket/core/service/CustomFieldsService.scala b/src/main/scala/gitbucket/core/service/CustomFieldsService.scala index cc5ddfa..0f5d41d 100644 --- a/src/main/scala/gitbucket/core/service/CustomFieldsService.scala +++ b/src/main/scala/gitbucket/core/service/CustomFieldsService.scala @@ -28,6 +28,7 @@ repository: String, fieldName: String, fieldType: String, + constraints: Option[String], enableForIssues: Boolean, enableForPullRequests: Boolean )(implicit s: Session): Int = { @@ -36,6 +37,7 @@ repositoryName = repository, fieldName = fieldName, fieldType = fieldType, + constraints = constraints, enableForIssues = enableForIssues, enableForPullRequests = enableForPullRequests ) @@ -47,6 +49,7 @@ fieldId: Int, fieldName: String, fieldType: String, + constraints: Option[String], enableForIssues: Boolean, enableForPullRequests: Boolean )( @@ -54,8 +57,8 @@ ): Unit = CustomFields .filter(_.byPrimaryKey(owner, repository, fieldId)) - .map(t => (t.fieldName, t.fieldType, t.enableForIssues, t.enableForPullRequests)) - .update((fieldName, fieldType, enableForIssues, enableForPullRequests)) + .map(t => (t.fieldName, t.fieldType, t.constraints, t.enableForIssues, t.enableForPullRequests)) + .update((fieldName, fieldType, constraints, enableForIssues, enableForPullRequests)) def deleteCustomField(owner: String, repository: String, fieldId: Int)(implicit s: Session): Unit = { IssueCustomFields diff --git a/src/main/scala/gitbucket/core/service/IssuesService.scala b/src/main/scala/gitbucket/core/service/IssuesService.scala index bb5dec9..d6aa5a5 100644 --- a/src/main/scala/gitbucket/core/service/IssuesService.scala +++ b/src/main/scala/gitbucket/core/service/IssuesService.scala @@ -964,39 +964,39 @@ def nonEmpty: Boolean = !isEmpty - def toFilterString: String = - ( - List( - Some(s"is:${state}"), - author.map(author => s"author:${author}"), - assigned.map(assignee => s"assignee:${assignee}"), - mentioned.map(mentioned => s"mentions:${mentioned}") - ).flatten ++ - labels.map(label => s"label:${label}") ++ - List( - milestone.map { - case Some(x) => s"milestone:${x}" - case None => "no:milestone" - }, - priority.map { - case Some(x) => s"priority:${x}" - case None => "no:priority" - }, - (sort, direction) match { - case ("created", "desc") => None - case ("created", "asc") => Some("sort:created-asc") - case ("comments", "desc") => Some("sort:comments-desc") - case ("comments", "asc") => Some("sort:comments-asc") - case ("updated", "desc") => Some("sort:updated-desc") - case ("updated", "asc") => Some("sort:updated-asc") - case ("priority", "desc") => Some("sort:priority-desc") - case ("priority", "asc") => Some("sort:priority-asc") - case x => throw new MatchError(x) - }, - visibility.map(visibility => s"visibility:${visibility}") - ).flatten ++ - groups.map(group => s"group:${group}") - ).mkString(" ") +// def toFilterString: String = +// ( +// List( +// Some(s"is:${state}"), +// author.map(author => s"author:${author}"), +// assigned.map(assignee => s"assignee:${assignee}"), +// mentioned.map(mentioned => s"mentions:${mentioned}") +// ).flatten ++ +// labels.map(label => s"label:${label}") ++ +// List( +// milestone.map { +// case Some(x) => s"milestone:${x}" +// case None => "no:milestone" +// }, +// priority.map { +// case Some(x) => s"priority:${x}" +// case None => "no:priority" +// }, +// (sort, direction) match { +// case ("created", "desc") => None +// case ("created", "asc") => Some("sort:created-asc") +// case ("comments", "desc") => Some("sort:comments-desc") +// case ("comments", "asc") => Some("sort:comments-asc") +// case ("updated", "desc") => Some("sort:updated-desc") +// case ("updated", "asc") => Some("sort:updated-asc") +// case ("priority", "desc") => Some("sort:priority-desc") +// case ("priority", "asc") => Some("sort:priority-asc") +// case x => throw new MatchError(x) +// }, +// visibility.map(visibility => s"visibility:${visibility}") +// ).flatten ++ +// groups.map(group => s"group:${group}") +// ).mkString(" ") def toURL: String = "?" + List( diff --git a/src/main/twirl/gitbucket/core/issues/issueinfo.scala.html b/src/main/twirl/gitbucket/core/issues/issueinfo.scala.html index 8502c95..bf19bca 100644 --- a/src/main/twirl/gitbucket/core/issues/issueinfo.scala.html +++ b/src/main/twirl/gitbucket/core/issues/issueinfo.scala.html @@ -140,8 +140,8 @@ } </div> <span id="label-assigned"> - @issueAssignees.map { asignee => - <div>@helpers.avatarLink(asignee.assigneeUserName, 20) @helpers.user(asignee.assigneeUserName, styleClass="username strong small")</div> + @issueAssignees.map { assignee => + <div>@helpers.avatarLink(assignee.assigneeUserName, 20) @helpers.user(assignee.assigneeUserName, styleClass="username strong small")</div> } @if(issueAssignees.isEmpty) { <span class="muted small">No one assigned</span> @@ -158,10 +158,10 @@ <div class="pull-right"> @gitbucket.core.model.CustomFieldBehavior(field.fieldType).map { behavior => @if(issue.nonEmpty) { - @Html(behavior.fieldHtml(repository, issue.get.issueId, field.fieldId, value.map(_.value).getOrElse(""), isManageable)) + @Html(behavior.fieldHtml(repository, issue.get.issueId, field.fieldId, field.fieldName, field.constraints, value.map(_.value).getOrElse(""), isManageable)) } @if(issue.isEmpty) { - @Html(behavior.createHtml(repository, field.fieldId)) + @Html(behavior.createHtml(repository, field.fieldId, field.fieldName, field.constraints)) } } </div> diff --git a/src/main/twirl/gitbucket/core/settings/issuesfield.scala.html b/src/main/twirl/gitbucket/core/settings/issuesfield.scala.html index 1daf1f4..d572782 100644 --- a/src/main/twirl/gitbucket/core/settings/issuesfield.scala.html +++ b/src/main/twirl/gitbucket/core/settings/issuesfield.scala.html @@ -7,6 +7,9 @@ </div> <div class="col-md-4"> @customField.fieldType + @customField.constraints.map { constraints => + (@constraints) + } </div> <div class="col-md-2"> @if(customField.enableForIssues) { diff --git a/src/main/twirl/gitbucket/core/settings/issuesfieldform.scala.html b/src/main/twirl/gitbucket/core/settings/issuesfieldform.scala.html index 6824ad6..7927c49 100644 --- a/src/main/twirl/gitbucket/core/settings/issuesfieldform.scala.html +++ b/src/main/twirl/gitbucket/core/settings/issuesfieldform.scala.html @@ -10,7 +10,9 @@ <option value="double" @if(field.map(_.fieldType == "double").getOrElse(false)){selected}>Double</option> <option value="string" @if(field.map(_.fieldType == "string").getOrElse(false)){selected}>String</option> <option value="date" @if(field.map(_.fieldType == "date").getOrElse(false)){selected}>Date</option> + <option value="enum" @if(field.map(_.fieldType == "enum").getOrElse(false)){selected}>Enum</option> </select> + <input type="text" id="constraints-@fieldId" style="width: 300px; @if(!field.exists(_.fieldType == "enum")){display: none;}" class="form-control input-sm" value="@field.map(_.constraints)" placeholder="Comma-separated enum values"> <label for="enableForIssues-@fieldId" class="normal" style="margin-left: 4px;"> <input type="checkbox" id="enableForIssues-@fieldId" @if(field.map(_.enableForIssues).getOrElse(false)){checked}> Issues </label> @@ -30,6 +32,7 @@ $.post('@helpers.url(repository)/settings/issues/fields/@{if(fieldId == "new") "new" else s"$fieldId/edit"}', { 'fieldName' : $('#fieldName-@fieldId').val(), 'fieldType': $('#fieldType-@fieldId option:selected').val(), + 'constraints': $('#constraints-@fieldId').val(), 'enableForIssues': $('#enableForIssues-@fieldId').prop('checked'), 'enableForPullRequests': $('#enableForPullRequests-@fieldId').prop('checked') }, function(data, status){ @@ -61,6 +64,14 @@ $('#field-@fieldId').show(); } }); + + $('#fieldType-@fieldId').change(function(){ + if($(this).val() == 'enum') { + $('#constraints-@fieldId').show(); + } else { + $('#constraints-@fieldId').hide(); + } + }); }); </script> }