Вопросы о платформе плагинов

Содержание

Как перемещаться по интерфейсу?

Плагин работает в iframe, поэтому переходы в интерфейс Трекера выполняются через uiApi.navigate. В React-пакете uiApi реэкспортируется из @weavix/sdk-react.

Программный переход — например, открыть очередь после импорта (так сделано в плагине импорта из CSV):

import { uiApi } from "@weavix/sdk-react";

const openQueue = (queueKey: string) => {
    uiApi.navigate({
        path: `/${queueKey}`,
        options: { newTab: true },
    });
};

Ссылки в разметке. TrackerPluginProvider перехватывает клики по <a href="..."> и элементам с data-href:

  • относительные пути и ссылки на домен плагина открываются внутри iframe плагина;
  • ссылки на Трекер и внешние сайты уходят в хост через uiApi.navigate (внешние — в новой вкладке).
// Откроется в Трекере (в текущей или новой вкладке — по target)
<a href="/TREK-123">Перейти к задаче</a>
<a href="/TREK-123" target="_blank">Открыть в новой вкладке</a>

// Откроется внутри плагина (если путь относительный)
<a href="/settings">Настройки плагина</a>

Подробнее: uiApi.navigate.

У меня несколько слотов, так можно?

Да. В manifest.json можно указать несколько слотов — плагин появится в каждой точке интеграции. Обычно для всех слотов используют один entrypoint (index.html), а поведение в коде различают по полю slot.

{
    "slots": {
        "tracker": {
            "navigation": [
                {
                    "entrypoint": "index.html",
                    "title": { "ru": "Мой отчет", "en": "My report" }
                }
            ],
            "issue.action": [
                {
                    "entrypoint": "index.html",
                    "title": {
                        "ru": "Действие с задачей",
                        "en": "Issue action"
                    }
                }
            ]
        }
    }
}

В коде — сужение по slot:

import { useTrackerPluginContext } from "@weavix/sdk-react";

function App() {
    const { slot, slotContext, theme, language } = useTrackerPluginContext();

    if (slot === "navigation") {
        return <ReportPage theme={theme} language={language} />;
    }

    if (slot === "issue.action") {
        return <IssueAction issue={slotContext} />;
    }

    return null;
}

Список слотов и формат контекста для каждого — в разделе Слоты.

А что такое контекст?

Контекст запуска — это то, что хост передает плагину при открытии. В React он доступен через useTrackerPluginContext():

Поле Что это
theme Тема Трекера: light, dark, system и т.д.
language Язык интерфейса: ru, en
slot Слот, из которого открыли плагин: navigation, issue.action, …
slotContext Данные окружения слота (формат зависит от уровня контекста)
contextLevel Уровень, объявленный в манифесте: basic или full

Контекст слота (slotContext) — данные со страницы, где открыли плагин. Объем данных задается полем contextLevel в конфигурации слота в manifest.json (обязательное поле). Уровень можно задать отдельно для каждого слота.

Уровень В манифесте Что в slotContext Как получить в коде
basic (по умолчанию в новых плагинах) "contextLevel": "basic" Только { entityId, entityMeta? } — идентификатор сущности и опциональные метаданные из URL iframe, без запроса к хосту useTrackerPluginContext() или useTrackerPluginContext('basic')
full "contextLevel": "full" Полный объект слота (Issue, контекст триггера и т.д.) — хост отдает данные через postMessage useTrackerPluginContext('full')
{
    "slots": {
        "tracker": {
            "issue.action": [
                {
                    "entrypoint": "index.html",
                    "title": { "ru": "Мое действие", "en": "My action" },
                    "contextLevel": "full"
                }
            ]
        }
    }
}

basic — когда достаточно знать, какую сущность открыли (ключ задачи в entityId или entityMeta), а поля загружаете сами через trackerApi. Быстрее старт плагина, меньше данных от хоста.

import { useEffect, useState } from "react";
import {
    trackerApi,
    useTrackerPluginContext,
} from "@weavix/sdk-react";
import type { Issue } from "@weavix/tracker-api-types";

