Пользовательский тип поля
Для примера давайте реализуем поле рейтинга.
Исходный код доступен по ссылке
Реализация FieldType
Создайте класс с реализацией интерфейса ru.slie.luna.issue.field.IssueField.
TIP
Вы так же можете использовать абстрактный класс ru.slie.luna.issue.field.type.AbstractFieldType, который реализует некоторые базовые методы
package ru.slie.luna.plugins.field;
import org.springframework.stereotype.Component;
import ru.slie.luna.exception.ValidateException;
import ru.slie.luna.issue.IssueContext;
import ru.slie.luna.issue.field.IssueField;
import ru.slie.luna.issue.field.options.Option;
import ru.slie.luna.issue.field.options.OptionsManager;
import ru.slie.luna.issue.field.type.AbstractFieldType;
import ru.slie.luna.locale.I18nResolver;
import java.util.List;
import java.util.Map;
@Component
public class RatingCustomFieldType extends AbstractFieldType<Option, Option> {
private final OptionsManager optionsManager;
private final I18nResolver i18n;
public RatingCustomFieldType(OptionsManager optionsManager,
I18nResolver i18n) {
this.optionsManager = optionsManager;
this.i18n = i18n;
}
@Override
public String getName() {
// так же можете использовать i18n.getText, предварительно создав бандл messages[_ru].properties
return "Rating field type";
}
@Override
public Option getValueFromDB(IssueField field, Object dbValue) {
// метод, который преобразует данные из БД в java-объект
if (dbValue instanceof Number) {
return optionsManager.getOptionById(field, ((Number) dbValue).longValue());
}
return null;
}
private boolean isNumber(Object value) {
if (value instanceof Number) {
return true;
} else if (value instanceof String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
}
return false;
}
@Override
public Object getValueForDB(IssueField field, Option value) {
// метод, который преобразует java-объект в значение для БД
if (value == null) {
return null;
}
return value.getId();
}
@Override
public Option parseJson(IssueField field, IssueContext context, Object jsonValue) {
// Тут обрабатывается запрос, который придет с формы вашего фронтенд-компонента. А так же обычные запросы через rest-api
// Ваш фронтенд компонент может передавать как примитив или список, так и объект вида {id: 100}
if (jsonValue instanceof Number) {
return optionsManager.getOptionById(field, ((Number) jsonValue).longValue());
} else if (jsonValue instanceof String && context != null) {
return optionsManager.getOptionByName(field.getContext(context), (String) jsonValue);
} else if (jsonValue instanceof Map<?,?> jsonMap) {
if (isNumber(jsonMap.get("id"))) {
return optionsManager.getOptionById(field, Long.parseLong(jsonMap.get("id").toString()));
} else if (jsonMap.containsKey("name")) {
return optionsManager.getOptionByName(field.getContext(context), jsonMap.get("name").toString());
}
}
return null;
}
@Override
public Option toJson(IssueField field, Option value) {
// Преобразуем java-объект для передачи в json в ваш фронтенд-компонент
return value;
}
@Override
public String getChangeItemString(IssueField field, Option value) {
// Формирование строки изменения
return getStringValue(field, value);
}
@Override
public String getStringValue(IssueField field, Option value) {
// Этот метод используется при генерации писем или в базовом поиске
if (value == null) {
return null;
}
return value.getName();
}
@Override
public void validateValue(IssueField field, Object value, ValidateException exception) {
// Метод валидации, например когда вы пытаетесь задать значение через MutableIssue.setFieldValue(IssueField, Object) и сохранить
if (value != null && !(value instanceof Option)) {
exception.addError(field.getId(), i18n.getText("app.field_type.single_select.bad_value"));
}
}
@Override
public List<String> getSupportedFieldSearcherKey() {
// Список поддерживаемых поисковых модулей, можете использовать встроенные, как этот
return List.of("ru.slie.luna.issue.field.searcher.impl.OptionsSearcher");
}
@Override
public Map<String, Object> getViewParams(IssueField field, IssueFieldContext context, Option value) {
// Добавим опции в view-компонент (:view-params)
return Map.of("options", context.getOptions());
}
@Override
public Map<String, Object> getEditParams(IssueField field, IssueFieldContext fieldContext, Option value) {
// Добавим опции в edit-компонент (:edit-params)
return Map.of("options", fieldContext.getOptions());
}
@Override
public Map<String, Object> getEditParams(IssueField field, BulkActionContext actionContext) {
// Добавим опции в edit-компонент для массового редактирования.
// Вы можете получить список проектов и контекстов в `actionContext` что бы составить список подходящих опций
return Map.of("options", List.of());
}
}расширение функционала поля
Для добавления возможностей полю, можете добавить реализацию следующих интерфейсов:
FieldTypeImportable- настройте маппинг поля при экспорте-иморте между разными инстансамиFieldTypeRankable- если вы реализуете тип, который позволяет ранжировать задачи на досках. Рекомендуем использовать стандартное поле RankAbstractComputedFieldType- можете использовать этот абстрактный класс, если ваше поле вычислимое и не поддерживает записьAbstractMultiFieldType- можете использовать этот абстрактный класс, если ваш тип поля хранит множествоAbstractFieldType- абстрактный класс, реализующий некоторые методыFieldType
Компоненты фронтенда
Если вы создавали плагин через SDK то у вас уже должна быть папка frontend.
Удалите демо-компоненты и создайте новые. Для просмотра и навигатора будем использовать один и тот же компонент RatingFieldView.vue
<script setup lang="ts">
import { computed, type PropType } from "vue";
import type { Option } from "luna";
const props = defineProps({
value: Object as PropType<Option<number>>,
viewParams: Object as PropType<{ options: Array<Option<number>> }>
});
const starIndex = computed(() => {
if (props.viewParams?.options && props.value) {
return props.viewParams.options.findIndex((o: Option<number>) => o.id == props.value?.id);
}
return -1;
});
</script>
<template>
<div class="rating-customfield" :title="value?.name">
<div class="star" :class="[(idx <= starIndex)? 'icon-star-full': 'icon-star-empty']" v-for="(opt, idx) in viewParams?.options" :key="opt.id"></div>
</div>
</template>
<style lang="scss">
@use "@/accets/rating.scss" as *;
</style><script setup lang="ts">
import { computed, PropType, ref } from "vue";
import type { Option } from "luna";
const props = defineProps({
editParams: Object as PropType<{options: Array<Option<number>>}>,
});
const value = defineModel<Option<number>>();
const active = ref<Option<number>>(null);
const currentIndex = computed(() => {
if (props.editParams?.options && value.value) {
return props.editParams.options.findIndex((o: Option<number>) => o.id == value.value?.id);
}
return -1;
});
const activeIndex = computed(() => {
if (props.editParams?.options && active.value) {
return props.editParams.options.findIndex((o: Option<number>) => o.id == active.value?.id);
}
return -1;
});
const displayIndex = computed(() => {
if (activeIndex.value > -1) {
return activeIndex.value;
}
if (currentIndex.value > -1) {
return currentIndex.value;
}
return -1;
});
const hoverOpt = (opt: Option<number>) => {
active.value = opt;
}
const setValue = (opt: Option<number>) => {
value.value = opt;
}
const unHoverOpt = () => {
active.value = null;
}
</script>
<template>
<div class="rating-customfield rating-customfield-edit">
<div class="star" @click="setValue(opt)" :class="[(idx <= displayIndex)? 'icon-star-full': 'icon-star-empty']" @mouseenter="hoverOpt(opt)" @mouseleave="unHoverOpt()" v-for="(opt, idx) in editParams?.options" :key="opt.id"></div>
<div class="icon-cancel-circle" @click="setValue(null)"></div>
<div class="label" v-if="displayIndex > -1 && editParams">{{ editParams.options[displayIndex].name }}</div>
</div>
</template>
<style lang="scss">
@use "@/accets/rating.scss" as *;
.rating-customfield.rating-customfield-edit {
min-height: var(--input-height);
padding-bottom: 5px;
.icon-cancel-circle:hover {
color: red;
cursor: pointer;
}
}
</style>Параметры
Если вам нужен доступ к issue используете inject
import { ref } from 'vue';
import { Injections } from 'luna';
const metaIssue = inject(Injections.metaIssue);
const issueKey = inject(Injections.issueKey);
``Так же создадим стили:
.rating-customfield {
display: flex;
align-items: center;
> * {
padding: 0 2px;
}
&.rating-customfield-edit {
.star {
cursor: pointer;
}
}
}Теперь настроим сборку, что бы эти компоненты собирались отдельными файлами:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import pluginExternal from "vite-plugin-external";
// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [
pluginExternal({
externals: {
'luna': '__LUNA_COMPONENTS__',
'vue': 'Vue',
'I18N': 'I18N',
}
}),
vue()
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
}
},
build: {
sourcemap: true,
minify: 'terser',
lib: {
entry: [
resolve(__dirname, 'src/components/RatingFieldView.vue'),
resolve(__dirname, 'src/components/RatingFieldEdit.vue'),
],
formats: ['es'],
},
rolldownOptions: {
output: {
entryFileNames: '[name].js',
exports: 'named',
},
},
outDir: resolve(__dirname, '../src/main/resources/frontend'),
emptyOutDir: true,
},
})Теперь осталось только прописать все наши компоненты в дескрипторе плагина
Конфигурация luna-plugin.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/thankjura/luna-schemas/refs/heads/master/luna-plugin.schema.json?ver=7
key: ${project.groupId}.${project.artifactId}
name: ${project.name}
version: ${project.version}
description: ${project.description}
vendor:
name: ${project.organization.name}
url: ${project.organization.url}
fieldTypes:
- key: RatingCustomFieldType
name: Rating customfield type
className: ru.slie.luna.plugins.field.RatingCustomFieldType
iconPath: images/rating.png # Рекомендуемый размер 480x120
viewComponents:
- path: frontend/RatingFieldView.js # Можно сделать несколько и изменять в настройках поля
editComponents:
- path: frontend/RatingFieldEdit.js
navigatorComponents:
- path: frontend/RatingFieldView.js
optionsComponent:
name: FieldContextOptions
resources:
- key: ratingCss
path: frontend/frontend.css
type: css