Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f66f08b0 | ||
|
|
f736e6519b | ||
|
|
7f4cfd0ea9 | ||
|
|
da85df11f7 | ||
|
|
8aa3128edf | ||
|
|
d4248775b9 | ||
|
|
e9e334cdcb | ||
|
|
4e179b40d2 | ||
|
|
14314de3b8 | ||
|
|
b742a9a59e | ||
|
|
e73ba429e7 | ||
|
|
a07d217953 | ||
|
|
a5a28f231c |
@@ -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,8 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/source-sans-3": "^5.2.9",
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"pinia": "^3.0.4",
|
||||
"primeicons": "^7.0.0",
|
||||
|
||||
@@ -1,85 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppMenubar from './components/AppMenubar.vue'
|
||||
import AppToolbar from './components/AppToolbar.vue'
|
||||
import AppStatusbar from './components/AppStatusbar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeView = computed<'home' | 'requirements'>(() => {
|
||||
return route.name === 'home' ? 'home' : 'requirements'
|
||||
})
|
||||
|
||||
const goHome = () => {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
|
||||
const goRequirements = () => {
|
||||
router.push({ name: 'requirements' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<div id="app">
|
||||
<AppMenubar
|
||||
:active-view="activeView"
|
||||
@home="goHome"
|
||||
@requirements="goRequirements"
|
||||
/>
|
||||
<AppToolbar
|
||||
:active-view="activeView"
|
||||
@home="goHome"
|
||||
@requirements="goRequirements"
|
||||
/>
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppStatusbar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,53 +1,20 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
color-scheme: light;
|
||||
color: #12213a;
|
||||
background-color: #eef3fa;
|
||||
font-family: 'Source Sans 3', sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
html {
|
||||
min-height: 100%;
|
||||
background-color: #eef3fa;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -60,27 +27,13 @@
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: inherit;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,35 +1,5 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
min-height: 100vh;
|
||||
}
|
||||
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', {})
|
||||
}
|
||||
270
kwa-ui/src/components/AppMenubar.vue
Normal file
270
kwa-ui/src/components/AppMenubar.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Menubar from 'primevue/menubar'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
interface Props {
|
||||
activeView: 'home' | 'requirements'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
home: []
|
||||
requirements: []
|
||||
}>()
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: 'File',
|
||||
items: [
|
||||
{
|
||||
label: 'New',
|
||||
icon: 'pi pi-fw pi-file',
|
||||
command: () => handleNew(),
|
||||
},
|
||||
{
|
||||
label: 'Open',
|
||||
icon: 'pi pi-fw pi-folder-open',
|
||||
command: () => handleOpen(),
|
||||
},
|
||||
{
|
||||
label: 'Save',
|
||||
icon: 'pi pi-fw pi-save',
|
||||
command: () => handleSave(),
|
||||
},
|
||||
{
|
||||
label: 'Save As...',
|
||||
icon: 'pi pi-fw pi-save',
|
||||
command: () => handleSaveAs(),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Close',
|
||||
icon: 'pi pi-fw pi-times',
|
||||
command: () => handleClose(),
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
icon: 'pi pi-fw pi-power-off',
|
||||
command: () => handleQuit(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
items: [
|
||||
{
|
||||
label: 'Undo',
|
||||
icon: 'pi pi-fw pi-undo',
|
||||
command: () => handleUndo(),
|
||||
},
|
||||
{
|
||||
label: 'Redo',
|
||||
icon: 'pi pi-fw pi-redo',
|
||||
command: () => handleRedo(),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Cut',
|
||||
icon: 'pi pi-fw pi-cut',
|
||||
command: () => handleCut(),
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
icon: 'pi pi-fw pi-copy',
|
||||
command: () => handleCopy(),
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
icon: 'pi pi-fw pi-paste',
|
||||
command: () => handlePaste(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
items: [
|
||||
{
|
||||
label: 'Refresh',
|
||||
icon: 'pi pi-fw pi-refresh',
|
||||
command: () => handleRefresh(),
|
||||
},
|
||||
{
|
||||
label: 'Zoom In',
|
||||
icon: 'pi pi-fw pi-search-plus',
|
||||
command: () => handleZoomIn(),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
icon: 'pi pi-fw pi-search-minus',
|
||||
command: () => handleZoomOut(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Windows',
|
||||
items: [
|
||||
{
|
||||
label: 'Home',
|
||||
icon: props.activeView === 'home' ? 'pi pi-fw pi-check' : 'pi pi-fw pi-home',
|
||||
command: () => emit('home'),
|
||||
},
|
||||
{
|
||||
label: 'Requirements',
|
||||
icon:
|
||||
props.activeView === 'requirements'
|
||||
? 'pi pi-fw pi-check'
|
||||
: 'pi pi-fw pi-list-check',
|
||||
command: () => emit('requirements'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tools',
|
||||
items: [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-fw pi-cog',
|
||||
command: () => handleSettings(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
items: [
|
||||
{
|
||||
label: 'About',
|
||||
icon: 'pi pi-fw pi-info-circle',
|
||||
command: () => handleAbout(),
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
icon: 'pi pi-fw pi-book',
|
||||
command: () => handleDocumentation(),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
// Handler functions
|
||||
const handleNew = () => {
|
||||
console.log('New file')
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
console.log('Open file')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Save')
|
||||
}
|
||||
|
||||
const handleSaveAs = () => {
|
||||
console.log('Save As')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
console.log('Close')
|
||||
}
|
||||
|
||||
const handleQuit = () => {
|
||||
if (confirm('Are you sure you want to quit?')) {
|
||||
console.log('Quit application')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
console.log('Undo')
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
console.log('Redo')
|
||||
}
|
||||
|
||||
const handleCut = () => {
|
||||
console.log('Cut')
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
console.log('Copy')
|
||||
}
|
||||
|
||||
const handlePaste = () => {
|
||||
console.log('Paste')
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
console.log('Refresh')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const handleZoomIn = () => {
|
||||
console.log('Zoom In')
|
||||
}
|
||||
|
||||
const handleZoomOut = () => {
|
||||
console.log('Zoom Out')
|
||||
}
|
||||
|
||||
const handleSettings = () => {
|
||||
console.log('Settings')
|
||||
}
|
||||
|
||||
const handleAbout = () => {
|
||||
console.log('About')
|
||||
}
|
||||
|
||||
const handleDocumentation = () => {
|
||||
console.log('Documentation')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="app-menubar">
|
||||
<Menubar :model="menuItems" />
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-menubar {
|
||||
border-bottom: 1px solid rgba(96, 117, 156, 0.16);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
:deep(.p-menubar) {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
:deep(.p-menubar-root-list) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
:deep(.p-menubar-item-label) {
|
||||
font-weight: 500;
|
||||
color: #44556f;
|
||||
}
|
||||
|
||||
:deep(.p-menubar-item:hover > .p-menubar-item-label) {
|
||||
color: #12213a;
|
||||
}
|
||||
|
||||
:deep(.p-menubar-item.p-menubar-item-active > .p-menubar-item-label) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
:deep(.p-submenu-list) {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(96, 117, 156, 0.14);
|
||||
box-shadow: 0 12px 40px rgba(34, 49, 77, 0.12);
|
||||
z-index: 1001;
|
||||
}
|
||||
</style>
|
||||
85
kwa-ui/src/components/AppStatusbar.vue
Normal file
85
kwa-ui/src/components/AppStatusbar.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRequirementsStore } from '../stores/requirements'
|
||||
|
||||
const requirementsStore = useRequirementsStore()
|
||||
const { requirements, selectedRequirement, stats } = storeToRefs(requirementsStore)
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
if (!selectedRequirement.value) {
|
||||
return 'No requirement selected'
|
||||
}
|
||||
return `${selectedRequirement.value.reference} - ${selectedRequirement.value.title}`
|
||||
})
|
||||
|
||||
const detailsMessage = computed(() => {
|
||||
return `Total: ${stats.value.total} | Approved: ${stats.value.approvedCount} | Blocked: ${stats.value.blockedCount}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="app-statusbar">
|
||||
<div class="status-section">
|
||||
<i :class="`pi ${selectedRequirement ? 'pi-check-circle text-success' : 'pi-info-circle text-info'}`" />
|
||||
<span class="status-text">{{ statusMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="status-divider" />
|
||||
|
||||
<div class="status-section">
|
||||
<span class="status-details">{{ detailsMessage }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
padding: 0 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-top: 1px solid rgba(96, 117, 156, 0.16);
|
||||
font-size: 0.8rem;
|
||||
color: #6c7b97;
|
||||
gap: 1rem;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.status-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-details {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-divider {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background: rgba(96, 117, 156, 0.14);
|
||||
}
|
||||
|
||||
:deep(.pi) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:deep(.pi.text-success) {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
:deep(.pi.text-info) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
152
kwa-ui/src/components/AppToolbar.vue
Normal file
152
kwa-ui/src/components/AppToolbar.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import Toolbar from 'primevue/toolbar'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
interface Props {
|
||||
activeView: 'home' | 'requirements'
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
home: []
|
||||
requirements: []
|
||||
new: []
|
||||
open: []
|
||||
save: []
|
||||
}>()
|
||||
|
||||
const handleHome = () => emit('home')
|
||||
const handleRequirements = () => emit('requirements')
|
||||
const handleNew = () => emit('new')
|
||||
const handleOpen = () => emit('open')
|
||||
const handleSave = () => emit('save')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toolbar class="app-toolbar">
|
||||
<template #start>
|
||||
<div class="toolbar-section">
|
||||
<Button
|
||||
icon="pi pi-file"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
@click="handleNew"
|
||||
v-tooltip="'New'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
@click="handleOpen"
|
||||
v-tooltip="'Open'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-save"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
@click="handleSave"
|
||||
v-tooltip="'Save'"
|
||||
/>
|
||||
|
||||
<Divider layout="vertical" />
|
||||
|
||||
<Button
|
||||
class="nav-button"
|
||||
icon="pi pi-home"
|
||||
rounded
|
||||
text
|
||||
:severity="activeView === 'home' ? 'contrast' : 'secondary'"
|
||||
@click="handleHome"
|
||||
v-tooltip="'Home'"
|
||||
/>
|
||||
<Button
|
||||
class="nav-button"
|
||||
icon="pi pi-list-check"
|
||||
rounded
|
||||
text
|
||||
:severity="activeView === 'requirements' ? 'contrast' : 'secondary'"
|
||||
@click="handleRequirements"
|
||||
v-tooltip="'Requirements'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #end>
|
||||
<div class="toolbar-section">
|
||||
<Button
|
||||
icon="pi pi-bell"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
@click="() => console.log('Notifications')"
|
||||
v-tooltip="'Notifications'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-user"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
@click="() => console.log('Profile')"
|
||||
v-tooltip="'Profile'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-toolbar {
|
||||
border-bottom: 1px solid rgba(96, 117, 156, 0.16);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0.5rem 1rem;
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
:deep(.p-toolbar) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
:deep(.p-toolbar-group-start),
|
||||
:deep(.p-toolbar-group-end) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
:deep(.nav-button.p-button) {
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.nav-button.p-button-contrast) {
|
||||
background: rgba(78, 107, 255, 0.15);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
:deep(.p-button.p-button-rounded) {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
:deep(.p-divider-vertical) {
|
||||
height: 1.5rem;
|
||||
margin: 0 0.5rem;
|
||||
background: rgba(96, 117, 156, 0.14);
|
||||
}
|
||||
|
||||
:deep(.p-tooltip) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
711
kwa-ui/src/components/RequirementDetail.vue
Normal file
711
kwa-ui/src/components/RequirementDetail.vue
Normal file
@@ -0,0 +1,711 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Chip from 'primevue/chip'
|
||||
import Divider from 'primevue/divider'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import TabView from 'primevue/tabview'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Tag from 'primevue/tag'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
|
||||
|
||||
interface Props {
|
||||
requirement: Requirement
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
title: string
|
||||
owner: string
|
||||
status: RequirementStatus
|
||||
priority: RequirementPriority
|
||||
description: string
|
||||
flexibility: string
|
||||
tolerances: string
|
||||
notes: string
|
||||
stakeholders: string
|
||||
acceptanceCriteria: string
|
||||
impactedModules: string
|
||||
blockers: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', requirement: Requirement): void
|
||||
(e: 'dirty-change', value: boolean): void
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
const form = reactive<FormState>({
|
||||
title: '',
|
||||
owner: '',
|
||||
status: 'Draft',
|
||||
priority: 'Low',
|
||||
description: '',
|
||||
flexibility: '',
|
||||
tolerances: '',
|
||||
notes: '',
|
||||
stakeholders: '',
|
||||
acceptanceCriteria: '',
|
||||
impactedModules: '',
|
||||
blockers: '',
|
||||
})
|
||||
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
const normalizeCsv = (value: string) => csvToList(value).join(',')
|
||||
const normalizeMultiline = (value: string) => multilineToList(value).join('|')
|
||||
|
||||
const listToCsv = (value: string[]) => value.join(', ')
|
||||
const listToMultiline = (value: string[]) => value.join('\n')
|
||||
|
||||
const csvToList = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const multilineToList = (value: string) =>
|
||||
value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const resetForm = (requirement: Requirement) => {
|
||||
form.title = requirement.title
|
||||
form.owner = requirement.owner
|
||||
form.status = requirement.status
|
||||
form.priority = requirement.priority
|
||||
form.description = requirement.description
|
||||
form.flexibility = requirement.flexibility
|
||||
form.tolerances = requirement.tolerances
|
||||
form.notes = requirement.notes
|
||||
form.stakeholders = listToCsv(requirement.stakeholders)
|
||||
form.acceptanceCriteria = listToMultiline(requirement.acceptanceCriteria)
|
||||
form.impactedModules = listToCsv(requirement.impactedModules)
|
||||
form.blockers = listToMultiline(requirement.blockers)
|
||||
}
|
||||
|
||||
const clearErrors = () => {
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
resetForm(props.requirement)
|
||||
clearErrors()
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false
|
||||
clearErrors()
|
||||
resetForm(props.requirement)
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
clearErrors()
|
||||
|
||||
if (!form.title.trim()) {
|
||||
errors.title = 'Title is required.'
|
||||
}
|
||||
|
||||
if (!form.owner.trim()) {
|
||||
errors.owner = 'Author is required.'
|
||||
}
|
||||
|
||||
if (!form.description.trim()) {
|
||||
errors.description = 'Description is required.'
|
||||
}
|
||||
|
||||
if (csvToList(form.stakeholders).length === 0) {
|
||||
errors.stakeholders = 'At least one stakeholder is required.'
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
|
||||
emit('save', {
|
||||
...props.requirement,
|
||||
title: form.title.trim(),
|
||||
owner: form.owner.trim(),
|
||||
status: form.status,
|
||||
priority: form.priority,
|
||||
description: form.description.trim(),
|
||||
flexibility: form.flexibility.trim(),
|
||||
tolerances: form.tolerances.trim(),
|
||||
notes: form.notes.trim(),
|
||||
stakeholders: csvToList(form.stakeholders),
|
||||
acceptanceCriteria: multilineToList(form.acceptanceCriteria),
|
||||
impactedModules: csvToList(form.impactedModules),
|
||||
blockers: multilineToList(form.blockers),
|
||||
})
|
||||
|
||||
clearErrors()
|
||||
}
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!isEditing.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
form.title.trim() !== props.requirement.title ||
|
||||
form.owner.trim() !== props.requirement.owner ||
|
||||
form.status !== props.requirement.status ||
|
||||
form.priority !== props.requirement.priority ||
|
||||
form.description.trim() !== props.requirement.description ||
|
||||
form.flexibility.trim() !== props.requirement.flexibility ||
|
||||
form.tolerances.trim() !== props.requirement.tolerances ||
|
||||
form.notes.trim() !== props.requirement.notes ||
|
||||
normalizeCsv(form.stakeholders) !== props.requirement.stakeholders.join(',') ||
|
||||
normalizeCsv(form.impactedModules) !== props.requirement.impactedModules.join(',') ||
|
||||
normalizeMultiline(form.acceptanceCriteria) !==
|
||||
props.requirement.acceptanceCriteria.join('|') ||
|
||||
normalizeMultiline(form.blockers) !== props.requirement.blockers.join('|')
|
||||
)
|
||||
})
|
||||
|
||||
const statusSeverity = (status: RequirementStatus) => {
|
||||
const map: Record<RequirementStatus, 'secondary' | 'info' | 'success' | 'danger' | 'contrast'> = {
|
||||
Draft: 'secondary',
|
||||
'In Review': 'info',
|
||||
Approved: 'success',
|
||||
Blocked: 'danger',
|
||||
Delivered: 'contrast',
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
const prioritySeverity = (priority: RequirementPriority) => {
|
||||
const map: Record<RequirementPriority, 'success' | 'info' | 'warn' | 'danger'> = {
|
||||
Low: 'success',
|
||||
Medium: 'info',
|
||||
High: 'warn',
|
||||
Critical: 'danger',
|
||||
}
|
||||
return map[priority]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.requirement,
|
||||
(requirement) => {
|
||||
resetForm(requirement)
|
||||
clearErrors()
|
||||
isEditing.value = false
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(isDirty, (value) => {
|
||||
emit('dirty-change', value)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="detail-zone">
|
||||
<section class="detail-surface">
|
||||
<header
|
||||
class="detail-card__header"
|
||||
:class="{ 'detail-card__header--editing': isEditing }"
|
||||
>
|
||||
<div class="detail-card__main">
|
||||
<p class="eyebrow">Selected requirement</p>
|
||||
<h2 v-if="!isEditing">{{ requirement.title }}</h2>
|
||||
<InputText
|
||||
v-else
|
||||
v-model="form.title"
|
||||
class="edit-input"
|
||||
placeholder="Requirement title"
|
||||
/>
|
||||
<small v-if="errors.title" class="error-text">{{ errors.title }}</small>
|
||||
<p class="detail-card__subtitle">
|
||||
{{ requirement.reference }} - Owner: {{ isEditing ? form.owner : requirement.owner }}
|
||||
</p>
|
||||
<p class="detail-card__uuid">UUID: {{ requirement.uuid }}</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<Button
|
||||
v-if="!isEditing"
|
||||
label="Edit"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="startEdit"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<TabView class="detail-tabs">
|
||||
<TabPanel value="details" header="Details">
|
||||
<div class="detail-badges">
|
||||
<Tag
|
||||
v-if="!isEditing"
|
||||
:value="requirement.status"
|
||||
:severity="statusSeverity(requirement.status)"
|
||||
/>
|
||||
<select v-else v-model="form.status" class="edit-select">
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="In Review">In Review</option>
|
||||
<option value="Approved">Approved</option>
|
||||
<option value="Blocked">Blocked</option>
|
||||
<option value="Delivered">Delivered</option>
|
||||
</select>
|
||||
|
||||
<Tag
|
||||
v-if="!isEditing"
|
||||
:value="requirement.priority"
|
||||
:severity="prioritySeverity(requirement.priority)"
|
||||
rounded
|
||||
/>
|
||||
<select v-else v-model="form.priority" class="edit-select">
|
||||
<option value="Low">Low</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="!isEditing" class="detail-description">{{ requirement.description }}</p>
|
||||
<div v-else>
|
||||
<Textarea
|
||||
v-model="form.description"
|
||||
class="edit-textarea"
|
||||
rows="3"
|
||||
placeholder="Requirement description"
|
||||
/>
|
||||
<small v-if="errors.description" class="error-text">{{ errors.description }}</small>
|
||||
</div>
|
||||
|
||||
<article v-if="isEditing || requirement.blockers.length" class="info-panel info-panel--warning">
|
||||
<h3>Identified blockers</h3>
|
||||
<ul v-if="!isEditing" class="blockers-list">
|
||||
<li v-for="blocker in requirement.blockers" :key="blocker">
|
||||
<i class="pi pi-exclamation-triangle" />
|
||||
<span>{{ blocker }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="form.blockers"
|
||||
class="edit-textarea"
|
||||
rows="3"
|
||||
placeholder="One blocker per line"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="detail-grid">
|
||||
<article class="info-panel">
|
||||
<h3>Author</h3>
|
||||
<p v-if="!isEditing">{{ requirement.owner }}</p>
|
||||
<div v-else>
|
||||
<InputText v-model="form.owner" class="edit-input" placeholder="Author" />
|
||||
<small v-if="errors.owner" class="error-text">{{ errors.owner }}</small>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="info-panel">
|
||||
<h3>Stakeholders</h3>
|
||||
<div v-if="!isEditing" class="chip-row">
|
||||
<Chip v-for="stakeholder in requirement.stakeholders" :key="stakeholder" :label="stakeholder" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<InputText
|
||||
v-model="form.stakeholders"
|
||||
class="edit-input"
|
||||
placeholder="Comma separated stakeholders"
|
||||
/>
|
||||
<small v-if="errors.stakeholders" class="error-text">{{ errors.stakeholders }}</small>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="info-panel">
|
||||
<h3>Flexibility</h3>
|
||||
<p v-if="!isEditing">{{ requirement.flexibility }}</p>
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="form.flexibility"
|
||||
class="edit-textarea"
|
||||
rows="2"
|
||||
placeholder="Flexibility constraints"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="info-panel">
|
||||
<h3>Tolerances</h3>
|
||||
<p v-if="!isEditing">{{ requirement.tolerances }}</p>
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="form.tolerances"
|
||||
class="edit-textarea"
|
||||
rows="2"
|
||||
placeholder="Tolerance details"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="detail-columns">
|
||||
<article class="info-panel">
|
||||
<h3>Acceptance criteria</h3>
|
||||
<ul v-if="!isEditing" class="checklist">
|
||||
<li v-for="item in requirement.acceptanceCriteria" :key="item">
|
||||
<i class="pi pi-check-circle" />
|
||||
<span>{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="form.acceptanceCriteria"
|
||||
class="edit-textarea"
|
||||
rows="6"
|
||||
placeholder="One acceptance criterion per line"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="info-panel">
|
||||
<h3>Impacted modules</h3>
|
||||
<div v-if="!isEditing" class="chip-row">
|
||||
<Chip v-for="module in requirement.impactedModules" :key="module" :label="module" />
|
||||
</div>
|
||||
<InputText
|
||||
v-else
|
||||
v-model="form.impactedModules"
|
||||
class="edit-input"
|
||||
placeholder="Comma separated modules"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3>Remarks</h3>
|
||||
<p v-if="!isEditing">{{ requirement.notes }}</p>
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="form.notes"
|
||||
class="edit-textarea"
|
||||
rows="4"
|
||||
placeholder="Remarks"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="tests" header="Tests to perform">
|
||||
<div class="tests-placeholder">
|
||||
<i class="pi pi-inbox" />
|
||||
<p>No tests defined yet</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
|
||||
<footer v-if="isEditing" class="edit-footer">
|
||||
<Button
|
||||
label="Cancel"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
icon="pi pi-check"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-zone {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.detail-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.detail-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.detail-card__header--editing {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-card__header--editing .detail-card__main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-card__header--editing .detail-actions {
|
||||
margin-top: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.detail-card__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #5c6b88;
|
||||
}
|
||||
|
||||
.detail-surface h2 {
|
||||
margin: 0;
|
||||
color: #12213a;
|
||||
}
|
||||
|
||||
.detail-card__subtitle {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #6c7b97;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.detail-card__uuid {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #7c8aa5;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.edit-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(96, 117, 156, 0.12);
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs.p-tabview) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs .p-tabview-nav-container),
|
||||
:deep(.detail-tabs .p-tabview-nav-content),
|
||||
:deep(.detail-tabs .p-tabview-nav) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs .p-tabview-nav) {
|
||||
position: relative;
|
||||
border-bottom: 1px solid rgba(96, 117, 156, 0.2) !important;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs .p-tabview-nav::before),
|
||||
:deep(.detail-tabs .p-tabview-nav::after) {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs .p-tabview-ink-bar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs [role='tab']) {
|
||||
border-bottom: 2px solid transparent !important;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs [role='tab'][aria-selected='true']) {
|
||||
border-bottom-color: #2563eb !important;
|
||||
color: #1d4ed8 !important;
|
||||
}
|
||||
|
||||
:deep(.detail-tabs .p-tabview-panels) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.detail-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-description {
|
||||
margin: 0;
|
||||
color: #44556f;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.detail-columns {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(96, 117, 156, 0.14);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(247, 249, 255, 0.9);
|
||||
}
|
||||
|
||||
.info-panel h3 {
|
||||
margin: 0;
|
||||
color: #12213a;
|
||||
}
|
||||
|
||||
.info-panel p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #4c5d77;
|
||||
}
|
||||
|
||||
.info-panel--warning {
|
||||
margin-top: 0.75rem;
|
||||
background: linear-gradient(180deg, rgba(255, 244, 236, 0.95), rgba(255, 249, 244, 0.95));
|
||||
border-color: rgba(223, 134, 57, 0.24);
|
||||
}
|
||||
|
||||
.checklist,
|
||||
.blockers-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.checklist li,
|
||||
.blockers-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
color: #44556f;
|
||||
}
|
||||
|
||||
.checklist i {
|
||||
margin-top: 0.2rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.blockers-list i {
|
||||
margin-top: 0.2rem;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tests-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tests-placeholder i {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.edit-input,
|
||||
.edit-textarea,
|
||||
.edit-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-select {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: #b91c1c;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.detail-grid,
|
||||
.detail-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.detail-card__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.detail-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
318
kwa-ui/src/components/RequirementsTreeList.vue
Normal file
318
kwa-ui/src/components/RequirementsTreeList.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Tree from 'primevue/tree'
|
||||
import Tag from 'primevue/tag'
|
||||
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
|
||||
|
||||
export interface TreeNode {
|
||||
key: string
|
||||
label?: string
|
||||
data?: Requirement
|
||||
children?: TreeNode[]
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
treeData: TreeNode[]
|
||||
searchQuery: string
|
||||
selectedId: number
|
||||
stats: {
|
||||
total: number
|
||||
approvedCount: number
|
||||
blockedCount: number
|
||||
criticalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:searchQuery', value: string): void
|
||||
(e: 'update:selectedId', value: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const expandedKeys = ref<Record<string, boolean>>({
|
||||
'SECT-ELE': true,
|
||||
'SECT-INFO': true,
|
||||
'SECT-MEC': true,
|
||||
})
|
||||
|
||||
const selectedKey = ref<string>('101')
|
||||
|
||||
const filteredTreeData = computed(() => {
|
||||
const query = props.searchQuery.trim().toLowerCase()
|
||||
|
||||
if (!query) {
|
||||
return props.treeData
|
||||
}
|
||||
|
||||
const filterNode = (node: TreeNode): TreeNode | null => {
|
||||
const matchesFilter =
|
||||
node.label?.toLowerCase().includes(query) ||
|
||||
(node.data?.reference.toLowerCase().includes(query) ?? false) ||
|
||||
(node.data?.owner.toLowerCase().includes(query) ?? false)
|
||||
|
||||
const childrenMatch = node.children
|
||||
?.map((child) => filterNode(child))
|
||||
.filter((child) => child !== null) as TreeNode[]
|
||||
|
||||
if (matchesFilter || (childrenMatch && childrenMatch.length > 0)) {
|
||||
return {
|
||||
...node,
|
||||
children: childrenMatch && childrenMatch.length > 0 ? childrenMatch : node.children,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return props.treeData
|
||||
.map((node) => filterNode(node))
|
||||
.filter((node) => node !== null) as TreeNode[]
|
||||
})
|
||||
|
||||
const statusSeverity = (status: Requirement['status']) => {
|
||||
const map: Record<
|
||||
RequirementStatus,
|
||||
'secondary' | 'info' | 'success' | 'danger' | 'contrast'
|
||||
> = {
|
||||
Draft: 'secondary',
|
||||
'In Review': 'info',
|
||||
Approved: 'success',
|
||||
Blocked: 'danger',
|
||||
Delivered: 'contrast',
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
const prioritySeverity = (priority: Requirement['priority']) => {
|
||||
const map: Record<RequirementPriority, 'success' | 'info' | 'warn' | 'danger'> = {
|
||||
Low: 'success',
|
||||
Medium: 'info',
|
||||
High: 'warn',
|
||||
Critical: 'danger',
|
||||
}
|
||||
return map[priority]
|
||||
}
|
||||
|
||||
const onNodeSelect = (node: TreeNode) => {
|
||||
if (node.data?.id) {
|
||||
emit('update:selectedId', node.data.id)
|
||||
selectedKey.value = node.key
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="requirements-tree">
|
||||
<div class="stats-bar">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total</span>
|
||||
<strong>{{ stats.total }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Approved</span>
|
||||
<strong>{{ stats.approvedCount }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Blocked</span>
|
||||
<strong>{{ stats.blockedCount }}</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Critical</span>
|
||||
<strong>{{ stats.criticalCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="search-field">
|
||||
<InputText
|
||||
:value="searchQuery"
|
||||
@input="(event) => $emit('update:searchQuery', (event.target as HTMLInputElement).value)"
|
||||
placeholder="Filter by title, ref, owner..."
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="tree-container">
|
||||
<Tree
|
||||
:value="filteredTreeData"
|
||||
:expanded-keys="expandedKeys"
|
||||
@node-select="(node) => onNodeSelect(node)"
|
||||
selection-mode="single"
|
||||
:selection-keys="{ [selectedKey]: true }"
|
||||
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
||||
class="requirements-tree-view"
|
||||
>
|
||||
<template #default="{ node }">
|
||||
<div class="tree-node-content">
|
||||
<span class="node-label">{{ node.label }}</span>
|
||||
<div v-if="node.data" class="node-tags">
|
||||
<Tag :value="node.data.status" :severity="statusSeverity(node.data.status)" />
|
||||
<Tag
|
||||
:value="node.data.priority"
|
||||
:severity="prioritySeverity(node.data.priority)"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Tree>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirements-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
|
||||
backdrop-filter: blur(18px);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid rgba(96, 117, 156, 0.12);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: #667690;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
color: #12213a;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-field :is(.p-inputtext) {
|
||||
width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
padding-left: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.requirements-tree-view {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.requirements-tree-view :deep(.p-tree) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.requirements-tree-view :deep(.p-tree-container) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.requirements-tree-view :deep(.p-treenode) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.requirements-tree-view :deep(.p-treenode-content) {
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-radius: 0.6rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.requirements-tree-view :deep(.p-treenode-content:hover) {
|
||||
background-color: rgba(78, 107, 255, 0.08);
|
||||
}
|
||||
|
||||
.requirements-tree-view :deep(.p-treenode-content.p-treenode-selected) {
|
||||
background-color: rgba(78, 107, 255, 0.16);
|
||||
color: #12213a;
|
||||
}
|
||||
|
||||
.tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: #12213a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.node-tags {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-tags :deep(.p-tag) {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.stats-bar {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.requirements-tree {
|
||||
padding: 0.6rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.search-field :is(.p-inputtext) {
|
||||
font-size: 0.85rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -3,25 +3,41 @@ import './assets/main.css'
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import 'primeicons/primeicons.css';
|
||||
import PrimeVue from 'primevue/config'
|
||||
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, {
|
||||
theme: {
|
||||
preset: Aura, // Use the Aura theme. It's a modern and elegant theme that provides a great user experience.
|
||||
options: {
|
||||
darkModeSelector: 'system', // Adapts to the Windows dark mode
|
||||
}
|
||||
}
|
||||
});
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
installRequirementsBridge(useRequirementsStore(pinia))
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Do not block bridge initialization on font asset loading in embedded hosts.
|
||||
void loadEmbeddedFonts()
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import { routes } from './routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
],
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
20
kwa-ui/src/router/routes.ts
Normal file
20
kwa-ui/src/router/routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import RequirementsView from '../views/RequirementsView.vue'
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/requirements',
|
||||
name: 'requirements',
|
||||
component: RequirementsView,
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: { name: 'home' },
|
||||
},
|
||||
]
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
224
kwa-ui/src/stores/requirements.ts
Normal file
224
kwa-ui/src/stores/requirements.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type RequirementStatus = 'Draft' | 'In Review' | 'Approved' | 'Blocked' | 'Delivered'
|
||||
export type RequirementPriority = 'Low' | 'Medium' | 'High' | 'Critical'
|
||||
|
||||
export interface Requirement {
|
||||
id: number // Internal identifier, not exposed to users
|
||||
reference: string // User-facing reference code (e.g., "REQ-101")
|
||||
uuid: string // Unique identifier for traceability
|
||||
title: string // Short, descriptive title of the requirement
|
||||
description: string // Detailed description of the requirement
|
||||
status: RequirementStatus // Current lifecycle status of the requirement
|
||||
priority: RequirementPriority // Business priority level for implementation
|
||||
owner: string // Person responsible for the requirement
|
||||
progress: number // Completion percentage of the requirement
|
||||
dueDate: string // Expected completion date
|
||||
rationale: string // Reasoning behind the requirement
|
||||
acceptanceCriteria: string[] // List of conditions that must be met for acceptance
|
||||
impactedModules: string[] // List of application modules affected by this requirement
|
||||
blockers: string[] // List of issues preventing progress on this requirement
|
||||
notes: string // Additional information or comments about the requirement
|
||||
stakeholders: string[] // List of individuals or groups with an interest in the requirement
|
||||
flexibility: string // Degree of flexibility in requirement implementation (e.g., "Low", "Medium", "High")
|
||||
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[]>([
|
||||
{
|
||||
id: 101,
|
||||
reference: 'REQ-101',
|
||||
uuid: '9f6c4f7a-9d2f-4dc0-8fa2-7ac2d79b0c11',
|
||||
title: 'Full requirement traceability',
|
||||
status: 'Approved',
|
||||
priority: 'Critical',
|
||||
owner: 'Claire Martin',
|
||||
progress: 86,
|
||||
dueDate: 'May 21, 2026',
|
||||
description:
|
||||
'Each requirement must be linked to a source, a validation status, and the design or test artifacts that depend on it.',
|
||||
rationale:
|
||||
'The team must be able to audit functional decisions, reduce scope drift, and prepare compliance reviews.',
|
||||
acceptanceCriteria: [
|
||||
'The source for each requirement is visible in the detail view.',
|
||||
'The validation status is recorded in the history.',
|
||||
'Links to related test cases are available from the detail panel.',
|
||||
],
|
||||
impactedModules: ['Backlog', 'Tests', 'Audit', 'Reporting'],
|
||||
blockers: [],
|
||||
notes: 'Plan a PDF export for milestone reviews.',
|
||||
stakeholders: ['QA Team', 'Product Owner', 'Tech Lead'],
|
||||
flexibility: 'Low—cannot alter core traceability chain without major rework',
|
||||
tolerances: 'Maximum 5% of requirements can have incomplete artifact links',
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
reference: 'REQ-102',
|
||||
uuid: '8aa1b6f5-2378-4c47-b490-2b92c7ee8b5f',
|
||||
title: 'Business status management',
|
||||
status: 'In Review',
|
||||
priority: 'High',
|
||||
owner: 'Julien Bernard',
|
||||
progress: 54,
|
||||
dueDate: 'May 24, 2026',
|
||||
description:
|
||||
'The product must provide consistent statuses to track a requirement from creation to delivery.',
|
||||
rationale:
|
||||
'Business and product teams do not use the same words at the same time. A stable naming scheme avoids ambiguity.',
|
||||
acceptanceCriteria: [
|
||||
'Statuses are readable at a glance.',
|
||||
'The status list is consistent across the application.',
|
||||
'Blocked items are visually highlighted.',
|
||||
],
|
||||
impactedModules: ['Workflow', 'Detail', 'Dashboard'],
|
||||
blockers: ['Final label approval from the Product Owner'],
|
||||
notes: 'Can be connected to workflow transitions later.',
|
||||
stakeholders: ['Product Managers', 'Business Analysts', 'UI Team'],
|
||||
flexibility: 'Medium—limited room for additional statuses, current labels are firm',
|
||||
tolerances: 'Terminology must match approved glossary within 95% confidence',
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
reference: 'REQ-103',
|
||||
uuid: '6f99d740-67bf-4a1f-88f2-4fd947f9cb8a',
|
||||
title: 'Fast search and filtering',
|
||||
status: 'Draft',
|
||||
priority: 'Medium',
|
||||
owner: 'Nadia Petit',
|
||||
progress: 28,
|
||||
dueDate: 'May 29, 2026',
|
||||
description:
|
||||
'Users must be able to find a requirement by title, reference, owner, or functional keyword.',
|
||||
rationale:
|
||||
'Once the project reaches a moderate volume, manual navigation becomes too expensive and slows down reviews.',
|
||||
acceptanceCriteria: [
|
||||
'Search filters the list instantly.',
|
||||
'The number of visible items remains clear.',
|
||||
'No result state is displayed explicitly.',
|
||||
],
|
||||
impactedModules: ['Sidebar', 'Search', 'Performance'],
|
||||
blockers: ['Definition of the priority search criteria'],
|
||||
notes: 'To be extended with type and batch filters.',
|
||||
stakeholders: ['End Users', 'Performance Team'],
|
||||
flexibility: 'High—can adapt search algorithm and UI patterns to user feedback',
|
||||
tolerances: 'Search response time must stay under 200ms for 99% of queries',
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
reference: 'REQ-104',
|
||||
uuid: 'f47e5f4a-c54d-46c9-8fef-f5f56e5e4d7a',
|
||||
title: 'Delivery scope by release',
|
||||
status: 'Blocked',
|
||||
priority: 'Critical',
|
||||
owner: 'Sophie Laurent',
|
||||
progress: 41,
|
||||
dueDate: 'May 31, 2026',
|
||||
description:
|
||||
'Each requirement must be linked to a target release so batch and delivery decisions can be prepared.',
|
||||
rationale:
|
||||
'Decision makers need a concise view to balance load and dependencies across releases.',
|
||||
acceptanceCriteria: [
|
||||
'The target release appears in the interface.',
|
||||
'Blocked requirements are visually identified.',
|
||||
'The delivery batch is readable in the detail view.',
|
||||
],
|
||||
impactedModules: ['Release', 'Roadmap', 'Governance'],
|
||||
blockers: ['Architecture decision pending'],
|
||||
notes: 'To be linked to a milestone calendar.',
|
||||
stakeholders: ['Release Manager', 'Governance Board', 'Architects'],
|
||||
flexibility: 'Low—release scope is constrained by business commitments',
|
||||
tolerances: '100% of deliverables must link to a release before code freeze',
|
||||
},
|
||||
])
|
||||
|
||||
const selectedId = ref(101)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const selectedRequirement = computed<Requirement>(() => {
|
||||
const fallback = requirements.value[0]
|
||||
const found = requirements.value.find((requirement) => requirement.id === selectedId.value)
|
||||
return found ?? fallback!
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = requirements.value.length
|
||||
const approvedCount = requirements.value.filter(
|
||||
(requirement) => requirement.status === 'Approved'
|
||||
).length
|
||||
const blockedCount = requirements.value.filter(
|
||||
(requirement) => requirement.status === 'Blocked'
|
||||
).length
|
||||
const criticalCount = requirements.value.filter(
|
||||
(requirement) => requirement.priority === 'Critical'
|
||||
).length
|
||||
|
||||
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 null
|
||||
}
|
||||
|
||||
requirements.value[index] = cloneRequirement(updatedRequirement)
|
||||
|
||||
return requirements.value[index]
|
||||
}
|
||||
|
||||
return {
|
||||
requirements,
|
||||
selectedId,
|
||||
searchQuery,
|
||||
selectedRequirement,
|
||||
stats,
|
||||
getRequirementById,
|
||||
selectRequirement,
|
||||
setRequirements,
|
||||
updateRequirement,
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
<section class="home-view">
|
||||
<section class="home-panel">
|
||||
<p class="eyebrow">Home</p>
|
||||
<h1>Requirements Workspace</h1>
|
||||
<p class="subtitle">
|
||||
Use the toolbar icons to switch between this home page and the requirements dashboard.
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(78, 107, 255, 0.18), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(21, 184, 164, 0.14), transparent 28%),
|
||||
linear-gradient(180deg, rgba(245, 248, 255, 1) 0%, rgba(235, 241, 250, 1) 100%);
|
||||
}
|
||||
|
||||
.home-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
|
||||
backdrop-filter: blur(18px);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #5c6b88;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #12213a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.75rem;
|
||||
color: #4c5d77;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.home-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
kwa-ui/src/views/RequirementsView.vue
Normal file
272
kwa-ui/src/views/RequirementsView.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
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'
|
||||
|
||||
const onSplitterResizeStart = () => {
|
||||
document.body.classList.add(splitterDraggingClass)
|
||||
}
|
||||
|
||||
const onSplitterResizeEnd = () => {
|
||||
document.body.classList.remove(splitterDraggingClass)
|
||||
}
|
||||
|
||||
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 [
|
||||
{
|
||||
key: 'SECT-ELE',
|
||||
label: 'Electronics',
|
||||
icon: 'pi pi-fw pi-bolt',
|
||||
children: [
|
||||
{
|
||||
key: '101',
|
||||
label: `${requirements.value[0]!.reference} - ${requirements.value[0]!.title}`,
|
||||
data: requirements.value[0]!,
|
||||
},
|
||||
{
|
||||
key: '102-child',
|
||||
label: 'Sub-requirement: Power management',
|
||||
data: { ...requirements.value[1]!, id: 1021, reference: 'REQ-102.1' } as Requirement,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'SECT-INFO',
|
||||
label: 'Software',
|
||||
icon: 'pi pi-fw pi-code',
|
||||
children: [
|
||||
{
|
||||
key: '102',
|
||||
label: `${requirements.value[1]!.reference} - ${requirements.value[1]!.title}`,
|
||||
data: requirements.value[1]!,
|
||||
},
|
||||
{
|
||||
key: '103',
|
||||
label: `${requirements.value[2]!.reference} - ${requirements.value[2]!.title}`,
|
||||
data: requirements.value[2]!,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'SECT-MEC',
|
||||
label: 'Mechanical',
|
||||
icon: 'pi pi-fw pi-cog',
|
||||
children: [
|
||||
{
|
||||
key: '104',
|
||||
label: `${requirements.value[3]!.reference} - ${requirements.value[3]!.title}`,
|
||||
data: requirements.value[3]!,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const treeData = computed(() => buildTreeData())
|
||||
|
||||
const onSaveRequirement = (updatedRequirement: Requirement) => {
|
||||
requirementsStore.updateRequirement(updatedRequirement)
|
||||
hasUnsavedChanges.value = false
|
||||
}
|
||||
|
||||
const onDetailDirtyChange = (value: boolean) => {
|
||||
hasUnsavedChanges.value = value
|
||||
}
|
||||
|
||||
const onSelectRequirement = (nextId: number) => {
|
||||
if (nextId === selectedId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges.value) {
|
||||
const confirmed = window.confirm(
|
||||
'You have unsaved changes. Switching requirement will discard them. Continue?'
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hasUnsavedChanges.value = false
|
||||
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"
|
||||
@resizestart="onSplitterResizeStart"
|
||||
@resizeend="onSplitterResizeEnd"
|
||||
>
|
||||
<SplitterPanel :size="28" :min-size="20">
|
||||
<RequirementsTreeList
|
||||
:tree-data="treeData"
|
||||
:searchQuery="searchQuery"
|
||||
:selectedId="selectedId"
|
||||
:stats="stats"
|
||||
@update:searchQuery="searchQuery = $event"
|
||||
@update:selectedId="onSelectRequirement"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel :size="72" :min-size="35">
|
||||
<RequirementDetail
|
||||
:requirement="selectedRequirement"
|
||||
@save="onSaveRequirement"
|
||||
@dirty-change="onDetailDirtyChange"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirements-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(78, 107, 255, 0.18), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(21, 184, 164, 0.14), transparent 28%),
|
||||
linear-gradient(180deg, rgba(245, 248, 255, 1) 0%, rgba(235, 241, 250, 1) 100%);
|
||||
}
|
||||
|
||||
.workspace-splitter {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.workspace-splitter :deep(.p-splitter-panel) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.timeout-message {
|
||||
margin: 0;
|
||||
color: #3f4d68;
|
||||
}
|
||||
|
||||
.workspace-splitter :deep(.p-splitter-gutter) {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.workspace-splitter :deep(.p-splitter-gutter::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: rgba(96, 117, 156, 0.32);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.workspace-splitter :deep(.p-splitter-gutter:hover::before),
|
||||
.workspace-splitter :deep(.p-splitter-gutter:active::before) {
|
||||
background: rgba(78, 107, 255, 0.6);
|
||||
}
|
||||
|
||||
.workspace-splitter :deep(.p-splitter-gutter-handle) {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
:global(body.is-splitter-dragging),
|
||||
:global(body.is-splitter-dragging *) {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.workspace-splitter {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.requirements-view {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user