V13-FE-005: restore direct UI components and harden grid provider
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
| Acceptance requirement | Evidence |
|
| Acceptance requirement | Evidence |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Feature direct vendor import is zero | `tools/validate_v16.py` scans only `.ts`/`.vue` sources and rejects PrimeVue/AG Grid imports outside the approved adapter boundary. |
|
| Feature direct vendor import is zero | `tools/validate_v16.py` rejects PrimeVue/AG Grid imports in feature code and unrelated shared code. It allows the approved direct-vendor ownership boundary (`shared/ui/components/`) and the adapter boundary (`shared/ui/adapter/primevue/`). |
|
||||||
| Validator remains valid as WBS evolves | Master WBS IDs must be nonblank and unique; the validator no longer uses a stale fixed total row count. |
|
| Validator remains valid as WBS evolves | Master WBS IDs must be nonblank and unique; the validator no longer uses a stale fixed total row count. |
|
||||||
| Reproducible evidence | `python tools/validate_v16.py` result is recorded in the WBS tracker after execution. |
|
| Reproducible evidence | `python tools/validate_v16.py` result is recorded in the WBS tracker after execution. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# V13-FE-005 — Existing Component Direct Vendor Restore
|
||||||
|
|
||||||
|
## Source / Assumption / Unknown / Decision Required
|
||||||
|
|
||||||
|
- **Source:** `frontend/src/shared/ui/components/`, existing PrimeVue/AG Grid adapters, and current component contract tests.
|
||||||
|
- **Assumption:** Existing `Ks*` components are application-owned components whose established behavior must not be narrowed by the vendor-neutral adapter contract.
|
||||||
|
- **Unknown:** Whether the native provider is still a supported production target for every existing component. This must not be inferred from the presence of `VITE_UI_ADAPTER`.
|
||||||
|
- **Decision Required:** Confirm the supported provider matrix before removing the remaining adapter-backed input, dialog, status, paginator, and tabs components.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Behavior-preserving refactoring under `V13-FE-005`:
|
||||||
|
|
||||||
|
- Existing components may use the selected vendor directly inside `shared/ui/components`.
|
||||||
|
- Feature code remains vendor-import free.
|
||||||
|
- New reusable components may use the adapter pattern only when a vendor-neutral contract is an explicit requirement.
|
||||||
|
- No policy, API, database, migration, or production automation change.
|
||||||
|
|
||||||
|
## Implemented
|
||||||
|
|
||||||
|
- `KsButton.vue` now uses PrimeVue Button directly and preserves the existing public events and semantic props.
|
||||||
|
- `KsDataGrid.vue` now uses AG Grid directly and preserves the existing grid behavior while retaining the client-side row model module boundary.
|
||||||
|
- Core tests were changed from adapter-injection assertions to behavior assertions for direct component ownership.
|
||||||
|
|
||||||
|
## Remaining
|
||||||
|
|
||||||
|
- Existing `shared/ui/components/*.vue` no longer imports `useUiAdapter`; the direct-vendor restore is complete for the current component set.
|
||||||
|
- Reassess native provider support and remove obsolete adapter implementation files only after a separate provider-retirement decision. The adapter remains available for new vendor-neutral components.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Targeted tests: shared component tests — 25/25 PASS.
|
||||||
|
- Full frontend tests: `pnpm test -- --run` — 63 files / 168 tests PASS.
|
||||||
|
- Typecheck: `pnpm typecheck` — PASS.
|
||||||
|
- Build: `pnpm build` — PASS; Vite retains a >500 kB warning and reports `main-CffH25aC.js` 587.58 kB / gzip 163.16 kB.
|
||||||
|
- Boundary check: no `useUiAdapter` import remains under `frontend/src/shared/ui/components`.
|
||||||
|
- Harness: `frontend/src/shared/ui/components/tests/directVendorOwnership.contract.spec.ts` fails closed if an existing component reintroduces adapter-owned rendering.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# V13-FE-005 — component/template contract test hardening
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **WBS:** V13-FE-005
|
||||||
|
- **Requirement/API/UI/Test:** REQ-FE-UI-PORT / Cross / UI-COMPONENTS / T-FE-COMPONENT-CONTRACT
|
||||||
|
- **Source:** existing `KsButton`, `KsTextField`, `FieldShell`, native adapter contracts, and shared screen-template tests.
|
||||||
|
- **Change:** add behavior-focused contract coverage for loading/disabled buttons, adapter-neutral activation, field label/error wiring, input type forwarding, and model updates.
|
||||||
|
- **Not changed:** component runtime behavior, vendor provider implementation, API/data ownership, policy thresholds, or screen state semantics.
|
||||||
|
|
||||||
|
## Source / Assumption / Unknown / Decision Required
|
||||||
|
|
||||||
|
- **Source:** current component props/emits and native adapter implementation.
|
||||||
|
- **Assumption:** native adapter is the deterministic test provider; provider-specific visual behavior remains outside unit-test scope.
|
||||||
|
- **Unknown:** visual and assistive-technology behavior in a real browser across PrimeVue and native providers.
|
||||||
|
- **Decision Required:** visual/AT/browser approval remains required before completion.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Targeted contract test: 1 file / 3 tests passed.
|
||||||
|
- Full frontend regression: 59 files / 156 tests passed.
|
||||||
|
- `pnpm typecheck`: passed.
|
||||||
|
- `pnpm build`: passed; known Vite >500 kB chunk warning remains and is not claimed as a performance-gate pass.
|
||||||
|
- Evidence: `evidence/V13-FE-005/component-template-tests_20260813.log`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# V13-FE-005 — detail action button port adoption
|
||||||
|
|
||||||
|
ModelDetail and ShadowRunDetail action buttons now use the shared `KsButton` port. Variant-to-severity mapping is explicit; detail query, mutation, routes, and policy state are unchanged. Input/grid vendor boundaries remain separate slices.
|
||||||
|
|
||||||
|
Evidence is recorded with the subsequent full frontend regression. Visual/AT/browser evidence remains outstanding.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# V13-FE-005 — ModelsList vendor boundary adoption
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Change:** ModelsList search input and buttons now use existing `KsTextField`/`KsButton` ports instead of direct KBX vendor-bound components.
|
||||||
|
- **Not changed:** KbxListPage, query semantics, routes, API, or model state. The grid was migrated separately under V13-FE-023 after column/event characterization.
|
||||||
|
- **Source:** current shared UI adapter contracts and existing ModelsList usage.
|
||||||
|
|
||||||
|
## Source / Assumption / Unknown / Decision Required
|
||||||
|
|
||||||
|
- **Source:** `KsTextField`, `KsButton`, PrimeVue/native adapter ports, and ModelsList characterization.
|
||||||
|
- **Assumption:** adding a visible search label is an accessibility-preserving contract improvement.
|
||||||
|
- **Resolved:** KbxDataGrid column/event parity was characterized under V13-FE-023; ModelsList now uses `KsDataGrid` with explicit `Model.modelId` navigation.
|
||||||
|
- **Decision Required:** visual/AT/browser evidence for the shared grid boundary remains outstanding under V13-FE-023.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Targeted characterization test added; full FE regression, typecheck, and build required before completion.
|
||||||
|
- No API, DB, or domain policy changed.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# V13-FE-005 — ShadowRunList vendor boundary adoption
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Migrated the search/date fields and action buttons to `KsTextField`/`KsButton`; the shared text-field contract now supports the bounded date input type.
|
||||||
|
- Preserved query state, date inputs, status filter, and routes. Grid behavior was migrated separately under V13-FE-023 to `KsDataGrid` with explicit `ShadowRun.runId` navigation.
|
||||||
|
- No API, DB, job, model policy, or KIS path changed.
|
||||||
|
|
||||||
|
## Decision boundary
|
||||||
|
|
||||||
|
Date-specific input behavior and grid column/event parity remain separate because the current shared ports do not express the complete Kbx contracts.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Full FE regression, typecheck, and build are required before completion. Visual/AT/browser evidence remains outstanding.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Grid provider decision
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The default grid is AG Grid Community. The application does not depend on `ag-grid-enterprise`, Enterprise modules, Enterprise licensing, or Enterprise-only APIs.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `frontend/package.json` declares `ag-grid-community` and has no `ag-grid-enterprise` dependency.
|
||||||
|
- `frontend/src/shared/ui/components/KsDataGrid.vue` imports `AgGridVue` and `ClientSideRowModelModule` from AG Grid Community.
|
||||||
|
- `frontend/src/shared/ui/adapter/primevue/AgGridAdapter.vue` follows the same Community module boundary for new vendor-neutral components.
|
||||||
|
- `frontend/src/shared/ui/components/tests/gridProvider.contract.spec.ts` enforces the dependency and module boundary.
|
||||||
|
|
||||||
|
## Consequence
|
||||||
|
|
||||||
|
Community functionality is the baseline for existing direct components. Enterprise-only features require a separate approved dependency, license, contract, performance, and security decision; they must not be introduced implicitly.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Legacy adapter inventory and cleanup boundary
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Legacy adapter files are not bulk-deleted. The current tree contains active references from list pages, layouts, registry code, provider contracts, and tests. Deletion is split into an inventory/retirement Slice so behavior-preserving component restoration is not mixed with removal.
|
||||||
|
|
||||||
|
## Active or contract-required
|
||||||
|
|
||||||
|
- `KbxListPage.vue`: referenced by existing model and shadow-run list pages.
|
||||||
|
- `KsListPage.vue`: direct-owned replacement used by model and shadow-run list pages.
|
||||||
|
- `contracts.ts`, `compatibility.ts`, `useUiAdapter.ts`: provider and contract tests still use these symbols.
|
||||||
|
- `primevue/*` and `native/*`: provider contract implementations; native is test/reference-only but still required by contract tests.
|
||||||
|
|
||||||
|
## Removed after zero-reference verification
|
||||||
|
|
||||||
|
- `KbxButton.vue`, `KbxInput.vue`, `KbxDataGrid.vue`: had no internal runtime consumers; only legacy index exports remained. Their exports and files were removed after static zero-reference verification.
|
||||||
|
- `KbxListPage.vue`: replaced by `shared/ui/components/KsListPage.vue`; the legacy file and adapter export were removed after migrating both runtime consumers.
|
||||||
|
|
||||||
|
## Remaining cleanup candidates
|
||||||
|
|
||||||
|
- Duplicate `.js` source companions where the TypeScript/Vue source is authoritative and no runtime import requires the JavaScript file.
|
||||||
|
- Legacy `Kbx*` components after all current consumers have migrated and a zero-reference test is preserved.
|
||||||
|
|
||||||
|
## Required evidence before deletion
|
||||||
|
|
||||||
|
1. Static import graph shows zero runtime consumers.
|
||||||
|
2. Contract and feature tests pass without the candidate.
|
||||||
|
3. Production build no longer includes the candidate.
|
||||||
|
4. Rollback path and migration note identify the removed file set.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Do not delete adapter contracts merely because existing components now use direct vendors.
|
||||||
|
- Do not remove native reference provider without replacing its contract-test role.
|
||||||
|
- Do not mix legacy deletion with AG Grid performance or visual/accessibility changes.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Native provider decision record
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`native-accessible` is retained as a test/reference provider only. It is not an approved production provider and is not used as a fallback for existing application-owned components.
|
||||||
|
|
||||||
|
PrimeVue/AG Grid (`primevue-aggrid`) is the explicit default provider when `VITE_UI_ADAPTER` is omitted.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `frontend/src/shared/ui/adapter/native/index.ts` declares `productionEligible: false`.
|
||||||
|
- Repository configuration and deployment references contain no approved `VITE_UI_ADAPTER=native` production target.
|
||||||
|
- Existing `frontend/src/shared/ui/components/*.vue` components directly own PrimeVue/AG Grid behavior and do not render through the native provider.
|
||||||
|
- `frontend/src/shared/ui/provider/tests/resolveUiProvider.spec.ts` uses native resolution as a deterministic contract test.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Do not delete native adapters yet; they remain useful for adapter contract and accessibility smoke tests.
|
||||||
|
- Do not advertise `native` as a production switch. A future provider switch requires a full component parity Slice first.
|
||||||
|
- Existing production bootstrap remains PrimeVue by default. No automatic provider fallback is introduced.
|
||||||
|
- `resolveUiProvider('native')` now fails closed in production artifacts; native remains available only to the non-production contract harness.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
Provider retirement may be proposed only with explicit evidence that no test or contract harness requires it, plus an approved WBS/ADR. Until then this is deliberate retained debt, not an accidental runtime dependency.
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@primevue/themes": "4.5.4",
|
||||||
"@tanstack/vue-query": "^5.0.0",
|
"@tanstack/vue-query": "^5.0.0",
|
||||||
"ag-grid-community": "^34.0.0",
|
"ag-grid-community": "^34.0.0",
|
||||||
"ag-grid-vue3": "^34.0.0",
|
"ag-grid-vue3": "^34.0.0",
|
||||||
|
|||||||
Generated
+20
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@primevue/themes':
|
||||||
|
specifier: 4.5.4
|
||||||
|
version: 4.5.4
|
||||||
'@tanstack/vue-query':
|
'@tanstack/vue-query':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.101.4(vue@3.5.40(typescript@5.9.3))
|
version: 5.101.4(vue@3.5.40(typescript@5.9.3))
|
||||||
@@ -162,6 +165,9 @@ packages:
|
|||||||
'@primeuix/styles@2.0.3':
|
'@primeuix/styles@2.0.3':
|
||||||
resolution: {integrity: sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==}
|
resolution: {integrity: sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==}
|
||||||
|
|
||||||
|
'@primeuix/themes@2.0.3':
|
||||||
|
resolution: {integrity: sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==}
|
||||||
|
|
||||||
'@primeuix/utils@0.6.4':
|
'@primeuix/utils@0.6.4':
|
||||||
resolution: {integrity: sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==}
|
resolution: {integrity: sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==}
|
||||||
engines: {node: '>=12.11.0'}
|
engines: {node: '>=12.11.0'}
|
||||||
@@ -176,6 +182,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==}
|
resolution: {integrity: sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==}
|
||||||
engines: {node: '>=12.11.0'}
|
engines: {node: '>=12.11.0'}
|
||||||
|
|
||||||
|
'@primevue/themes@4.5.4':
|
||||||
|
resolution: {integrity: sha512-rUFZxMHLanTZdvZq4zgZPk+KRBZ3s7fE3bBK32OrZBkHQhEJmkJ7Ftd4w4QFlXyz1B7c+k5invZiOOCjwHXg9Q==}
|
||||||
|
engines: {node: '>=12.11.0'}
|
||||||
|
deprecated: 'Deprecated. This package is no longer maintained. Please migrate to @primeuix/themes: https://www.npmjs.com/package/@primeuix/themes'
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.1':
|
'@rolldown/binding-android-arm64@1.2.1':
|
||||||
resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==}
|
resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1305,6 +1316,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@primeuix/styled': 0.7.4
|
'@primeuix/styled': 0.7.4
|
||||||
|
|
||||||
|
'@primeuix/themes@2.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@primeuix/styled': 0.7.4
|
||||||
|
|
||||||
'@primeuix/utils@0.6.4': {}
|
'@primeuix/utils@0.6.4': {}
|
||||||
|
|
||||||
'@primevue/core@4.5.5(vue@3.5.40(typescript@5.9.3))':
|
'@primevue/core@4.5.5(vue@3.5.40(typescript@5.9.3))':
|
||||||
@@ -1320,6 +1335,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@primevue/themes@4.5.4':
|
||||||
|
dependencies:
|
||||||
|
'@primeuix/styled': 0.7.4
|
||||||
|
'@primeuix/themes': 2.0.3
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.2.1':
|
'@rolldown/binding-android-arm64@1.2.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import KbxListPage from '@shared/ui/adapter/KbxListPage.vue'
|
import KsListPage from '@shared/ui/components/KsListPage.vue'
|
||||||
import KbxDataGrid from '@shared/ui/adapter/KbxDataGrid.vue'
|
import { KsButton, KsDataGrid, KsTextField } from '@shared/ui/components'
|
||||||
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
|
|
||||||
import KbxInput from '@shared/ui/adapter/KbxInput.vue'
|
|
||||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||||
import { useModelsList } from '../composables/useModels'
|
import { useModelsList, type Model } from '../composables/useModels'
|
||||||
import type { ModelListParams } from '../composables/useModels'
|
import type { ModelListParams } from '../composables/useModels'
|
||||||
|
import { toUiGridColumns } from '@shared/ui/gridColumnAdapter'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const registry = useKbxRegistry()
|
const registry = useKbxRegistry()
|
||||||
|
|
||||||
@@ -18,6 +16,8 @@ const screenDef = computed(() =>
|
|||||||
registry.getScreen('model-ops.models.list'),
|
registry.getScreen('model-ops.models.list'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const modelColumns = computed(() => toUiGridColumns(screenDef.value?.grid?.columnDefs ?? []))
|
||||||
|
|
||||||
// Search and filter state
|
// Search and filter state
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const phaseFilter = ref('all')
|
const phaseFilter = ref('all')
|
||||||
@@ -82,6 +82,11 @@ const handleRowClick = (modelId: string) => {
|
|||||||
router.push(`/model-ops/models/${modelId}`)
|
router.push(`/model-ops/models/${modelId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRowSelected = (row: unknown) => {
|
||||||
|
const model = row as Partial<Model>
|
||||||
|
if (typeof model.modelId === 'string') handleRowClick(model.modelId)
|
||||||
|
}
|
||||||
|
|
||||||
const handleQuickFilter = (filterId: string) => {
|
const handleQuickFilter = (filterId: string) => {
|
||||||
if (filterId === 'active') {
|
if (filterId === 'active') {
|
||||||
activeFilter.value = activeFilter.value === 'active' ? 'all' : 'active'
|
activeFilter.value = activeFilter.value === 'active' ? 'all' : 'active'
|
||||||
@@ -116,7 +121,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="screenDef" class="models-list">
|
<div v-if="screenDef" class="models-list">
|
||||||
<KbxListPage
|
<KsListPage
|
||||||
:screen="screenDef"
|
:screen="screenDef"
|
||||||
:data-state="dataState"
|
:data-state="dataState"
|
||||||
:loading="dataState === 'pending'"
|
:loading="dataState === 'pending'"
|
||||||
@@ -127,9 +132,9 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<!-- Header Actions -->
|
<!-- Header Actions -->
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<KbxButton
|
<KsButton
|
||||||
label="New Model"
|
label="New Model"
|
||||||
variant="primary"
|
severity="primary"
|
||||||
@click="handleNewModel"
|
@click="handleNewModel"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -138,14 +143,15 @@ onUnmounted(() => {
|
|||||||
<template #search>
|
<template #search>
|
||||||
<div class="models-search">
|
<div class="models-search">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<KbxInput
|
<KsTextField
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
|
label="Model search"
|
||||||
placeholder="Search by model name..."
|
placeholder="Search by model name..."
|
||||||
@keydown.enter="handleSearch"
|
@keydown.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<KbxButton
|
<KsButton
|
||||||
label="Search"
|
label="Search"
|
||||||
variant="default"
|
severity="secondary"
|
||||||
@click="handleSearch"
|
@click="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,15 +178,15 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<template #content>
|
<template #content>
|
||||||
<KbxDataGrid
|
<KsDataGrid
|
||||||
v-if="screenDef.grid && modelsQuery.data.value?.items"
|
v-if="screenDef.grid && modelsQuery.data.value?.items"
|
||||||
:columns="modelsQuery.data.value?.items.length ? screenDef.grid.columnDefs : []"
|
:columns="modelsQuery.data.value?.items.length ? modelColumns : []"
|
||||||
:rows="modelsQuery.data.value?.items || []"
|
:rows="modelsQuery.data.value?.items || []"
|
||||||
:loading="modelsQuery.isPending.value"
|
:loading="modelsQuery.isPending.value"
|
||||||
@row-click="handleRowClick"
|
@row-selected="handleRowSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</KbxListPage>
|
</KsListPage>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import KbxListPage from '@shared/ui/adapter/KbxListPage.vue'
|
import KsListPage from '@shared/ui/components/KsListPage.vue'
|
||||||
import KbxDataGrid from '@shared/ui/adapter/KbxDataGrid.vue'
|
import { KsButton, KsDataGrid, KsTextField } from '@shared/ui/components'
|
||||||
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
|
|
||||||
import KbxInput from '@shared/ui/adapter/KbxInput.vue'
|
|
||||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||||
import { useShadowRunsList } from '../composables/useShadowRuns'
|
import { useShadowRunsList, type ShadowRun } from '../composables/useShadowRuns'
|
||||||
import type { ShadowRunListParams } from '../composables/useShadowRuns'
|
import type { ShadowRunListParams } from '../composables/useShadowRuns'
|
||||||
|
import { toUiGridColumns } from '@shared/ui/gridColumnAdapter'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const registry = useKbxRegistry()
|
const registry = useKbxRegistry()
|
||||||
|
|
||||||
@@ -18,6 +16,8 @@ const screenDef = computed(() =>
|
|||||||
registry.getScreen('model-ops.shadow-run.list'),
|
registry.getScreen('model-ops.shadow-run.list'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const shadowRunColumns = computed(() => toUiGridColumns(screenDef.value?.grid?.columnDefs ?? []))
|
||||||
|
|
||||||
// Search and filter state
|
// Search and filter state
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
@@ -84,6 +84,11 @@ const handleRowClick = (runId: string) => {
|
|||||||
router.push(`/model-ops/shadow-runs/${runId}`)
|
router.push(`/model-ops/shadow-runs/${runId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRowSelected = (row: unknown) => {
|
||||||
|
const shadowRun = row as Partial<ShadowRun>
|
||||||
|
if (typeof shadowRun.runId === 'string') handleRowClick(shadowRun.runId)
|
||||||
|
}
|
||||||
|
|
||||||
const handleQuickFilter = (filterId: string) => {
|
const handleQuickFilter = (filterId: string) => {
|
||||||
statusFilter.value = filterId
|
statusFilter.value = filterId
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
@@ -115,7 +120,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="screenDef" class="shadow-run-list">
|
<div v-if="screenDef" class="shadow-run-list">
|
||||||
<KbxListPage
|
<KsListPage
|
||||||
:screen="screenDef"
|
:screen="screenDef"
|
||||||
:data-state="dataState"
|
:data-state="dataState"
|
||||||
:loading="dataState === 'pending'"
|
:loading="dataState === 'pending'"
|
||||||
@@ -126,9 +131,9 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<!-- Header Actions -->
|
<!-- Header Actions -->
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<KbxButton
|
<KsButton
|
||||||
label="New Shadow Run"
|
label="New Shadow Run"
|
||||||
variant="primary"
|
severity="primary"
|
||||||
@click="handleNewRun"
|
@click="handleNewRun"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -137,26 +142,29 @@ onUnmounted(() => {
|
|||||||
<template #search>
|
<template #search>
|
||||||
<div class="shadow-run-search">
|
<div class="shadow-run-search">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<KbxInput
|
<KsTextField
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
|
label="Shadow run search"
|
||||||
placeholder="Search by model name..."
|
placeholder="Search by model name..."
|
||||||
@keydown.enter="handleSearch"
|
@keydown.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<KbxButton
|
<KsButton
|
||||||
label="Search"
|
label="Search"
|
||||||
variant="default"
|
severity="secondary"
|
||||||
@click="handleSearch"
|
@click="handleSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<KbxInput
|
<KsTextField
|
||||||
v-model="dateRangeStart"
|
v-model="dateRangeStart"
|
||||||
type="date"
|
type="date"
|
||||||
|
label="Start date"
|
||||||
placeholder="Start Date"
|
placeholder="Start Date"
|
||||||
/>
|
/>
|
||||||
<KbxInput
|
<KsTextField
|
||||||
v-model="dateRangeEnd"
|
v-model="dateRangeEnd"
|
||||||
type="date"
|
type="date"
|
||||||
|
label="End date"
|
||||||
placeholder="End Date"
|
placeholder="End Date"
|
||||||
/>
|
/>
|
||||||
<select v-model="statusFilter" class="status-filter">
|
<select v-model="statusFilter" class="status-filter">
|
||||||
@@ -171,15 +179,15 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<template #content>
|
<template #content>
|
||||||
<KbxDataGrid
|
<KsDataGrid
|
||||||
v-if="screenDef.grid && shadowRunsQuery.data.value?.items"
|
v-if="screenDef.grid && shadowRunsQuery.data.value?.items"
|
||||||
:columns="shadowRunsQuery.data.value?.items.length ? screenDef.grid.columnDefs : []"
|
:columns="shadowRunsQuery.data.value?.items.length ? shadowRunColumns : []"
|
||||||
:rows="shadowRunsQuery.data.value?.items || []"
|
:rows="shadowRunsQuery.data.value?.items || []"
|
||||||
:loading="shadowRunsQuery.isPending.value"
|
:loading="shadowRunsQuery.isPending.value"
|
||||||
@row-click="handleRowClick"
|
@row-selected="handleRowSelected"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</KbxListPage>
|
</KsListPage>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PButton from 'primevue/button'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label?: string
|
|
||||||
variant?: 'default' | 'primary' | 'danger' | 'success'
|
|
||||||
size?: 'sm' | 'md' | 'lg'
|
|
||||||
disabled?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
icon?: string
|
|
||||||
iconPosition?: 'left' | 'right'
|
|
||||||
fullWidth?: boolean
|
|
||||||
text?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'md',
|
|
||||||
disabled: false,
|
|
||||||
loading: false,
|
|
||||||
iconPosition: 'left',
|
|
||||||
fullWidth: false,
|
|
||||||
text: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ click: [] }>()
|
|
||||||
|
|
||||||
// Map KBX variants to PrimeVue severity
|
|
||||||
const severityMap = {
|
|
||||||
default: 'secondary',
|
|
||||||
primary: 'primary',
|
|
||||||
danger: 'danger',
|
|
||||||
success: 'success',
|
|
||||||
}
|
|
||||||
|
|
||||||
const severity = computed(() => severityMap[props.variant])
|
|
||||||
|
|
||||||
// Size classes (density-aware via CSS variables)
|
|
||||||
const sizeClasses = computed(() => {
|
|
||||||
const densityVar = 'var(--kbx-density, compact)'
|
|
||||||
return {
|
|
||||||
'kbx-button--sm': props.size === 'sm',
|
|
||||||
'kbx-button--md': props.size === 'md',
|
|
||||||
'kbx-button--lg': props.size === 'lg',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<PButton
|
|
||||||
:label="label"
|
|
||||||
:severity="severity"
|
|
||||||
:disabled="disabled || loading"
|
|
||||||
:loading="loading"
|
|
||||||
:icon="icon"
|
|
||||||
:icon-pos="iconPosition"
|
|
||||||
:class="{ ...sizeClasses, 'p-button-text': text, 'w-full': fullWidth }"
|
|
||||||
@click="emit('click')"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</PButton>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.kbx-button--sm :deep(.p-button) {
|
|
||||||
min-height: calc(var(--kbx-input-height, 34px) - 4px);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-button--md :deep(.p-button) {
|
|
||||||
min-height: var(--kbx-input-height, 34px);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-button--lg :deep(.p-button) {
|
|
||||||
min-height: calc(var(--kbx-input-height, 34px) + 6px);
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Density-aware spacing */
|
|
||||||
:deep(.p-button) {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.p-button:not(:disabled):hover) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import type { GridOptions } from 'ag-grid-community'
|
|
||||||
import type { KbxGridColumn, KbxDensity } from '@shared/contracts/kbx-types'
|
|
||||||
|
|
||||||
interface Props<T = any> {
|
|
||||||
columns: KbxGridColumn<T>[]
|
|
||||||
rows?: T[]
|
|
||||||
rowKey?: keyof T | string
|
|
||||||
loading?: boolean
|
|
||||||
density?: KbxDensity
|
|
||||||
allowSelection?: boolean
|
|
||||||
serverSideDatasource?: boolean
|
|
||||||
pageSize?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
rows: () => [],
|
|
||||||
loading: false,
|
|
||||||
density: 'compact',
|
|
||||||
allowSelection: false,
|
|
||||||
serverSideDatasource: false,
|
|
||||||
pageSize: 50,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
rowClick: [row: any]
|
|
||||||
selectionChange: [selectedRows: any[]]
|
|
||||||
loadMore: [{ offset: number; pageSize: number }]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const selectedRows = ref<any[]>([])
|
|
||||||
|
|
||||||
// Density-aware row height
|
|
||||||
const densityHeights = {
|
|
||||||
compact: 34,
|
|
||||||
comfortable: 36,
|
|
||||||
touch: 48,
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridOptions = computed(() => {
|
|
||||||
const colDefs = props.columns.map(col => ({
|
|
||||||
field: String(col.field),
|
|
||||||
headerName: col.header,
|
|
||||||
width: typeof col.width === 'number' ? col.width : undefined,
|
|
||||||
pinned: col.pinned || undefined,
|
|
||||||
sortable: col.sortable !== false,
|
|
||||||
filter: col.filterable !== false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
columnDefs: colDefs,
|
|
||||||
rowData: props.rows,
|
|
||||||
rowSelection: props.allowSelection ? ('multiple' as const) : undefined,
|
|
||||||
rowHeight: densityHeights[props.density],
|
|
||||||
pagination: !props.serverSideDatasource,
|
|
||||||
paginationPageSize: props.pageSize,
|
|
||||||
suppressMovableColumns: false,
|
|
||||||
suppressColumnMoveAnimation: false,
|
|
||||||
headerHeight: 36,
|
|
||||||
theme: 'ag-theme-quartz',
|
|
||||||
} as unknown as GridOptions<any>
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSelectionChanged = (event: any) => {
|
|
||||||
selectedRows.value = event.api.getSelectedRows()
|
|
||||||
emit('selectionChange', selectedRows.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRowClicked = (event: any) => {
|
|
||||||
emit('rowClick', event.data)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="kbx-data-grid"
|
|
||||||
:class="`kbx-data-grid--${density}`"
|
|
||||||
:style="{
|
|
||||||
'--kbx-grid-row-height': `${densityHeights[density]}px`,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div v-if="loading" class="kbx-data-grid__loader">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AgGridVue
|
|
||||||
:grid-options="gridOptions"
|
|
||||||
class="ag-theme-quartz"
|
|
||||||
@selection-changed="onSelectionChanged"
|
|
||||||
@row-clicked="onRowClicked"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.kbx-data-grid {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-data-grid__loader {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
|
||||||
z-index: 10;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 4px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
border-top-color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Density variants */
|
|
||||||
.kbx-data-grid--compact {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-data-grid--comfortable {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-data-grid--touch {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ag-theme-quartz) {
|
|
||||||
--ag-row-height: var(--kbx-grid-row-height, 34px);
|
|
||||||
--ag-header-height: 36px;
|
|
||||||
--ag-font-size: 0.875rem;
|
|
||||||
--ag-border-color: var(--kbx-color-border, #e5e7eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ag-header-cell-text) {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--kbx-color-text, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ag-row) {
|
|
||||||
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ag-row:hover) {
|
|
||||||
background-color: var(--kbx-color-hover, #f9fafb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ag-row-selected) {
|
|
||||||
background-color: var(--kbx-color-primary-alpha, rgba(59, 130, 246, 0.1));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PInputText from 'primevue/inputtext'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
modelValue?: string | null
|
|
||||||
type?: 'text' | 'email' | 'password' | 'number' | 'date'
|
|
||||||
placeholder?: string
|
|
||||||
disabled?: boolean
|
|
||||||
readonly?: boolean
|
|
||||||
invalid?: boolean
|
|
||||||
label?: string
|
|
||||||
help?: string
|
|
||||||
required?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
modelValue: '',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: undefined,
|
|
||||||
disabled: false,
|
|
||||||
readonly: false,
|
|
||||||
invalid: false,
|
|
||||||
required: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: string]
|
|
||||||
focus: []
|
|
||||||
blur: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const inputClasses = computed(() => ({
|
|
||||||
'p-invalid': props.invalid,
|
|
||||||
'kbx-input--disabled': props.disabled,
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="kbx-input-wrapper">
|
|
||||||
<label v-if="label" class="kbx-input__label">
|
|
||||||
{{ label }}
|
|
||||||
<span v-if="required" class="kbx-input__required">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<PInputText
|
|
||||||
:model-value="modelValue || ''"
|
|
||||||
:type="type"
|
|
||||||
:placeholder="placeholder || ''"
|
|
||||||
:disabled="disabled"
|
|
||||||
:readonly="readonly"
|
|
||||||
:class="inputClasses"
|
|
||||||
class="kbx-input"
|
|
||||||
@update:model-value="emit('update:modelValue', $event || '')"
|
|
||||||
@focus="emit('focus')"
|
|
||||||
@blur="emit('blur')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<small v-if="help" class="kbx-input__help">{{ help }}</small>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.kbx-input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--kbx-color-text, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input__required {
|
|
||||||
color: var(--kbx-color-danger, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input {
|
|
||||||
min-height: var(--kbx-input-height, 34px);
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input:focus {
|
|
||||||
border-color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
box-shadow: 0 0 0 3px var(--kbx-color-primary-alpha, rgba(59, 130, 246, 0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input:disabled,
|
|
||||||
.kbx-input--disabled {
|
|
||||||
background-color: var(--kbx-color-disabled, #f3f4f6);
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input.p-invalid {
|
|
||||||
border-color: var(--kbx-color-danger, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-input__help {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--kbx-color-text-muted, #6b7280);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type {
|
|
||||||
KbxScreenDefinition,
|
|
||||||
KbxAsyncState,
|
|
||||||
KbxSummaryItem,
|
|
||||||
KbxQuickFilterItem,
|
|
||||||
KbxScreenContext,
|
|
||||||
} from '@shared/contracts/kbx-types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
screen: KbxScreenDefinition
|
|
||||||
dataState?: KbxAsyncState
|
|
||||||
loading?: boolean
|
|
||||||
selectionCount?: number
|
|
||||||
summaryItems?: KbxSummaryItem[]
|
|
||||||
quickFilters?: KbxQuickFilterItem[]
|
|
||||||
context?: KbxScreenContext | null
|
|
||||||
allowActions?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
dataState: 'ready',
|
|
||||||
loading: false,
|
|
||||||
selectionCount: 0,
|
|
||||||
summaryItems: () => [],
|
|
||||||
quickFilters: () => [],
|
|
||||||
context: null,
|
|
||||||
allowActions: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
command: [commandId: string]
|
|
||||||
quickFilter: [filterId: string]
|
|
||||||
refresh: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Visibility states
|
|
||||||
const showSearch = computed(() => !!props.screen.type)
|
|
||||||
const showSummary = computed(() => props.summaryItems.length > 0)
|
|
||||||
const showQuickFilter = computed(() => props.quickFilters.length > 0)
|
|
||||||
|
|
||||||
const stateMessages = {
|
|
||||||
idle: 'Ready to search',
|
|
||||||
pending: 'Loading data...',
|
|
||||||
ready: 'Data loaded',
|
|
||||||
error: 'Error loading data',
|
|
||||||
empty: 'No results found',
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentStateMessage = computed(() => stateMessages[props.dataState])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="kbx-list-page">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<header class="kbx-list-page__header">
|
|
||||||
<div class="kbx-list-page__header-content">
|
|
||||||
<div>
|
|
||||||
<h1 class="kbx-list-page__title">{{ screen.title }}</h1>
|
|
||||||
<p v-if="screen.description" class="kbx-list-page__description">
|
|
||||||
{{ screen.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="kbx-list-page__header-actions">
|
|
||||||
<slot name="header-actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Search Panel (slot) -->
|
|
||||||
<div v-if="showSearch" class="kbx-list-page__search">
|
|
||||||
<slot name="search" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Filters -->
|
|
||||||
<div v-if="showQuickFilter" class="kbx-list-page__quick-filters">
|
|
||||||
<button
|
|
||||||
v-for="filter in quickFilters"
|
|
||||||
:key="filter.id"
|
|
||||||
class="kbx-list-page__quick-filter-item"
|
|
||||||
:class="{ active: filter.active }"
|
|
||||||
@click="emit('quickFilter', filter.id)"
|
|
||||||
>
|
|
||||||
{{ filter.label }}
|
|
||||||
<span v-if="filter.badge" class="badge">{{ filter.badge }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Context Bar -->
|
|
||||||
<div v-if="context" class="kbx-list-page__context">
|
|
||||||
<slot name="context" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Area -->
|
|
||||||
<main class="kbx-list-page__content">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="dataState === 'pending'" class="kbx-list-page__state">
|
|
||||||
<div class="state-spinner"></div>
|
|
||||||
{{ currentStateMessage }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-else-if="dataState === 'empty'" class="kbx-list-page__state">
|
|
||||||
<p>{{ currentStateMessage }}</p>
|
|
||||||
<button class="kbx-button--secondary" @click="emit('command', 'new')">
|
|
||||||
Create New
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="dataState === 'error'" class="kbx-list-page__state kbx-list-page__state--error">
|
|
||||||
<p>{{ currentStateMessage }}</p>
|
|
||||||
<button class="kbx-button--primary" @click="emit('refresh')">
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Slot -->
|
|
||||||
<slot v-else name="content" />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Summary Bar (sticky footer) -->
|
|
||||||
<footer v-if="showSummary" class="kbx-list-page__footer">
|
|
||||||
<div class="kbx-list-page__summary">
|
|
||||||
<span v-if="selectionCount > 0" class="summary-item">
|
|
||||||
{{ selectionCount }} item(s) selected
|
|
||||||
</span>
|
|
||||||
<div class="summary-items">
|
|
||||||
<span v-for="item in summaryItems" :key="item.label" class="summary-item">
|
|
||||||
<strong>{{ item.label }}:</strong>
|
|
||||||
{{ item.value }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.kbx-list-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
background-color: var(--kbx-color-background, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__header {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
background-color: var(--kbx-color-shell-chrome, #f9fafb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__header-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__title {
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
color: var(--kbx-color-text, #000);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__description {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--kbx-color-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__search {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
background-color: var(--kbx-color-surface, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__quick-filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
background-color: var(--kbx-color-surface, #fff);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__quick-filter-item {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__quick-filter-item:hover {
|
|
||||||
border-color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__quick-filter-item.active {
|
|
||||||
background-color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: var(--kbx-color-surface, #fff);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__state {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--kbx-color-text-muted, #6b7280);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__state--error {
|
|
||||||
color: var(--kbx-color-danger, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.state-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
border: 4px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
border-top-color: var(--kbx-color-primary, #3b82f6);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__footer {
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-top: 1px solid var(--kbx-color-border, #e5e7eb);
|
|
||||||
background-color: var(--kbx-color-shell-chrome, #f9fafb);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbx-list-page__summary {
|
|
||||||
display: flex;
|
|
||||||
gap: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--kbx-color-text-muted, #6b7280);
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-items {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-item {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Component, InjectionKey } from 'vue'
|
import type { Component, InjectionKey } from 'vue'
|
||||||
|
import type { UiGridStatusMap } from '../gridStatus'
|
||||||
|
|
||||||
export type UiSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
|
export type UiSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
|
||||||
export type UiButtonType = 'button' | 'submit' | 'reset'
|
export type UiButtonType = 'button' | 'submit' | 'reset'
|
||||||
@@ -53,8 +54,11 @@ export interface UiGridColumn {
|
|||||||
filterable?: boolean
|
filterable?: boolean
|
||||||
sensitive?: boolean
|
sensitive?: boolean
|
||||||
formatter?: (value: unknown, row: unknown) => string
|
formatter?: (value: unknown, row: unknown) => string
|
||||||
|
statusMap?: UiGridStatusMap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UiTextFieldType = 'text' | 'email' | 'password' | 'number' | 'date'
|
||||||
|
|
||||||
export interface UiAdapterDescriptor {
|
export interface UiAdapterDescriptor {
|
||||||
readonly id: UiAdapterId
|
readonly id: UiAdapterId
|
||||||
readonly version: string
|
readonly version: string
|
||||||
|
|||||||
@@ -7,10 +7,6 @@
|
|||||||
export * from '@shared/contracts/kbx-types'
|
export * from '@shared/contracts/kbx-types'
|
||||||
|
|
||||||
// Adapter Components (PrimeVue wrapped)
|
// Adapter Components (PrimeVue wrapped)
|
||||||
export { default as KbxButton } from './KbxButton.vue'
|
|
||||||
export { default as KbxInput } from './KbxInput.vue'
|
|
||||||
export { default as KbxDataGrid } from './KbxDataGrid.vue'
|
|
||||||
export { default as KbxListPage } from './KbxListPage.vue'
|
|
||||||
|
|
||||||
// Density tokens
|
// Density tokens
|
||||||
export const densityTokens = {
|
export const densityTokens = {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import {
|
import {
|
||||||
AllCommunityModule,
|
ClientSideRowModelModule,
|
||||||
ModuleRegistry,
|
TextFilterModule,
|
||||||
themeQuartz,
|
themeQuartz,
|
||||||
|
ModuleRegistry,
|
||||||
|
RowSelectionModule,
|
||||||
type ColDef,
|
type ColDef,
|
||||||
type RowClickedEvent
|
type RowClickedEvent
|
||||||
} from 'ag-grid-community'
|
} from 'ag-grid-community'
|
||||||
import type { UiGridColumn } from '../contracts'
|
import type { UiGridColumn } from '../contracts'
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule])
|
ModuleRegistry.registerModules([ClientSideRowModelModule, TextFilterModule, RowSelectionModule])
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
rows: unknown[]
|
rows: unknown[]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* PrimeVue v4 is intentionally installed in unstyled mode. Vendor DOM classes stay in this adapter boundary. */
|
/* PrimeVue theme owns baseline control, combo, calendar, overlay, and focus styles. */
|
||||||
/* PrimeVue 4.5.5's unstyled Dialog emits data-pc-name/data-pc-section attributes, not p-dialog* class names - verified via rendered DOM. */
|
/* PrimeVue 4.5.5's unstyled Dialog emits data-pc-name/data-pc-section attributes, not p-dialog* class names - verified via rendered DOM. */
|
||||||
[data-pc-section="mask"]:has(> [data-pc-name="dialog"]) { background: rgb(15 23 42 / 48%); padding: var(--ks-space-4); }
|
[data-pc-section="mask"]:has(> [data-pc-name="dialog"]) { background: rgb(15 23 42 / 48%); padding: var(--ks-space-4); }
|
||||||
.p-dialog.ks-dialog, [data-pc-name="dialog"][data-pc-section="root"].ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
|
.p-dialog.ks-dialog, [data-pc-name="dialog"][data-pc-section="root"].ks-dialog { width: min(42rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-lg); background: #fff; box-shadow: var(--ks-shadow-lg); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
|
import Aura from '@primevue/themes/aura'
|
||||||
import type { UiProvider } from '../../provider/UiProvider'
|
import type { UiProvider } from '../../provider/UiProvider'
|
||||||
import { installUiAdapter } from '../useUiAdapter'
|
import { installUiAdapter } from '../useUiAdapter'
|
||||||
import { primeVueUiAdapter } from './index'
|
import { primeVueUiAdapter } from './index'
|
||||||
@@ -8,7 +9,12 @@ import './adapter.css'
|
|||||||
export const primeVueUiProvider: UiProvider = {
|
export const primeVueUiProvider: UiProvider = {
|
||||||
id: 'primevue-aggrid',
|
id: 'primevue-aggrid',
|
||||||
install(app: App): void {
|
install(app: App): void {
|
||||||
app.use(PrimeVue, { unstyled: true })
|
app.use(PrimeVue, {
|
||||||
|
theme: {
|
||||||
|
preset: Aura,
|
||||||
|
options: { darkModeSelector: false }
|
||||||
|
}
|
||||||
|
})
|
||||||
installUiAdapter(app, primeVueUiAdapter)
|
installUiAdapter(app, primeVueUiAdapter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
import type { UiButtonType, UiSeverity } from '../adapter/contracts'
|
import type { UiButtonType, UiSeverity } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
|
||||||
|
|
||||||
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), {
|
withDefaults(defineProps<{ label?: string; severity?: UiSeverity; type?: UiButtonType; disabled?: boolean; loading?: boolean }>(), {
|
||||||
severity: 'primary', type: 'button', disabled: false, loading: false
|
severity: 'primary', type: 'button', disabled: false, loading: false
|
||||||
})
|
})
|
||||||
const emit = defineEmits<{ click: [event: MouseEvent] }>()
|
const emit = defineEmits<{ click: [event: MouseEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component :is="adapter.components.Button" v-bind="$props" @activate="emit('click', $event)"><slot /></component>
|
<Button v-bind="$props" class="ks-button" @click="emit('click', $event)"><slot /></Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useId } from 'vue'
|
import { computed, useId } from 'vue'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import Checkbox from 'primevue/checkbox'
|
||||||
const props = defineProps<{ modelValue: boolean; label: string; inputId?: string; disabled?: boolean }>()
|
const props = defineProps<{ modelValue: boolean; label: string; inputId?: string; disabled?: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
const resolvedId = computed(() => props.inputId ?? `ks-check-${generatedId}`)
|
const resolvedId = computed(() => props.inputId ?? `ks-check-${generatedId}`)
|
||||||
</script>
|
</script>
|
||||||
<template><label class="ks-check" :for="resolvedId"><component :is="adapter.components.Checkbox" :input-id="resolvedId" :model-value="modelValue" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /><span>{{ label }}</span></label></template>
|
<template><label class="ks-check" :for="resolvedId"><Checkbox class="ks-checkbox" :input-id="resolvedId" :model-value="modelValue" binary :disabled="disabled" @update:model-value="emit('update:modelValue', Boolean($event))" /><span>{{ label }}</span></label></template>
|
||||||
<style scoped>.ks-check { display: inline-flex; align-items: center; gap: var(--ks-space-2); cursor: pointer; }</style>
|
<style scoped>.ks-check { display: inline-flex; align-items: center; gap: var(--ks-space-2); cursor: pointer; }</style>
|
||||||
|
|||||||
@@ -1,8 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
|
import { ClientSideRowModelModule, ModuleRegistry, RowSelectionModule, TextFilterModule, themeQuartz, type ColDef, type RowClickedEvent } from 'ag-grid-community'
|
||||||
import type { UiGridColumn } from '../adapter/contracts'
|
import type { UiGridColumn } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
|
||||||
withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
ModuleRegistry.registerModules([ClientSideRowModelModule, TextFilterModule, RowSelectionModule])
|
||||||
|
|
||||||
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
|
const emit = defineEmits<{ rowSelected: [row: unknown] }>()
|
||||||
const adapter = useUiAdapter()
|
const props = withDefaults(defineProps<{ rows: unknown[]; columns: UiGridColumn[]; loading?: boolean; height?: string; rowSelection?: 'single' | 'multiple' | 'none' }>(), { loading: false, height: '32rem', rowSelection: 'single' })
|
||||||
|
const columnDefs = computed<ColDef[]>(() => props.columns.map(column => ({
|
||||||
|
field: column.field,
|
||||||
|
headerName: column.header,
|
||||||
|
width: column.width,
|
||||||
|
minWidth: column.minWidth ?? 120,
|
||||||
|
sortable: column.sortable ?? true,
|
||||||
|
filter: column.filterable ?? true,
|
||||||
|
valueFormatter: column.formatter ? params => column.formatter?.(params.value, params.data) ?? '' : undefined
|
||||||
|
})))
|
||||||
|
const rowSelectionOptions = computed(() => props.rowSelection === 'none' ? undefined : ({ mode: props.rowSelection === 'multiple' ? 'multiRow' : 'singleRow' } as const))
|
||||||
|
function onRowClicked(event: RowClickedEvent): void {
|
||||||
|
if (event.data) emit('rowSelected', event.data)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.DataGrid" v-bind="$props" @row-selected="emit('rowSelected', $event)" /></template>
|
<template>
|
||||||
|
<div class="ks-grid" :style="{ height: props.height }" :aria-busy="props.loading">
|
||||||
|
<AgGridVue
|
||||||
|
style="height: 100%; width: 100%"
|
||||||
|
:theme="themeQuartz"
|
||||||
|
:row-data="props.rows"
|
||||||
|
:column-defs="columnDefs"
|
||||||
|
:row-selection="rowSelectionOptions"
|
||||||
|
:loading="props.loading"
|
||||||
|
:suppress-no-rows-overlay="props.rows.length > 0"
|
||||||
|
@row-clicked="onRowClicked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ks-grid { min-height: 12rem; border: 1px solid var(--ks-color-neutral-200); border-radius: var(--ks-radius-md); overflow: hidden; background: #fff; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import DatePicker from 'primevue/datepicker'
|
||||||
|
import { computed } from 'vue'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
const props = defineProps<{ modelValue: string | Date | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: Date; max?: Date }>()
|
const props = defineProps<{ modelValue: string | Date | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: Date; max?: Date }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string | Date | null]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
const dateValue = computed(() => typeof props.modelValue === 'string' ? new Date(props.modelValue) : props.modelValue)
|
||||||
|
function handleDateChange(value: Date | Date[] | (Date | null)[] | null | undefined): void {
|
||||||
|
if (value instanceof Date) emit('update:modelValue', value)
|
||||||
|
else if (value == null) emit('update:modelValue', null)
|
||||||
|
else emit('update:modelValue', value[0] ?? null)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><component :is="adapter.components.DateField" :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :min="min" :max="max" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /></FieldShell></template>
|
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><DatePicker :input-id="field.inputId" :model-value="dateValue" :disabled="disabled" :invalid="field.invalid" :min-date="min" :max-date="max" date-format="yy-mm-dd" show-icon :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="handleDateChange" @blur="emit('blur', $event as unknown as FocusEvent)" /></FieldShell></template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import Dialog from 'primevue/dialog'
|
||||||
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
|
defineProps<{ visible: boolean; title: string; modal?: boolean; closable?: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.Dialog" v-bind="$props" @update:visible="emit('update:visible', $event)"><slot /><template #footer><slot name="footer" /></template></component></template>
|
<template><Dialog class="ks-dialog" :visible="visible" :header="title" :modal="modal ?? true" :closable="closable ?? true" @update:visible="emit('update:visible', $event)"><slot /><template #footer><slot name="footer" /></template></Dialog></template>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UiSeverity } from '../adapter/contracts'
|
import type { UiSeverity } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import Message from 'primevue/message'
|
||||||
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
withDefaults(defineProps<{ severity?: UiSeverity; title?: string; message: string; dismissible?: boolean }>(), { severity: 'info', dismissible: false })
|
||||||
const emit = defineEmits<{ dismiss: [] }>()
|
const emit = defineEmits<{ dismiss: [] }>()
|
||||||
const adapter = useUiAdapter()
|
const map = { primary: 'info', secondary: 'secondary', success: 'success', info: 'info', warning: 'warn', danger: 'error' } as const
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.InlineMessage" v-bind="$props" @dismiss="emit('dismiss')" /></template>
|
<template><Message :severity="map[severity]" :closable="dismissible" @close="emit('dismiss')"><strong v-if="title">{{ title }} </strong>{{ message }}</Message></template>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { KbxScreenDefinition, KbxAsyncState, KbxSummaryItem, KbxQuickFilterItem, KbxScreenContext } from '@shared/contracts/kbx-types'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
screen: KbxScreenDefinition
|
||||||
|
dataState?: KbxAsyncState
|
||||||
|
loading?: boolean
|
||||||
|
selectionCount?: number
|
||||||
|
summaryItems?: KbxSummaryItem[]
|
||||||
|
quickFilters?: KbxQuickFilterItem[]
|
||||||
|
context?: KbxScreenContext | null
|
||||||
|
allowActions?: boolean
|
||||||
|
}>(), { dataState: 'ready', loading: false, selectionCount: 0, summaryItems: () => [], quickFilters: () => [], context: null, allowActions: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ command: [commandId: string]; quickFilter: [filterId: string]; refresh: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ks-list-page">
|
||||||
|
<header class="ks-list-page__header">
|
||||||
|
<div><h1>{{ screen.title }}</h1><p v-if="screen.description">{{ screen.description }}</p></div>
|
||||||
|
<div><slot name="header-actions" /></div>
|
||||||
|
</header>
|
||||||
|
<section v-if="screen.type" class="ks-list-page__search"><slot name="search" /></section>
|
||||||
|
<nav v-if="quickFilters.length" class="ks-list-page__quick-filters" aria-label="빠른 필터">
|
||||||
|
<button v-for="filter in quickFilters" :key="filter.id" type="button" :aria-pressed="filter.active" @click="emit('quickFilter', filter.id)">{{ filter.label }}<span v-if="filter.badge"> {{ filter.badge }}</span></button>
|
||||||
|
</nav>
|
||||||
|
<section v-if="context" class="ks-list-page__context"><slot name="context" /></section>
|
||||||
|
<main class="ks-list-page__content" :aria-busy="loading">
|
||||||
|
<div v-if="dataState === 'pending'">Loading data...</div>
|
||||||
|
<div v-else-if="dataState === 'empty'">No results found <button type="button" @click="emit('command', 'new')">Create New</button></div>
|
||||||
|
<div v-else-if="dataState === 'error'" role="alert">Error loading data <button type="button" @click="emit('refresh')">Retry</button></div>
|
||||||
|
<slot v-else name="content" />
|
||||||
|
</main>
|
||||||
|
<footer v-if="summaryItems.length" class="ks-list-page__footer">
|
||||||
|
<span v-if="selectionCount">{{ selectionCount }} item(s) selected</span>
|
||||||
|
<span v-for="item in summaryItems" :key="item.label"><strong>{{ item.label }}:</strong> {{ item.value }}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ks-list-page { display:flex; flex-direction:column; height:100%; background:var(--ks-color-surface,#fff); }
|
||||||
|
.ks-list-page__header,.ks-list-page__search,.ks-list-page__quick-filters,.ks-list-page__footer { padding:var(--ks-space-4); border-bottom:1px solid var(--ks-color-neutral-200); }
|
||||||
|
.ks-list-page__header { display:flex; justify-content:space-between; gap:var(--ks-space-4); background:var(--ks-color-neutral-50); }
|
||||||
|
.ks-list-page__header h1 { margin:0; }
|
||||||
|
.ks-list-page__header p { margin:.5rem 0 0; color:var(--ks-color-text-muted); }
|
||||||
|
.ks-list-page__quick-filters { display:flex; gap:var(--ks-space-2); overflow:auto; }
|
||||||
|
.ks-list-page__quick-filters button { padding:.5rem .75rem; border:1px solid var(--ks-color-neutral-300); border-radius:var(--ks-radius-sm); background:#fff; }
|
||||||
|
.ks-list-page__quick-filters button[aria-pressed='true'] { background:var(--ks-color-action); color:#fff; }
|
||||||
|
.ks-list-page__content { flex:1; overflow:auto; }
|
||||||
|
.ks-list-page__footer { display:flex; gap:var(--ks-space-4); flex-wrap:wrap; background:var(--ks-color-neutral-50); }
|
||||||
|
</style>
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import InputNumber from 'primevue/inputnumber'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; currency?: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number }>(), { currency: 'KRW' })
|
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; currency?: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number }>(), { currency: 'KRW' })
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
||||||
<span class="ks-money-field">
|
<span class="ks-money-field">
|
||||||
<component
|
<InputNumber
|
||||||
:is="adapter.components.NumberField"
|
|
||||||
:input-id="field.inputId"
|
:input-id="field.inputId"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -21,7 +19,7 @@ const adapter = useUiAdapter()
|
|||||||
:aria-describedby="field.describedBy"
|
:aria-describedby="field.describedBy"
|
||||||
:aria-required="field.required || undefined"
|
:aria-required="field.required || undefined"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
@blur="emit('blur', $event)"
|
@blur="emit('blur', $event as unknown as FocusEvent)"
|
||||||
/>
|
/>
|
||||||
<span class="ks-money-field__currency" aria-hidden="true">{{ props.currency }}</span>
|
<span class="ks-money-field__currency" aria-hidden="true">{{ props.currency }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UiSelectOption } from '../adapter/contracts'
|
import type { UiSelectOption } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import MultiSelect from 'primevue/multiselect'
|
||||||
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
withDefaults(defineProps<{ modelValue?: Array<string | number | boolean | null>; options: UiSelectOption[]; label?: string; disabled?: boolean; required?: boolean }>(), { modelValue: () => [] })
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: Array<string | number | boolean | null>] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.MultiSelect" v-bind="$props" @update:model-value="emit('update:modelValue', $event)" /></template>
|
<template><label class="ks-field"><span v-if="label">{{ label }}<b v-if="required" aria-hidden="true"> *</b></span><MultiSelect class="ks-multi-select" :model-value="modelValue" :options="options" option-label="label" option-value="value" option-disabled="disabled" :disabled="disabled" @update:model-value="emit('update:modelValue', $event)" /></label></template>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import InputNumber from 'primevue/inputnumber'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
const props = defineProps<{ modelValue: number | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
const props = defineProps<{ modelValue: number | null; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; min?: number; max?: number; minFractionDigits?: number; maxFractionDigits?: number }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><component :is="adapter.components.NumberField" :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" /></FieldShell></template>
|
<template><FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field"><InputNumber :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :min="min" :max="max" :min-fraction-digits="minFractionDigits" :max-fraction-digits="maxFractionDigits" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event as unknown as FocusEvent)" /></FieldShell></template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import Paginator from 'primevue/paginator'
|
||||||
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
withDefaults(defineProps<{ page: number; pageSize: number; total: number; pageSizes?: number[]; disabled?: boolean }>(), { pageSizes: () => [20, 50, 100], disabled: false })
|
||||||
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
const emit = defineEmits<{ pageChange: [value: { page: number; pageSize: number }] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.Paginator" v-bind="$props" @page-change="emit('pageChange', $event)" /></template>
|
<template><Paginator :first="(page - 1) * pageSize" :rows="pageSize" :total-records="total" :rows-per-page-options="pageSizes" :disabled="disabled" @page="emit('pageChange', { page: $event.page + 1, pageSize: $event.rows })" /></template>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import InputNumber from 'primevue/inputnumber'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; unit?: string; availableQuantity?: number; allowNegative?: boolean; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string }>(), { unit: '주', allowNegative: false })
|
const props = withDefaults(defineProps<{ modelValue: number | null; label: string; unit?: string; availableQuantity?: number; allowNegative?: boolean; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string }>(), { unit: '주', allowNegative: false })
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: number | null]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
|
|
||||||
const exceedsAvailable = computed(() =>
|
const exceedsAvailable = computed(() =>
|
||||||
props.modelValue != null && props.availableQuantity != null && props.modelValue > props.availableQuantity
|
props.modelValue != null && props.availableQuantity != null && props.modelValue > props.availableQuantity
|
||||||
@@ -15,8 +14,7 @@ const effectiveHelp = computed(() => props.availableQuantity != null ? `가용 $
|
|||||||
<template>
|
<template>
|
||||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="effectiveError" :help="effectiveHelp" v-slot="field">
|
<FieldShell :label="label" :input-id="inputId" :required="required" :error="effectiveError" :help="effectiveHelp" v-slot="field">
|
||||||
<span class="ks-quantity-field">
|
<span class="ks-quantity-field">
|
||||||
<component
|
<InputNumber
|
||||||
:is="adapter.components.NumberField"
|
|
||||||
:input-id="field.inputId"
|
:input-id="field.inputId"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -27,7 +25,7 @@ const effectiveHelp = computed(() => props.availableQuantity != null ? `가용 $
|
|||||||
:aria-describedby="field.describedBy"
|
:aria-describedby="field.describedBy"
|
||||||
:aria-required="field.required || undefined"
|
:aria-required="field.required || undefined"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
@blur="emit('blur', $event)"
|
@blur="emit('blur', $event as unknown as FocusEvent)"
|
||||||
/>
|
/>
|
||||||
<span class="ks-quantity-field__unit" aria-hidden="true">{{ props.unit }}</span>
|
<span class="ks-quantity-field__unit" aria-hidden="true">{{ props.unit }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UiSelectOption } from '../adapter/contracts'
|
import type { UiSelectOption } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import Select from 'primevue/select'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
const props = defineProps<{ modelValue: unknown; label: string; options: UiSelectOption[]; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
const props = defineProps<{ modelValue: unknown; label: string; options: UiSelectOption[]; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: unknown]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
||||||
<component :is="adapter.components.Select" :input-id="field.inputId" :model-value="modelValue" :options="options" :disabled="disabled" :invalid="field.invalid" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
|
<Select class="ks-select" :input-id="field.inputId" :model-value="modelValue" :options="options" option-label="label" option-value="value" option-disabled="disabled" :disabled="disabled" :invalid="field.invalid" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event as unknown as FocusEvent)" />
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UiSeverity } from '../adapter/contracts'
|
import type { UiSeverity } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import type { UiGridStatusSemantic } from '../gridStatus'
|
||||||
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string }>()
|
import Tag from 'primevue/tag'
|
||||||
const adapter = useUiAdapter()
|
defineProps<{ value: string; severity?: UiSeverity; iconLabel?: string; semantic?: UiGridStatusSemantic; unknown?: boolean }>()
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.StatusTag" v-bind="$props" /></template>
|
<template><Tag class="ks-status-tag" :severity="severity ?? 'info'" :data-semantic="semantic" :data-unknown="unknown || undefined" :aria-label="unknown ? `정의되지 않음: ${value}` : value"><span aria-hidden="true">{{ iconLabel ?? (unknown ? '?' : '●') }}</span><span>{{ value }}</span></Tag></template>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UiTabItem } from '../adapter/contracts'
|
import type { UiTabItem } from '../adapter/contracts'
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
|
||||||
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
withDefaults(defineProps<{ modelValue: string; items: UiTabItem[]; ariaLabel?: string }>(), { ariaLabel: '탭' })
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template><component :is="adapter.components.Tabs" v-bind="$props" @update:model-value="emit('update:modelValue', $event)"><template #default="slotProps"><slot :active-id="slotProps.activeId" /></template></component></template>
|
<template><div><div class="ks-tabs" role="tablist" :aria-label="ariaLabel"><button v-for="item in items" :key="item.id" type="button" role="tab" :aria-selected="modelValue === item.id" :disabled="item.disabled" @click="emit('update:modelValue', item.id)">{{ item.label }}<small v-if="item.badge"> {{ item.badge }}</small></button></div><div role="tabpanel"><slot :active-id="modelValue" /></div></div></template>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import Textarea from 'primevue/textarea'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; rows?: number; placeholder?: string }>()
|
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; rows?: number; placeholder?: string }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
||||||
<component :is="adapter.components.TextArea" :input-id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :rows="rows" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', $event)" @blur="emit('blur', $event)" />
|
<Textarea class="ks-textarea" :id="field.inputId" :model-value="modelValue" :disabled="disabled" :invalid="field.invalid" :rows="rows ?? 4" :placeholder="placeholder" :aria-describedby="field.describedBy" :aria-required="field.required || undefined" @update:model-value="emit('update:modelValue', String($event ?? ''))" @blur="emit('blur', $event)" />
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUiAdapter } from '../adapter/useUiAdapter'
|
import InputText from 'primevue/inputtext'
|
||||||
import FieldShell from './FieldShell.vue'
|
import FieldShell from './FieldShell.vue'
|
||||||
|
import type { UiTextFieldType } from '../adapter/contracts'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: string; label: string; inputId?: string; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
const props = defineProps<{ modelValue: string; label: string; inputId?: string; type?: UiTextFieldType; disabled?: boolean; required?: boolean; error?: string; help?: string; placeholder?: string }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string]; blur: [event: FocusEvent] }>()
|
||||||
const adapter = useUiAdapter()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
<FieldShell :label="label" :input-id="inputId" :required="required" :error="error" :help="help" v-slot="field">
|
||||||
<component
|
<InputText
|
||||||
:is="adapter.components.TextField"
|
|
||||||
:input-id="field.inputId"
|
:input-id="field.inputId"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
|
:type="type"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:invalid="field.invalid"
|
:invalid="field.invalid"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:aria-describedby="field.describedBy"
|
:aria-describedby="field.describedBy"
|
||||||
:aria-required="field.required || undefined"
|
:aria-required="field.required || undefined"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', String($event ?? ''))"
|
||||||
@blur="emit('blur', $event)"
|
@blur="emit('blur', $event)"
|
||||||
/>
|
/>
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export { default as KsInlineMessage } from './KsInlineMessage.vue'
|
|||||||
export { default as KsPaginator } from './KsPaginator.vue'
|
export { default as KsPaginator } from './KsPaginator.vue'
|
||||||
export { default as KsTabs } from './KsTabs.vue'
|
export { default as KsTabs } from './KsTabs.vue'
|
||||||
export { default as KsDataGrid } from './KsDataGrid.vue'
|
export { default as KsDataGrid } from './KsDataGrid.vue'
|
||||||
|
export { default as KsListPage } from './KsListPage.vue'
|
||||||
export { default as FieldShell } from './FieldShell.vue'
|
export { default as FieldShell } from './FieldShell.vue'
|
||||||
export { default as KsDataContextHeader } from './KsDataContextHeader.vue'
|
export { default as KsDataContextHeader } from './KsDataContextHeader.vue'
|
||||||
export { default as KsCommandBar } from './KsCommandBar.vue'
|
export { default as KsCommandBar } from './KsCommandBar.vue'
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
function componentSources(root: string): string[] {
|
||||||
|
return readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isFile() && entry.name.endsWith('.vue'))
|
||||||
|
.map(entry => join(root, entry.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('existing component ownership boundary', () => {
|
||||||
|
it('prevents existing components from regressing to adapter-owned rendering', () => {
|
||||||
|
const root = join(process.cwd(), 'src', 'shared', 'ui', 'components')
|
||||||
|
const forbidden = /useUiAdapter|adapter\.components/
|
||||||
|
const violations = componentSources(root).filter(path => forbidden.test(readFileSync(path, 'utf8')))
|
||||||
|
|
||||||
|
expect(violations).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
describe('grid provider policy', () => {
|
||||||
|
it('uses AG Grid Community as the default direct grid implementation', () => {
|
||||||
|
const packageJson = readFileSync('package.json', 'utf8')
|
||||||
|
const source = readFileSync('src/shared/ui/components/KsDataGrid.vue', 'utf8')
|
||||||
|
|
||||||
|
expect(packageJson).toContain('"ag-grid-community"')
|
||||||
|
expect(packageJson).not.toContain('ag-grid-enterprise')
|
||||||
|
expect(source).toContain("from 'ag-grid-community'")
|
||||||
|
expect(source).toContain('ClientSideRowModelModule')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,9 @@ export async function resolveUiProvider(name: string | undefined): Promise<UiPro
|
|||||||
return (await import('../adapter/primevue/installPrimeVueAdapter')).primeVueUiProvider
|
return (await import('../adapter/primevue/installPrimeVueAdapter')).primeVueUiProvider
|
||||||
}
|
}
|
||||||
if (normalized === 'native') {
|
if (normalized === 'native') {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
throw new Error('The native UI provider is test/reference-only and cannot be used in a production artifact.')
|
||||||
|
}
|
||||||
return (await import('../adapter/native/installNativeAdapter')).nativeUiProvider
|
return (await import('../adapter/native/installNativeAdapter')).nativeUiProvider
|
||||||
}
|
}
|
||||||
throw new Error(`Unsupported VITE_UI_ADAPTER '${name}'. Allowed values: primevue, native.`)
|
throw new Error(`Unsupported VITE_UI_ADAPTER '${name}'. Allowed values: primevue, native.`)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createApp } from 'vue'
|
||||||
import { resolveUiProvider } from '../resolveUiProvider'
|
import { resolveUiProvider } from '../resolveUiProvider'
|
||||||
|
import { useUiAdapter } from '../../adapter/useUiAdapter'
|
||||||
|
|
||||||
describe('resolveUiProvider', () => {
|
describe('resolveUiProvider', () => {
|
||||||
|
it('uses PrimeVue as the default provider', async () => {
|
||||||
|
await expect(resolveUiProvider(undefined)).resolves.toMatchObject({ id: 'primevue-aggrid' })
|
||||||
|
})
|
||||||
|
|
||||||
it('loads the selected native provider', async () => {
|
it('loads the selected native provider', async () => {
|
||||||
await expect(resolveUiProvider('native')).resolves.toMatchObject({ id: 'native-accessible' })
|
await expect(resolveUiProvider('native')).resolves.toMatchObject({ id: 'native-accessible' })
|
||||||
})
|
})
|
||||||
@@ -11,4 +17,18 @@ describe('resolveUiProvider', () => {
|
|||||||
"Unsupported VITE_UI_ADAPTER 'unsupported'. Allowed values: primevue, native."
|
"Unsupported VITE_UI_ADAPTER 'unsupported'. Allowed values: primevue, native."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('installs the validated adapter before the application is mounted', async () => {
|
||||||
|
const provider = await resolveUiProvider('native')
|
||||||
|
const app = createApp({ template: '<div />' })
|
||||||
|
|
||||||
|
provider.install(app)
|
||||||
|
|
||||||
|
expect(app.runWithContext(() => useUiAdapter()).descriptor.id).toBe('native-accessible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the native provider available only to the non-production contract harness', async () => {
|
||||||
|
const provider = await resolveUiProvider('native')
|
||||||
|
expect(provider.id).toBe('native-accessible')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ for rel in ['frontend/src/shared/ui/adapter/compatibility.ts','frontend/src/shar
|
|||||||
for p in (root/'frontend/src').rglob('*'):
|
for p in (root/'frontend/src').rglob('*'):
|
||||||
if p.suffix not in {'.ts','.vue'}: continue
|
if p.suffix not in {'.ts','.vue'}: continue
|
||||||
t=p.read_text(encoding='utf-8')
|
t=p.read_text(encoding='utf-8')
|
||||||
if re.search(r"from ['\"](primevue/|ag-grid|ag-grid-vue3)",t) and '/shared/ui/adapter/primevue/' not in p.as_posix(): fail(f'vendor import outside adapter {p.relative_to(root)}')
|
if re.search(r"from ['\"](primevue/|ag-grid|ag-grid-vue3)",t):
|
||||||
|
normalized = p.as_posix()
|
||||||
|
allowed = ('/shared/ui/adapter/' in normalized or '/shared/ui/components/' in normalized)
|
||||||
|
if not allowed:
|
||||||
|
fail(f'vendor import outside approved UI ownership boundary {p.relative_to(root)}')
|
||||||
reg=(root/'src/KArtSell.Modules.ModelOperations/Domain/ModelOperationDefinition.cs').read_text()
|
reg=(root/'src/KArtSell.Modules.ModelOperations/Domain/ModelOperationDefinition.cs').read_text()
|
||||||
for code in ['J41','J42']:
|
for code in ['J41','J42']:
|
||||||
if f'"{code}"' not in reg: fail(f'missing registry {code}')
|
if f'"{code}"' not in reg: fail(f'missing registry {code}')
|
||||||
|
|||||||
Reference in New Issue
Block a user