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
18 changes: 18 additions & 0 deletions PLUGINS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Trakli UI plugins

Set `TRAKLI_UI_LAYERS` to a comma-separated list of installed Nuxt layer paths before development or build. Paths are relative to the web UI checkout, or absolute.

```bash
TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run dev
TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run build
TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run test:run
TRAKLI_UI_LAYERS=/path/to/plugin/ui npm run check:plugins
```

Without this setting, the app loads no optional plugin layers. A layer supplies its pages, translations and optional components. Keep the matching backend plugin enabled. Installing or removing a layer requires restarting development or rebuilding the app.

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()`.

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.
6 changes: 6 additions & 0 deletions composables/useExtensionSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const componentRegistry = ref<Record<string, Component>>({});
*/
export const useExtensionSlots = () => {
const { integrations } = useIntegrations();
const router = useRouter();

const contributionsFor = (slot: ExtensionSlot) =>
computed<SlotContribution[]>(() => {
Expand All @@ -39,6 +40,11 @@ export const useExtensionSlots = () => {
continue;
}

const href = ui.onboarding?.href || ui.card?.href;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

|| takes the first present href, so a contribution is gated on onboarding.href even when the card's own card.href route exists (and vice versa). A working link then disappears from a slot. Check every local href and skip only when none resolve.

Suggested change
const href = ui.onboarding?.href || ui.card?.href;
const localHrefs = [ui.onboarding?.href, ui.card?.href].filter(
(href): href is string => typeof href === 'string' && href.startsWith('/')
);
if (localHrefs.length && localHrefs.every((href) => !router.resolve(href).matched.length)) {
continue;
}

if (href?.startsWith('/') && !router.resolve(href).matched.length) {
continue;
}

items.push({
key: integration.key,
slot,
Expand Down
7 changes: 6 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { defineNuxtConfig } from 'nuxt/config';
import Icons from 'unplugin-icons/vite';

const pluginLayers = (process.env.TRAKLI_UI_LAYERS || '')
.split(',')
.map((layer) => layer.trim())
.filter(Boolean);

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
vite: {
Expand All @@ -12,7 +17,7 @@ export default defineNuxtConfig({
// A layer registers a component against a slot key with
// useExtensionSlots().registerComponent(), and DescriptorRenderer resolves it when a
// descriptor sets `ui.component`. That is how plugin UI reaches the app.
extends: ['@whilesmart/eloquent-admin-ui'],
extends: ['@whilesmart/eloquent-admin-ui', ...pluginLayers],
routeRules: {
'/ai-insights': { redirect: '/assistant' }
},
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"format:check": "prettier --check ./",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"check:plugins": "node scripts/check-plugin-layers.mjs"
},
"dependencies": {
"@whilesmart/eloquent-admin-ui": "^0.1.0",
Expand Down
49 changes: 49 additions & 0 deletions scripts/check-plugin-layers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readdir, readFile } from 'node:fs/promises';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ESLint } from 'eslint';
import * as prettier from 'prettier';

const host = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const layers = (process.env.TRAKLI_UI_LAYERS || '')
.split(',')
.map((layer) => layer.trim())
.filter(Boolean)
.map((layer) => resolve(host, layer));
const extensions = new Set(['.ts', '.js', '.mjs', '.vue', '.json', '.md']);
const excluded = new Set(['node_modules', '.nuxt', '.output', '.git']);

async function sources(directory) {
const files = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (excluded.has(entry.name)) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...(await sources(path)));
else if (extensions.has(extname(path))) files.push(path);
}
return files;
}

if (layers.length) {
let common = host;
for (const layer of layers) {
while (relative(common, layer).startsWith('..')) common = dirname(common);
}
const eslint = new ESLint({ cwd: common, overrideConfigFile: join(host, 'eslint.config.mjs') });
const results = await eslint.lintFiles(layers.map((layer) => `${layer}/**/*.{ts,js,mjs,vue}`));
console.log(await (await eslint.loadFormatter('stylish')).format(results));
if (results.some((result) => result.errorCount)) process.exitCode = 1;

const options = await prettier.resolveConfig(join(host, 'nuxt.config.ts'));
for (const layer of layers) {
for (const file of await sources(layer)) {
if (!(await prettier.check(await readFile(file, 'utf8'), { ...options, filepath: file }))) {
console.error(`Formatting failed: ${relative(host, file)}`);
process.exitCode = 1;
}
}
}
if (!process.exitCode) console.log('Plugin layer lint and formatting passed.');
} else {
console.log('No plugin layers configured.');
}
11 changes: 11 additions & 0 deletions tests/composables/useExtensionSlots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ function makeIntegration(overrides: Partial<Integration>): Integration {
describe('useExtensionSlots', () => {
beforeEach(() => {
mockIntegrations.value = [];
vi.stubGlobal('useRouter', () => ({ resolve: () => ({ matched: [{}] }) }));
});

it('hides local links when their plugin route is not installed', () => {
vi.stubGlobal('useRouter', () => ({ resolve: () => ({ matched: [] }) }));
mockIntegrations.value = [
makeIntegration({
ui: { slots: ['sidebar.nav'], card: { title: 'Optional plugin', href: '/optional' } }
})
];
expect(useExtensionSlots().contributionsFor('sidebar.nav').value).toEqual([]);
});

it('collects only contributions that declare the slot', () => {
Expand Down
14 changes: 13 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,32 @@ import vue from '@vitejs/plugin-vue';
import Icons from 'unplugin-icons/vite';
import { resolve } from 'path';

const pluginLayers = (process.env.TRAKLI_UI_LAYERS || '')
.split(',')
.map((layer) => layer.trim())
.filter(Boolean);

export default defineConfig({
plugins: [Icons({ compiler: 'vue3', autoInstall: false }), vue()],
server: {
fs: { allow: [resolve(__dirname), ...pluginLayers.map((layer) => resolve(__dirname, layer))] }
},
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.{test,spec}.{js,ts}'],
include: [
'tests/**/*.{test,spec}.{js,ts}',
...pluginLayers.map((layer) => `${layer}/tests/**/*.{test,spec}.{js,ts}`)
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['components/**/*.vue', 'composables/**/*.ts', 'services/**/*.ts']
}
},
resolve: {
dedupe: ['vue', '@vue/test-utils', 'vitest'],
alias: {
'@': resolve(__dirname, './'),
'~': resolve(__dirname, './')
Expand Down
Loading