UI update
This commit is contained in:
@@ -2,6 +2,92 @@
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Backend JSON Bridge
|
||||
|
||||
The UI exposes a JSON bridge intended for integration inside a native host such as a C++ application.
|
||||
|
||||
### Available bridge entry points
|
||||
|
||||
- `window.kwaUiBridge.receiveFromBackend(message)` lets the host inject a JSON string or already parsed message object into the UI.
|
||||
- `window.kwaUiBridge.sendRequest(topic, payload)` sends a `requestMessage` to the host and resolves with the matching `responseMessage.payload`.
|
||||
- `window.kwaUiBridge.emitEvent(topic, payload)` sends a one-way `eventMessage` to the host.
|
||||
- Outgoing messages are also mirrored as a browser event named `kwa-ui:outgoing-message` whose `detail` contains the serialized JSON.
|
||||
- Incoming messages can also be injected by dispatching `kwa-ui:incoming-message` with the raw JSON string in `detail`.
|
||||
|
||||
### Message envelopes
|
||||
|
||||
Every message is JSON and uses one of the following envelope shapes.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "requestMessage",
|
||||
"id": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f",
|
||||
"timestamp": "2026-05-22T15:28:00.000Z"
|
||||
"topic": "requirements.list",
|
||||
"payload": {},
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "responseMessage",
|
||||
"id": "7e7e0cba-d4b8-4d8f-a0cb-31f4f3110db5",
|
||||
"requestId": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f",
|
||||
"timestamp": "2026-05-22T15:28:00.010Z"
|
||||
"topic": "requirements.list",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"requirements": []
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "eventMessage",
|
||||
"id": "94f2b4d4-6f16-4f14-8dc7-869f6d3e1a55",
|
||||
"timestamp": "2026-05-22T15:28:00.020Z"
|
||||
"topic": "requirements.updated",
|
||||
"payload": {
|
||||
"requirement": {
|
||||
"id": 101
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Native host transport
|
||||
|
||||
The bridge will send outgoing JSON to the first available transport below:
|
||||
|
||||
- `window.kwaBackendHost.postMessage(string)`
|
||||
- `window.chrome.webview.postMessage(string)`
|
||||
- `window.webkit.messageHandlers.kwaUiBridge.postMessage(string)`
|
||||
|
||||
If none of these are available, the UI still emits `kwa-ui:outgoing-message`, which is useful for testing or for a wrapper script.
|
||||
|
||||
### Registered topics
|
||||
|
||||
- `requirements.list`: request the full requirements collection and current selection.
|
||||
- `requirements.get`: request payload `{ "id": number }`, returns one requirement or `null`.
|
||||
- `requirements.select`: request or event payload `{ "id": number }`, selects the matching requirement.
|
||||
- `requirements.update`: request payload `{ "requirement": { ... } }`, updates a requirement and returns the saved value.
|
||||
- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the current collection in the UI.
|
||||
|
||||
### Events pushed by the backend to the UI
|
||||
|
||||
The backend can push an `eventMessage` at any time by calling `window.kwaUiBridge.receiveFromBackend(json)`. The following topics are handled:
|
||||
|
||||
- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the full requirements collection in the UI.
|
||||
- `requirements.select`: event payload `{ "id": number }`, selects the matching requirement in the UI.
|
||||
|
||||
### Events emitted by the UI
|
||||
|
||||
- `ui.ready`: emitted once the bridge is installed.
|
||||
- `requirements.selectionChanged`: emitted whenever the selected requirement changes.
|
||||
- `requirements.updated`: emitted after a requirement update initiated in the UI.
|
||||
- `requirements.replaced`: emitted after the full requirements collection is replaced.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
29
kwa-ui/env.d.ts
vendored
29
kwa-ui/env.d.ts
vendored
@@ -1 +1,30 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { BridgeMessage, UiBridgeApi } from './src/bridge/backendBridge'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kwaBackendHost?: {
|
||||
postMessage: (message: string) => void
|
||||
}
|
||||
kwaBridgeConfig?: {
|
||||
webkitHandlerNames?: string[]
|
||||
}
|
||||
kwaUiBridge?: UiBridgeApi
|
||||
chrome?: {
|
||||
webview?: {
|
||||
postMessage: (message: string) => void
|
||||
}
|
||||
}
|
||||
webkit?: {
|
||||
messageHandlers?: Record<string, { postMessage: (message: string) => void }>
|
||||
}
|
||||
}
|
||||
|
||||
interface WindowEventMap {
|
||||
'kwa-ui:incoming-message': CustomEvent<string | BridgeMessage>
|
||||
'kwa-ui:outgoing-message': CustomEvent<string>
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
|
||||
394
kwa-ui/src/bridge/backendBridge.ts
Normal file
394
kwa-ui/src/bridge/backendBridge.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
const OUTGOING_EVENT_NAME = 'kwa-ui:outgoing-message'
|
||||
const INCOMING_EVENT_NAME = 'kwa-ui:incoming-message'
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
export type BridgeMessageKind = 'requestMessage' | 'responseMessage' | 'eventMessage'
|
||||
|
||||
export interface BridgeMessageBase<TKind extends BridgeMessageKind, TPayload = unknown> {
|
||||
kind: TKind
|
||||
id: string
|
||||
timestamp: string
|
||||
topic: string
|
||||
payload: TPayload
|
||||
}
|
||||
|
||||
export interface RequestMessage<TPayload = unknown>
|
||||
extends BridgeMessageBase<'requestMessage', TPayload> {}
|
||||
|
||||
export interface ResponseMessage<TPayload = unknown>
|
||||
extends BridgeMessageBase<'responseMessage', TPayload> {
|
||||
requestId: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface EventMessage<TPayload = unknown>
|
||||
extends BridgeMessageBase<'eventMessage', TPayload> {}
|
||||
|
||||
export type BridgeMessage<TPayload = unknown> =
|
||||
| RequestMessage<TPayload>
|
||||
| ResponseMessage<TPayload>
|
||||
| EventMessage<TPayload>
|
||||
|
||||
export interface RequestOptions {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface UiBridgeApi {
|
||||
receiveFromBackend: (message: string | BridgeMessage) => boolean
|
||||
sendRequest: <TResponse = unknown, TPayload = unknown>(
|
||||
topic: string,
|
||||
payload: TPayload,
|
||||
options?: RequestOptions,
|
||||
) => Promise<TResponse>
|
||||
emitEvent: <TPayload = unknown>(topic: string, payload: TPayload) => void
|
||||
}
|
||||
|
||||
type RequestHandler<TPayload = unknown, TResponse = unknown> = (
|
||||
message: RequestMessage<TPayload>,
|
||||
) => Promise<TResponse> | TResponse
|
||||
|
||||
type EventHandler<TPayload = unknown> = (
|
||||
message: EventMessage<TPayload>,
|
||||
) => Promise<void> | void
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (payload: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
timeoutHandle: ReturnType<typeof window.setTimeout>
|
||||
}
|
||||
|
||||
interface OutboundHost {
|
||||
postMessage: (message: string) => void
|
||||
}
|
||||
|
||||
interface WebkitMessageHandler {
|
||||
postMessage: (message: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_WEBKIT_HANDLER_NAMES = ['ui_bridge_handler'] as const
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
const isBridgeMessageKind = (value: unknown): value is BridgeMessageKind => {
|
||||
return value === 'requestMessage' || value === 'responseMessage' || value === 'eventMessage'
|
||||
}
|
||||
|
||||
const isBridgeMessage = (value: unknown): value is BridgeMessage => {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isBridgeMessageKind(value.kind)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.topic === 'string' &&
|
||||
typeof value.timestamp === 'string' &&
|
||||
'payload' in value
|
||||
)
|
||||
}
|
||||
|
||||
const parseBridgeMessage = (value: string | BridgeMessage | unknown): BridgeMessage | null => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return isBridgeMessage(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return isBridgeMessage(value) ? value : null
|
||||
}
|
||||
|
||||
const createMessageId = () => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
const normalizeError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return typeof error === 'string' ? error : 'Unknown bridge error'
|
||||
}
|
||||
|
||||
const isWebkitMessageHandler = (value: unknown): value is WebkitMessageHandler => {
|
||||
return isRecord(value) && typeof value.postMessage === 'function'
|
||||
}
|
||||
|
||||
const resolveWebkitHandlers = (): OutboundHost[] => {
|
||||
const messageHandlers = window.webkit?.messageHandlers
|
||||
|
||||
if (!isRecord(messageHandlers)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const handlers: OutboundHost[] = []
|
||||
|
||||
for (const handlerName of DEFAULT_WEBKIT_HANDLER_NAMES) {
|
||||
const handler = (messageHandlers as Record<string, unknown>)[handlerName]
|
||||
|
||||
if (!isWebkitMessageHandler(handler)) {
|
||||
continue
|
||||
}
|
||||
|
||||
handlers.push({
|
||||
postMessage: (message: string) => handler.postMessage(message),
|
||||
})
|
||||
}
|
||||
|
||||
return handlers
|
||||
}
|
||||
|
||||
const resolveOutboundHosts = (): OutboundHost[] => {
|
||||
const hosts: OutboundHost[] = []
|
||||
|
||||
if (window.kwaBackendHost?.postMessage) {
|
||||
hosts.push(window.kwaBackendHost)
|
||||
}
|
||||
|
||||
if (window.chrome?.webview?.postMessage) {
|
||||
hosts.push({
|
||||
postMessage: (message: string) => window.chrome?.webview?.postMessage(message),
|
||||
})
|
||||
}
|
||||
|
||||
hosts.push(...resolveWebkitHandlers())
|
||||
|
||||
const legacyExternal = (window as Window & { external?: { notify?: (message: string) => void } }).external
|
||||
if (legacyExternal?.notify) {
|
||||
hosts.push({
|
||||
postMessage: (message: string) => legacyExternal.notify?.(message),
|
||||
})
|
||||
}
|
||||
|
||||
if (window.parent && window.parent !== window) {
|
||||
hosts.push({
|
||||
postMessage: (message: string) => window.parent.postMessage(message, '*'),
|
||||
})
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
class BackendBridge {
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>()
|
||||
private readonly eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private readonly pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('message', this.handleWindowMessage)
|
||||
window.addEventListener(INCOMING_EVENT_NAME, this.handleCustomIncoming as EventListener)
|
||||
}
|
||||
|
||||
sendRequest = <TResponse = unknown, TPayload = unknown>(
|
||||
topic: string,
|
||||
payload: TPayload,
|
||||
options?: RequestOptions,
|
||||
): Promise<TResponse> => {
|
||||
const requestMessage: RequestMessage<TPayload> = {
|
||||
kind: 'requestMessage',
|
||||
id: createMessageId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
topic,
|
||||
payload,
|
||||
}
|
||||
|
||||
return new Promise<TResponse>((resolve, reject) => {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
||||
const timeoutHandle = window.setTimeout(() => {
|
||||
this.pendingRequests.delete(requestMessage.id)
|
||||
reject(new Error(`Bridge request timed out for topic "${topic}".`))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pendingRequests.set(requestMessage.id, {
|
||||
resolve: (responsePayload) => resolve(responsePayload as TResponse),
|
||||
reject,
|
||||
timeoutHandle,
|
||||
})
|
||||
|
||||
this.postMessage(requestMessage)
|
||||
})
|
||||
}
|
||||
|
||||
sendResponse = <TPayload = unknown>(
|
||||
requestId: string,
|
||||
topic: string,
|
||||
payload: TPayload,
|
||||
ok = true,
|
||||
error?: string,
|
||||
) => {
|
||||
const responseMessage: ResponseMessage<TPayload> = {
|
||||
kind: 'responseMessage',
|
||||
id: createMessageId(),
|
||||
requestId,
|
||||
topic,
|
||||
payload,
|
||||
ok,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
this.postMessage(responseMessage)
|
||||
}
|
||||
|
||||
emitEvent = <TPayload = unknown>(topic: string, payload: TPayload) => {
|
||||
const eventMessage: EventMessage<TPayload> = {
|
||||
kind: 'eventMessage',
|
||||
id: createMessageId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
topic,
|
||||
payload,
|
||||
}
|
||||
|
||||
this.postMessage(eventMessage)
|
||||
}
|
||||
|
||||
onRequest = <TPayload = unknown, TResponse = unknown>(
|
||||
topic: string,
|
||||
handler: RequestHandler<TPayload, TResponse>,
|
||||
) => {
|
||||
this.requestHandlers.set(topic, handler as RequestHandler)
|
||||
|
||||
return () => {
|
||||
this.requestHandlers.delete(topic)
|
||||
}
|
||||
}
|
||||
|
||||
onEvent = <TPayload = unknown>(topic: string, handler: EventHandler<TPayload>) => {
|
||||
const handlers = this.eventHandlers.get(topic) ?? new Set<EventHandler>()
|
||||
handlers.add(handler as EventHandler)
|
||||
this.eventHandlers.set(topic, handlers)
|
||||
|
||||
return () => {
|
||||
handlers.delete(handler as EventHandler)
|
||||
|
||||
if (handlers.size === 0) {
|
||||
this.eventHandlers.delete(topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
receiveFromBackend = (message: string | BridgeMessage) => {
|
||||
const parsedMessage = parseBridgeMessage(message)
|
||||
|
||||
if (!parsedMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
void this.dispatchIncoming(parsedMessage)
|
||||
return true
|
||||
}
|
||||
|
||||
private readonly handleWindowMessage = (event: MessageEvent) => {
|
||||
this.receiveFromBackend(event.data)
|
||||
}
|
||||
|
||||
private readonly handleCustomIncoming = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<string | BridgeMessage>
|
||||
this.receiveFromBackend(customEvent.detail)
|
||||
}
|
||||
|
||||
private postMessage(message: BridgeMessage) {
|
||||
const serializedMessage = JSON.stringify(message)
|
||||
|
||||
for (const host of resolveOutboundHosts()) {
|
||||
try {
|
||||
host.postMessage(serializedMessage)
|
||||
} catch {
|
||||
// Best effort transport fan-out.
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<string>(OUTGOING_EVENT_NAME, {
|
||||
detail: serializedMessage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async dispatchIncoming(message: BridgeMessage) {
|
||||
if (message.kind === 'responseMessage') {
|
||||
this.resolvePendingRequest(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.kind === 'requestMessage') {
|
||||
await this.handleRequestMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
await this.handleEventMessage(message)
|
||||
}
|
||||
|
||||
private resolvePendingRequest(message: ResponseMessage) {
|
||||
const pendingRequest = this.pendingRequests.get(message.requestId)
|
||||
|
||||
if (!pendingRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
window.clearTimeout(pendingRequest.timeoutHandle)
|
||||
this.pendingRequests.delete(message.requestId)
|
||||
|
||||
if (!message.ok) {
|
||||
pendingRequest.reject(new Error(message.error ?? `Bridge request failed for topic "${message.topic}".`))
|
||||
return
|
||||
}
|
||||
|
||||
pendingRequest.resolve(message.payload)
|
||||
}
|
||||
|
||||
private async handleRequestMessage(message: RequestMessage) {
|
||||
const handler = this.requestHandlers.get(message.topic)
|
||||
|
||||
if (!handler) {
|
||||
this.sendResponse(message.id, message.topic, null, false, `No request handler for topic "${message.topic}".`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await handler(message)
|
||||
this.sendResponse(message.id, message.topic, payload, true)
|
||||
} catch (error) {
|
||||
this.sendResponse(message.id, message.topic, null, false, normalizeError(error))
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEventMessage(message: EventMessage) {
|
||||
const handlers = this.eventHandlers.get(message.topic)
|
||||
|
||||
if (!handlers || handlers.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const handler of handlers) {
|
||||
await handler(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const backendBridge = new BackendBridge()
|
||||
|
||||
export const uiBridgeApi: UiBridgeApi = {
|
||||
receiveFromBackend: backendBridge.receiveFromBackend,
|
||||
sendRequest: backendBridge.sendRequest,
|
||||
emitEvent: backendBridge.emitEvent,
|
||||
}
|
||||
|
||||
window.kwaUiBridge = uiBridgeApi
|
||||
|
||||
export const bridgeEventNames = {
|
||||
incoming: INCOMING_EVENT_NAME,
|
||||
outgoing: OUTGOING_EVENT_NAME,
|
||||
}
|
||||
80
kwa-ui/src/bridge/requirementsBridge.ts
Normal file
80
kwa-ui/src/bridge/requirementsBridge.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { watch } from 'vue'
|
||||
import { backendBridge } from './backendBridge'
|
||||
import { useRequirementsStore, type Requirement } from '../stores/requirements'
|
||||
|
||||
interface RequirementSelectionPayload {
|
||||
id: number
|
||||
}
|
||||
|
||||
interface RequirementUpdatePayload {
|
||||
requirement: Requirement
|
||||
}
|
||||
|
||||
interface RequirementReplacePayload {
|
||||
requirements: Requirement[]
|
||||
}
|
||||
|
||||
export const installRequirementsBridge = (store: ReturnType<typeof useRequirementsStore>) => {
|
||||
backendBridge.onRequest('requirements.list', () => {
|
||||
return {
|
||||
requirements: store.requirements,
|
||||
selectedId: store.selectedId,
|
||||
}
|
||||
})
|
||||
|
||||
backendBridge.onRequest<RequirementSelectionPayload>('requirements.get', ({ payload }) => {
|
||||
return {
|
||||
requirement: store.getRequirementById(payload.id),
|
||||
}
|
||||
})
|
||||
|
||||
backendBridge.onRequest<RequirementSelectionPayload>('requirements.select', ({ payload }) => {
|
||||
return {
|
||||
requirement: store.selectRequirement(payload.id),
|
||||
}
|
||||
})
|
||||
|
||||
backendBridge.onRequest<RequirementUpdatePayload>('requirements.update', ({ payload }) => {
|
||||
return {
|
||||
requirement: store.updateRequirement(payload.requirement),
|
||||
}
|
||||
})
|
||||
|
||||
backendBridge.onEvent<RequirementReplacePayload>('requirements.replaceAll', ({ payload }) => {
|
||||
store.setRequirements(payload.requirements)
|
||||
})
|
||||
|
||||
backendBridge.onEvent<RequirementSelectionPayload>('requirements.select', ({ payload }) => {
|
||||
store.selectRequirement(payload.id)
|
||||
})
|
||||
|
||||
store.$onAction(({ name, after }) => {
|
||||
after((result) => {
|
||||
if (name === 'updateRequirement' && result) {
|
||||
backendBridge.emitEvent('requirements.updated', {
|
||||
requirement: result,
|
||||
})
|
||||
}
|
||||
|
||||
if (name === 'setRequirements') {
|
||||
backendBridge.emitEvent('requirements.replaced', {
|
||||
requirements: store.requirements,
|
||||
selectedId: store.selectedId,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.selectedId,
|
||||
(selectedId) => {
|
||||
backendBridge.emitEvent('requirements.selectionChanged', {
|
||||
selectedId,
|
||||
requirement: store.getRequirementById(selectedId),
|
||||
})
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
backendBridge.emitEvent('ui.ready', {})
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
import '@fontsource/source-sans-3/latin-400.css'
|
||||
import '@fontsource/source-sans-3/latin-500.css'
|
||||
import '@fontsource/source-sans-3/latin-600.css'
|
||||
import '@fontsource/source-sans-3/latin-700.css'
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
@@ -12,10 +8,22 @@ import Aura from '@primeuix/themes/aura'
|
||||
import 'primeicons/primeicons.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { installRequirementsBridge } from './bridge/requirementsBridge'
|
||||
import { useRequirementsStore } from './stores/requirements'
|
||||
|
||||
const loadEmbeddedFonts = async () => {
|
||||
await Promise.allSettled([
|
||||
import('@fontsource/source-sans-3/latin-400.css'),
|
||||
import('@fontsource/source-sans-3/latin-500.css'),
|
||||
import('@fontsource/source-sans-3/latin-600.css'),
|
||||
import('@fontsource/source-sans-3/latin-700.css'),
|
||||
])
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.use(PrimeVue, {
|
||||
@@ -27,4 +35,9 @@ app.use(PrimeVue, {
|
||||
},
|
||||
})
|
||||
|
||||
installRequirementsBridge(useRequirementsStore(pinia))
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Do not block bridge initialization on font asset loading in embedded hosts.
|
||||
void loadEmbeddedFonts()
|
||||
|
||||
@@ -25,6 +25,16 @@ export interface Requirement {
|
||||
tolerances: string // Acceptable limits for deviations from the requirement (e.g., "Maximum 5% of requirements can have incomplete artifact links")
|
||||
}
|
||||
|
||||
const cloneRequirement = (requirement: Requirement): Requirement => {
|
||||
return {
|
||||
...requirement,
|
||||
acceptanceCriteria: [...requirement.acceptanceCriteria],
|
||||
impactedModules: [...requirement.impactedModules],
|
||||
blockers: [...requirement.blockers],
|
||||
stakeholders: [...requirement.stakeholders],
|
||||
}
|
||||
}
|
||||
|
||||
export const useRequirementsStore = defineStore('requirements', () => {
|
||||
const requirements = ref<Requirement[]>([
|
||||
{
|
||||
@@ -157,22 +167,47 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
||||
return { total, approvedCount, blockedCount, criticalCount }
|
||||
})
|
||||
|
||||
const getRequirementById = (id: number) => {
|
||||
return requirements.value.find((requirement) => requirement.id === id) ?? null
|
||||
}
|
||||
|
||||
const selectRequirement = (nextId: number) => {
|
||||
const nextRequirement = getRequirementById(nextId)
|
||||
|
||||
if (!nextRequirement) {
|
||||
return null
|
||||
}
|
||||
|
||||
selectedId.value = nextId
|
||||
return nextRequirement
|
||||
}
|
||||
|
||||
const setRequirements = (nextRequirements: Requirement[]) => {
|
||||
requirements.value = nextRequirements.map(cloneRequirement)
|
||||
|
||||
if (requirements.value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!getRequirementById(selectedId.value)) {
|
||||
selectedId.value = requirements.value[0]!.id
|
||||
}
|
||||
|
||||
return requirements.value
|
||||
}
|
||||
|
||||
const updateRequirement = (updatedRequirement: Requirement) => {
|
||||
const index = requirements.value.findIndex(
|
||||
(requirement) => requirement.id === updatedRequirement.id
|
||||
)
|
||||
|
||||
if (index === -1) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
requirements.value[index] = {
|
||||
...updatedRequirement,
|
||||
acceptanceCriteria: [...updatedRequirement.acceptanceCriteria],
|
||||
impactedModules: [...updatedRequirement.impactedModules],
|
||||
blockers: [...updatedRequirement.blockers],
|
||||
stakeholders: [...updatedRequirement.stakeholders],
|
||||
}
|
||||
requirements.value[index] = cloneRequirement(updatedRequirement)
|
||||
|
||||
return requirements.value[index]
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,6 +216,9 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
||||
searchQuery,
|
||||
selectedRequirement,
|
||||
stats,
|
||||
getRequirementById,
|
||||
selectRequirement,
|
||||
setRequirements,
|
||||
updateRequirement,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
|
||||
@@ -8,14 +9,21 @@ import RequirementsTreeList from '../components/RequirementsTreeList.vue'
|
||||
import RequirementDetail from '../components/RequirementDetail.vue'
|
||||
import { useRequirementsStore } from '../stores/requirements'
|
||||
import type { Requirement } from '../stores/requirements'
|
||||
import { backendBridge } from '../bridge/backendBridge'
|
||||
|
||||
import type { TreeNode } from '../components/RequirementsTreeList.vue'
|
||||
|
||||
interface RequirementsListResponse {
|
||||
requirements: Requirement[]
|
||||
selectedId?: number
|
||||
}
|
||||
|
||||
const requirementsStore = useRequirementsStore()
|
||||
const { requirements, selectedId, searchQuery, selectedRequirement, stats } =
|
||||
storeToRefs(requirementsStore)
|
||||
|
||||
const hasUnsavedChanges = ref(false)
|
||||
const showTimeoutDialog = ref(false)
|
||||
|
||||
const splitterDraggingClass = 'is-splitter-dragging'
|
||||
|
||||
@@ -31,6 +39,37 @@ onBeforeUnmount(() => {
|
||||
document.body.classList.remove(splitterDraggingClass)
|
||||
})
|
||||
|
||||
const loadRequirementsFromBackend = async () => {
|
||||
try {
|
||||
const response = await backendBridge.sendRequest<RequirementsListResponse>('requirements.list', {}, {
|
||||
timeoutMs: 5000,
|
||||
})
|
||||
|
||||
if (!response || !Array.isArray(response.requirements)) {
|
||||
return
|
||||
}
|
||||
|
||||
requirementsStore.setRequirements(response.requirements)
|
||||
|
||||
if (typeof response.selectedId === 'number') {
|
||||
requirementsStore.selectRequirement(response.selectedId)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (message.toLowerCase().includes('timed out')) {
|
||||
showTimeoutDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('Failed to load requirements from backend:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadRequirementsFromBackend()
|
||||
})
|
||||
|
||||
const buildTreeData = (): TreeNode[] => {
|
||||
return [
|
||||
{
|
||||
@@ -109,12 +148,25 @@ const onSelectRequirement = (nextId: number) => {
|
||||
}
|
||||
|
||||
hasUnsavedChanges.value = false
|
||||
selectedId.value = nextId
|
||||
requirementsStore.selectRequirement(nextId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="requirements-view">
|
||||
<Dialog
|
||||
v-model:visible="showTimeoutDialog"
|
||||
header="Backend timeout"
|
||||
modal
|
||||
:closable="true"
|
||||
:draggable="false"
|
||||
:style="{ width: '28rem' }"
|
||||
>
|
||||
<p class="timeout-message">
|
||||
The retrieval of requirements has timed out. Please check the connection with the backend and try again.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
<Splitter
|
||||
class="workspace-splitter"
|
||||
:gutter-size="6"
|
||||
@@ -165,6 +217,11 @@ const onSelectRequirement = (nextId: number) => {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.timeout-message {
|
||||
margin: 0;
|
||||
color: #3f4d68;
|
||||
}
|
||||
|
||||
.workspace-splitter :deep(.p-splitter-gutter) {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
|
||||
Reference in New Issue
Block a user