function IssueHeader() {
    const { slotContext } = useTrackerPluginContext<"issue.action">();
    const [issue, setIssue] = useState<Issue | null>(null);

    useEffect(() => {
        if (!slotContext?.entityId) return;
        trackerApi.v3.get["/issues/{id}"]({
            pathParams: { id: slotContext.entityId },
        }).then(({ data }) => setIssue(data));
    }, [slotContext?.entityId]);

    if (!issue) return null;
    return (
        <p>
            {issue.key}: {issue.summary}
        </p>
    );
}

full — когда нужны поля задачи сразу, без отдельного запроса (как в mvp-plugin). В манифесте обязательно "contextLevel": "full", иначе при вызове useTrackerPluginContext('full') SDK выбросит ошибку.

import { getField, useTrackerPluginContext } from "@weavix/sdk-react";

function IssueHeader() {
    const { slotContext } = useTrackerPluginContext<"issue.action">("full");

    if (!slotContext) return null;

    return (
        <p>
            {slotContext.key}: {getField(slotContext, "summary")}
        </p>
    );
}

При full тип slotContext совпадает с публичным API для слота, например:

  • issue.action, issue.block, issue.tabIssue;
  • issue.comment.action → данные комментария;
  • trigger.create.action → ключ очереди;
  • navigation → пустой объект {} (данные всё равно через trackerApi).

Полная таблица слотов — в разделе Слоты.

Тема и язык нужны, чтобы плагин выглядел как часть Трекера (ThemeProvider, useLocalizedString). Уровень full удобен на странице тикета, basic — когда нужен только идентификатор или вы и так загружаете данные через API.

У меня запрос с пагинацией, как это сделать?

trackerApi проксирует публичное API Трекера. Ответ приходит в виде { data, headers } — заголовки нужны для пагинации.

Тип пагинации зависит от эндпоинта. Для поиска задач POST /issues/_search — см. постраничное отображение и относительную пагинацию.

Постраничный поиск (filter, query или keys в теле) — параметры perPage и page, в заголовках X-Total-Count и X-Total-Pages:

import { useCallback, useState } from "react";
import { trackerApi } from "@weavix/sdk-react";
import type { Issue } from "@weavix/tracker-api-types";

function IssueList() {
    const [issues, setIssues] = useState<Issue[]>([]);
    const [page, setPage] = useState(1);
    const [totalPages, setTotalPages] = useState(1);
    const perPage = 20;

    const loadPage = useCallback(async (nextPage: number) => {
        const { data, headers } = await trackerApi.v3.post["/issues/_search"]({
            queryParams: { perPage, page: nextPage },
            bodyParams: {
                filter: { assignee: "me", status: "open" },
            },
        });

        setIssues(data);
        setPage(nextPage);
        setTotalPages(Number(headers["x-total-pages"] ?? 1));
    }, []);

    return (
        <>
            <ul>
                {issues.map((issue) => (
                    <li key={issue.id}>{issue.key}</li>
                ))}
            </ul>
            <button disabled={page <= 1} onClick={() => loadPage(page - 1)}>
                Назад
            </button>
            <span>
                {page} / {totalPages}
            </span>
            <button
                disabled={page >= totalPages}
                onClick={() => loadPage(page + 1)}
            >
                Вперед
            </button>
        </>
    );
}

Поиск по очереди (queue в теле) — относительная пагинация: вместо page передается id из заголовка Link предыдущего ответа:

const loadFirst = async () => {
    const { data, headers } = await trackerApi.v3.post["/issues/_search"]({
        queryParams: { perPage: 20 },
        bodyParams: { queue: "TREK" },
    });
    setIssues(data);
    setNextPageId(parseNextId(headers.link)); // id из Link: ...; rel="next"
};

const loadNext = async (pageId: string) => {
    const { data, headers } = await trackerApi.v3.post["/issues/_search"]({
        queryParams: { perPage: 20, id: pageId },
        bodyParams: { queue: "TREK" },
    });
    setIssues((prev) => [...prev, ...data]);
    setNextPageId(parseNextId(headers.link));
};

function parseNextId(linkHeader?: string): string | null {
    if (!linkHeader) return null;
    const match = linkHeader.match(/[?&]id=([^&>]+)/);
    return match?.[1] ?? null;
}

