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.
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Backend JSON Bridge
|
||||||
|
|
||||||
|
The UI exposes a JSON bridge intended for integration inside a native host such as a C++ application.
|
||||||
|
|
||||||
|
### Available bridge entry points
|
||||||
|
|
||||||
|
- `window.kwaUiBridge.receiveFromBackend(message)` lets the host inject a JSON string or already parsed message object into the UI.
|
||||||
|
- `window.kwaUiBridge.sendRequest(topic, payload)` sends a `requestMessage` to the host and resolves with the matching `responseMessage.payload`.
|
||||||
|
- `window.kwaUiBridge.emitEvent(topic, payload)` sends a one-way `eventMessage` to the host.
|
||||||
|
- Outgoing messages are also mirrored as a browser event named `kwa-ui:outgoing-message` whose `detail` contains the serialized JSON.
|
||||||
|
- Incoming messages can also be injected by dispatching `kwa-ui:incoming-message` with the raw JSON string in `detail`.
|
||||||
|
|
||||||
|
### Message envelopes
|
||||||
|
|
||||||
|
Every message is JSON and uses one of the following envelope shapes.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "requestMessage",
|
||||||
|
"id": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f",
|
||||||
|
"timestamp": "2026-05-22T15:28:00.000Z"
|
||||||
|
"topic": "requirements.list",
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "responseMessage",
|
||||||
|
"id": "7e7e0cba-d4b8-4d8f-a0cb-31f4f3110db5",
|
||||||
|
"requestId": "3f4f58aa-a49f-49b1-a43a-3ca3bff8469f",
|
||||||
|
"timestamp": "2026-05-22T15:28:00.010Z"
|
||||||
|
"topic": "requirements.list",
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"requirements": []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "eventMessage",
|
||||||
|
"id": "94f2b4d4-6f16-4f14-8dc7-869f6d3e1a55",
|
||||||
|
"timestamp": "2026-05-22T15:28:00.020Z"
|
||||||
|
"topic": "requirements.updated",
|
||||||
|
"payload": {
|
||||||
|
"requirement": {
|
||||||
|
"id": 101
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native host transport
|
||||||
|
|
||||||
|
The bridge will send outgoing JSON to the first available transport below:
|
||||||
|
|
||||||
|
- `window.kwaBackendHost.postMessage(string)`
|
||||||
|
- `window.chrome.webview.postMessage(string)`
|
||||||
|
- `window.webkit.messageHandlers.kwaUiBridge.postMessage(string)`
|
||||||
|
|
||||||
|
If none of these are available, the UI still emits `kwa-ui:outgoing-message`, which is useful for testing or for a wrapper script.
|
||||||
|
|
||||||
|
### Registered topics
|
||||||
|
|
||||||
|
- `requirements.list`: request the full requirements collection and current selection.
|
||||||
|
- `requirements.get`: request payload `{ "id": number }`, returns one requirement or `null`.
|
||||||
|
- `requirements.select`: request or event payload `{ "id": number }`, selects the matching requirement.
|
||||||
|
- `requirements.update`: request payload `{ "requirement": { ... } }`, updates a requirement and returns the saved value.
|
||||||
|
- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the current collection in the UI.
|
||||||
|
|
||||||
|
### Events pushed by the backend to the UI
|
||||||
|
|
||||||
|
The backend can push an `eventMessage` at any time by calling `window.kwaUiBridge.receiveFromBackend(json)`. The following topics are handled:
|
||||||
|
|
||||||
|
- `requirements.replaceAll`: event payload `{ "requirements": [ ... ] }`, replaces the full requirements collection in the UI.
|
||||||
|
- `requirements.select`: event payload `{ "id": number }`, selects the matching requirement in the UI.
|
||||||
|
|
||||||
|
### Events emitted by the UI
|
||||||
|
|
||||||
|
- `ui.ready`: emitted once the bridge is installed.
|
||||||
|
- `requirements.selectionChanged`: emitted whenever the selected requirement changes.
|
||||||
|
- `requirements.updated`: emitted after a requirement update initiated in the UI.
|
||||||
|
- `requirements.replaced`: emitted after the full requirements collection is replaced.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Recommended IDE Setup
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|||||||
29
kwa-ui/env.d.ts
vendored
29
kwa-ui/env.d.ts
vendored
@@ -1 +1,30 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { BridgeMessage, UiBridgeApi } from './src/bridge/backendBridge'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
kwaBackendHost?: {
|
||||||
|
postMessage: (message: string) => void
|
||||||
|
}
|
||||||
|
kwaBridgeConfig?: {
|
||||||
|
webkitHandlerNames?: string[]
|
||||||
|
}
|
||||||
|
kwaUiBridge?: UiBridgeApi
|
||||||
|
chrome?: {
|
||||||
|
webview?: {
|
||||||
|
postMessage: (message: string) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webkit?: {
|
||||||
|
messageHandlers?: Record<string, { postMessage: (message: string) => void }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowEventMap {
|
||||||
|
'kwa-ui:incoming-message': CustomEvent<string | BridgeMessage>
|
||||||
|
'kwa-ui:outgoing-message': CustomEvent<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>Vite App</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"type-check": "vue-tsc --build"
|
"type-check": "vue-tsc --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/source-sans-3": "^5.2.9",
|
||||||
"@primeuix/themes": "^2.0.3",
|
"@primeuix/themes": "^2.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
|
|||||||
@@ -1,85 +1,66 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { computed } from 'vue'
|
||||||
import HelloWorld from './components/HelloWorld.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<div id="app">
|
||||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
<AppMenubar
|
||||||
|
:active-view="activeView"
|
||||||
<div class="wrapper">
|
@home="goHome"
|
||||||
<HelloWorld msg="You did it!" />
|
@requirements="goRequirements"
|
||||||
|
/>
|
||||||
<nav>
|
<AppToolbar
|
||||||
<RouterLink to="/">Home</RouterLink>
|
:active-view="activeView"
|
||||||
<RouterLink to="/about">About</RouterLink>
|
@home="goHome"
|
||||||
</nav>
|
@requirements="goRequirements"
|
||||||
</div>
|
/>
|
||||||
</header>
|
<main class="app-main">
|
||||||
|
<RouterView />
|
||||||
<RouterView />
|
</main>
|
||||||
|
<AppStatusbar />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
header {
|
* {
|
||||||
line-height: 1.5;
|
box-sizing: border-box;
|
||||||
max-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
html,
|
||||||
display: block;
|
body {
|
||||||
margin: 0 auto 2rem;
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
#app {
|
||||||
width: 100%;
|
display: flex;
|
||||||
font-size: 12px;
|
flex-direction: column;
|
||||||
text-align: center;
|
height: 100vh;
|
||||||
margin-top: 2rem;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a.router-link-exact-active {
|
.app-main {
|
||||||
color: var(--color-text);
|
flex: 1;
|
||||||
}
|
overflow: auto;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,53 +1,20 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
|
||||||
:root {
|
:root {
|
||||||
--vt-c-white: #ffffff;
|
color-scheme: light;
|
||||||
--vt-c-white-soft: #f8f8f8;
|
color: #12213a;
|
||||||
--vt-c-white-mute: #f2f2f2;
|
background-color: #eef3fa;
|
||||||
|
font-family: 'Source Sans 3', sans-serif;
|
||||||
--vt-c-black: #181818;
|
font-size: 15px;
|
||||||
--vt-c-black-soft: #222222;
|
line-height: 1.6;
|
||||||
--vt-c-black-mute: #282828;
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
--vt-c-indigo: #2c3e50;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
html {
|
||||||
:root {
|
min-height: 100%;
|
||||||
--color-background: var(--vt-c-white);
|
background-color: #eef3fa;
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
-webkit-text-size-adjust: 100%;
|
||||||
--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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -60,27 +27,13 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
color: var(--color-text);
|
color: inherit;
|
||||||
background: var(--color-background);
|
background: inherit;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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';
|
@import './base.css';
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
min-height: 100vh;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
import PrimeVue from 'primevue/config';
|
import PrimeVue from 'primevue/config'
|
||||||
import Aura from '@primeuix/themes/aura';
|
import Aura from '@primeuix/themes/aura'
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { installRequirementsBridge } from './bridge/requirementsBridge'
|
||||||
|
import { useRequirementsStore } from './stores/requirements'
|
||||||
|
|
||||||
|
const loadEmbeddedFonts = async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
import('@fontsource/source-sans-3/latin-400.css'),
|
||||||
|
import('@fontsource/source-sans-3/latin-500.css'),
|
||||||
|
import('@fontsource/source-sans-3/latin-600.css'),
|
||||||
|
import('@fontsource/source-sans-3/latin-700.css'),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(pinia)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
preset: Aura, // Use the Aura theme. It's a modern and elegant theme that provides a great user experience.
|
preset: Aura,
|
||||||
options: {
|
options: {
|
||||||
darkModeSelector: 'system', // Adapts to the Windows dark mode
|
darkModeSelector: false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
|
installRequirementsBridge(useRequirementsStore(pinia))
|
||||||
|
|
||||||
app.mount('#app')
|
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 { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import { routes } from './routes'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: [
|
routes,
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'home',
|
|
||||||
component: HomeView,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
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>
|
<template>
|
||||||
<main>
|
<section class="home-view">
|
||||||
<TheWelcome />
|
<section class="home-panel">
|
||||||
</main>
|
<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>
|
</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