-
Notifications
You must be signed in to change notification settings - Fork 3
feat(extensions): Render plugin-described UI via extension slots #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,200 @@ | ||||||
| <template> | ||||||
| <!-- Escape hatch: a plugin Nuxt layer registered a real component. --> | ||||||
| <component :is="resolved" v-if="resolved" :contribution="contribution" @next="$emit('next')" /> | ||||||
|
|
||||||
| <!-- Sidebar nav entry --> | ||||||
| <NuxtLink v-else-if="variant === 'sidebar.nav'" :to="href || '#'" class="ext-nav-item"> | ||||||
| <component :is="icon" class="ext-nav-icon" /> | ||||||
| <span class="ext-nav-label">{{ card?.title || integration.name }}</span> | ||||||
| </NuxtLink> | ||||||
|
|
||||||
| <!-- Onboarding step --> | ||||||
| <div v-else-if="variant === 'onboarding.steps'" class="ext-onboarding"> | ||||||
| <div class="ext-onboarding-icon"> | ||||||
| <component :is="icon" /> | ||||||
| </div> | ||||||
| <h2 class="ext-onboarding-title">{{ onboarding?.title || card?.title || integration.name }}</h2> | ||||||
| <p v-if="onboarding?.description || card?.description" class="ext-onboarding-desc"> | ||||||
| {{ onboarding?.description || card?.description }} | ||||||
| </p> | ||||||
| <div class="ext-onboarding-actions"> | ||||||
| <TButton | ||||||
| v-if="href" | ||||||
| :to="href" | ||||||
| :text="card?.cta || 'Open'" | ||||||
| variant="outline" | ||||||
| :full-width="false" | ||||||
| /> | ||||||
| <TButton :text="'Continue'" :full-width="false" @click="$emit('next')" /> | ||||||
| </div> | ||||||
| </div> | ||||||
|
|
||||||
| <!-- Default: an integration card (settings.integrations, dashboard.widgets) --> | ||||||
| <TCard v-else class="ext-card"> | ||||||
| <div class="ext-card-head"> | ||||||
| <span class="ext-card-icon"><component :is="icon" /></span> | ||||||
| <h3 class="ext-card-title">{{ card?.title || integration.name }}</h3> | ||||||
| <span v-if="!contribution.configured" class="ext-card-badge">{{ 'Needs setup' }}</span> | ||||||
| </div> | ||||||
| <p v-if="card?.description || integration.description" class="ext-card-desc"> | ||||||
| {{ card?.description || integration.description }} | ||||||
| </p> | ||||||
| <TButton | ||||||
| v-if="card?.cta" | ||||||
| :to="href || undefined" | ||||||
| :text="card.cta" | ||||||
| variant="outline" | ||||||
| size="small" | ||||||
| :full-width="false" | ||||||
| @click="$emit('action', contribution)" | ||||||
| /> | ||||||
| </TCard> | ||||||
| </template> | ||||||
|
|
||||||
| <script setup lang="ts"> | ||||||
| import { computed } from 'vue'; | ||||||
| import type { Component } from 'vue'; | ||||||
| import * as LucideIcons from 'lucide-vue-next'; | ||||||
| import TCard from '@/components/TCard.vue'; | ||||||
| import TButton from '@/components/TButton.vue'; | ||||||
| import type { SlotContribution } from '~/types/integration'; | ||||||
| import { useExtensionSlots } from '~/composables/useExtensionSlots'; | ||||||
|
|
||||||
| const props = defineProps<{ contribution: SlotContribution }>(); | ||||||
| defineEmits<{ next: []; action: [SlotContribution] }>(); | ||||||
|
|
||||||
| const { resolveComponent } = useExtensionSlots(); | ||||||
|
|
||||||
| const integration = computed(() => props.contribution.integration); | ||||||
| const ui = computed(() => props.contribution.ui); | ||||||
| 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); | ||||||
| const resolved = computed(() => resolveComponent(ui.value.component)); | ||||||
|
|
||||||
| // Resolve the descriptor's icon name (kebab or PascalCase) to a lucide | ||||||
| // component, with a generic glyph fallback so every plugin surface carries an | ||||||
| // icon like its built-in neighbors. | ||||||
| const lucide = LucideIcons as unknown as Record<string, Component>; | ||||||
| const icon = computed<Component>(() => { | ||||||
| const name = integration.value.icon; | ||||||
| if (name) { | ||||||
| const pascal = name.replace(/(^\w|[-_ ]\w)/g, (m) => m.replace(/[-_ ]/, '').toUpperCase()); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||
| return lucide[name] || lucide[pascal] || lucide.Puzzle; | ||||||
| } | ||||||
| return lucide.Puzzle; | ||||||
| }); | ||||||
| </script> | ||||||
|
|
||||||
| <style lang="scss" scoped> | ||||||
| @use '@/assets/scss/_variables.scss' as *; | ||||||
|
|
||||||
| .ext-card-head { | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| gap: $spacing-3; | ||||||
| margin-bottom: $spacing-2; | ||||||
| } | ||||||
|
|
||||||
| .ext-card-icon { | ||||||
| display: inline-flex; | ||||||
| align-items: center; | ||||||
| justify-content: center; | ||||||
| width: 36px; | ||||||
| height: 36px; | ||||||
| border-radius: $radius-lg; | ||||||
| background: $primary-light; | ||||||
| color: $primary; | ||||||
| flex-shrink: 0; | ||||||
|
|
||||||
| :deep(svg) { | ||||||
| width: 20px; | ||||||
| height: 20px; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| .ext-card-title { | ||||||
| font-size: $font-size-base; | ||||||
| font-weight: $font-semibold; | ||||||
| color: $text-primary; | ||||||
| margin: 0; | ||||||
| } | ||||||
|
|
||||||
| .ext-card-badge { | ||||||
| margin-left: auto; | ||||||
| font-size: $font-size-xs; | ||||||
| font-weight: $font-medium; | ||||||
| color: $text-muted; | ||||||
| background: $bg-gray; | ||||||
| border-radius: 999px; | ||||||
| padding: 2px 8px; | ||||||
| flex-shrink: 0; | ||||||
| } | ||||||
|
|
||||||
| .ext-card-desc { | ||||||
| font-size: $font-size-sm; | ||||||
| color: $text-secondary; | ||||||
| margin: 0 0 $spacing-4; | ||||||
| } | ||||||
|
|
||||||
| .ext-onboarding { | ||||||
| text-align: center; | ||||||
| } | ||||||
|
|
||||||
| .ext-onboarding-icon { | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| justify-content: center; | ||||||
| width: 56px; | ||||||
| height: 56px; | ||||||
| margin: 0 auto $spacing-4; | ||||||
| border-radius: 50%; | ||||||
| background: $primary-light; | ||||||
| color: $primary; | ||||||
|
|
||||||
| :deep(svg) { | ||||||
| width: 28px; | ||||||
| height: 28px; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| .ext-onboarding-title { | ||||||
| font-size: $font-size-lg; | ||||||
| font-weight: $font-semibold; | ||||||
| color: $text-primary; | ||||||
| } | ||||||
|
|
||||||
| .ext-onboarding-desc { | ||||||
| font-size: $font-size-base; | ||||||
| color: $text-secondary; | ||||||
| margin: $spacing-2 0 $spacing-6; | ||||||
| } | ||||||
|
|
||||||
| .ext-onboarding-actions { | ||||||
| display: flex; | ||||||
| gap: $spacing-3; | ||||||
| justify-content: center; | ||||||
| } | ||||||
|
|
||||||
| .ext-nav-item { | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| gap: $spacing-2; | ||||||
| padding: $spacing-2 $spacing-3; | ||||||
| border-radius: $radius-md; | ||||||
| color: $text-secondary; | ||||||
| text-decoration: none; | ||||||
|
|
||||||
| &:hover { | ||||||
| background: $primary-light; | ||||||
| color: $primary; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| .ext-nav-icon { | ||||||
| width: 20px; | ||||||
| height: 20px; | ||||||
| flex-shrink: 0; | ||||||
| } | ||||||
| </style> | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <template> | ||
| <DescriptorRenderer | ||
| v-for="contribution in contributions" | ||
| :key="contribution.key" | ||
| :contribution="contribution" | ||
| @action="$emit('action', $event)" | ||
| /> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import DescriptorRenderer from '@/components/extensions/DescriptorRenderer.vue'; | ||
| import type { ExtensionSlot, SlotContribution } from '~/types/integration'; | ||
| import { useExtensionSlots } from '~/composables/useExtensionSlots'; | ||
|
|
||
| const props = defineProps<{ name: ExtensionSlot }>(); | ||
| defineEmits<{ action: [SlotContribution] }>(); | ||
|
|
||
| const { contributionsFor } = useExtensionSlots(); | ||
| const contributions = contributionsFor(props.name); | ||
| </script> |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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(() => []); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silencing the error with an empty array in
Suggested change
|
||||||
| } 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); | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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<Record<string, Component>>({}); | ||||||||
|
|
||||||||
| /** | ||||||||
| * 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<SlotContribution[]>(() => { | ||||||||
| 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: | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||
| 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 | ||||||||
| }; | ||||||||
| }; | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Integration[]>([]); | ||
| const loading = ref(false); | ||
| const loaded = ref(false); | ||
|
|
||
| export const useIntegrations = () => { | ||
| const load = async (force = false): Promise<Integration[]> => { | ||
| // 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 | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The priority of
hrefresolution is slightly inconsistent. In the template (line 18-19), it checkshref(which includes onboarding), but for the card variant (line 39) it useshref || undefined. Centralizing this logic in the computed property prevents potential dead links.