Для больших выборок в _search также есть прокрутка (scroll) — параметры scrollType, scrollId, scrollToken в queryParams (подсказки есть в автодополнении trackerApi.v3.post['/issues/_search']).

Добавьте в manifest.json нужные разрешения, например tracker:issues:read для чтения задач.

Где хранить настройки плагина?

Для настроек уровня организации (общих для всех пользователей плагина в данной организации) используйте storageApi.orgShared. Это JSON-хранилище, опосредованное хостом — данные переживают перезагрузку и видны во всех вкладках.

Типичный сценарий — форма настроек плагина: загружаем текущее значение, отключаем редактирование, если у пользователя нет прав, сохраняем без явной версии (SDK сам повторит запрос при конфликтах).

import { useCallback, useEffect, useState } from "react";
import {
    PluginActionError,
    storageApi,
    useToaster,
    VERSION_CONFLICT,
} from "@weavix/sdk-react";

type Settings = {
    autoReply: boolean;
    welcomeMessage: string;
};

const DEFAULTS: Settings = { autoReply: false, welcomeMessage: "" };

function SettingsForm() {
    const toaster = useToaster();
    const [settings, setSettings] = useState<Settings>(DEFAULTS);
    const [canWrite, setCanWrite] = useState(false);
    const [saving, setSaving] = useState(false);

    useEffect(() => {
        storageApi.orgShared.get("settings").then((record) => {
            if (!record) {
                // Записи еще нет — ее сможем создать
                setCanWrite(true);
                return;
            }
            setSettings({ ...DEFAULTS, ...(record.data as Settings) });
            setCanWrite(record.canWrite);
        });
    }, []);

    const handleSave = useCallback(async () => {
        setSaving(true);
        try {
            // version не передаем — SDK сам прочитает текущую и повторит запрос при конфликтах
            const updated = await storageApi.orgShared.patch({
                bucket: "settings",
                data: settings,
            });
            // patch возвращает объединенный результат (включая поля от параллельных процессов записи)
            setSettings({ ...DEFAULTS, ...(updated.data as Settings) });
            toaster.add({ title: "Настройки сохранены", theme: "success" });
        } catch (e) {
            if (e instanceof PluginActionError && e.code === VERSION_CONFLICT) {
                toaster.add({
                    title: "Не удалось сохранить",
                    theme: "danger",
                    content:
                        "Данные изменили в параллельной сессии. Перезагрузите форму.",
                });
            } else {
                throw e;
            }
        } finally {
            setSaving(false);
        }
    }, [settings, toaster]);

    if (!canWrite) {
        return <p>Только администратор плагина может менять эти настройки.</p>;
    }

    return (
        <form
            onSubmit={(e) => {
                e.preventDefault();
                handleSave();
            }}
        >
            <label>
                <input
                    type="checkbox"
                    checked={settings.autoReply}
                    onChange={(e) =>
                        setSettings({
                            ...settings,
                            autoReply: e.target.checked,
                        })
                    }
                />
                Автоответ на новые задачи
            </label>
            <textarea
                value={settings.welcomeMessage}
                onChange={(e) =>
                    setSettings({ ...settings, welcomeMessage: e.target.value })
                }
            />
            <button type="submit" disabled={saving}>
                Сохранить
            </button>
        </form>
    );
}

Если приложение само держит актуальную version (например, экран редактирования долго висит у пользователя), передавайте ее в patch явно — тогда VERSION_CONFLICT придет сразу, без ретраев, и можно показать пользователю «данные изменились, перечитайте». Подробнее — в разделе Версионирование.

Как обращаться к внешним API?

Плагин работает в iframe с жесткой политикой CSP — прямые fetch/XMLHttpRequest к внешним сервисам браузер заблокирует. Все HTTP-запросы наружу обязаны идти через hostApi.externalApiCall(): плагин передает параметры запроса через мост, хост выполняет его на своей стороне и возвращает результат. Домены, к которым обращается плагин, нужно заранее перечислить в manifest.json в секции permissions.external.

Подробнее о настройке манифеста, авторизации, обработке ошибок и всех методах externalApi* — в разделе Внешние API.

Готовые плагины

Исходники SDK: arcadia/data-ui/tracker-plugin-sdk.