From 76fe5289b2ebb8fa332f4fbcf5c0774f0656ce8b Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 29 Jun 2026 00:30:21 +0100 Subject: [PATCH] feat(extensions): Render plugin-described UI via extension slots 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. --- components/TSidebar.vue | 5 + components/extensions/DescriptorRenderer.vue | 200 +++++++++++++++++++ components/extensions/ExtensionSlot.vue | 20 ++ composables/useDataManager.ts | 8 + composables/useExtensionSlots.ts | 75 +++++++ composables/useIntegrations.ts | 46 +++++ nuxt.config.ts | 7 + pages/dashboard/index.vue | 11 + pages/onboarding.vue | 57 +++++- pages/settings.vue | 37 +++- services/api/index.ts | 3 + services/api/integrationsApi.ts | 27 +++ tests/components/DescriptorRenderer.test.ts | 78 ++++++++ tests/composables/useExtensionSlots.test.ts | 122 +++++++++++ types/integration.ts | 73 +++++++ 15 files changed, 753 insertions(+), 16 deletions(-) create mode 100644 components/extensions/DescriptorRenderer.vue create mode 100644 components/extensions/ExtensionSlot.vue create mode 100644 composables/useExtensionSlots.ts create mode 100644 composables/useIntegrations.ts create mode 100644 services/api/integrationsApi.ts create mode 100644 tests/components/DescriptorRenderer.test.ts create mode 100644 tests/composables/useExtensionSlots.test.ts create mode 100644 types/integration.ts diff --git a/components/TSidebar.vue b/components/TSidebar.vue index b304c57..abca7d1 100644 --- a/components/TSidebar.vue +++ b/components/TSidebar.vue @@ -67,6 +67,10 @@ + +
@@ -126,6 +130,7 @@ import { import { Sparkles, Scale, Coins } from 'lucide-vue-next'; import { computed } from 'vue'; import Logo from './Logo.vue'; +import ExtensionSlot from '@/components/extensions/ExtensionSlot.vue'; import { useSidebar } from '@/composables/useSidebar'; import { useRoute, useRouter } from 'vue-router'; diff --git a/components/extensions/DescriptorRenderer.vue b/components/extensions/DescriptorRenderer.vue new file mode 100644 index 0000000..ac4aea4 --- /dev/null +++ b/components/extensions/DescriptorRenderer.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/components/extensions/ExtensionSlot.vue b/components/extensions/ExtensionSlot.vue new file mode 100644 index 0000000..496977c --- /dev/null +++ b/components/extensions/ExtensionSlot.vue @@ -0,0 +1,20 @@ + + + diff --git a/composables/useDataManager.ts b/composables/useDataManager.ts index e5f515a..fa84e47 100644 --- a/composables/useDataManager.ts +++ b/composables/useDataManager.ts @@ -47,6 +47,12 @@ export const useDataManager = () => { isInitialized.value = true; lastInitTime.value = Date.now(); + + // Integrations are non-critical and additive. Load them after the token + // cookie has settled (fresh login writes it asynchronously) and never let + // a failure here disrupt the session. + const { load: loadIntegrations } = useIntegrations(); + loadIntegrations().catch(() => []); } catch (err) { error.value = extractApiErrors(err); isInitialized.value = false; @@ -66,9 +72,11 @@ export const useDataManager = () => { try { const { clearAllData } = useSharedData(); const { clearTransactions } = useTransactions(); + const { clear: clearIntegrations } = useIntegrations(); clearAllData(); clearTransactions(); + clearIntegrations(); } catch (err) { console.warn('[DataManager] Error clearing data:', err); } diff --git a/composables/useExtensionSlots.ts b/composables/useExtensionSlots.ts new file mode 100644 index 0000000..45e9d13 --- /dev/null +++ b/composables/useExtensionSlots.ts @@ -0,0 +1,75 @@ +import { computed, markRaw, ref } from 'vue'; +import type { Component } from 'vue'; +import type { ExtensionSlot, SlotContribution } from '~/types/integration'; +import { useIntegrations } from '~/composables/useIntegrations'; + +const DEFAULT_ORDER = 100; + +// Components a plugin Nuxt layer registers for a slot key (the escape hatch). +const componentRegistry = ref>({}); + +/** + * Collects what installed integrations contribute to each named slot, ordered + * and gated, and resolves the optional custom component a plugin layer can + * register. The common cases render with no plugin JS; a registered component + * is the escape hatch. + */ +export const useExtensionSlots = () => { + const { integrations } = useIntegrations(); + + const contributionsFor = (slot: ExtensionSlot) => + computed(() => { + const items: SlotContribution[] = []; + + for (const integration of integrations.value) { + const ui = integration.ui; + if (!ui || !Array.isArray(ui.slots) || !ui.slots.includes(slot)) { + continue; + } + + // Hard gate: never surface something the user is not entitled to. The + // server owns the paid-vs-free decision; the client only honors it. + if (!integration.entitled) { + continue; + } + + // Hide contributions that are not ready to use unless the descriptor + // asks to surface an explicit needs-setup state. + if (!integration.configured && !ui.show_when_unconfigured) { + continue; + } + + items.push({ + key: integration.key, + slot, + integration: integration as SlotContribution['integration'], + ui, + order: + slot === 'onboarding.steps' ? (ui.onboarding?.order ?? DEFAULT_ORDER) : DEFAULT_ORDER, + configured: integration.configured + }); + } + + return items.sort((a, b) => a.order - b.order); + }); + + /** + * Register a real component for a slot key, used when a descriptor sets + * `ui.component`. Called from a plugin Nuxt layer. + */ + const registerComponent = (slotKey: string, component: Component): void => { + componentRegistry.value = { + ...componentRegistry.value, + [slotKey]: markRaw(component) + }; + }; + + const resolveComponent = (slotKey: string | null | undefined): Component | null => + slotKey ? (componentRegistry.value[slotKey] ?? null) : null; + + return { + contributionsFor, + registerComponent, + resolveComponent + }; +}; diff --git a/composables/useIntegrations.ts b/composables/useIntegrations.ts new file mode 100644 index 0000000..e841fc2 --- /dev/null +++ b/composables/useIntegrations.ts @@ -0,0 +1,46 @@ +import { ref, readonly } from 'vue'; +import type { Integration } from '~/types/integration'; +import integrationsApi from '~/services/api/integrationsApi'; + +// Module-level shared state (mirrors useSharedData's pattern). +const integrations = ref([]); +const loading = ref(false); +const loaded = ref(false); + +export const useIntegrations = () => { + const load = async (force = false): Promise => { + // Never call the authenticated endpoint without a token: a 401 here would + // trip the global handler and log the user out over a non-critical fetch. + const { token } = useAuth(); + if (!token.value) return integrations.value; + + if (loading.value) return integrations.value; + if (loaded.value && !force) return integrations.value; + + loading.value = true; + try { + integrations.value = await integrationsApi.fetchAll(); + loaded.value = true; + } catch (error) { + console.error('Error loading integrations:', error); + integrations.value = []; + } finally { + loading.value = false; + } + + return integrations.value; + }; + + const clear = () => { + integrations.value = []; + loaded.value = false; + }; + + return { + integrations: readonly(integrations), + loading: readonly(loading), + loaded: readonly(loaded), + load, + clear + }; +}; diff --git a/nuxt.config.ts b/nuxt.config.ts index af4a350..c19a0ba 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -9,6 +9,13 @@ export default defineNuxtConfig({ compatibilityDate: '2024-04-03', devtools: { enabled: false }, ssr: true, + // Plugin client UI ships as Nuxt layers consumed the same way as the shared + // ui-kit, e.g. extends: ['github:trakli/ui-kit', 'github:trakli/-ui']. + // A layer registers a component for a slot key via + // useExtensionSlots().registerComponent(); DescriptorRenderer resolves it + // when a descriptor sets `ui.component`. Host adoption of the layers is + // deferred, so the list is empty for now. + extends: [], routeRules: { '/ai-insights': { redirect: '/assistant' } }, diff --git a/pages/dashboard/index.vue b/pages/dashboard/index.vue index 9f2c8cd..577dafc 100644 --- a/pages/dashboard/index.vue +++ b/pages/dashboard/index.vue @@ -49,6 +49,10 @@ > + +
+ +
@@ -68,6 +72,7 @@ import RecentTransactions from '@/components/dashboard/RecentTransactions.vue'; import QuickInsights from '@/components/dashboard/QuickInsights.vue'; import OnboardingWizard from '@/components/onboarding/OnboardingWizard.vue'; import ComponentLoader from '@/components/ComponentLoader.vue'; +import ExtensionSlot from '@/components/extensions/ExtensionSlot.vue'; import { useStatistics } from '@/composables/useStatistics'; const router = useRouter(); @@ -158,4 +163,10 @@ definePageMeta({ grid-template-columns: 1fr; } } + +.dashboard-widgets { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: $spacing-4; +} diff --git a/pages/onboarding.vue b/pages/onboarding.vue index b699e84..418088b 100644 --- a/pages/onboarding.vue +++ b/pages/onboarding.vue @@ -18,8 +18,8 @@
- -
+ +
@@ -71,8 +71,8 @@
- -
+ +
@@ -203,8 +203,8 @@
- -
+ +
@@ -248,8 +248,13 @@
- -
+ +
+ +
+ + +
@@ -320,6 +325,8 @@ import IconCategory from '~icons/solar/widget-5-bold-duotone'; import { useSharedData } from '@/composables/useSharedData'; import { useNotifications } from '@/composables/useNotifications'; import { useWallets } from '@/composables/useWallets'; +import { useExtensionSlots } from '@/composables/useExtensionSlots'; +import DescriptorRenderer from '@/components/extensions/DescriptorRenderer.vue'; import configurationsApi from '@/services/api/configurationsApi'; import { CONFIGURATION_KEYS } from '@/utils/configurationKeys'; import { getCountries } from '@/utils/countries'; @@ -341,9 +348,37 @@ const { setComplete: setOnboardingComplete } = useOnboardingStatus(); const returnTo = computed(() => (route.query.returnTo as string) || '/dashboard'); const currentStep = ref(1); -const totalSteps = 4; -const progressPercentage = computed(() => (currentStep.value / totalSteps) * 100); +// Onboarding is a data-driven list: built-in steps plus any plugin +// contributions to the `onboarding.steps` slot, merged by order. The +// completion step is pinned last. +const { contributionsFor } = useExtensionSlots(); +const onboardingContributions = contributionsFor('onboarding.steps'); + +const builtInSteps = [ + { id: 'language', order: 10 }, + { id: 'wallet', order: 20 }, + { id: 'categories', order: 30 }, + { id: 'complete', order: 1000 } +]; + +const steps = computed(() => { + const pluginSteps = onboardingContributions.value.map((contribution) => ({ + id: contribution.key, + order: contribution.order, + contribution + })); + + return [...builtInSteps, ...pluginSteps].sort((a, b) => a.order - b.order); +}); + +const totalSteps = computed(() => steps.value.length); +const currentStepId = computed(() => steps.value[currentStep.value - 1]?.id); +const currentStepContribution = computed( + () => steps.value[currentStep.value - 1]?.contribution ?? null +); + +const progressPercentage = computed(() => (currentStep.value / totalSteps.value) * 100); // Data from shared state const incomeCategories = computed(() => sharedData.getIncomeCategories?.value || []); @@ -393,7 +428,7 @@ const isWalletCurrencySetupValid = computed(() => { }); const nextStep = () => { - if (currentStep.value < totalSteps) { + if (currentStep.value < totalSteps.value) { currentStep.value++; } }; diff --git a/pages/settings.vue b/pages/settings.vue index cca16ad..e9184d4 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -22,6 +22,14 @@ + +
@@ -30,8 +38,8 @@ diff --git a/services/api/index.ts b/services/api/index.ts index 8ea31ba..ee29840 100644 --- a/services/api/index.ts +++ b/services/api/index.ts @@ -9,6 +9,7 @@ import remindersApi from './remindersApi'; import notificationsApi from './notificationsApi'; import importsApi from './importsApi'; import budgetsApi from './budgetsApi'; +import integrationsApi from './integrationsApi'; import transactionApi from '../transactionApi'; // Re-export individual services @@ -23,6 +24,7 @@ export { notificationsApi, importsApi, budgetsApi, + integrationsApi, transactionApi }; @@ -41,5 +43,6 @@ export const api = { notifications: notificationsApi, imports: importsApi, budgets: budgetsApi, + integrations: integrationsApi, transactions: transactionApi } as const; diff --git a/services/api/integrationsApi.ts b/services/api/integrationsApi.ts new file mode 100644 index 0000000..757aecb --- /dev/null +++ b/services/api/integrationsApi.ts @@ -0,0 +1,27 @@ +import type { Integration } from '~/types/integration'; +import { extractResponseData } from './apiHelpers'; + +interface ApiResponse { + success?: boolean; + message?: string; + data?: T; +} + +/** + * Integrations API Service + * Lists installed integrations and the UI each one describes. + */ +const integrationsApi = { + /** + * Fetch all installed integrations. + * GET /integrations + */ + async fetchAll(): Promise { + const api = useApi(); + const response = await api>('/integrations'); + return extractResponseData(response, []); + } +}; + +export default integrationsApi; +export { integrationsApi }; diff --git a/tests/components/DescriptorRenderer.test.ts b/tests/components/DescriptorRenderer.test.ts new file mode 100644 index 0000000..808585f --- /dev/null +++ b/tests/components/DescriptorRenderer.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { defineComponent, h } from 'vue'; +import DescriptorRenderer from '~/components/extensions/DescriptorRenderer.vue'; +import { useExtensionSlots } from '~/composables/useExtensionSlots'; +import type { SlotContribution } from '~/types/integration'; + +const stubs = { + NuxtLink: { template: '' } +}; + +function contribution(overrides: Partial = {}): SlotContribution { + return { + key: 'statement-import', + slot: 'settings.integrations', + order: 100, + configured: true, + integration: { + key: 'statement-import', + name: 'Statement Import', + description: 'desc', + category: 'import', + icon: null, + feature_key: null, + configured: true, + entitled: true, + ui: null + }, + ui: { + slots: ['settings.integrations'], + card: { title: 'Import bank statements', cta: 'Import', description: 'Upload a statement' }, + component: null + }, + ...overrides + }; +} + +describe('DescriptorRenderer', () => { + it('renders an integration card from the descriptor (no plugin JS)', () => { + const wrapper = mount(DescriptorRenderer, { + props: { contribution: contribution() }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Import bank statements'); + expect(wrapper.text()).toContain('Import'); + }); + + it('shows a needs-setup badge when the integration is not configured', () => { + const wrapper = mount(DescriptorRenderer, { + props: { contribution: contribution({ configured: false }) }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('Needs setup'); + }); + + it('renders a registered component when the descriptor sets ui.component (escape hatch)', () => { + const Custom = defineComponent({ render: () => h('div', 'CUSTOM-WIDGET') }); + useExtensionSlots().registerComponent('statement-import.custom', Custom); + + const wrapper = mount(DescriptorRenderer, { + props: { + contribution: contribution({ + ui: { + slots: ['settings.integrations'], + card: { title: 'unused' }, + component: 'statement-import.custom' + } + }) + }, + global: { stubs } + }); + + expect(wrapper.text()).toContain('CUSTOM-WIDGET'); + expect(wrapper.text()).not.toContain('unused'); + }); +}); diff --git a/tests/composables/useExtensionSlots.test.ts b/tests/composables/useExtensionSlots.test.ts new file mode 100644 index 0000000..ecdca0f --- /dev/null +++ b/tests/composables/useExtensionSlots.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, defineComponent, h } from 'vue'; +import type { Integration } from '~/types/integration'; +import { useExtensionSlots } from '~/composables/useExtensionSlots'; + +const mockIntegrations = ref([]); + +vi.mock('~/composables/useIntegrations', () => ({ + useIntegrations: () => ({ integrations: mockIntegrations }) +})); + +function makeIntegration(overrides: Partial): Integration { + return { + key: 'k', + name: 'Name', + description: null, + category: 'import', + icon: null, + feature_key: null, + configured: true, + entitled: true, + ui: null, + ...overrides + }; +} + +describe('useExtensionSlots', () => { + beforeEach(() => { + mockIntegrations.value = []; + }); + + it('collects only contributions that declare the slot', () => { + mockIntegrations.value = [ + makeIntegration({ key: 'a', ui: { slots: ['settings.integrations'] } }), + makeIntegration({ key: 'b', ui: { slots: ['dashboard.widgets'] } }) + ]; + + const { contributionsFor } = useExtensionSlots(); + const settings = contributionsFor('settings.integrations'); + + expect(settings.value.map((c) => c.key)).toEqual(['a']); + }); + + it('orders onboarding contributions by their declared order', () => { + mockIntegrations.value = [ + makeIntegration({ + key: 'late', + ui: { slots: ['onboarding.steps'], onboarding: { step: 's', order: 90 } } + }), + makeIntegration({ + key: 'early', + ui: { slots: ['onboarding.steps'], onboarding: { step: 's', order: 20 } } + }) + ]; + + const { contributionsFor } = useExtensionSlots(); + const steps = contributionsFor('onboarding.steps'); + + expect(steps.value.map((c) => c.key)).toEqual(['early', 'late']); + expect(steps.value.map((c) => c.order)).toEqual([20, 90]); + }); + + it('hides contributions the user is not entitled to', () => { + mockIntegrations.value = [ + makeIntegration({ key: 'free', entitled: true, ui: { slots: ['settings.integrations'] } }), + makeIntegration({ + key: 'paid', + feature_key: 'paid', + entitled: false, + ui: { slots: ['settings.integrations'] } + }) + ]; + + const { contributionsFor } = useExtensionSlots(); + + expect(contributionsFor('settings.integrations').value.map((c) => c.key)).toEqual(['free']); + }); + + it('hides an unconfigured contribution by default', () => { + mockIntegrations.value = [ + makeIntegration({ + key: 'ready', + configured: true, + ui: { slots: ['settings.integrations'] } + }), + makeIntegration({ + key: 'unset', + configured: false, + ui: { slots: ['settings.integrations'] } + }) + ]; + + const { contributionsFor } = useExtensionSlots(); + + expect(contributionsFor('settings.integrations').value.map((c) => c.key)).toEqual(['ready']); + }); + + it('shows an unconfigured contribution when the descriptor opts in', () => { + mockIntegrations.value = [ + makeIntegration({ + key: 'unset', + configured: false, + ui: { slots: ['settings.integrations'], show_when_unconfigured: true } + }) + ]; + + const { contributionsFor } = useExtensionSlots(); + const items = contributionsFor('settings.integrations').value; + + expect(items.map((c) => c.key)).toEqual(['unset']); + expect(items[0].configured).toBe(false); + }); + + it('resolves a component a plugin layer registered for a slot key', () => { + const { registerComponent, resolveComponent } = useExtensionSlots(); + const Custom = defineComponent({ render: () => h('div', 'custom') }); + + expect(resolveComponent('plaid.connect')).toBeNull(); + registerComponent('plaid.connect', Custom); + expect(resolveComponent('plaid.connect')).toBe(Custom); + }); +}); diff --git a/types/integration.ts b/types/integration.ts new file mode 100644 index 0000000..df89b1f --- /dev/null +++ b/types/integration.ts @@ -0,0 +1,73 @@ +/** + * Slots a plugin descriptor can target. These names are the contract shared + * with the backend (see trakli/internal#9); a contribution only renders in a + * declared slot. + */ +export type ExtensionSlot = + | 'settings.integrations' + | 'settings.tabs' + | 'onboarding.steps' + | 'dashboard.widgets' + | 'sidebar.nav'; + +export interface IntegrationCardDescriptor { + title: string; + cta?: string; + description?: string; + href?: string; +} + +export interface IntegrationConnectDescriptor { + type: string; + [key: string]: unknown; +} + +export interface IntegrationOnboardingDescriptor { + step: string; + optional?: boolean; + order?: number; + title?: string; + description?: string; + href?: string; +} + +export interface IntegrationUiDescriptor { + slots: ExtensionSlot[]; + card?: IntegrationCardDescriptor | null; + connect?: IntegrationConnectDescriptor | null; + onboarding?: IntegrationOnboardingDescriptor | null; + /** Slot key a plugin Nuxt layer registered a real component for, else null. */ + component?: string | null; + /** + * Render this contribution even when the integration is not configured, + * surfacing an explicit needs-setup state instead of hiding it. Default is + * to hide unconfigured contributions. + */ + show_when_unconfigured?: boolean; +} + +export interface Integration { + key: string; + name: string; + description: string | null; + category: string; + icon: string | null; + feature_key: string | null; + configured: boolean; + entitled: boolean; + ui: IntegrationUiDescriptor | null; +} + +/** + * One integration's contribution to a single slot, flattened from its `ui` + * descriptor and carrying the gating state the registry resolved. + */ +export interface SlotContribution { + key: string; + slot: ExtensionSlot; + integration: Integration; + ui: IntegrationUiDescriptor; + order: number; + /** Operator has supplied what the integration needs to run. */ + configured: boolean; +}