Plugin platform questions

Table of contents

How to navigate the interface?

The plugin runs in an iframe, so navigation to the Tracker interface is handled via uiApi.navigate. In the React package, uiApi is re-exported from @weavix/sdk-react.

Programmatic navigation — for example, opening a queue after import:

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

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

Links in markup. TrackerPluginProvider intercepts clicks on <a href="..."> and elements with data-href:

  • Relative paths and links to the plugin domain open inside the plugin iframe.
  • Links to Tracker and external sites go to the host via uiApi.navigate (external ones open in a new tab).
// Opens in Tracker (in the current or new tab depending on target)
<a href="/TREK-123">Go to issue</a>
<a href="/TREK-123" target="_blank">Open in a new tab</a>

// Opens inside the plugin (if the path is relative)
<a href="/settings">Plugin settings</a>

For more information, see uiApi.navigate.

I have multiple slots, is that OK?

Yes. You can specify multiple slots in manifest.json — the plugin will appear at each integration point. Usually, all slots share the same entrypoint (index.html), and the behavior in the code is differentiated by the slot field.

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

In the code, narrow it down by 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;
}

For the list of slots and the context format for each, see the Slots section.

What is context?

Launch context is what the host passes to the plugin when it opens. In React, it's available via useTrackerPluginContext():

Field What it is
theme Tracker theme: light, dark, system, etc.
language Interface language: ru, en
slot Slot the plugin was opened from: navigation, issue.action, …
slotContext Slot environment data (format depends on the context level)
contextLevel Level declared in the manifest: basic or full

Slot context (slotContext) is data from the page where the plugin was opened. The amount of data is set by the contextLevel field in the slot configuration in manifest.json (a required field). You can set the level separately for each slot.

Level In the manifest What's in slotContext How to get it in code
basic (default in new plugins) "contextLevel": "basic" Only { entityId, entityMeta? } — entity ID and optional metadata from the iframe URL, without a request to the host useTrackerPluginContext() or useTrackerPluginContext('basic')
full "contextLevel": "full" Full slot object (Issue, trigger context, etc.) — the host provides the data via postMessage useTrackerPluginContext('full')
{
    "slots": {
        "tracker": {
            "issue.action": [
                {
                    "entrypoint": "index.html",
                    "title": { "ru": "Мое действие", "en": "My action" },
                    "contextLevel": "full"
                }
            ]
        }
    }
}

basic — use this when it's enough to know which entity was opened (issue key in entityId or entityMeta), and you load the fields yourself via trackerApi. This ensures a faster plugin startup and less data from the host.

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 — use this when you need the issue fields right away, without a separate request. The manifest must have "contextLevel": "full", otherwise the SDK will throw an error when you call useTrackerPluginContext('full').

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>
    );
}

With full, the slotContext type matches the public API for the slot, for example:

  • issue.action, issue.block, issue.tabIssue
  • issue.comment.action → comment data
  • trigger.create.action → queue key
  • navigation → empty object {} (data is still loaded via trackerApi)

For the full table of slots, see the Slots section.

Theme and language are needed so that the plugin looks like part of Tracker (ThemeProvider, useLocalizedString). The full level is convenient on the issue page, while basic is useful when you only need an ID or are already loading data via the API.

My request uses pagination, how do I do this?

trackerApi proxies the Tracker public API. The response comes as { data, headers } — headers are used for pagination.

The pagination type depends on the endpoint. For issue search, POST /issues/_search, see page-based pagination and relative pagination.

Page-based search (filter, query, or keys in the body) — perPage and page parameters, X-Total-Count and X-Total-Pages headers:

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)}>
                Previous
            </button>
            <span>
                {page} / {totalPages}
            </span>
            <button
                disabled={page >= totalPages}
                onClick={() => loadPage(page + 1)}
            >
                Next
            </button>
        </>
    );
}

Queue search (queue in the body) — relative pagination: instead of page, pass the id from the Link header of the previous response:

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 from 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;
}

For large datasets in _search, there's also scrollscrollType, scrollId, and scrollToken parameters in queryParams (hints are available in trackerApi.v3.post['/issues/_search'] autocomplete).

Add the required permissions to manifest.json, such as tracker:issues:read for reading issues.

Where should I store plugin settings?

For organization-level settings (shared by all plugin users in that organization), use storageApi.orgShared. This is a JSON storage mediated by the host — data survives reloads and is visible across all tabs.

A typical scenario is a plugin settings form: load the current value, disable editing if the user lacks permissions, and save without an explicit version (the SDK will retry the request on conflicts).

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) {
                // Record doesn't exist yet — we can create it
                setCanWrite(true);
                return;
            }
            setSettings({ ...DEFAULTS, ...(record.data as Settings) });
            setCanWrite(record.canWrite);
        });
    }, []);

    const handleSave = useCallback(async () => {
        setSaving(true);
        try {
            // Don't pass version — the SDK reads the current one and retries the request on conflicts
            const updated = await storageApi.orgShared.patch({
                bucket: "settings",
                data: settings,
            });
            // patch returns the merged result (including fields from parallel write processes)
            setSettings({ ...DEFAULTS, ...(updated.data as Settings) });
            toaster.add({ title: "Settings saved", theme: "success" });
        } catch (e) {
            if (e instanceof PluginActionError && e.code === VERSION_CONFLICT) {
                toaster.add({
                    title: "Failed to save",
                    theme: "danger",
                    content:
                        "Data was changed in a parallel session. Reload the form.",
                });
            } else {
                throw e;
            }
        } finally {
            setSaving(false);
        }
    }, [settings, toaster]);

    if (!canWrite) {
        return <p>Only the plugin administrator can change these settings.</p>;
    }

    return (
        <form
            onSubmit={(e) => {
                e.preventDefault();
                handleSave();
            }}
        >
            <label>
                <input
                    type="checkbox"
                    checked={settings.autoReply}
                    onChange={(e) =>
                        setSettings({
                            ...settings,
                            autoReply: e.target.checked,
                        })
                    }
                />
                Auto-reply to new issues
            </label>
            <textarea
                value={settings.welcomeMessage}
                onChange={(e) =>
                    setSettings({ ...settings, welcomeMessage: e.target.value })
                }
            />
            <button type="submit" disabled={saving}>
                Save
            </button>
        </form>
    );
}

If the app maintains the current version itself (for example, an edit screen stays open for a long time), pass it explicitly to patch — then VERSION_CONFLICT is returned immediately, without retries, and you can show the user "data has changed, reload". For more information, see the Versioning section.

How do I call external APIs?

The plugin runs in an iframe with a strict CSP policy — direct fetch or XMLHttpRequest calls to external services will be blocked by the browser. All HTTP requests to external services must go through hostApi.externalApiCall(): the plugin passes the request parameters via a bridge, the host executes it on its side, and returns the result. The domains that the plugin accesses must be listed in advance in manifest.json under the permissions.external section.

For more information about configuring the manifest, authorization, error handling, and all externalApi* methods, see the External APIs section.