Skip to content

feat(extensions): Render plugin-described UI via extension slots#98

Merged
nfebe merged 1 commit into
devfrom
feat/plugin-extension-slots
Jul 4, 2026
Merged

feat(extensions): Render plugin-described UI via extension slots#98
nfebe merged 1 commit into
devfrom
feat/plugin-extension-slots

Conversation

@nfebe

@nfebe nfebe commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Closes #95. Part of the plugin-UI extensibility work.

Onboarding steps, settings tabs, dashboard widgets, and sidebar nav were all hardcoded, and there was no integrations client, so nothing a backend plugin contributes could appear without editing core UI. This loads each integration's ui descriptor from the integrations endpoint and renders it into named slots, so a plugin lands in the right place with no per-plugin UI code.

  • Five slots mounted: settings.integrations, settings.tabs, onboarding.steps, dashboard.widgets, sidebar.nav. A registry composable collects contributions per slot, ordered and gated.
  • Gating follows the server: renders only when the user is entitled and the integration is configured, unless the descriptor opts into a needs-setup state.
  • Common cases render from a fixed primitive catalog with no plugin JS. A descriptor can instead name a component that a plugin Nuxt layer registered (the trakli/ui-kit layer mechanism) for custom UI.
  • Onboarding is now a data-driven step list that merges plugin steps by order, replacing the hardcoded branches.

Consumes the backend contract in trakli/webservice#270. Host adoption of the ui-kit primitives stays out of scope.

@sourceant

sourceant Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review Summary

This PR introduces a robust extension slot system that allows backend-driven UI contributions. It removes hardcoded logic in onboarding and settings, replacing it with a flexible primitive-based rendering system and a component registry 'escape hatch'.

🚀 Key Improvements

  • Converted hardcoded onboarding steps in pages/onboarding.vue to a data-driven system.
  • Introduced DescriptorRenderer.vue to handle primitive UI rendering without requiring plugin-specific JavaScript.
  • Centralized integration fetching and state management in useIntegrations.ts.
  • Added support for custom Nuxt layers to register complex components via useExtensionSlots.

💡 Minor Suggestions

  • Improve the regex for PascalCase conversion in DescriptorRenderer.vue.
  • Consider explicit icons for plugin-contributed settings tabs.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

slot,
integration: integration as SlotContribution['integration'],
ui,
order:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for calculating the order is specific to onboarding. To keep this composable generic, it's better to default to the specific UI order if available, or fall back to the global default. This makes it easier to add ordering to other slots later.

Suggested change
order:
order:
ui.onboarding?.order ?? ui.card?.order ?? DEFAULT_ORDER,

const card = computed(() => ui.value.card);
const onboarding = computed(() => ui.value.onboarding);
const variant = computed(() => props.contribution.slot);
const href = computed(() => onboarding.value?.href || card.value?.href || null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The priority of href resolution is slightly inconsistent. In the template (line 18-19), it checks href (which includes onboarding), but for the card variant (line 39) it uses href || undefined. Centralizing this logic in the computed property prevents potential dead links.

Suggested change
const href = computed(() => onboarding.value?.href || card.value?.href || null);
const href = computed(() => ui.value.onboarding?.href || ui.value.card?.href || null);

Comment thread pages/onboarding.vue
const { contributionsFor } = useExtensionSlots();
const onboardingContributions = contributionsFor('onboarding.steps');

const builtInSteps = [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded step IDs and orders should ideally be defined in a constant or configuration file to avoid magic numbers and make the onboarding flow easier to modify without touching the main page logic.

Suggested change
const builtInSteps = [
const BUILT_IN_STEPS = [
{ id: 'language', order: 10 },
{ id: 'wallet', order: 20 },
{ id: 'categories', order: 30 },
{ id: 'complete', order: 1000 }
];

The client now renders UI that backend integrations describe, with no
per-plugin code for the common cases. Installed integrations are loaded after
login and surfaced into named slots (settings integrations panel and tab bar,
onboarding steps, dashboard widgets, sidebar nav). A descriptor renderer draws
the common cases (card, connect, gated panel, onboarding step) from a fixed
catalog; when a descriptor names a component, a plugin Nuxt layer can register a
real one for that slot as an escape hatch.

Onboarding is now a data-driven step list: built-in steps and plugin steps
merge by order instead of hardcoded branches. Contributions are gated on the
server's entitlement decision and shown with a needs-setup state when not yet
configured.
@nfebe nfebe force-pushed the feat/plugin-extension-slots branch from 2d6212e to 76fe528 Compare July 4, 2026 14:57
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 4, 2026

Copy link
Copy Markdown

Deploying trakli-dev with  Cloudflare Pages  Cloudflare Pages

Latest commit: 76fe528
Status: ✅  Deploy successful!
Preview URL: https://b7c7fb5d.trakli-dev.pages.dev
Branch Preview URL: https://feat-plugin-extension-slots.trakli-dev.pages.dev

View logs

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

const icon = computed<Component>(() => {
const name = integration.value.icon;
if (name) {
const pascal = name.replace(/(^\w|[-_ ]\w)/g, (m) => m.replace(/[-_ ]/, '').toUpperCase());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex used for PascalCase conversion is slightly inefficient. While functional for short strings like icon names, a more standard approach or utility would be cleaner. More importantly, ensure that lucide[name] check accounts for the case where name might be kebab-case while Lucide exports are PascalCase.

Suggested change
const pascal = name.replace(/(^\w|[-_ ]\w)/g, (m) => m.replace(/[-_ ]/, '').toUpperCase());
const pascal = name.split(/[-_ ]/).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('');

// cookie has settled (fresh login writes it asynchronously) and never let
// a failure here disrupt the session.
const { load: loadIntegrations } = useIntegrations();
loadIntegrations().catch(() => []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silencing the error with an empty array in .catch(() => []) might make debugging difficult if the integrations API fails for reasons other than auth (e.g., 500 error). It is better to let useIntegrations handle the error logging (which it does) but perhaps not suppress the promise rejection entirely if upstream needs to know.

Suggested change
loadIntegrations().catch(() => []);
loadIntegrations();

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying webui with  Cloudflare Pages  Cloudflare Pages

Latest commit: 76fe528
Status: ✅  Deploy successful!
Preview URL: https://2fb9d93f.webui-9fh.pages.dev
Branch Preview URL: https://feat-plugin-extension-slots.webui-9fh.pages.dev

View logs

@nfebe nfebe merged commit a3b668d into dev Jul 4, 2026
6 checks passed
@nfebe nfebe deleted the feat/plugin-extension-slots branch July 4, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Render plugin-described UI via extension slots

1 participant