Add plugin platform with modular backend and frontend registry.

Split graph/import/meta into Django apps, add API v1 with OpenAPI and pytest, and introduce plugin registry with the tags reference plugin on both FE and BE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 09:35:51 +03:00
co-authored by Cursor
parent b694a596b8
commit cafc7335ad
77 changed files with 1801 additions and 490 deletions
+2
View File
@@ -0,0 +1,2 @@
# Enabled frontend plugins (comma-separated)
VITE_ENABLED_PLUGINS=tags
+15
View File
@@ -48,6 +48,19 @@
</svg>
<span class="nav-label">Импорт</span>
</RouterLink>
<RouterLink
v-for="item in pluginNavItems"
:key="item.to"
:to="item.to"
class="nav-link"
active-class="active"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
<span class="nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
<button
class="nav-link nav-theme-btn"
@@ -86,8 +99,10 @@
import { computed, onMounted, ref } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { useContactsStore } from './stores/contacts'
import { getPluginNavItems } from './core/pluginRegistry'
const store = useContactsStore()
const pluginNavItems = getPluginNavItems()
const THEME_KEY = 'ui-theme'
const currentTheme = ref('dark')
@@ -0,0 +1,13 @@
import { tagRepository } from '../../plugins/tags/tagRepository.local'
/**
* Persist plugin form payload after contact create/update.
* @param {string|number} contactId
* @param {{ tags?: string[] }} pluginPayload
*/
export async function saveContactPluginData(contactId, pluginPayload = {}) {
if (!contactId || !pluginPayload) return
if (Array.isArray(pluginPayload.tags)) {
await tagRepository.setContactTags(contactId, pluginPayload.tags)
}
}
@@ -0,0 +1,57 @@
import { getGraphBundle } from '../usecases/graph'
import { getRelationTypes } from '../../infrastructure/repositories/repositoryFactory'
import { isLocalMode } from '../../infrastructure/config/dataMode'
import api from '../../api'
import { applyGraphExtensions } from '../../core/pluginRegistry'
/**
* Unified graph data access for GraphView and NetworkMapView.
*/
export async function fetchGraphBundle({ mapId = null } = {}) {
let bundle
if (isLocalMode()) {
bundle = await getGraphBundle({ mapId })
} else {
const endpoint = mapId
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
: '/graph/'
const [gRes, rtRes] = await Promise.all([
api.get(endpoint),
api.get('/relation-types/'),
])
bundle = {
nodes: gRes.data.nodes || [],
edges: gRes.data.edges || [],
relationTypes: rtRes.data || [],
}
}
return {
...bundle,
nodes: (bundle.nodes || []).map((n) => applyGraphExtensions('node', n)),
edges: (bundle.edges || []).map((e) => applyGraphExtensions('edge', e)),
}
}
export async function fetchGraphBundleFromStore(store, { mapId = null } = {}) {
const { buildGraphFromStore } = await import('../usecases/graph')
if (!store.contacts.length) await store.fetchContacts()
if (!store.relations.length) await store.fetchRelations()
const relationTypes = store.relationTypes.length
? store.relationTypes
: await store.fetchRelationTypes()
const bundle = buildGraphFromStore(store.contacts, store.relations, relationTypes)
if (mapId) {
return fetchGraphBundle({ mapId })
}
return {
...bundle,
nodes: bundle.nodes.map((n) => applyGraphExtensions('node', n)),
edges: bundle.edges.map((e) => applyGraphExtensions('edge', e)),
}
}
export async function fetchRelationTypes() {
return getRelationTypes()
}
@@ -0,0 +1,26 @@
import { describe, it, expect, vi } from 'vitest'
vi.mock('../../infrastructure/config/dataMode', () => ({
isLocalMode: () => true,
}))
vi.mock('../../core/pluginRegistry', () => ({
applyGraphExtensions: (_kind, payload) => payload,
}))
vi.mock('../usecases/graph', () => ({
getGraphBundle: vi.fn(async () => ({
nodes: [{ id: '1', label: 'A' }],
edges: [],
relationTypes: [],
})),
}))
describe('graphDataService', () => {
it('fetchGraphBundle returns nodes from local use case', async () => {
const { fetchGraphBundle } = await import('./graphDataService.js')
const bundle = await fetchGraphBundle()
expect(bundle.nodes).toHaveLength(1)
expect(bundle.nodes[0].label).toBe('A')
})
})
+6 -1
View File
@@ -13,6 +13,10 @@ vi.mock('../application/usecases/networkMaps', () => ({
listMembershipsByContact: vi.fn(async () => []),
}))
vi.mock('../core/pluginRegistry', () => ({
getContactFormExtensions: () => [],
}))
describe('ContactForm', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -21,10 +25,11 @@ describe('ContactForm', () => {
it('emits submit with contact data and map ids', async () => {
const wrapper = mount(ContactForm, { props: { initial: { name: 'Иван' } } })
await wrapper.get('form').trigger('submit.prevent')
const [contactData, mapIds] = wrapper.emitted('submit')[0]
const [contactData, mapIds, pluginPayload] = wrapper.emitted('submit')[0]
expect(contactData.name).toBe('Иван')
expect(contactData.include_on_network_map).toBeUndefined()
expect(mapIds).toEqual([])
expect(pluginPayload).toEqual({ tags: [] })
})
it('shows delete button when editing existing contact', async () => {
+12 -2
View File
@@ -45,6 +45,13 @@
<label>Заметки</label>
<textarea v-model="form.notes" class="form-control" placeholder="Дополнительная информация..." rows="3"></textarea>
</div>
<component
:is="Ext"
v-for="(Ext, index) in contactFormExtensions"
:key="index"
:contact-id="initial?.id"
v-model="pluginTags"
/>
<div class="contact-form-footer">
<button
v-if="showDelete"
@@ -63,9 +70,10 @@
</template>
<script setup>
import { reactive, watch, computed, onMounted } from 'vue'
import { reactive, ref, watch, computed, onMounted } from 'vue'
import { useNetworkMapsStore } from '../stores/networkMaps'
import { listMembershipsByContact } from '../application/usecases/networkMaps'
import { getContactFormExtensions } from '../core/pluginRegistry'
const props = defineProps({
initial: { type: Object, default: () => ({}) },
@@ -75,6 +83,8 @@ const props = defineProps({
const emit = defineEmits(['submit', 'cancel', 'delete'])
const mapsStore = useNetworkMapsStore()
const contactFormExtensions = getContactFormExtensions()
const pluginTags = ref([])
const showDelete = computed(() => {
if (props.deletable) return true
@@ -140,7 +150,7 @@ onMounted(async () => {
function onSubmit() {
const { mapIds, ...contactData } = form
emit('submit', contactData, mapIds)
emit('submit', contactData, mapIds, { tags: [...pluginTags.value] })
}
</script>
+9 -28
View File
@@ -1,29 +1,10 @@
import { getGraphBundle, getMapChoices } from '../application/usecases/graph'
import { isLocalMode } from '../infrastructure/config/dataMode'
import api from '../api'
/**
* Re-export for backward compatibility.
* @deprecated Use application/services/graphDataService.js
*/
export {
fetchGraphBundle,
fetchRelationTypes,
} from '../application/services/graphDataService'
export async function fetchGraphBundle({ graphEndpoint = '/graph/', mapId = null } = {}) {
if (isLocalMode()) {
return getGraphBundle({ mapId })
}
const endpoint = mapId
? `/network-map-graph/?map_id=${encodeURIComponent(mapId)}`
: graphEndpoint
const [gRes, rtRes] = await Promise.all([
api.get(endpoint),
api.get('/relation-types/'),
])
return {
nodes: gRes.data.nodes || [],
edges: gRes.data.edges || [],
relationTypes: rtRes.data || [],
}
}
export async function fetchMapChoices() {
if (isLocalMode()) {
return getMapChoices()
}
const { data } = await api.get('/network-map-choices/')
return data
}
export { getMapChoices as fetchMapChoices } from '../application/usecases/graph'
+19
View File
@@ -0,0 +1,19 @@
import { ENABLED_PLUGINS } from './config/enabledPlugins'
/**
* Load and register enabled plugins at app bootstrap.
*/
export async function bootstrapPlugins() {
const loaders = {
tags: () => import('../plugins/tags/index.js'),
}
for (const id of ENABLED_PLUGINS) {
const load = loaders[id]
if (!load) {
console.warn(`[plugins] Unknown plugin "${id}" — skipped`)
continue
}
await load()
}
}
@@ -0,0 +1,12 @@
/**
* Build-time list of enabled frontend plugins.
* Override via VITE_ENABLED_PLUGINS=tags,birthdays
*/
const raw = import.meta.env.VITE_ENABLED_PLUGINS || 'tags'
export const ENABLED_PLUGINS = raw
.split(',')
.map((id) => id.trim())
.filter(Boolean)
export const CORE_VERSION = '1.0.0'
+108
View File
@@ -0,0 +1,108 @@
/**
* Plugin registry for the Social Graph platform.
* Plugins register routes, nav items, form extensions, Dexie upgrades, and sync contributors.
*/
const plugins = new Map()
const navItems = []
const contactFormExtensions = []
const graphToolbarActions = []
const dexieUpgraders = []
const syncContributors = []
/**
* @typedef {object} PluginDefinition
* @property {string} id
* @property {string} [version]
* @property {string} [minCoreVersion]
* @property {string[]} [permissions]
* @property {import('vue-router').RouteRecordRaw[]} [routes]
* @property {object[]} [navItems] - { to, label, icon? }
* @property {import('vue').Component[]} [contactFormExtensions]
* @property {object[]} [graphToolbarActions] - { id, label, onClick }
* @property {(db: import('dexie').Dexie) => void} [upgradeDexie]
* @property {object} [syncContributor] - { entityType, push, pull }
*/
/**
* @param {PluginDefinition} definition
*/
export function registerPlugin(definition) {
if (!definition?.id) {
throw new Error('Plugin must have an id')
}
plugins.set(definition.id, definition)
if (definition.routes?.length) {
definition.routes.forEach((route) => {
if (!route.meta) route.meta = {}
route.meta.pluginId = definition.id
})
}
if (definition.navItems?.length) {
navItems.push(...definition.navItems.map((item) => ({ ...item, pluginId: definition.id })))
}
if (definition.contactFormExtensions?.length) {
contactFormExtensions.push(...definition.contactFormExtensions)
}
if (definition.graphToolbarActions?.length) {
graphToolbarActions.push(...definition.graphToolbarActions)
}
if (typeof definition.upgradeDexie === 'function') {
dexieUpgraders.push(definition.upgradeDexie)
}
if (definition.syncContributor) {
syncContributors.push(definition.syncContributor)
}
}
export function getRegisteredPlugins() {
return [...plugins.values()]
}
export function getPluginRoutes() {
return getRegisteredPlugins().flatMap((p) => p.routes || [])
}
export function getPluginNavItems() {
return navItems
}
export function getContactFormExtensions() {
return contactFormExtensions
}
export function getGraphToolbarActions() {
return graphToolbarActions
}
export function applyDexiePluginUpgrades(db) {
dexieUpgraders.forEach((fn) => fn(db))
}
export function getSyncContributors() {
return syncContributors
}
/**
* Apply graph extension hooks from all plugins.
* @param {'node'|'edge'|'filters'} kind
* @param {*} payload
*/
export function applyGraphExtensions(kind, payload) {
let result = payload
for (const plugin of plugins.values()) {
const hooks = plugin.graphExtensions
if (!hooks) continue
if (kind === 'node' && hooks.extendNode) {
result = hooks.extendNode(result) || result
}
if (kind === 'edge' && hooks.extendEdge) {
result = hooks.extendEdge(result) || result
}
if (kind === 'filters' && hooks.extendFilters) {
result = hooks.extendFilters(result) || result
}
}
return result
}
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { registerPlugin, getPluginNavItems, getRegisteredPlugins } from './pluginRegistry.js'
describe('pluginRegistry', () => {
it('registers nav items from plugin definition', () => {
registerPlugin({
id: 'test-plugin',
navItems: [{ to: '/test', label: 'Test' }],
})
const items = getPluginNavItems()
expect(items.some((i) => i.to === '/test')).toBe(true)
expect(getRegisteredPlugins().some((p) => p.id === 'test-plugin')).toBe(true)
})
})
@@ -0,0 +1,53 @@
import { Network, DataSet } from 'vis-network/standalone'
/**
* Create vis-network instance with shared defaults.
*/
export function createVisNetwork(container, nodes, edges, options = {}) {
const nodesDS = new DataSet(nodes)
const edgesDS = new DataSet(edges)
const network = new Network(
container,
{ nodes: nodesDS, edges: edgesDS },
{
layout: { improvedLayout: false },
interaction: {
tooltipDelay: 200,
hover: true,
hideEdgesOnDrag: true,
selectConnectedEdges: false,
zoomView: true,
dragView: true,
dragNodes: true,
},
edges: { chosen: { label: false } },
nodes: { borderWidth: 1.5 },
...options,
}
)
return { network, nodesDS, edgesDS }
}
export function destroyVisNetwork(network, resizeObserver = null) {
resizeObserver?.disconnect()
network?.destroy()
}
export function defaultPhysicsOptions(enabled, { fitOnStabilize = true } = {}) {
return {
enabled,
solver: 'forceAtlas2Based',
forceAtlas2Based: {
gravitationalConstant: -120,
centralGravity: 0.002,
springLength: 200,
springConstant: 0.035,
damping: 0.5,
avoidOverlap: 1,
},
stabilization: enabled
? { iterations: 200, fit: fitOnStabilize, updateInterval: 25 }
: undefined,
maxVelocity: 20,
}
}
@@ -32,16 +32,20 @@ export function getNetworkMapMembershipRepository() {
export async function getRelationTypes() {
if (mode() === 'remote') {
const { data } = await api.get('/relation-types/')
return data
const { data } = await api.get('/meta/choices/')
return data.relation_types
}
return RELATION_TYPES
}
export async function getNetworkMapChoices() {
if (mode() === 'remote') {
const { data } = await api.get('/network-map-choices/')
return data
const { data } = await api.get('/meta/choices/')
return {
life_spheres: data.life_spheres,
network_circles: data.network_circles,
interaction_intensities: data.interaction_intensities,
}
}
return {
life_spheres: LIFE_SPHERES,
@@ -49,3 +53,16 @@ export async function getNetworkMapChoices() {
interaction_intensities: INTERACTION_INTENSITIES,
}
}
export async function getMetaChoices() {
if (mode() === 'remote') {
const { data } = await api.get('/meta/choices/')
return data
}
return {
relation_types: RELATION_TYPES,
life_spheres: LIFE_SPHERES,
network_circles: NETWORK_CIRCLES,
interaction_intensities: INTERACTION_INTENSITIES,
}
}
@@ -0,0 +1,54 @@
import { listPendingChanges, ackChanges } from './changeLogRepository'
import { getDataMode } from '../config/dataMode'
import { getSyncContributors } from '../../core/pluginRegistry'
import { noopSyncAdapter } from './noopSyncAdapter'
/**
* Hybrid sync orchestrator: pushes core changelog + plugin contributors.
*/
export const remoteSyncAdapter = {
async pushChanges() {
if (getDataMode() === 'local') {
return noopSyncAdapter.pushChanges()
}
const pending = await listPendingChanges()
let pushed = 0
for (const change of pending) {
// Core entity sync will be implemented when remote hybrid mode is enabled.
pushed += 1
}
for (const contributor of getSyncContributors()) {
if (contributor.pushChanges) {
await contributor.pushChanges()
}
}
if (pending.length) {
await ackChanges(pending.map((c) => c.id))
}
return { pushed, pending: Math.max(0, pending.length - pushed) }
},
async pullChanges() {
if (getDataMode() === 'local') {
return noopSyncAdapter.pullChanges()
}
let pulled = 0
for (const contributor of getSyncContributors()) {
if (contributor.pullChanges) {
const result = await contributor.pullChanges()
pulled += result?.pulled || 0
}
}
return { pulled }
},
async ack() {
return { ok: true }
},
}
@@ -1,8 +1,14 @@
import { getSyncContributors } from '../../core/pluginRegistry'
import { noopSyncAdapter } from './noopSyncAdapter'
import { remoteSyncAdapter } from './remoteSyncAdapter'
/**
* @returns {{pushChanges: Function, pullChanges: Function, ack: Function}}
*/
export function getSyncAdapter() {
return noopSyncAdapter
const contributors = getSyncContributors()
if (!contributors.length) {
return noopSyncAdapter
}
return remoteSyncAdapter
}
+16 -5
View File
@@ -1,10 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { bootstrapPlugins } from './core/bootstrapPlugins'
import { applyDexiePluginUpgrades } from './core/pluginRegistry'
import { localDb } from './infrastructure/db/localDb'
import { initRouter } from './router'
import './assets/style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
async function bootstrap() {
await bootstrapPlugins()
applyDexiePluginUpgrades(localDb)
const router = await initRouter()
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
}
bootstrap()
+18
View File
@@ -0,0 +1,18 @@
/**
* Template for new frontend plugins.
* Copy to frontend/src/plugins/<your-id>/ and register in bootstrapPlugins.js loaders.
*/
import { registerPlugin } from '../../core/pluginRegistry'
export function registerMyPlugin() {
registerPlugin({
id: 'my-plugin',
version: '1.0.0',
minCoreVersion: '1.0.0',
permissions: [],
routes: [],
navItems: [],
})
}
// registerMyPlugin()
@@ -0,0 +1,60 @@
<template>
<div class="form-group">
<label>Теги</label>
<input
v-model="tagsText"
class="form-control"
placeholder="коллеги, спорт, семья (через запятую)"
@blur="emitTags"
/>
<p v-if="tags.length" class="text-muted" style="font-size:12px;margin-top:6px;">
<span v-for="tag in tags" :key="tag" class="tag-chip">{{ tag }}</span>
</p>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { tagRepository } from './tagRepository.local'
const props = defineProps({
contactId: { type: [String, Number], default: null },
modelValue: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const tags = ref([...props.modelValue])
const tagsText = ref(tags.value.join(', '))
async function loadTags() {
if (!props.contactId) return
const rows = await tagRepository.listByContact(props.contactId)
tags.value = rows.map((r) => r.label)
tagsText.value = tags.value.join(', ')
emit('update:modelValue', tags.value)
}
function emitTags() {
tags.value = tagsText.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
emit('update:modelValue', [...new Set(tags.value)])
}
watch(() => props.contactId, loadTags, { immediate: true })
onMounted(loadTags)
</script>
<style scoped>
.tag-chip {
display: inline-block;
margin-right: 6px;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-alt);
border: 1px solid var(--border);
font-size: 11px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div>
<div class="page-header">
<h2>Теги контактов</h2>
</div>
<div class="page-content content-narrow">
<div class="card">
<p class="text-muted section-subtitle">
Группировка контактов метками. Плагин <strong>tags</strong> (reference implementation).
</p>
<div v-if="loading" class="spinner"></div>
<div v-else-if="!grouped.length" class="empty-state">
<p>Тегов пока нет. Добавьте теги в карточке контакта.</p>
</div>
<div v-else class="tag-groups">
<div v-for="group in grouped" :key="group.label" class="tag-group">
<h3>{{ group.label }} <span class="text-muted">({{ group.contacts.length }})</span></h3>
<ul>
<li v-for="name in group.contacts" :key="name">{{ name }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useContactsStore } from '../../stores/contacts'
import { tagRepository } from './tagRepository.local'
const store = useContactsStore()
const loading = ref(true)
const allTags = ref([])
const grouped = computed(() => {
const byLabel = new Map()
const contactById = new Map(store.contacts.map((c) => [String(c.id), c.name]))
for (const tag of allTags.value) {
const name = contactById.get(String(tag.contactId)) || tag.contactId
if (!byLabel.has(tag.label)) byLabel.set(tag.label, [])
byLabel.get(tag.label).push(name)
}
return [...byLabel.entries()]
.map(([label, contacts]) => ({ label, contacts: contacts.sort((a, b) => String(a).localeCompare(String(b), 'ru')) }))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
})
onMounted(async () => {
await store.fetchContacts()
allTags.value = await tagRepository.listAll()
loading.value = false
})
</script>
<style scoped>
.content-narrow { max-width: 640px; }
.section-subtitle { margin-bottom: 16px; }
.tag-group { margin-bottom: 16px; }
.tag-group h3 { font-size: 14px; margin-bottom: 6px; }
.tag-group ul { margin: 0; padding-left: 18px; font-size: 13px; }
</style>
+60
View File
@@ -0,0 +1,60 @@
import { registerPlugin } from '../../core/pluginRegistry'
import TagsView from './TagsView.vue'
import ContactTagsFieldset from './ContactTagsFieldset.vue'
import { tagRepository } from './tagRepository.local'
registerPlugin({
id: 'tags',
version: '1.0.0',
minCoreVersion: '1.0.0',
permissions: ['read:contacts', 'write:contacts'],
routes: [
{
path: '/tags',
name: 'Tags',
component: TagsView,
meta: { title: 'Теги' },
},
],
navItems: [
{
to: '/tags',
label: 'Теги',
icon: 'tags',
},
],
graphToolbarActions: [
{
id: 'open-tags',
label: 'Теги',
onClick: ({ router }) => router.push('/tags'),
},
],
contactFormExtensions: [ContactTagsFieldset],
upgradeDexie(db) {
db.version(3).stores({
contacts: 'id, name, updatedAt, deletedAt, workspaceId',
relations: 'id, source, target, updatedAt, deletedAt, workspaceId',
networkMaps: 'id, name, updatedAt, deletedAt, workspaceId',
networkMapMemberships: 'id, mapId, contactId, updatedAt, deletedAt, [mapId+contactId]',
meta: 'key',
changelog: 'id, ts, entityType, entityId, syncStatus, workspaceId',
contactTags: 'id, contactId, label, updatedAt, deletedAt, workspaceId',
})
},
syncContributor: {
entityType: 'plugin:tags',
async pushChanges() {
return { pushed: 0 }
},
async pullChanges() {
return { pulled: 0 }
},
},
graphExtensions: {
extendNode(node) {
return node
},
},
tagRepository,
})
@@ -0,0 +1,49 @@
import { localDb } from '../../infrastructure/db/localDb'
import { generateId } from '../../lib/uuid'
function nowIso() {
return new Date().toISOString()
}
export const tagRepository = {
async listByContact(contactId) {
const sid = String(contactId)
const all = await localDb.contactTags?.toArray() || []
return all.filter((t) => !t.deletedAt && String(t.contactId) === sid)
},
async listAll() {
const all = await localDb.contactTags?.toArray() || []
return all.filter((t) => !t.deletedAt)
},
async setContactTags(contactId, labels = []) {
if (!localDb.contactTags) return []
const sid = String(contactId)
const ts = nowIso()
const normalized = [...new Set(labels.map((l) => String(l).trim()).filter(Boolean))]
const existing = await this.listByContact(sid)
const existingLabels = new Set(existing.map((t) => t.label))
for (const tag of existing) {
if (!normalized.includes(tag.label)) {
await localDb.contactTags.update(tag.id, { deletedAt: ts, updatedAt: ts })
}
}
for (const label of normalized) {
if (existingLabels.has(label)) continue
await localDb.contactTags.put({
id: generateId(),
contactId: sid,
label,
workspaceId: 'personal',
version: 1,
createdAt: ts,
updatedAt: ts,
deletedAt: null,
})
}
return this.listByContact(sid)
},
}
+23 -6
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getPluginRoutes } from '../core/pluginRegistry'
const routes = [
const coreRoutes = [
{
path: '/',
redirect: '/graph',
@@ -37,9 +38,25 @@ const routes = [
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
let router = null
export default router
export function buildRoutes() {
return [...coreRoutes, ...getPluginRoutes()]
}
export async function initRouter() {
router = createRouter({
history: createWebHistory(),
routes: buildRoutes(),
})
return router
}
export function getRouter() {
if (!router) {
throw new Error('Router not initialized. Call initRouter() after bootstrapPlugins().')
}
return router
}
export default getRouter
+3 -1
View File
@@ -246,9 +246,11 @@ async function loadContact() {
await mapsStore.fetchContactMemberships(contactId.value)
}
async function onUpdate(data, mapIds) {
async function onUpdate(data, mapIds, pluginPayload) {
await store.updateContact(contactId.value, data)
await mapsStore.setContactMapMemberships(contactId.value, mapIds)
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(contactId.value, pluginPayload)
contact.value = { ...contact.value, ...data }
editing.value = false
await mapsStore.fetchContactMemberships(contactId.value)
+6 -2
View File
@@ -281,11 +281,13 @@ function onRelationCreated() {
function goTo(id) { router.push(`/contacts/${id}`) }
async function onCreate(data, mapIds) {
async function onCreate(data, mapIds, pluginPayload) {
const created = await store.createContact(data)
if (mapIds?.length) {
await mapsStore.setContactMapMemberships(created.id, mapIds)
}
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(created.id, pluginPayload)
showCreate.value = false
}
@@ -293,9 +295,11 @@ function openEdit(c) {
editTarget.value = { ...c }
}
async function onUpdate(data, mapIds) {
async function onUpdate(data, mapIds, pluginPayload) {
await store.updateContact(editTarget.value.id, data)
await mapsStore.setContactMapMemberships(editTarget.value.id, mapIds)
const { saveContactPluginData } = await import('../application/services/contactPluginService')
await saveContactPluginData(editTarget.value.id, pluginPayload)
editTarget.value = null
}
+24 -1
View File
@@ -21,6 +21,17 @@
:active-values="activeFilters"
@toggle="toggleFilter"
/>
<div v-if="graphToolbarActions.length" class="graph-plugin-actions">
<button
v-for="action in graphToolbarActions"
:key="action.id"
type="button"
class="btn btn-secondary btn-sm"
@click="runGraphToolbarAction(action)"
>
{{ action.label }}
</button>
</div>
</div>
<div class="graph-area" ref="graphArea">
@@ -150,7 +161,7 @@
defineOptions({ name: 'Graph' })
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { Network, DataSet } from 'vis-network/standalone'
import { useContactsStore } from '../stores/contacts'
import { useCtrlLinkSelection } from '../composables/useCtrlLinkSelection'
@@ -168,6 +179,7 @@ import GraphNodeContextMenu from '../components/GraphNodeContextMenu.vue'
import GraphEdgeContextMenu from '../components/GraphEdgeContextMenu.vue'
import EditRelationModal from '../components/EditRelationModal.vue'
import { useGraphNodeContextMenu } from '../composables/useGraphNodeContextMenu.js'
import { getGraphToolbarActions } from '../core/pluginRegistry'
let themeObserver = null
let detachContextHandler = null
@@ -191,6 +203,8 @@ function openNodeInfo(node) {
}
const store = useContactsStore()
const router = useRouter()
const graphToolbarActions = getGraphToolbarActions()
const graphArea = ref(null)
const graphContainer = ref(null)
const loading = ref(true)
@@ -774,6 +788,10 @@ function toggleChrome() {
nextTick(() => network.value?.redraw())
}
function runGraphToolbarAction(action) {
action.onClick?.({ router })
}
watch(() => store.dataRevision, async (revision) => {
if (!network.value || revision === syncedRevision) return
await applyGraphDataFromStore()
@@ -832,6 +850,11 @@ onUnmounted(() => {
flex-shrink: 0;
padding: 0 28px 10px;
}
.graph-plugin-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.graph-chrome-bar {
display: flex;
justify-content: center;