Initial commit: Add project files
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { StandardScreenState } from '../ui/contracts/screenContract'
|
||||
import PageLayout from '../ui/layouts/PageLayout.vue'
|
||||
import FormPageLayout from '../ui/layouts/FormPageLayout.vue'
|
||||
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
|
||||
withDefaults(defineProps<{ title: string; subtitle?: string; state?: StandardScreenState; dirty?: boolean; readonly?: boolean; asOf?: string; version?: string }>(), { state: 'READY', dirty: false, readonly: false })
|
||||
const emit = defineEmits<{ retry: []; submit: []; cancel: [] }>()
|
||||
</script>
|
||||
<template>
|
||||
<PageLayout :title="title" :subtitle="subtitle" :status="readonly ? 'READONLY' : state" :as-of="asOf" :version="version">
|
||||
<StandardScreenBoundary :state="readonly ? 'READONLY' : (dirty ? 'DIRTY' : state)" :stale-at="asOf" @retry="emit('retry')">
|
||||
<FormPageLayout @submit="emit('submit')">
|
||||
<slot />
|
||||
<template v-if="$slots.aside" #preview><slot name="aside" /></template>
|
||||
</FormPageLayout>
|
||||
</StandardScreenBoundary>
|
||||
<template #footer><slot name="actions" /></template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { UiGridColumn } from '../ui/adapter/contracts'
|
||||
import type { StandardScreenState } from '../ui/contracts/screenContract'
|
||||
import PageLayout from '../ui/layouts/PageLayout.vue'
|
||||
import StandardScreenBoundary from '../ui/screen-types/v2/StandardScreenBoundary.vue'
|
||||
import KsDataGrid from '../ui/components/KsDataGrid.vue'
|
||||
import KsPaginator from '../ui/components/KsPaginator.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
state?: StandardScreenState
|
||||
rows: unknown[]
|
||||
columns: UiGridColumn[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
asOf?: string
|
||||
version?: string
|
||||
warning?: string
|
||||
}>(), { state: 'READY', rows: () => [] })
|
||||
const emit = defineEmits<{ retry: []; rowSelected: [row: unknown]; pageChange: [value: { page: number; pageSize: number }] }>()
|
||||
</script>
|
||||
<template>
|
||||
<PageLayout :title="title" :subtitle="subtitle" :status="state" :as-of="asOf" :version="version">
|
||||
<template #actions><slot name="actions" /></template>
|
||||
<template #summary><slot name="summary" /></template>
|
||||
<template #filters><slot name="filters" /></template>
|
||||
<StandardScreenBoundary :state="state" :warning="warning" :stale-at="asOf" @retry="emit('retry')">
|
||||
<KsDataGrid :rows="rows" :columns="columns" @row-selected="emit('rowSelected', $event)" />
|
||||
<KsPaginator :page="page" :page-size="pageSize" :total="total" @page-change="emit('pageChange', $event)" />
|
||||
</StandardScreenBoundary>
|
||||
<template v-if="$slots.detail" #aside><slot name="detail" /></template>
|
||||
<template v-if="$slots.footer" #footer><slot name="footer" /></template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { UiGridColumn, UiGridFilter, UiGridSort } from '../ui/adapter/contracts'
|
||||
|
||||
export type CrudPermission = 'read' | 'create' | 'update' | 'delete' | 'export' | 'review' | 'publish'
|
||||
export interface CrudListQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
sorts: UiGridSort[]
|
||||
filters: UiGridFilter[]
|
||||
}
|
||||
export interface CrudPageResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
asOf: string
|
||||
projectionVersion: string
|
||||
watermark?: string
|
||||
stale: boolean
|
||||
}
|
||||
export interface CrudMutationContext {
|
||||
idempotencyKey: string
|
||||
ifMatch?: string
|
||||
reason?: string
|
||||
correlationId?: string
|
||||
}
|
||||
export interface CrudResourceContract<TItem, TForm> {
|
||||
resourceCode: string
|
||||
routeBase: string
|
||||
queryKey: readonly string[]
|
||||
columns: UiGridColumn[]
|
||||
formSchema: ZodType<TForm>
|
||||
permissions: Partial<Record<CrudPermission, string>>
|
||||
defaultQuery: CrudListQuery
|
||||
parseListResponse(payload: unknown): CrudPageResult<TItem>
|
||||
parseDetailResponse(payload: unknown): TItem
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CrudListQuery } from './contracts'
|
||||
|
||||
const positiveInt = (value: string | null, fallback: number): number => {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function decodeCrudQuery(params: URLSearchParams, fallback: CrudListQuery): CrudListQuery {
|
||||
const sorts = params.getAll('sort').flatMap(value => {
|
||||
const [field, direction] = value.split(':')
|
||||
return field && (direction === 'asc' || direction === 'desc') ? [{ field, direction }] : []
|
||||
})
|
||||
const filters = params.getAll('filter').flatMap(value => {
|
||||
const first = value.indexOf(':')
|
||||
const second = value.indexOf(':', first + 1)
|
||||
if (first <= 0 || second <= first) return []
|
||||
return [{ field: value.slice(0, first), operator: value.slice(first + 1, second), value: value.slice(second + 1) }]
|
||||
})
|
||||
return {
|
||||
page: positiveInt(params.get('page'), fallback.page),
|
||||
pageSize: positiveInt(params.get('pageSize'), fallback.pageSize),
|
||||
search: params.get('search')?.trim() || undefined,
|
||||
sorts: sorts.length ? sorts : fallback.sorts,
|
||||
filters: filters.length ? filters : fallback.filters
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeCrudQuery(query: CrudListQuery): URLSearchParams {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(query.page))
|
||||
params.set('pageSize', String(query.pageSize))
|
||||
if (query.search) params.set('search', query.search)
|
||||
for (const sort of query.sorts) params.append('sort', `${sort.field}:${sort.direction}`)
|
||||
for (const filter of query.filters) params.append('filter', `${filter.field}:${filter.operator}:${String(filter.value ?? '')}`)
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ZodType } from 'zod'
|
||||
import type { UiGridColumn } from '../ui/adapter/contracts'
|
||||
export type CrudConcurrencyMode = 'NONE' | 'ETAG_IF_MATCH'
|
||||
export type CrudIdempotencyMode = 'NONE' | 'IDEMPOTENCY_KEY'
|
||||
export interface CrudResourceDefinition<TQuery, TRow, TForm> {
|
||||
resourceId: string
|
||||
querySchemaVersion: string
|
||||
responseSchemaVersion: string
|
||||
querySchema: ZodType<TQuery>
|
||||
rowSchema: ZodType<TRow>
|
||||
formSchema: ZodType<TForm>
|
||||
columns: readonly UiGridColumn[]
|
||||
sensitiveFields: readonly string[]
|
||||
permissionPolicy: string
|
||||
concurrencyMode: CrudConcurrencyMode
|
||||
idempotencyMode: CrudIdempotencyMode
|
||||
}
|
||||
export function assertCrudResourceDefinition(definition: CrudResourceDefinition<unknown, unknown, unknown>): void {
|
||||
if (!definition.resourceId.trim()) throw new Error('resourceId is required')
|
||||
const fields = new Set(definition.columns.map(x => x.field))
|
||||
for (const sensitive of definition.sensitiveFields) if (!fields.has(sensitive)) throw new Error(`Sensitive field '${sensitive}' has no grid column contract`)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeCrudQuery, encodeCrudQuery } from '../queryCodec'
|
||||
const fallback = { page: 1, pageSize: 20, sorts: [], filters: [] }
|
||||
describe('CRUD URL codec', () => {
|
||||
it('round-trips paging, sort, filter and search without hidden Pinia state', () => {
|
||||
const source = { page: 3, pageSize: 50, search: '005930', sorts: [{ field: 'asOf', direction: 'desc' as const }], filters: [{ field: 'status', operator: 'eq', value: 'WARN' }] }
|
||||
expect(decodeCrudQuery(encodeCrudQuery(source), fallback)).toEqual(source)
|
||||
})
|
||||
it('fails closed to approved defaults for invalid paging', () => {
|
||||
expect(decodeCrudQuery(new URLSearchParams('page=0&pageSize=-1'), fallback)).toEqual({ ...fallback, search: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CrudListQuery } from './contracts'
|
||||
|
||||
export function useCrudListState(initial: CrudListQuery) {
|
||||
const query = ref<CrudListQuery>({ ...initial, sorts: [...initial.sorts], filters: [...initial.filters] })
|
||||
const selectedId = ref<string | number | null>(null)
|
||||
const dirty = ref(false)
|
||||
|
||||
function replace(next: CrudListQuery): void { query.value = { ...next, sorts: [...next.sorts], filters: [...next.filters] } }
|
||||
function setPage(page: number, pageSize = query.value.pageSize): void { query.value = { ...query.value, page, pageSize } }
|
||||
function setSearch(search?: string): void { query.value = { ...query.value, page: 1, search: search?.trim() || undefined } }
|
||||
function reset(): void { replace(initial); selectedId.value = null; dirty.value = false }
|
||||
|
||||
return {
|
||||
query,
|
||||
selectedId,
|
||||
dirty,
|
||||
offset: computed(() => (query.value.page - 1) * query.value.pageSize),
|
||||
replace,
|
||||
setPage,
|
||||
setSearch,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue'
|
||||
import { createIdempotencyKey } from '../commands/idempotency'
|
||||
export interface OptimisticCommandRequest<T> { payload: T; etag?: string }
|
||||
export interface OptimisticCommandResponse<TResult> { data: TResult; etag?: string; correlationId?: string }
|
||||
export function useOptimisticCommand<TPayload, TResult>(execute: (request: OptimisticCommandRequest<TPayload>, headers: Record<string,string>) => Promise<OptimisticCommandResponse<TResult>>) {
|
||||
const pending = ref(false); const conflict = ref(false); const lastCorrelationId = ref<string>()
|
||||
async function run(request: OptimisticCommandRequest<TPayload>): Promise<OptimisticCommandResponse<TResult>> {
|
||||
if (pending.value) throw new Error('Command is already in progress')
|
||||
pending.value=true; conflict.value=false
|
||||
try {
|
||||
const headers: Record<string,string> = { 'Idempotency-Key': createIdempotencyKey() }
|
||||
if (request.etag) headers['If-Match']=request.etag
|
||||
const response=await execute(request,headers); lastCorrelationId.value=response.correlationId; return response
|
||||
} catch (error: unknown) {
|
||||
const status=(error as { response?: { status?: number } })?.response?.status
|
||||
if (status===409 || status===412) conflict.value=true
|
||||
throw error
|
||||
} finally { pending.value=false }
|
||||
}
|
||||
return { pending, conflict, lastCorrelationId, run }
|
||||
}
|
||||
Reference in New Issue
Block a user