UI update
This commit is contained in:
@@ -2,6 +2,92 @@
|
|||||||
|
|
||||||
This template should help get you started developing with Vue 3 in Vite.
|
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
|
## Recommended IDE Setup
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
[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" />
|
/// <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>
|
<!DOCTYPE html>
|
||||||
<html lang="fr">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<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 './assets/main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
@@ -12,10 +8,22 @@ import Aura from '@primeuix/themes/aura'
|
|||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
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 app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
@@ -27,4 +35,9 @@ app.use(PrimeVue, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
installRequirementsBridge(useRequirementsStore(pinia))
|
||||||
|
|
||||||
app.mount('#app')
|
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")
|
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', () => {
|
export const useRequirementsStore = defineStore('requirements', () => {
|
||||||
const requirements = ref<Requirement[]>([
|
const requirements = ref<Requirement[]>([
|
||||||
{
|
{
|
||||||
@@ -157,22 +167,47 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
return { total, approvedCount, blockedCount, criticalCount }
|
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 updateRequirement = (updatedRequirement: Requirement) => {
|
||||||
const index = requirements.value.findIndex(
|
const index = requirements.value.findIndex(
|
||||||
(requirement) => requirement.id === updatedRequirement.id
|
(requirement) => requirement.id === updatedRequirement.id
|
||||||
)
|
)
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
requirements.value[index] = {
|
requirements.value[index] = cloneRequirement(updatedRequirement)
|
||||||
...updatedRequirement,
|
|
||||||
acceptanceCriteria: [...updatedRequirement.acceptanceCriteria],
|
return requirements.value[index]
|
||||||
impactedModules: [...updatedRequirement.impactedModules],
|
|
||||||
blockers: [...updatedRequirement.blockers],
|
|
||||||
stakeholders: [...updatedRequirement.stakeholders],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -181,6 +216,9 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
selectedRequirement,
|
selectedRequirement,
|
||||||
stats,
|
stats,
|
||||||
|
getRequirementById,
|
||||||
|
selectRequirement,
|
||||||
|
setRequirements,
|
||||||
updateRequirement,
|
updateRequirement,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
import Splitter from 'primevue/splitter'
|
import Splitter from 'primevue/splitter'
|
||||||
import SplitterPanel from 'primevue/splitterpanel'
|
import SplitterPanel from 'primevue/splitterpanel'
|
||||||
|
|
||||||
@@ -8,14 +9,21 @@ import RequirementsTreeList from '../components/RequirementsTreeList.vue'
|
|||||||
import RequirementDetail from '../components/RequirementDetail.vue'
|
import RequirementDetail from '../components/RequirementDetail.vue'
|
||||||
import { useRequirementsStore } from '../stores/requirements'
|
import { useRequirementsStore } from '../stores/requirements'
|
||||||
import type { Requirement } from '../stores/requirements'
|
import type { Requirement } from '../stores/requirements'
|
||||||
|
import { backendBridge } from '../bridge/backendBridge'
|
||||||
|
|
||||||
import type { TreeNode } from '../components/RequirementsTreeList.vue'
|
import type { TreeNode } from '../components/RequirementsTreeList.vue'
|
||||||
|
|
||||||
|
interface RequirementsListResponse {
|
||||||
|
requirements: Requirement[]
|
||||||
|
selectedId?: number
|
||||||
|
}
|
||||||
|
|
||||||
const requirementsStore = useRequirementsStore()
|
const requirementsStore = useRequirementsStore()
|
||||||
const { requirements, selectedId, searchQuery, selectedRequirement, stats } =
|
const { requirements, selectedId, searchQuery, selectedRequirement, stats } =
|
||||||
storeToRefs(requirementsStore)
|
storeToRefs(requirementsStore)
|
||||||
|
|
||||||
const hasUnsavedChanges = ref(false)
|
const hasUnsavedChanges = ref(false)
|
||||||
|
const showTimeoutDialog = ref(false)
|
||||||
|
|
||||||
const splitterDraggingClass = 'is-splitter-dragging'
|
const splitterDraggingClass = 'is-splitter-dragging'
|
||||||
|
|
||||||
@@ -31,6 +39,37 @@ onBeforeUnmount(() => {
|
|||||||
document.body.classList.remove(splitterDraggingClass)
|
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[] => {
|
const buildTreeData = (): TreeNode[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -109,12 +148,25 @@ const onSelectRequirement = (nextId: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasUnsavedChanges.value = false
|
hasUnsavedChanges.value = false
|
||||||
selectedId.value = nextId
|
requirementsStore.selectRequirement(nextId)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="requirements-view">
|
<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
|
<Splitter
|
||||||
class="workspace-splitter"
|
class="workspace-splitter"
|
||||||
:gutter-size="6"
|
:gutter-size="6"
|
||||||
@@ -165,6 +217,11 @@ const onSelectRequirement = (nextId: number) => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeout-message {
|
||||||
|
margin: 0;
|
||||||
|
color: #3f4d68;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-splitter :deep(.p-splitter-gutter) {
|
.workspace-splitter :deep(.p-splitter-gutter) {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
Reference in New Issue
Block a user