Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ Without this setting, the app loads no optional plugin layers. A layer supplies

Backend integration descriptors insert links into the existing sidebar and settings slots. Links to local routes are hidden when the route is absent. Custom controls can still register through `useExtensionSlots().registerComponent()`.

A plugin adds its own Settings tab by listing `settings.tabs` in its descriptor slots and describing the tab:

```php
'tab' => ['key' => 'banks', 'title' => 'Bank connections', 'component' => 'plaid.settings', 'order' => 20],
```

The layer registers the tab body under the same component key from a Nuxt plugin. The tab appears once both exist, at `/settings?tab=<key>`. Lower `order` values come first.

Layer pages use the host's authentication middleware, layouts, components and API client. Keep provider return URLs on the same browser hostname as the UI to preserve login cookies.

Plugin tests live under the layer's `tests` directory. The host test runner includes tests from the configured layers and supplies its aliases and dependencies.
15 changes: 2 additions & 13 deletions components/extensions/DescriptorRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@
<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';
import { integrationIcon } from '~/utils/integrationIcon';

const props = defineProps<{ contribution: SlotContribution }>();
defineEmits<{ next: []; action: [SlotContribution] }>();
Expand All @@ -73,18 +73,7 @@ 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());
return lucide[name] || lucide[pascal] || lucide.Puzzle;
}
return lucide.Puzzle;
});
const icon = computed<Component>(() => integrationIcon(integration.value.icon));
</script>

<style lang="scss" scoped>
Expand Down
10 changes: 9 additions & 1 deletion composables/useExtensionSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export const useExtensionSlots = () => {
continue;
}

if (slot === 'settings.tabs' && !componentRegistry.value[ui.tab?.component ?? '']) {
continue;
}

