ADR-013 Phase 4: relocate 4 self-contained plugin frontends

Relocate applications, geenforce, knowledgebase, and machines - each owns only
its own views dir, so a clean move to plugins/<name>/frontend/ (views/ +
routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its
vitest spec move with it (ManifestEditor imports it as a sibling).

Machinery fixes this batch surfaced:
- routes.gen.js codegen uses namespace imports (import * as p_x). A route file
  without a `toplevel` export is undefined on the namespace instead of a strict-
  ESM missing-binding build error.
- vitest gains a `pretest` stage so plugin-frontend specs (now under
  plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/.

Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper)
renders fully from its staged frontend. Build + 58 vitest + naming green.
This commit is contained in:
cproudlock
2026-07-18 23:51:23 -04:00
parent af9a3b190b
commit 23dc9fa379
21 changed files with 52 additions and 49 deletions

View File

@@ -0,0 +1,29 @@
/**
* Knowledge Base plugin routes
*/
export default [
{
path: 'knowledgebase',
name: 'knowledgebase',
component: () => import('./views/KnowledgeBaseList.vue'),
meta: { plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/new',
name: 'knowledgebase-new',
component: () => import('./views/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true, plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/:id',
name: 'knowledgebase-detail',
component: () => import('./views/KnowledgeBaseDetail.vue'),
meta: { plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/:id/edit',
name: 'knowledgebase-edit',
component: () => import('./views/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true, plugin: 'knowledgebase' }
}
]

View File

@@ -0,0 +1,212 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveArticle">
<div class="form-group">
<label for="shortdescription">Description *</label>
<input
id="shortdescription"
v-model="form.shortdescription"
type="text"
class="form-control"
required
maxlength="500"
placeholder="Brief description of the article"
/>
</div>
<div class="form-group">
<label for="linkurl">URL *</label>
<input
id="linkurl"
v-model="form.linkurl"
type="url"
class="form-control"
required
maxlength="2000"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="keywords">Keywords</label>
<input
id="keywords"
v-model="form.keywords"
type="text"
class="form-control"
maxlength="500"
placeholder="Space-separated keywords"
/>
<small class="form-hint">Keywords help with search - separate with spaces</small>
</div>
<div class="form-group">
<label for="appid">Topic (Application)</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
>
<option value="">-- Select Topic (Optional) --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Select the application/topic this article relates to</small>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
</button>
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { knowledgebaseApi, applicationsApi } from '@/api'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const form = ref({
shortdescription: '',
linkurl: '',
keywords: '',
appid: ''
})
const applications = ref([])
onMounted(async () => {
try {
// Load applications for topic dropdown
const appsRes = await applicationsApi.list({ perpage: 1000 })
applications.value = appsRes.data.data || []
// Load article if editing
if (isEdit.value) {
const response = await knowledgebaseApi.get(route.params.id)
const article = response.data.data
form.value = {
shortdescription: article.shortdescription || '',
linkurl: article.linkurl || '',
keywords: article.keywords || '',
appid: article.application?.appid || ''
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveArticle() {
error.value = ''
saving.value = true
try {
const articleData = {
shortdescription: form.value.shortdescription,
linkurl: form.value.linkurl,
keywords: form.value.keywords || null,
appid: form.value.appid || null
}
if (isEdit.value) {
await knowledgebaseApi.update(route.params.id, articleData)
} else {
await knowledgebaseApi.create(articleData)
}
router.push('/knowledgebase')
} catch (err) {
console.error('Error saving article:', err)
error.value = apiError(err, 'Failed to save article')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light, #666);
}
</style>

View File

@@ -0,0 +1,263 @@
<template>
<div>
<div class="page-header">
<div class="header-left">
<h2>Knowledge Base</h2>
<span v-if="stats" class="stats-badge">
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
</span>
</div>
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search articles..."
@input="debouncedSearch"
/>
<select v-model="topicFilter" class="form-control" @change="loadArticles">
<option value="">All Topics</option>
<option
v-for="app in topics"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable" @click="toggleSort('topic')">
Topic
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" @click="toggleSort('description')">
Description
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
Clicks
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="article in articles" :key="article.linkid">
<td>
<router-link
v-if="article.application"
:to="`/applications/${article.application.appid}`"
>
{{ article.application.appname }}
</router-link>
<span v-else class="text-muted">-</span>
</td>
<td>
<a
href="#"
class="article-link"
@click.prevent="openArticle(article)"
:title="article.linkurl"
>
{{ truncate(article.shortdescription, 95) }}
</a>
</td>
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
<td class="actions">
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
View
</router-link>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
Edit
</router-link>
</td>
</tr>
<tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
const sort = ref('clicks')
const order = ref('desc')
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadArticles(),
loadTopics(),
loadStats()
])
})
async function loadArticles() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
sort: sort.value,
order: order.value
}
if (search.value) params.search = search.value
if (topicFilter.value) params.appid = topicFilter.value
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)
} finally {
loading.value = false
}
}
async function loadTopics() {
try {
const response = await applicationsApi.list({ perpage: 1000 })
topics.value = response.data.data || []
} catch (error) {
console.error('Error loading topics:', error)
}
}
async function loadStats() {
try {
const response = await knowledgebaseApi.getStats()
stats.value = response.data.data
} catch (error) {
console.error('Error loading stats:', error)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadArticles()
}, 300)
}
function goToPage(p) {
setPage(p)
loadArticles()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadArticles()
}
function toggleSort(column) {
if (sort.value === column) {
order.value = order.value === 'desc' ? 'asc' : 'desc'
} else {
sort.value = column
order.value = 'desc'
}
loadArticles()
}
function truncate(text, length) {
if (!text) return ''
if (text.length <= length) return text
return text.substring(0, length) + '...'
}
async function openArticle(article) {
try {
await knowledgebaseApi.trackClick(article.linkid)
article.clicks = (article.clicks || 0) + 1
if (stats.value) {
stats.value.totalclicks++
}
if (article.linkurl) {
window.open(article.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
if (article.linkurl) {
window.open(article.linkurl, '_blank')
}
}
}
</script>
<style scoped>
/* Knowledge Base specific styles only */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-left h2 {
margin: 0;
}
.stats-badge {
background: var(--bg);
color: var(--link);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
}
.article-link {
color: var(--text);
}
.article-link:hover {
color: var(--link);
}
.text-muted {
color: var(--text-light);
}
</style>