Вопросы о платформе плагинов
Содержание
- Как перемещаться по интерфейсу?
- У меня несколько слотов, так можно?
- А что такое контекст?
- У меня запрос с пагинацией, как это сделать?
- Где хранить настройки плагина?
- Как обращаться к внешним API?
- Готовые плагины
Как перемещаться по интерфейсу?
Плагин работает в 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.tab→Issue;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.
Готовые плагины
- импорт задач из CSV — слот
navigation,trackerApi,uiApi.navigate - mvp-plugin — слот
issue.action, работа сslotContextиtrackerApi - шаблоны в CLI
Исходники SDK: arcadia/data-ui/tracker-plugin-sdk.