const href = ui.onboarding?.href || ui.card?.href;
if (href?.startsWith('/') && !router.resolve(href).matched.length) {
continue;
Expand All @@ -51,7 +55,11 @@ export const useExtensionSlots = () => {
integration: integration as SlotContribution['integration'],
ui,
order:
slot === 'onboarding.steps' ? (ui.onboarding?.order ?? DEFAULT_ORDER) : DEFAULT_ORDER,
slot === 'onboarding.steps'
? (ui.onboarding?.order ?? DEFAULT_ORDER)
: slot === 'settings.tabs'
? (ui.tab?.order ?? DEFAULT_ORDER)
: DEFAULT_ORDER,
configured: integration.configured
});
}
Expand Down
7 changes: 2 additions & 5 deletions composables/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ const THEME_STORAGE_KEY = 'trakli-theme';
function applyTheme(dark: boolean) {
if (typeof document === 'undefined') return;

if (dark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
document.documentElement.classList.toggle('dark', dark);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
isDark.value = dark;
}

Expand Down
33 changes: 24 additions & 9 deletions pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</div>

<div class="settings-card">
<TTabs v-model:active-tab="activeTab" :tabs="tabs">
<TTabs :active-tab="activeTab" :tabs="tabs" @update:active-tab="selectTab">
<template #account>
<SettingsAccount @open-password-modal="showPasswordModal = true" />
</template>
Expand All @@ -30,8 +30,12 @@
<template #connections>
<SettingsConnections />
</template>
<template v-for="contribution in pluginTabs" :key="contribution.key" #[contribution.key]>
<DescriptorRenderer :contribution="contribution" />
<template
v-for="contribution in pluginTabs"
:key="contribution.key"
#[contribution.ui.tab.key]
>
<component :is="resolveComponent(contribution.ui.tab.component)" />
</template>
</TTabs>
</div>
Expand All @@ -43,6 +47,8 @@
<script setup>
import { ref, computed, markRaw } from 'vue';
import { Settings, Globe, Wallet, Sun, User, Bell, Puzzle, Bot } from 'lucide-vue-next';
import { integrationIcon } from '~/utils/integrationIcon';
import { selectedSettingsTab } from '~/utils/settingsTabs';
import TTabs from '@/components/TTabs.vue';
import SettingsAccount from '@/components/settings/SettingsAccount.vue';
import SettingsConnections from '@/components/settings/SettingsConnections.vue';
Expand All @@ -52,7 +58,6 @@ import SettingsDisplay from '@/components/settings/SettingsDisplay.vue';
import SettingsNotifications from '@/components/settings/SettingsNotifications.vue';
import PasswordModal from '@/components/settings/PasswordModal.vue';
import ExtensionSlot from '@/components/extensions/ExtensionSlot.vue';
import DescriptorRenderer from '@/components/extensions/DescriptorRenderer.vue';
import { useExtensionSlots } from '@/composables/useExtensionSlots';

const { t } = useI18n();
Expand All @@ -63,9 +68,10 @@ definePageMeta({
});

const showPasswordModal = ref(false);
const activeTab = ref('account');
const route = useRoute();
const router = useRouter();

const { contributionsFor } = useExtensionSlots();
const { contributionsFor, resolveComponent } = useExtensionSlots();
const pluginTabs = contributionsFor('settings.tabs');

const tabs = computed(() => [
Expand All @@ -77,11 +83,20 @@ const tabs = computed(() => [
{ id: 'connections', label: t('Connected AI clients'), icon: markRaw(Bot) },
{ id: 'integrations', label: t('Integrations'), icon: markRaw(Puzzle) },
...pluginTabs.value.map((contribution) => ({
id: contribution.key,
label: contribution.ui.card?.title || contribution.integration.name,
icon: markRaw(Puzzle)
id: contribution.ui.tab.key,
label: t(contribution.ui.tab.title),
icon: markRaw(integrationIcon(contribution.integration.icon))
}))
]);

const activeTab = computed(() =>
selectedSettingsTab(
route.query,
tabs.value.map((tab) => tab.id)
)
);

const selectTab = (tab) => router.replace({ query: { tab } });
</script>

<style lang="scss" scoped>
Expand Down
27 changes: 27 additions & 0 deletions tests/composables/useExtensionSlots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ describe('useExtensionSlots', () => {
expect(items[0].configured).toBe(false);
});

it('shows a settings tab only once its layer has registered the tab body', () => {
const Tab = defineComponent({ render: () => h('div', 'tab') });
mockIntegrations.value = [
makeIntegration({
key: 'late',
ui: {
slots: ['settings.tabs'],
tab: { key: 'late', title: 'Late', component: 'late.settings', order: 20 }
}
}),
makeIntegration({
key: 'early',
ui: {
slots: ['settings.tabs'],
tab: { key: 'early', title: 'Early', component: 'early.settings', order: 10 }
}
})
];
const { contributionsFor, registerComponent } = useExtensionSlots();
const tabs = contributionsFor('settings.tabs');

expect(tabs.value).toEqual([]);
registerComponent('late.settings', Tab);
registerComponent('early.settings', Tab);
expect(tabs.value.map((c) => c.key)).toEqual(['early', 'late']);
});

it('resolves a component a plugin layer registered for a slot key', () => {
const { registerComponent, resolveComponent } = useExtensionSlots();
const Custom = defineComponent({ render: () => h('div', 'custom') });
Expand Down
22 changes: 22 additions & 0 deletions tests/utils/settingsTabs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { selectedSettingsTab } from '~/utils/settingsTabs';

const available = ['account', 'general', 'banks'];

describe('selectedSettingsTab', () => {
it('opens the tab named in the address', () => {
expect(selectedSettingsTab({ tab: 'banks' }, available)).toBe('banks');
});

it('falls back to the account tab when none is named', () => {
expect(selectedSettingsTab({}, available)).toBe('account');
});

it('falls back to the account tab while a plugin tab is not available', () => {
expect(selectedSettingsTab({ tab: 'billing' }, available)).toBe('account');
});

it('ignores a repeated tab parameter', () => {
expect(selectedSettingsTab({ tab: ['banks', 'general'] }, available)).toBe('account');
});
});
10 changes: 9 additions & 1 deletion types/integration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* 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
* with the backend; a contribution only renders in a
* declared slot.
*/
export type ExtensionSlot =
Expand Down Expand Up @@ -31,11 +31,19 @@ export interface IntegrationOnboardingDescriptor {
href?: string;
}

export interface IntegrationTabDescriptor {
key: string;
title: string;
component: string;
order?: number;
}

export interface IntegrationUiDescriptor {
slots: ExtensionSlot[];
card?: IntegrationCardDescriptor | null;
connect?: IntegrationConnectDescriptor | null;
onboarding?: IntegrationOnboardingDescriptor | null;
tab?: IntegrationTabDescriptor | null;
/** Slot key a plugin Nuxt layer registered a real component for, else null. */
component?: string | null;
/**
Expand Down
10 changes: 10 additions & 0 deletions utils/integrationIcon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Component } from 'vue';
import * as LucideIcons from 'lucide-vue-next';

const lucide = LucideIcons as unknown as Record<string, Component>;

export function integrationIcon(name: string | null | undefined): Component {
if (!name) return lucide.Puzzle!;
const pascal = name.replace(/(^\w|[-_ ]\w)/g, (m) => m.replace(/[-_ ]/, '').toUpperCase());
return lucide[name] || lucide[pascal] || lucide.Puzzle!;
}
8 changes: 8 additions & 0 deletions utils/settingsTabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { LocationQuery } from 'vue-router';

export const DEFAULT_SETTINGS_TAB = 'account';

export function selectedSettingsTab(query: LocationQuery, available: string[]): string {
const requested = typeof query.tab === 'string' ? query.tab : '';
return available.includes(requested) ? requested : DEFAULT_SETTINGS_TAB;
}
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default defineConfig({
}
},
resolve: {
dedupe: ['vue', '@vue/test-utils', 'vitest'],
dedupe: ['vue', '@vue/test-utils', 'vitest', '@whilesmart/design'],
alias: {
'@': resolve(__dirname, './'),
'~': resolve(__dirname, './')
Expand Down
Loading