Skip to content

Пользовательский тип поля

Для примера давайте реализуем поле рейтинга.

Исходный код доступен по ссылке

Реализация FieldType

Создайте класс с реализацией интерфейса ru.slie.luna.issue.field.IssueField.

TIP

Вы так же можете использовать абстрактный класс ru.slie.luna.issue.field.type.AbstractFieldType, который реализует некоторые базовые методы

java
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 - если вы реализуете тип, который позволяет ранжировать задачи на досках. Рекомендуем использовать стандартное поле Rank
  • AbstractComputedFieldType - можете использовать этот абстрактный класс, если ваше поле вычислимое и не поддерживает запись
  • AbstractMultiFieldType - можете использовать этот абстрактный класс, если ваш тип поля хранит множество
  • AbstractFieldType - абстрактный класс, реализующий некоторые методы FieldType

Компоненты фронтенда

Если вы создавали плагин через SDK то у вас уже должна быть папка frontend.

Удалите демо-компоненты и создайте новые. Для просмотра и навигатора будем использовать один и тот же компонент RatingFieldView.vue

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

ts
import { ref } from 'vue';
import { Injections } from 'luna';
const metaIssue = inject(Injections.metaIssue);
const issueKey = inject(Injections.issueKey);
``

Так же создадим стили:

scss
.rating-customfield {
  display: flex;
  align-items: center;

  > * {
    padding: 0 2px;
  }

  &.rating-customfield-edit {
    .star {
      cursor: pointer;
    }
  }
}

Теперь настроим сборку, что бы эти компоненты собирались отдельными файлами:

ts
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
# 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