Skip to content

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

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

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

Реализация FieldType ​

Поля делятся на 2 типа: StorableFieldType и ComputableFieldType

TIP

Если ваше поле должно хранить значение в БД - реализуйте интерфейс ru.slie.luna.issue.field.type.StorableFieldType

Если поле рассчитывается на внешних данных - реализуйте интерфес ru.slie.luna.issue.field.type.ComputableFieldType

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 AbstractStorableFieldType<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 getViewValue(IssueField field, Option value) {
        if (value == null) {
             return null;
        }

        // Используем вспомогательный класс в котором добавлены поля index и total, что бы отрисовать зведы

        for (IssueFieldContext c: field.getContexts()) {
            List<Option> options = c.getOptions();
            for (int i = 0; i < options.size(); i++) {
                if (options.get(i).getId().equals(value.getId())) {
                    return new RatingOption(value, i, options.size());
                }
            }

        }

        return null;
    }

    @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 Object getFormValue(IssueField field, Option value) {
        // для значения в форме(props.modelValue или defineModel) вернем только id
        if (value != null) {
            return value.getId();
        }

        return null;
    }

    @Override
    public List<Option> getFormParams(IssueField field, BulkActionContext actionContext) {
        List<Option> options = new ArrayList<>();
        // Добавим опции в form-компонент (props.params) для массового редактирования
        for (IssueFieldContext context: field.getContexts()) {
            options.addAll(context.getOptions());
        }
        return options;
    }


    @Override
    public List<Option> getFormParams(IssueField field, IssueContext issueContext, Option value) {
        // Добавим опции в form-компонент (props.params) для конкретной задачи
        IssueFieldContext context = field.getContext(issueContext);
        if (context == null) {
            return List.of();
        }

        return context.getOptions();
    }

    @Override
    public List<Option> getFormParams(IssueField field, IssueFieldContext fieldContext, Option value) {
        // Добавим опции в form-компонент (props.params) для формы выбора дефолтного значения
        return fieldContext.getOptions();
    }
}

расширение функционала поля

Для добавления возможностей полю, можете добавить реализацию следующих интерфейсов:

  • FieldTypeImportable - настройте маппинг поля при экспорте-иморте между разными инстансами
  • FieldTypeRankable - если вы реализуете тип, который позволяет ранжировать задачи на досках. Рекомендуем использовать стандартное поле Rank
  • AbstractComputableFieldType - можете использовать этот абстрактный класс, если ваше поле вычислимое и не поддерживает запись
  • AbstractStorableMultiFieldType - можете использовать этот абстрактный класс, если ваш тип поля хранит множество
  • AbstractStorableFieldType - абстрактный класс, реализующий некоторые методы StorableFieldType

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

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

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

vue
<script setup lang="ts">
import { type PropType } from "vue";
import type { Option } from "luna";

interface RatingOption extends Option<number> {
  index: number,
  total: number,
}

defineProps({
  value: Object as PropType<RatingOption>,
});

</script>

<template>
  <div class="rating-customfield" :title="value?.name" v-if="value">
    <div class="star" :class="[((n - 1) <= value.index)? 'icon-star-full': 'icon-star-empty']"  v-for="n in value.total" :key="n"></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({
  params: Object as PropType<Array<Option<number>>>,
});

const value = defineModel<number>();
const active = ref<number>(null);

const currentIndex = computed(() => {
  if (props.params && value.value) {
    return props.params.findIndex((o: Option<number>) => o.id == value.value);
  }

  return -1;
});

const activeIndex = computed(() => {
  if (props.params && active.value) {
    return props.params.findIndex((o: Option<number>) => o.id == active.value);
  }

  return -1;
});

const displayIndex = computed(() => {
  if (activeIndex.value > -1) {
    return activeIndex.value;
  }
  if (currentIndex.value > -1) {
    return currentIndex.value;
  }

  return -1;
});

const hoverOpt = (opt: number) => {
  active.value = opt;
}

const setValue = (opt: number) => {
  value.value = opt;
}

const unHoverOpt = () => {
  active.value = null;
}

</script>

<template>
  <div class="rating-customfield rating-customfield-edit">
    <div class="star" @click="setValue(opt.id)" :class="[(idx <= displayIndex)? 'icon-star-full': 'icon-star-empty']" @mouseenter="hoverOpt(opt.id)" @mouseleave="unHoverOpt()" v-for="(opt, idx) in params" :key="opt.id"></div>
    <div class="icon-cancel-circle" @click="setValue(null)"></div>
    <div class="label" v-if="displayIndex > -1 && params">{{ params[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 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 # Можно сделать несколько и изменять в настройках поля
    formComponents:
      - path: frontend/RatingFieldEdit.js
    navigatorComponents:
      - path: frontend/RatingFieldView.js
    optionsComponent:
        name: FieldContextOptions
    resources:
      - key: ratingCss
        path: frontend/frontend.css
        type: css

TIP

Для optionsComponent мы возьмем встроенный компонент редактирования значений FieldContextOptions, поэтому укажем только название