Enum support in custom fields (#3195)

This commit is contained in:
Naoki Takezoe
2022-12-18 22:46:11 +09:00
committed by GitHub
parent d5c083b70f
commit e68a21ee30
9 changed files with 257 additions and 57 deletions

View File

@@ -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>

View File

@@ -115,5 +115,6 @@ object GitBucketCoreModule
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")),
)

View File

@@ -126,6 +126,7 @@ trait RepositorySettingsControllerBase extends ControllerBase {
case class CustomFieldForm(
fieldName: String,
fieldType: String,
constraints: Option[String],
enableForIssues: Boolean,
enableForPullRequests: Boolean
)
@@ -133,6 +134,7 @@ trait RepositorySettingsControllerBase extends ControllerBase {
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 @@ trait RepositorySettingsControllerBase extends ControllerBase {
repository.name,
form.fieldName,
form.fieldType,
if (form.fieldType == "enum") form.constraints else None,
form.enableForIssues,
form.enableForPullRequests
)
@@ -533,6 +536,7 @@ trait RepositorySettingsControllerBase extends ControllerBase {
params("fieldId").toInt,
form.fieldName,
form.fieldType,
if (form.fieldType == "enum") form.constraints else None,
form.enableForIssues,
form.enableForPullRequests
)

View File

@@ -5,6 +5,7 @@ import gitbucket.core.service.RepositoryService.RepositoryInfo
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 @@ trait CustomFieldComponent extends TemplateComponent { self: Profile =>
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 @@ case class CustomField(
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 @@ object CustomFieldBehavior {
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 @@ object CustomFieldBehavior {
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 @@ object CustomFieldBehavior {
}
}
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 @@ object CustomFieldBehavior {
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 @@ object CustomFieldBehavior {
}
}
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 @@ object CustomFieldBehavior {
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 @@ object CustomFieldBehavior {
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 @@ object CustomFieldBehavior {
|
|$$('#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 @@ object CustomFieldBehavior {
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
}
}

View File

@@ -28,6 +28,7 @@ trait CustomFieldsService {
repository: String,
fieldName: String,
fieldType: String,
constraints: Option[String],
enableForIssues: Boolean,
enableForPullRequests: Boolean
)(implicit s: Session): Int = {
@@ -36,6 +37,7 @@ trait CustomFieldsService {
repositoryName = repository,
fieldName = fieldName,
fieldType = fieldType,
constraints = constraints,
enableForIssues = enableForIssues,
enableForPullRequests = enableForPullRequests
)
@@ -47,6 +49,7 @@ trait CustomFieldsService {
fieldId: Int,
fieldName: String,
fieldType: String,
constraints: Option[String],
enableForIssues: Boolean,
enableForPullRequests: Boolean
)(
@@ -54,8 +57,8 @@ trait CustomFieldsService {
): 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

View File

@@ -964,39 +964,39 @@ object IssuesService {
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(

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>
}