(feature): init

This commit is contained in:
Maksym Sadovnychyy 2026-05-24 12:07:27 +02:00
commit 2c18605699
169 changed files with 12970 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
npm-debug.log*
utils/Release-Package/.npmrc.release-temp
src/.npmrc.release-temp

11
CHANGELOG.md Normal file
View File

@ -0,0 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial `@maksit/webui-contracts`, `@maksit/webui-core`, and `@maksit/webui-components` packages extracted from Certs UI and Vault WebUI.

51
README.md Normal file
View File

@ -0,0 +1,51 @@
# maksit-webui
Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps.
## Packages
| npm package | Description |
|-------------|-------------|
| `@maksit/webui-contracts` | Shared TypeScript contracts (paging, gallery types, patch ops, scopes) |
| `@maksit/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` |
| `@maksit/webui-components` | React components, layout, editors, DataTable, auth shell |
Source lives under `src/` (npm workspaces). Release automation lives under `utils/` (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)).
## Local development
```bash
cd src
npm install
npm run build
```
## Release to npmjs
1. Set **`NPMJS_MAKS_IT`** to your npm automation token (same pattern as `NUGET_MAKS_IT` for NuGet).
2. Bump **`src/package.json`** `version` (and tag `vX.Y.Z` on `main` when using the publish guard).
3. Run **`utils/Release-Package/Release-Package.bat`** (or `pwsh utils/Release-Package/Release-Package.ps1`).
Configured plugins (see `utils/Release-Package/scriptsettings.json`):
| Plugin | Role |
|--------|------|
| `NpmReleaseVersion` | Read semver from `src/package.json`; sync `packages/*/package.json` |
| `NpmBuild` | `npm ci` + `npm run build` |
| `ReleasePublishGuard` | Branch/tag checks before publish |
| `GitHub` | GitHub release (optional; needs `GITHUB_MAKS_IT_COM`) |
| `NpmPublish` | Publish workspace packages in dependency order |
Refresh shared utils from repoutils: **`utils/Update-RepoUtils/Update-RepoUtils.bat`**.
## Consume in product repos
```bash
npm install @maksit/webui-contracts @maksit/webui-core @maksit/webui-components
```
Wrap the app with `WebUiProvider` and pass axios/redux adapters — see [assets/docs/NPM_CONSUMPTION.md](assets/docs/NPM_CONSUMPTION.md).
## License
MIT

View File

@ -0,0 +1,47 @@
# Consuming @maksit/webui-* in Certs UI / Vault
Install:
```bash
npm install @maksit/webui-contracts @maksit/webui-core @maksit/webui-components
```
Wrap the app:
```tsx
import { WebUiProvider, Loader, Authorization } from '@maksit/webui-components'
<WebUiProvider
api={{
getData: (url) => getData(url),
getDataWithoutLoader: (url) => getDataWithoutLoader(url),
postData: (url, body) => postData(url, body),
postDataWithoutLoader: (url, body) => postDataWithoutLoader(url, body),
}}
loader={{
disableLoader: () => dispatch(disableLoader()),
enableLoader: () => dispatch(enableLoader()),
}}
auth={{
identity,
hydrated,
login: (c) => dispatch(login(c)),
logout: (r) => dispatch(logout(r)),
setIdentityFromLocalStorage: () => dispatch(setIdentityFromLocalStorage()),
showUserOffcanvas,
setShowUserOffcanvas: () => dispatch(setShowUserOffcanvas()),
setHideUserOffcanvas: () => dispatch(setHideUserOffcanvas()),
}}
loading={loading}
>
<Loader />
<Authorization>{/* routes */}</Authorization>
</WebUiProvider>
```
## API differences vs copied local folders
- `RemoteSelectBoxComponent`: use `searchRoute` (absolute API path string) instead of `apiRoute: ApiRoutes`.
- `SecretComponent`: pass `generateSecretRoute` when `enableGenerate` is true.
- ACL: generic `parseAclEntry` / `parseAclEntries` from `@maksit/webui-core`; per-app entity maps and `parse*AclEntries` live in each WebUI project (`models/acl.ts`).
- Identity request types and Zod schemas (`LoginRequest` + `LoginRequestSchema`, `LogoutRequest` + `LogoutRequestSchema`, `RefreshTokenRequest` + `RefreshTokenRequestSchema`) live in `@maksit/webui-contracts`.

View File

@ -0,0 +1,84 @@
# Publishing `@maksit/webui-*` to npm
Packages are published under the **`@maksit` scope** to [registry.npmjs.org](https://registry.npmjs.org), managed from the [maks-it.com npm org](https://www.npmjs.com/settings/maks-it.com/packages).
Published packages:
| Package | npm |
|---------|-----|
| `@maksit/webui-contracts` | https://www.npmjs.com/package/@maksit/webui-contracts |
| `@maksit/webui-core` | https://www.npmjs.com/package/@maksit/webui-core |
| `@maksit/webui-components` | https://www.npmjs.com/package/@maksit/webui-components |
## One-time npm setup
1. Sign in at https://www.npmjs.com/ with the **maks-it.com** org account.
2. Confirm the **`@maksit` scope** exists under [Packages](https://www.npmjs.com/settings/maks-it.com/packages). Create the org/scope on npm if this is the first `@maksit/*` publish.
3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maksit/*`:
- https://www.npmjs.com/settings/maks-it.com/tokens
4. Store the token for release tooling:
- **CI / Release-Package:** set env var `NPMJS_MAKS_IT` to the token value (same pattern as `NUGET_MAKS_IT`).
- **Local one-off publish:** `npm login` or a user-level `~/.npmrc` entry:
```
//registry.npmjs.org/:_authToken=YOUR_TOKEN
```
Scoped packages must use **`--access public`** (already configured in each package `publishConfig`).
## Manual first publish (0.1.0)
From the repo root:
```powershell
cd src
npm ci
npm run build
npm publish -w @maksit/webui-contracts --access public
npm publish -w @maksit/webui-core --access public
npm publish -w @maksit/webui-components --access public
```
Order matters: **contracts → core → components**.
Or use the helper script:
```powershell
.\scripts\publish-npm.ps1
```
Verify:
```powershell
npm view @maksit/webui-contracts version
npm view @maksit/webui-core version
npm view @maksit/webui-components version
```
## Release pipeline (recommended)
From `utils/Release-Package/`:
1. Bump version in `src/package.json` (or tag drives `NpmReleaseVersion`).
2. Tag `HEAD` with exact semver, e.g. `git tag v0.1.0 && git push origin v0.1.0`.
3. Set `NPMJS_MAKS_IT` and run `Release-Package.ps1`.
`scriptsettings.json` runs `NpmBuild` then `NpmPublish` in dependency order.
## After publish — Certs UI / Vault
In each WebUI app (`MaksIT.WebUI/package.json`):
```json
"@maksit/webui-contracts": "^0.1.0",
"@maksit/webui-core": "^0.1.0",
"@maksit/webui-components": "^0.1.0"
```
Then refresh the lockfile:
```bash
cd src/MaksIT.WebUI
npm install
```
Docker builds use `npm ci` from the lockfile; no sibling `maksit-webui` clone is required in the image context.

52
scripts/publish-npm.ps1 Normal file
View File

@ -0,0 +1,52 @@
#requires -Version 7.0
# Publish @maksit/webui-* to registry.npmjs.org (maks-it.com org / @maksit scope).
# Requires: npm login or //registry.npmjs.org/:_authToken in ~/.npmrc, or NPMJS_MAKS_IT env var.
$ErrorActionPreference = 'Stop'
$workspaceRoot = Join-Path $PSScriptRoot '..' 'src' | Resolve-Path
$publishOrder = @(
'@maksit/webui-contracts',
'@maksit/webui-core',
'@maksit/webui-components'
)
Push-Location $workspaceRoot
try {
if (-not [string]::IsNullOrWhiteSpace($env:NPMJS_MAKS_IT)) {
$tempNpmRc = Join-Path $workspaceRoot '.npmrc.publish-temp'
@"
registry=https://registry.npmjs.org
//registry.npmjs.org/:_authToken=$($env:NPMJS_MAKS_IT)
"@ | Set-Content -Path $tempNpmRc -Encoding utf8 -NoNewline
$npmUserConfig = @('--userconfig', $tempNpmRc)
}
else {
$npmUserConfig = @()
Write-Host 'NPMJS_MAKS_IT not set; using default npm auth (~/.npmrc or npm login).' -ForegroundColor Yellow
}
Write-Host 'Installing workspace dependencies...' -ForegroundColor Cyan
npm ci @npmUserConfig
if ($LASTEXITCODE -ne 0) { throw 'npm ci failed.' }
Write-Host 'Building packages...' -ForegroundColor Cyan
npm run build @npmUserConfig
if ($LASTEXITCODE -ne 0) { throw 'npm run build failed.' }
foreach ($packageName in $publishOrder) {
Write-Host "Publishing $packageName..." -ForegroundColor Cyan
npm publish -w $packageName --access public @npmUserConfig
if ($LASTEXITCODE -ne 0) { throw "npm publish failed for $packageName." }
Write-Host "Published $packageName." -ForegroundColor Green
}
Write-Host 'All @maksit/webui-* packages published.' -ForegroundColor Green
}
finally {
Pop-Location
$tempNpmRc = Join-Path $workspaceRoot '.npmrc.publish-temp'
if (Test-Path $tempNpmRc) {
Remove-Item -Path $tempNpmRc -Force -ErrorAction SilentlyContinue
}
}

2216
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
src/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "maksit-webui",
"private": true,
"version": "0.1.0",
"description": "Shared React UI library for MaksIT Certs UI and Vault WebUI",
"workspaces": [
"packages/*"
],
"engines": {
"node": ">=20"
},
"overrides": {
"zod": "^4.3.6"
},
"scripts": {
"build": "npm run build -w @maksit/webui-contracts && npm run build -w @maksit/webui-core && npm run build -w @maksit/webui-components",
"typecheck": "npm run typecheck --workspaces --if-present",
"clean": "npm run clean --workspaces --if-present"
},
"author": "MaksIT",
"license": "MIT"
}

View File

@ -0,0 +1,66 @@
{
"name": "@maksit/webui-components",
"version": "0.1.0",
"description": "Shared React components for MaksIT WebUI apps",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean --external react --external react-dom --external react-router-dom --external lucide-react --external @tanstack/react-table --external react-virtualized",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"prepublishOnly": "npm run build"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/MAKS-IT-COM/maksit-webui.git",
"directory": "src/packages/components"
},
"dependencies": {
"@maksit/webui-contracts": "^0.1.0",
"@maksit/webui-core": "^0.1.0",
"date-fns": "^4.1.0",
"lodash": "^4.17.23",
"uuid": "^13.0.0"
},
"peerDependencies": {
"@tanstack/react-table": "^8.0.0",
"lucide-react": "^0.500.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"react-router-dom": "^7.0.0",
"react-virtualized": "^9.22.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@tanstack/react-table": "^8.21.3",
"@types/lodash": "^4.17.24",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-virtualized": "^9.22.3",
"lucide-react": "^0.576.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
"react-virtualized": "^9.22.6",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,476 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
import { mapPagedToDataTable, type DataTablePageView, type PagedResponse } from '@maksit/webui-core'
import { Plus, Trash2, Edit } from 'lucide-react'
import { debounce } from 'lodash'
interface FilterProps {
columnId: string
}
interface CellProps<T, K extends keyof T = keyof T> {
columnId: string
data: T
value: T[K]
}
export interface DataTableColumn<T, K extends keyof T = keyof T> {
id: string
accessorKey: K
header: string
filter: (
props: FilterProps,
onFilterChange: (filterId: string, columnId: string, filters: string) => void
) => React.ReactNode
cell: (props: CellProps<T, K>) => React.ReactNode
}
interface DataTableProps<T> {
rawd?: PagedResponse<T> | DataTablePageView<T>
columns: DataTableColumn<T>[]
maxRecordsPerPage?: number
idFields?: string[]
allowAddRow?: () => boolean
onAddRow?: () => void
allowEditRow?: (ids: Record<string, string>) => boolean
onEditRow?: (ids: Record<string, string>) => void
allowDeleteRow?: (ids: Record<string, string>) => boolean
onDeleteRow?: (ids: Record<string, string>) => void
onFilterChange?: (filters: Record<string, string>) => void
onPreviousPage?: (pageNumber: number) => void
onNextPage?: (pageNumber: number) => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
storageKey?: string
}
const DEFAULT_ACTION_WIDTH = 80
const DEFAULT_COL_WIDTH = 150
const HEADER_ROWS = 2
const ROW_HEIGHT = 40
function toDataTableView<T>(rawd: PagedResponse<T> | DataTablePageView<T> | undefined): DataTablePageView<T> {
return mapPagedToDataTable(rawd as PagedResponse<T> | undefined)
}
const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>) => {
const {
rawd,
columns,
idFields = ['id'],
allowAddRow = () => false,
onAddRow,
allowEditRow = (_) => false,
onEditRow,
allowDeleteRow = (_) => false,
onDeleteRow,
onFilterChange,
onPreviousPage,
onNextPage,
colspan = 12,
storageKey,
} = props
const {
items,
pageNumber,
pageSize,
totalCount,
totalPages,
hasPreviousPage,
hasNextPage,
} = toDataTableView(rawd)
const gridRef = useRef<MultiGrid>(null)
const filterMeasureRef = useRef<HTMLDivElement>(null)
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const [measuredFilterRowHeight, setMeasuredFilterRowHeight] = useState(0)
const filterValues = useRef<Record<string, Record<string, string>>>({})
const [colWidths, setColWidths] = useState<number[]>(() => {
const defaultWidths = [DEFAULT_ACTION_WIDTH, ...columns.map(() => DEFAULT_COL_WIDTH)]
if (storageKey) {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
if (Array.isArray(parsed) && parsed.length === columns.length + 1) {
return parsed
}
localStorage.removeItem(storageKey)
return defaultWidths
}
}
catch {
return defaultWidths
}
}
return defaultWidths
})
useEffect(() => {
if (storageKey) {
localStorage.setItem(storageKey, JSON.stringify(colWidths))
}
if (gridRef.current) {
gridRef.current.recomputeGridSize()
gridRef.current.forceUpdateGrids()
}
}, [colWidths, storageKey])
useEffect(() => {
const el = filterMeasureRef.current
if (!el || columns.length === 0) return
const padding = 12
const updateHeight = () => {
const contentHeight = el.offsetHeight
if (contentHeight <= 0) return
const total = contentHeight + padding
setMeasuredFilterRowHeight((prev) => (prev !== total ? total : prev))
}
const ro = new ResizeObserver(() => {
updateHeight()
gridRef.current?.recomputeGridSize()
})
ro.observe(el)
updateHeight()
return () => ro.disconnect()
}, [columns, colWidths])
useEffect(() => {
if (measuredFilterRowHeight && gridRef.current) {
gridRef.current.recomputeGridSize()
}
}, [measuredFilterRowHeight])
const debouncedOnFilterChange = useMemo(
() => (onFilterChange ? debounce(onFilterChange, 500) : undefined),
[onFilterChange]
)
const handleFilterChange = (
filterId: string,
columnId: string,
filters: string
) => {
const prev = filterValues.current
const newValues = {
...prev,
[filterId]: {
...prev[filterId],
[columnId]: filters,
},
}
filterValues.current = newValues
const linqQueries = Object.fromEntries(
Object.entries(newValues).map(([fid, cols]) => {
const q = Object.values(cols)
.filter((v) => v)
.map((v) => `(${v})`)
.join(' && ')
return [fid, q]
})
)
debouncedOnFilterChange?.(linqQueries)
}
const handlePreviousPage = () => onPreviousPage?.(pageNumber - 1)
const handleNextPage = () => onNextPage?.(pageNumber + 1)
const getRealIdsFromRow = (rowIndex: number) => {
const row = items[rowIndex]
const ids = Object.fromEntries(
idFields.map((key) => [key, `${row[key]}`])
)
return ids
}
const handleAddRow = () => onAddRow?.()
const handleEditRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
onEditRow?.(ids)
}
const handleDeleteRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
onDeleteRow?.(ids)
}
const handleAllowAddRow = () => {
return allowAddRow?.()
}
const handleAllowEditRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
return allowEditRow?.(ids)
}
const handleAllowDeleteRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
return allowDeleteRow?.(ids)
}
const handleRowClick = (idx: number) =>
setSelectedRowIndex((prev) => (prev === idx ? null : idx))
const handleHeaderResize = (colIdx: number, startX: number, startWidth: number) => {
const onMouseMove = (e: MouseEvent) => {
const delta = e.clientX - startX
setColWidths(prev => {
const next = [...prev]
next[colIdx] = Math.max(40, startWidth + delta)
return next
})
}
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
const cellRenderer = ({ columnIndex, key, rowIndex, style }: GridCellProps) => {
const isActionCol = columnIndex === 0
const col = columns[columnIndex - 1]
const commonClasses = [
'box-border',
'flex',
'items-center',
'px-2',
'py-1',
'border-b',
'border-r',
'border-gray-200',
'overflow-hidden',
'whitespace-nowrap',
'truncate',
rowIndex >= HEADER_ROWS ? 'cursor-pointer' : '',
rowIndex >= HEADER_ROWS && selectedRowIndex === rowIndex - HEADER_ROWS ? 'bg-sky-100' : '',
].filter(Boolean).join(' ')
if (rowIndex === 0) {
const allowAddRowResult = handleAllowAddRow()
if (isActionCol) {
return (
<div key={key} style={style} className={commonClasses}>
<button
onClick={handleAddRow}
disabled={!allowAddRowResult}
className={`p-1 ${allowAddRowResult
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
}`}>
<Plus />
</button>
</div>
)
}
return (
<div
key={key}
style={{ ...style, userSelect: 'none' }}
className={commonClasses}
>
<span>{col.header}</span>
<div
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: 8,
cursor: 'col-resize',
zIndex: 10,
userSelect: 'none',
}}
onMouseDown={e => {
e.preventDefault()
handleHeaderResize(columnIndex, e.clientX, colWidths[columnIndex])
}}
/>
</div>
)
}
if (rowIndex === 1) {
return isActionCol ? (
<div
key={key}
style={{ ...style, transition: 'height 0.25s ease-out' }}
className={commonClasses}
/>
) : (
<div
key={key}
style={{ ...style, transition: 'height 0.25s ease-out' }}
className={'box-border flex min-w-0 items-stretch overflow-hidden border-b border-r border-gray-200 px-2 py-1'}
>
<div className={'w-full min-w-0 self-start'}>
{col.filter({ columnId: col.id }, handleFilterChange)}
</div>
</div>
)
}
const dataIdx = rowIndex - HEADER_ROWS
if (isActionCol) {
const allowEditRowResult = handleAllowEditRow(dataIdx)
const allowDeleteRowResult = handleAllowDeleteRow(dataIdx)
return (
<div
key={key}
style={style}
className={commonClasses} onClick={() => handleRowClick(dataIdx)}>
<button
onClick={e => {
e.stopPropagation()
handleEditRow(dataIdx)
}}
disabled={!allowEditRowResult}
className={`p-1 ${allowEditRowResult
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
}`}>
<Edit />
</button>
<button
onClick={e => {
e.stopPropagation()
handleDeleteRow(dataIdx)
}}
disabled={!allowDeleteRowResult}
className={`p-1 ${allowDeleteRowResult
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
}`}>
<Trash2 />
</button>
</div>
)
}
const row = items[dataIdx]
return (
<div
key={key}
style={style}
className={commonClasses}
onClick={() => setSelectedRowIndex(dataIdx)}
>
{col.cell({
columnId: col.id,
data: row,
value: row[col.accessorKey]
})}
</div>
)
}
const handleGridScroll = ({
scrollTop,
clientHeight,
scrollHeight,
}: {
scrollTop: number
clientHeight: number
scrollHeight: number
}) => {
if (scrollTop + clientHeight >= scrollHeight - 2 && hasNextPage) {
handleNextPage()
}
if (scrollTop <= 2 && hasPreviousPage) {
handlePreviousPage()
}
}
return (
<div className={`col-span-${colspan} flex flex-col h-full w-full relative`}>
{columns[0] && (
<div
ref={filterMeasureRef}
aria-hidden={true}
style={{
position: 'absolute',
left: -9999,
top: 0,
width: colWidths[1],
visibility: 'hidden',
pointerEvents: 'none',
}}
>
{columns[0].filter({ columnId: columns[0].id }, handleFilterChange)}
</div>
)}
<div className={'flex-1'}>
<AutoSizer>
{({ height, width }) => (
<MultiGrid
ref={gridRef}
cellRenderer={cellRenderer}
columnCount={columns.length + 1}
columnWidth={({ index }) => colWidths[index]}
fixedColumnCount={1}
fixedRowCount={HEADER_ROWS}
height={height}
rowCount={items.length + HEADER_ROWS}
rowHeight={({ index }) => index === 1 ? measuredFilterRowHeight : ROW_HEIGHT}
width={width}
onScroll={({ scrollTop, clientHeight, scrollHeight }) =>
handleGridScroll({ scrollTop, clientHeight, scrollHeight })
}
/>
)}
</AutoSizer>
</div>
<div className={'mt-4 text-sm'}>
<div className={'flex justify-end gap-4'}>
<span>Page Size: {pageSize}</span>
<span>Total Pages: {totalPages}</span>
<span>Total Count: {totalCount}</span>
</div>
<div className={'flex items-center justify-between mt-2'}>
<button
onClick={handlePreviousPage}
disabled={!hasPreviousPage}
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
aria-disabled={!hasPreviousPage}
>
Previous
</button>
<span>Page {pageNumber} of {totalPages}</span>
<button
onClick={handleNextPage}
disabled={!hasNextPage}
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
aria-disabled={!hasNextPage}
>
Next
</button>
</div>
</div>
</div>
)
}
export { DataTable }

View File

@ -0,0 +1,151 @@
import { useMemo, useState } from 'react'
import { debounce } from 'lodash'
interface FilterPropsBase {
filterId?: string
columnId: string
accessorKey: string
value?: FilterState
disabled?: boolean
onFilterChange?: (filterId: string, columnId: string, filters: string) => void
}
interface NormalFilterProps extends FilterPropsBase {
type: 'normal'
}
export type DataTableRemoteFilterDataSource<T extends { id: string }> = (
filters: string
) => Promise<T[] | undefined>
interface RemoteFilterProps<T extends { id: string }> extends FilterPropsBase {
type: 'remote'
dataSource: DataTableRemoteFilterDataSource<T>
}
type FilterProps<T extends { id: string }> = NormalFilterProps | RemoteFilterProps<T>
interface FilterState {
value: string
operator: string
}
function toPascalCase(s: string): string {
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1)
}
const DataTableFilter = <T extends { id: string }>(props: FilterProps<T>) => {
const {
type,
filterId = 'filters',
columnId,
accessorKey,
value = { value: '', operator: 'contains' },
disabled = false,
onFilterChange,
} = props
const [filterState, setFilterState] = useState<FilterState>(value)
const debounceOnFilterChange = useMemo(() => {
if (!onFilterChange || type !== 'remote')
return
const { dataSource } = props as RemoteFilterProps<T>
return debounce((filters: string) => {
void dataSource(filters).then((rows) => {
if (!rows)
return
const linqQuery = rows.map((item) => `${columnId} == "${item.id}"`).join(' || ')
onFilterChange(filterId, columnId, linqQuery)
})
}, 500)
}, [filterId, columnId, onFilterChange, props, type])
const handleFilterChange = (nextValue: string, operator: string) => {
setFilterState({ value: nextValue, operator })
if (nextValue === '') {
onFilterChange?.(filterId, columnId, '')
return
}
const propName = toPascalCase(accessorKey)
let linqQuery = ''
switch (operator) {
case 'contains':
linqQuery = `${propName}.Contains("${nextValue}")`
break
case 'startsWith':
linqQuery = `${propName}.StartsWith("${nextValue}")`
break
case 'endsWith':
linqQuery = `${propName}.EndsWith("${nextValue}")`
break
case '=':
linqQuery = `${propName} == "${nextValue}"`
break
case '!=':
linqQuery = `${propName} != "${nextValue}"`
break
case '>':
linqQuery = `${propName} > "${nextValue}"`
break
case '<':
linqQuery = `${propName} < "${nextValue}"`
break
case '>=':
linqQuery = `${propName} >= "${nextValue}"`
break
case '<=':
linqQuery = `${propName} <= "${nextValue}"`
break
default:
linqQuery = `${propName}.Contains("${nextValue}")`
break
}
if (type === 'normal') {
onFilterChange?.(filterId, columnId, linqQuery)
}
if (type === 'remote' && debounceOnFilterChange) {
debounceOnFilterChange(linqQuery)
}
}
return (
<div className={'flex w-full min-w-0 flex-col gap-1 overflow-hidden justify-center h-full'}>
<input
type={'text'}
placeholder={'Filter...'}
className={'block w-full min-w-0 max-w-full border rounded h-8 py-1 px-2 text-sm text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500/30 border-gray-300 bg-white disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-default'}
value={filterState.value}
onChange={(e) => handleFilterChange(e.target.value, filterState.operator)}
disabled={disabled}
/>
<select
value={filterState.operator}
onChange={(e) => handleFilterChange(filterState.value, e.target.value)}
disabled={disabled}
className={'block w-full min-w-0 max-w-full border rounded h-8 py-1 px-2 text-sm text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500/30 border-gray-300 bg-white disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-default'}
>
<option value={'contains'}>Contains</option>
<option value={'startsWith'}>Starts With</option>
<option value={'endsWith'}>Ends With</option>
<option value={'='}>=</option>
<option value={'!='}>!=</option>
<option value={'>'}>{'>'}</option>
<option value={'<'}>{'<'}</option>
<option value={'>='}>{'>='}</option>
<option value={'<='}>{'<='}</option>
</select>
</div>
)
}
export { DataTableFilter }
export type { FilterProps as DataTableFilterProps, FilterState as DataTableFilterState }

View File

@ -0,0 +1,57 @@
import { useEffect, useMemo, useState } from 'react'
import { formatISODateString } from '@maksit/webui-core'
interface NormalLabelProps {
type: 'normal'
value?: string
dataType?: 'string' | 'date'
}
export type DataTableRemoteLabelDataSource<T extends Record<string, unknown>> = () => Promise<T | undefined>
interface RemoteLabelProps<T extends Record<string, unknown>> {
type: 'remote'
accessorKey: keyof T & string
dataSource: DataTableRemoteLabelDataSource<T>
}
type LabelProps<T extends Record<string, unknown>> = NormalLabelProps | RemoteLabelProps<T>
const DataTableLabel = <T extends Record<string, unknown>>(props: LabelProps<T>) => {
const [remoteLabel, setRemoteLabel] = useState<string>('')
const label = useMemo(() => {
if (props.type !== 'normal')
return remoteLabel
const { value = '', dataType = 'string' } = props
switch (dataType) {
case 'date':
return formatISODateString(value)
case 'string':
default:
return value
}
}, [props, remoteLabel])
useEffect(() => {
if (props.type !== 'remote')
return
const { dataSource, accessorKey } = props
void dataSource().then((payload) => {
if (!payload)
return
const value = payload[accessorKey]
setRemoteLabel(value != null ? String(value) : '')
})
}, [props])
return <p>{label}</p>
}
export { DataTableLabel }
export type { LabelProps as DataTableLabelProps }

View File

@ -0,0 +1,12 @@
import type { DataTableColumn } from './DataTable'
const createColumn = <T, K extends keyof T>(col: DataTableColumn<T, K>): DataTableColumn<T, K> => {
return col
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createColumns = <T>(cols: DataTableColumn<T, any>[]) => {
return cols as unknown as DataTableColumn<T, keyof T>[]
}
export { createColumn, createColumns }

View File

@ -0,0 +1,28 @@
import {
DataTable,
DataTableColumn
} from './DataTable'
import {
createColumn,
createColumns
} from './helpers'
export type {
DataTableColumn
}
export { DataTableFilter } from './DataTableFilter'
export type {
DataTableFilterProps,
DataTableFilterState,
DataTableRemoteFilterDataSource,
} from './DataTableFilter'
export { DataTableLabel } from './DataTableLabel'
export type { DataTableLabelProps, DataTableRemoteLabelDataSource } from './DataTableLabel'
export {
DataTable,
createColumn,
createColumns
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
interface FormContainerProps {
children?: ReactNode
}
const FormContainer: FC<FormContainerProps> = (props) => {
const {
children
} = props
return <div className={'grid grid-rows-[auto_1fr_auto] h-full gap-0'}>
{children}
</div>
}
export {
FormContainer
}

View File

@ -0,0 +1,23 @@
import { FC, ReactNode } from 'react'
interface FormContentProps {
children?: ReactNode
/** Merged after base layout; use e.g. `flex flex-col overflow-hidden` when a child should fill height (iframe). */
className?: string
}
const FormContent: FC<FormContentProps> = (props) => {
const {
children,
className
} = props
const base = 'bg-gray-100 w-full h-full min-h-0 p-4'
return <div className={className ? `${base} ${className}` : `${base} overflow-y-auto`}>
{children}
</div>
}
export {
FormContent
}

View File

@ -0,0 +1,31 @@
import { FC, ReactNode } from 'react'
interface FormFooterProps {
children?: ReactNode,
leftChildren?: ReactNode,
rightChildren?: ReactNode
}
const FormFooter: FC<FormFooterProps> = (props) => {
const {
children,
leftChildren,
rightChildren
} = props
return <div className={'bg-gray-200 p-4 h-14 flex justify-between items-center'}>
{children ?? <>
<div className={'flex space-x-4'}>{leftChildren}</div>
<div className={'flex space-x-4'}>{rightChildren}</div>
</>}
</div>
}
export {
FormFooter
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
interface FormHeaderProps {
children?: ReactNode
}
const FormHeader: FC<FormHeaderProps> = (props) => {
const {
children
} = props
return <h1 className={'bg-gray-200 p-4 h-14 text-2xl font-bold'}>
{children}
</h1>
}
export {
FormHeader
}

View File

@ -0,0 +1,11 @@
import { FormContainer } from './FormContainer'
import { FormHeader } from './FormHeader'
import { FormContent } from './FormContent'
import { FormFooter } from './FormFooter'
export {
FormContainer,
FormHeader,
FormContent,
FormFooter
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContentProps {
children: ReactNode
}
const Container: FC<ContentProps> = (props) => {
const { children } = props
return <div className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
{children}
</div>
}
export {
Container
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContentProps {
children: ReactNode
}
const Content: FC<ContentProps> = (props) => {
const { children } = props
return <main className={'bg-gray-100 h-full w-full overflow-y-auto'}>
{children}
</main>
}
export {
Content
}

View File

@ -0,0 +1,16 @@
import { FC, ReactNode } from 'react'
export interface FooterProps {
children: ReactNode
}
const Footer: FC<FooterProps> = (props) => {
const { children } = props
return <footer className={'bg-blue-500 text-white p-4 h-14 flex items-center justify-center'}>
{children}
</footer>
}
export {
Footer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
export interface HeaderProps {
children: ReactNode
}
const Header: FC<HeaderProps> = (props) => {
const { children } = props
return <header>
<div className={'bg-blue-500 text-white p-4 h-14 flex justify-between items-center'}>
{children}
</div>
</header>
}
export {
Header
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContainerProps {
children: ReactNode
}
const MainContainer: FC<ContainerProps> = (props) => {
const { children } = props
return <div className={'grid grid-cols-[250px_1fr] h-screen w-screen'}>
{children}
</div>
}
export {
MainContainer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
export interface ContainerProps {
headerChildren?: ReactNode
children: ReactNode
footerChildren?: ReactNode
}
const Container: FC<ContainerProps> = (props) => {
const { children } = props
return <aside className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
{children}
</aside>
}
export {
Container
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContentProps {
children: ReactNode
}
const Content: FC<ContentProps> = (props) => {
const { children } = props
return <main className={'bg-gray-200 h-full p-4 overflow-y-auto border-r border-blue-500'}>
{children}
</main>
}
export {
Content
}

View File

@ -0,0 +1,16 @@
import { FC, ReactNode } from 'react'
export interface FooterProps {
children: ReactNode
}
const Footer: FC<FooterProps> = (props) => {
const { children } = props
return <footer className={'bg-blue-500 text-white p-4 h-14'}>
{children}
</footer>
}
export {
Footer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
export interface HeaderProps {
children: ReactNode
}
const Header: FC<HeaderProps> = (props) => {
const { children } = props
return <header>
<div className={'bg-blue-500 text-white p-4 h-14'}>
{children}
</div>
</header>
}
export {
Header
}

View File

@ -0,0 +1,32 @@
import { FC, ReactNode } from 'react'
import { Container } from '../Container'
import { Header } from './Header'
import { Content } from './Content'
import { Footer } from './Footer'
export interface SideMenuProps {
headerChildren?: ReactNode
children: ReactNode
footerChildren?: ReactNode
}
const SideMenu: FC<SideMenuProps> = (props) => {
const { headerChildren, children, footerChildren } = props
return <Container>
<Header>
{headerChildren}
</Header>
<Content>
{children}
</Content>
<Footer>
{footerChildren}
</Footer>
</Container>
}
export {
SideMenu
}

View File

@ -0,0 +1,40 @@
import { FC } from 'react'
import { MainContainer } from './MainContainer'
import { SideMenu, SideMenuProps } from './SideMenu'
import { Container } from './Container'
import { Header, HeaderProps } from './Header'
import { Footer, FooterProps } from './Footer'
import { Content } from './Content'
interface LayoutProps {
sideMenu: SideMenuProps
header: HeaderProps
children: React.ReactNode
footer: FooterProps
}
const Layout: FC<LayoutProps> = (props) => {
const { sideMenu, header, children, footer } = props
return <MainContainer>
<SideMenu
headerChildren={sideMenu.headerChildren}
footerChildren={sideMenu.footerChildren}
>
{sideMenu.children}
</SideMenu>
<Container>
<Header>
{header.children}
</Header>
<Content>
{children}
</Content>
<Footer>{footer.children}</Footer>
</Container>
</MainContainer>
}
export {
Layout
}

View File

@ -0,0 +1,86 @@
import { FC, useEffect, useRef, useState } from 'react'
interface LazyLoadTableColumnProps {
key: string
title: string
dataIndex: string
renderColumn?: (value: unknown) => React.ReactNode
}
interface LazyLoadTableProps {
data: Record<string, unknown>[]
columns: LazyLoadTableColumnProps[]
loadMore: () => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
}
const LazyLoadTable: FC<LazyLoadTableProps> = (props) => {
const {
data,
columns,
loadMore,
colspan = 6
} = props
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const observerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{ threshold: 1.0 }
)
if (observerRef.current) {
observer.observe(observerRef.current)
}
return () => {
if (observerRef.current) {
observer.unobserve(observerRef.current)
}
}
}, [loadMore])
const handleRowClick = (index: number) => {
setSelectedRowIndex(selectedRowIndex === index ? null : index)
}
return (
<div className={`col-span-${colspan}`}>
<table className={'w-full border-collapse'}>
<thead>
<tr>
{columns.map((column, index) => (
<th key={index} className={'bg-gray-50 text-left py-2 px-4 border-b'}>{column.title}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr
key={index}
className={`hover:bg-gray-100 ${selectedRowIndex === index ? 'bg-blue-100' : ''} transition-colors`}
onClick={() => handleRowClick(index)}
>
{columns.map((column, colIndex) => (
<td className={'py-2 px-4 border-b'} key={colIndex}>
{column.renderColumn
? column.renderColumn(row[column.dataIndex])
: String(row[column.dataIndex] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
<div ref={observerRef} className={'text-center py-4'}>Loading more...</div>
</div>
)
}
export { LazyLoadTable }

View File

@ -0,0 +1,56 @@
import { FC, ReactNode, useCallback, useEffect } from 'react'
export interface OffcanvasProps {
children: ReactNode
isOpen?: boolean
onOpen?: () => void
onClose?: () => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
}
const Offcanvas: FC<OffcanvasProps> = (props) => {
const {
children,
isOpen = false,
onOpen,
onClose,
colspan = 6
} = props
const handleOnOpen = useCallback(() => {
onOpen?.()
}, [onOpen])
const handleOnClose = useCallback(() => {
onClose?.()
}, [onClose])
useEffect(() => {
if (isOpen) handleOnOpen()
else handleOnClose()
}, [isOpen, handleOnOpen, handleOnClose])
const leftSpan = 12 - colspan
return (
<div
className={[
'fixed inset-0 h-screen w-screen',
'bg-black/20 backdrop-blur-md',
'z-40 transition-opacity duration-300 ease-in-out',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
].join(' ')}
>
<div className={'grid grid-cols-12 h-full w-full'}>
{/* colonna di offset */}
<div className={`col-span-${leftSpan}`} />
{/* area principale */}
<div className={`col-span-${colspan} min-h-0`}>
{children}
</div>
</div>
</div>
)
}
export { Offcanvas }

View File

@ -0,0 +1,55 @@
import type { SearchEntityScopeEntry } from '@maksit/webui-contracts'
export interface EntityScopesSummaryProps<TScopeEntityType extends number = number> {
entries: SearchEntityScopeEntry<TScopeEntityType>[]
title?: string
/** Maps scope entity type enum value to a display label (e.g. app `ScopeEntityType`). */
formatScopeEntityType?: (scopeEntityType: TScopeEntityType) => string
}
const permChars = <T extends number>(e: SearchEntityScopeEntry<T>): string => {
const parts: string[] = []
if (e.read) parts.push('R')
if (e.write) parts.push('W')
if (e.delete) parts.push('D')
if (e.create) parts.push('C')
return parts.length ? parts.join('') : '—'
}
export const EntityScopesSummary = <TScopeEntityType extends number = number>({
entries,
title = 'Scopes',
formatScopeEntityType,
}: EntityScopesSummaryProps<TScopeEntityType>) => {
if (!entries?.length) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-2">{title}</h3>
<p className="text-sm text-gray-500">No scopes.</p>
</div>
)
}
const scopeLabel = (type: TScopeEntityType) =>
formatScopeEntityType?.(type) ?? String(type)
return (
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
<h3 className="text-sm font-semibold text-gray-800 px-4 py-2 border-b border-gray-200 bg-gray-50">
{title}
</h3>
<ul className="divide-y divide-gray-100">
{entries.map((entry, idx) => (
<li key={`${entry.scopeEntityType}-${entry.entityId}-${idx}`} className="px-4 py-2 flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-gray-700">
{scopeLabel(entry.scopeEntityType)}: {entry.entityName ?? entry.entityId}
</span>
<span className="text-gray-500 text-xs font-mono" title="Read, Write, Delete, Create">
{permChars(entry)}
</span>
</li>
))}
</ul>
</div>
)
}

View File

@ -0,0 +1,2 @@
export { EntityScopesSummary } from './EntityScopesSummary'
export type { EntityScopesSummaryProps } from './EntityScopesSummary'

View File

@ -0,0 +1,13 @@
// Define the types for the toast
interface AddToastProps {
message: string;
type: 'info' | 'success' | 'warning' | 'error';
duration?: number;
}
export const addToast = (message: string, type: 'info' | 'success' | 'warning' | 'error', duration?: number): void => {
const event = new CustomEvent<AddToastProps>('add-toast', {
detail: { message, type, duration },
})
window.dispatchEvent(event)
}

View File

@ -0,0 +1,75 @@
import { useState, useEffect, FC } from 'react'
import { v4 as uuidv4 } from 'uuid'
// Define types for a toast
interface Toast {
id: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
duration?: number;
}
const Toast: FC = () => {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const handleAddToast = (event: CustomEvent<Toast>) => {
const { message, type, duration } = event.detail
// Add the new toast, avoiding duplicates with same message & type
const id = uuidv4()
setToasts(prev => {
const hasDuplicate = prev.some(t => t.message === message && t.type === type)
if (hasDuplicate) return prev
return [...prev, { id, message, type, duration }]
})
// Auto-remove if a duration is specified
if (duration) {
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, duration)
}
}
// Listen for the custom event
window.addEventListener('add-toast', handleAddToast as EventListener)
return () => {
// Cleanup event listener on component unmount
window.removeEventListener('add-toast', handleAddToast as EventListener)
}
}, [])
// Remove toast manually
const handleClose = (id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}
return (
<div className={'fixed bottom-16 right-4 flex flex-col gap-2 z-50'}>
{toasts.map((toast) => (
<div
key={toast.id}
className={`relative flex items-center justify-between gap-3 px-4 py-3 pr-10 rounded-md shadow-md text-white
${toast.type === 'success' ? 'bg-green-500' : ''}
${toast.type === 'error' ? 'bg-red-500' : ''}
${toast.type === 'warning' ? 'bg-yellow-500' : ''}
${toast.type === 'info' ? 'bg-blue-500' : ''}
`}
>
<span>{toast.message}</span>
<button
onClick={() => handleClose(toast.id)}
className={'absolute top-2 right-2 text-xl font-bold text-white hover:opacity-75'}
>&times;</button>
</div>
))}
</div>
)
}
export {
Toast
}

View File

@ -0,0 +1,85 @@
import { ReactNode } from 'react'
import { Link } from 'react-router-dom'
interface CommonButtonProps {
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
route?: string;
buttonHierarchy?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
onClick?: () => void;
disabled?: boolean;
}
type ButtonComponentProps =
| ({ label: string; children?: never } & CommonButtonProps)
| ({ children: ReactNode; label?: never } & CommonButtonProps);
const ButtonComponent: React.FC<ButtonComponentProps> = (props) => {
const {
colspan,
route,
buttonHierarchy,
onClick,
disabled = false
} = props
const isChildren = 'children' in props && props.children !== undefined
const content = 'label' in props ? props.label : props.children
const handleClick = (e?: React.MouseEvent) => {
if (disabled) {
e?.preventDefault()
return
}
onClick?.()
}
let buttonClass = ''
switch (buttonHierarchy) {
case 'primary':
buttonClass = 'bg-blue-500 text-white'
break
case 'secondary':
buttonClass = 'bg-gray-500 text-white'
break
case 'success':
buttonClass = 'bg-green-500 text-white'
break
case 'warning':
buttonClass = 'bg-yellow-500 text-white'
break
case 'error':
buttonClass = 'bg-red-500 text-white'
break
default:
buttonClass = 'bg-blue-500 text-white'
break
}
const disabledClass = disabled ? 'opacity-50 cursor-default' : 'cursor-pointer'
const centeringClass = isChildren ? 'flex justify-center items-center' : 'text-center'
return route
? (
<Link
to={route}
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} ${centeringClass} ${disabledClass}`}
onClick={handleClick}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
style={disabled ? { pointerEvents: 'none' } : undefined}
>
{content}
</Link>
) : (
<button
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} ${centeringClass} ${disabledClass}`}
onClick={handleClick}
disabled={disabled}
>
{content}
</button>
)
}
export { ButtonComponent }

View File

@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react'
import { FieldContainer } from './FieldContainer'
interface CheckBoxComponentProps {
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
label: string;
value: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
errorText?: string;
disabled?: boolean;
}
const CheckBoxComponent: React.FC<CheckBoxComponentProps> = (props) => {
const {
colspan = 6,
label,
value,
onChange,
errorText,
disabled = false
} = props
const prevValue = useRef<boolean>(value)
useEffect(() => {
prevValue.current = value
}, [value])
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (prevValue.current === e.target.checked)
return
prevValue.current = e.target.checked
onChange?.(e)
}
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<input
type={'checkbox'}
checked={value}
onChange={handleOnChange}
className={`mr-2 leading-tight ${errorText ? 'border-red-500' : ''}`}
disabled={disabled}
/>
</FieldContainer>
)
}
export {
CheckBoxComponent
}

View File

@ -0,0 +1,193 @@
import { ChangeEvent, FC, useState, useEffect, useRef } from 'react'
import { parseISO, formatISO, format, getDaysInMonth, addMonths, subMonths } from 'date-fns'
import { ButtonComponent } from './ButtonComponent'
import { TextBoxComponent } from './TextBoxComponent'
import { CircleX } from 'lucide-react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
interface DateTimePickerComponentProps {
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
label: string
value?: string
onChange?: (isoString?: string) => void
errorText?: string
placeholder?: string
readOnly?: boolean
disabled?: boolean
}
const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
colspan = 6,
label,
value,
onChange,
errorText,
placeholder,
readOnly = false,
disabled = false,
}) => {
const prevValueRef = useRef<string | undefined>(value)
const parsedValue = value ? parseISO(value) : undefined
const [showDropdown, setShowDropdown] = useState(false)
const [currentViewDate, setCurrentViewDate] = useState<Date>(parsedValue || new Date())
const [tempDate, setTempDate] = useState<Date>(parsedValue || new Date())
const dropdownRef = useRef<HTMLDivElement>(null)
const formatForDisplay = (date: Date) => format(date, DISPLAY_FORMAT)
const daysCount = getDaysInMonth(currentViewDate)
const daysArray = Array.from({ length: daysCount }, (_, i) => i + 1)
const handlePrevMonth = () => setCurrentViewDate((prev) => subMonths(prev, 1))
const handleNextMonth = () => setCurrentViewDate((prev) => addMonths(prev, 1))
const handleDayClick = (day: number) => {
const newDate = new Date(tempDate)
newDate.setFullYear(currentViewDate.getFullYear(), currentViewDate.getMonth(), day)
setTempDate(newDate)
}
const handleTimeChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const [hours, minutes] = e.target.value.split(':').map(Number)
const newDate = new Date(tempDate)
newDate.setHours(hours, minutes, 0, 0)
setTempDate(newDate)
}
const handleClear = () => {
if (prevValueRef.current !== undefined) {
onChange?.(undefined)
prevValueRef.current = undefined
}
setShowDropdown(false)
}
const handleConfirm = () => {
const isoString = formatISO(tempDate, { representation: 'complete' })
if (isoString !== prevValueRef.current) {
onChange?.(isoString)
prevValueRef.current = isoString
}
setShowDropdown(false)
}
const handleOpen = () => {
if (readOnly || disabled) {
return
}
const newDate = parsedValue || new Date()
setCurrentViewDate(newDate)
setTempDate(newDate)
prevValueRef.current = value
setShowDropdown(true)
}
const actionButtons = () => {
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
return [
!!value && !readOnly && (
<button
key={'clear'}
type={'button'}
onClick={handleClear}
className={className}
tabIndex={-1}
aria-label={'Clear'}
>
<CircleX />
</button>
),
].filter(Boolean)
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false)
}
}
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showDropdown])
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<div className={'relative'} ref={dropdownRef}>
<input
type={'text'}
value={value ? formatForDisplay(parsedValue!) : ''}
onFocus={handleOpen}
readOnly
placeholder={placeholder}
className={getInputClasses({ errorText, disabled, readOnly })}
disabled={disabled}
/>
{/* Fixed Action Buttons */}
<div className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}>
{actionButtons()}
</div>
{showDropdown && !readOnly && !disabled && (
<div className={'absolute left-0 top-full mt-1 w-72 min-w-0 bg-white border border-gray-300 rounded shadow-lg z-10'}>
<div className={'flex justify-between items-center px-2 py-1.5'}>
<button onClick={handlePrevMonth} type={'button'} className={'rounded py-1 px-2 text-gray-700 hover:bg-gray-100'}>
&lt;
</button>
<span className={'text-sm'}>{format(currentViewDate, 'MMMM yyyy')}</span>
<button onClick={handleNextMonth} type={'button'} className={'rounded py-1 px-2 text-gray-700 hover:bg-gray-100'}>
&gt;
</button>
</div>
<div className={'grid grid-cols-7 gap-0.5 px-2 pb-1.5'}>
{daysArray.map((day) => (
<div
key={day}
onClick={() => handleDayClick(day)}
className={`p-1.5 cursor-pointer text-center text-sm ${
tempDate.getDate() === day &&
tempDate.getMonth() === currentViewDate.getMonth() &&
tempDate.getFullYear() === currentViewDate.getFullYear()
? 'bg-blue-500 text-white rounded'
: 'hover:bg-gray-200 rounded'
}`}
>
{day}
</div>
))}
</div>
<div className={'px-2 py-1.5'}>
<TextBoxComponent
label={'Time'}
type={'time'}
value={format(tempDate, 'HH:mm')}
onChange={handleTimeChange}
placeholder={'HH:MM'}
readOnly={readOnly}
/>
</div>
<div className={'px-2 py-1.5 gap-2 flex justify-between'}>
<ButtonComponent label={'Clear'} buttonHierarchy={'secondary'} onClick={handleClear} />
<ButtonComponent label={'Confirm'} buttonHierarchy={'primary'} onClick={handleConfirm} />
</div>
</div>
)}
</div>
</FieldContainer>
)
}
export { DateTimePickerComponent }

View File

@ -0,0 +1,99 @@
import React, { useState } from 'react'
import { FieldContainer } from './FieldContainer'
interface DualListboxComponentProps {
label?: string;
availableItemsLabel?: string;
selectedItemsLabel?: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
idFieldName?: string;
availableItems: string[];
selectedItems: string[];
onChange: (selectedItems: string[]) => void;
errorText?: string;
}
const DualListboxComponent: React.FC<DualListboxComponentProps> = (props) => {
const {
label,
availableItemsLabel = 'Available Items',
selectedItemsLabel = 'Selected Items',
colspan = 6,
availableItems,
selectedItems,
onChange,
errorText
} = props
const [available, setAvailable] = useState<string[]>(availableItems)
const [selected, setSelected] = useState<string[]>(selectedItems)
const moveToSelected = () => {
const movedItems = available.filter(item => selected.includes(item))
setAvailable(available.filter(item => !movedItems.includes(item)))
setSelected([...selected, ...movedItems])
onChange([...selected, ...movedItems])
}
const moveToAvailable = () => {
const movedItems = selected.filter(item => !available.includes(item))
setSelected(selected.filter(item => !movedItems.includes(item)))
setAvailable([...available, ...movedItems])
onChange(selected.filter(item => !movedItems.includes(item)))
}
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<div className={'flex justify-center items-center gap-4 w-full h-full'}>
<div className={'flex flex-col'}>
<h3>{availableItemsLabel}</h3>
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
{available.map(item => (
<li
key={item}
className={'cursor-pointer hover:bg-gray-200'}
onClick={() => setAvailable(available.filter(i => i !== item))}
>
{item}
</li>
))}
</ul>
</div>
<div className={'flex flex-col gap-2'}>
<button
onClick={moveToSelected}
className={'border px-4 py-2 bg-blue-500 text-white hover:bg-blue-600'}
>
&gt;
</button>
<button
onClick={moveToAvailable}
className={'border px-4 py-2 bg-red-500 text-white hover:bg-red-600'}
>
&lt;
</button>
</div>
<div className={'flex flex-col'}>
<h3>{selectedItemsLabel}</h3>
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
{selected.map(item => (
<li
key={item}
className={'cursor-pointer hover:bg-gray-200'}
onClick={() => setSelected(selected.filter(i => i !== item))}
>
{item}
</li>
))}
</ul>
</div>
</div>
</FieldContainer>
)
}
export {
DualListboxComponent
}

View File

@ -0,0 +1,28 @@
import { FC, ReactNode } from 'react'
interface FieldContainerProps {
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
label?: string;
errorText?: string;
children: ReactNode
}
const FieldContainer: FC<FieldContainerProps> = (props) => {
const {
colspan,
label,
errorText,
children
} = props
return <div className={`${colspan ? `col-span-${colspan}` : 'w-full'}`}>
<label className={`block text-gray-700 text-sm font-bold mb-2 ${!label ? 'invisible' : ''}`}>{label || '\u00A0'}</label>
{children}
<p className={`text-red-500 text-xs italic mt-2 ${!errorText ? 'invisible' : ''}`}>{errorText || '\u00A0'}</p>
</div>
}
export {
FieldContainer
}

View File

@ -0,0 +1,182 @@
import React, { useRef, useState } from 'react'
import { ButtonComponent } from './ButtonComponent'
import { TrashIcon } from 'lucide-react'
interface FileUploadComponentProps {
label?: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
multiple?: boolean
files?: File[]
onChange?: (files: File[]) => void
disabled?: boolean
}
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
label = 'Select files',
colspan = 6,
multiple = true,
files,
onChange,
disabled = false,
}) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [showPopup, setShowPopup] = useState(false)
const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0})
const popupRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Focus popup when it opens
React.useEffect(() => {
if (showPopup && popupRef.current) {
popupRef.current.focus()
}
}, [showPopup])
const areFilesEqual = (left: File[], right: File[]) =>
left.length === right.length &&
left.every((file, index) => {
const other = right[index]
return other &&
file.name === other.name &&
file.size === other.size &&
file.lastModified === other.lastModified &&
file.type === other.type
})
const displayFiles = files ?? selectedFiles
// Keep native input in sync for controlled resets.
React.useEffect(() => {
if (files !== undefined && files.length === 0 && inputRef.current)
inputRef.current.value = ''
}, [files])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextFiles = e.target.files ? Array.from(e.target.files) : []
if (files === undefined) {
setSelectedFiles(nextFiles)
}
if (!areFilesEqual(nextFiles, displayFiles)) {
onChange?.(nextFiles)
}
}
const handleClear = () => {
if (files === undefined) {
setSelectedFiles([])
}
if (inputRef.current) inputRef.current.value = ''
if (displayFiles.length > 0) {
onChange?.([])
}
}
const handleSelectFiles = () => {
if (!disabled) inputRef.current?.click()
}
return (
<div className={`grid grid-cols-4 gap-2 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
{/* File input (hidden) */}
<input
ref={inputRef}
type={'file'}
multiple={multiple}
style={{ display: 'none' }}
onChange={handleFileChange}
disabled={disabled}
/>
{/* Files counter with hover popup */}
<div
className={'col-span-1 flex items-center justify-center relative'}
onMouseEnter={e => {
setShowPopup(true)
if (!showPopup) {
setPopupPos({ x: e.clientX, y: e.clientY })
}
}}
onMouseLeave={e => {
// Only close if not moving into popup
if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) {
setShowPopup(false)
}
}}
>
<span
className={'bg-gray-200 px-4 py-2 rounded w-full text-center select-none block'}
style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{displayFiles.length} file{displayFiles.length !== 1 ? 's' : ''}
</span>
{showPopup && displayFiles.length > 0 && (
<div
ref={popupRef}
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'}
tabIndex={0}
style={{
left: popupPos.x + 2,
top: popupPos.y + 2,
maxWidth: '400px',
whiteSpace: 'nowrap',
minWidth: '120px',
pointerEvents: 'auto',
outline: 'none',
}}
onBlur={e => {
// Only close if focus moves outside popup and counter
if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) {
setShowPopup(false)
}
}}
onMouseLeave={e => {
// Only close if not moving back to counter
const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1')
if (!parent) setShowPopup(false)
}}
onKeyDown={e => {
if (e.key === 'Escape') setShowPopup(false)
}}
onFocus={() => {}}
>
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
{displayFiles.map((file, idx) => (
<li key={file.name + idx} className={'truncate'} title={file.name}>
{file.name}
</li>
))}
</ul>
</div>
)}
</div>
{/* Clear selection button */}
<ButtonComponent
buttonHierarchy={'secondary'}
onClick={handleClear}
disabled={disabled || displayFiles.length === 0}
colspan={1}
>
<TrashIcon />
</ButtonComponent>
{/* Select files button */}
<ButtonComponent
colspan={2}
children={label}
buttonHierarchy={'primary'}
onClick={handleSelectFiles}
disabled={disabled}
/>
</div>
)
}
export { FileUploadComponent }

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react'
import { FieldContainer } from './FieldContainer'
interface ListboxComponentProps {
label?: string;
itemsLabel?: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
items: string[];
onChange: (items: string[]) => void;
errorText?: string;
}
const ListboxComponent: React.FC<ListboxComponentProps> = (props) => {
const {
label,
itemsLabel = 'Items',
colspan = 6,
items,
onChange,
errorText
} = props
const [selectedItems, setSelectedItems] = useState<string[]>([])
const toggleItemSelection = (item: string) => {
if (selectedItems.includes(item)) {
const updatedSelection = selectedItems.filter(i => i !== item)
setSelectedItems(updatedSelection)
onChange(updatedSelection)
} else {
const updatedSelection = [...selectedItems, item]
setSelectedItems(updatedSelection)
onChange(updatedSelection)
}
}
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<div className={'flex flex-col'}>
<h3>{itemsLabel}</h3>
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
{items.map(item => (
<li
key={item}
className={`cursor-pointer hover:bg-gray-200 ${selectedItems.includes(item) ? 'bg-gray-300' : ''}`}
onClick={() => toggleItemSelection(item)}
>
{item}
</li>
))}
</ul>
</div>
</FieldContainer>
)
}
export {
ListboxComponent
}

View File

@ -0,0 +1,77 @@
import React, { useEffect, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'
interface RadioOption {
value: string
label: string
}
interface RadioGroupComponentProps {
options: RadioOption[]
label?: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
value?: string
onChange?: (value: string) => void
errorText?: string
readOnly?: boolean
disabled?: boolean
}
const RadioGroupComponent: React.FC<RadioGroupComponentProps> = (props) => {
const {
options,
label,
colspan = 6,
value = '',
onChange,
errorText,
readOnly = false,
disabled = false
} = props
const prevValue = useRef<string>(value)
const [selectedValue, setSelectedValue] = useState<string>(value)
useEffect(() => {
prevValue.current = value
setSelectedValue(value)
}, [value])
const handleOptionChange = (val: string) => {
if (readOnly || disabled) return
if (prevValue.current === val) return
prevValue.current = val
setSelectedValue(val)
onChange?.(val)
}
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<div className={'flex flex-col'}>
{options.map(option => {
// Use default cursor (arrow) if disabled or readOnly, else pointer
const isInactive = disabled || readOnly
return (
<label
key={option.value}
className={`flex items-center mb-2 ${disabled ? 'opacity-50' : ''} ${isInactive ? 'cursor-default' : 'cursor-pointer'}`}
>
<input
type={'radio'}
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionChange(option.value)}
className={'mr-2'}
disabled={disabled}
readOnly={readOnly}
/>
{option.label}
</label>
)
})}
</div>
</FieldContainer>
)
}
export { RadioGroupComponent }

View File

@ -0,0 +1,100 @@
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
import type { PagedRequest } from '@maksit/webui-contracts'
import type { SearchResponseBase } from '@maksit/webui-contracts'
import { deepEqual } from '@maksit/webui-core'
import { SelectBoxComponent } from './SelectBoxComponent'
export type RemoteSelectSearchDataSource<TRequest extends PagedRequest> = (
request: TRequest,
options?: { showLoader?: boolean }
) => Promise<SearchResponseBase[] | undefined>
export interface RemoteSelectBoxProps<TRequest extends PagedRequest> {
dataSource: RemoteSelectSearchDataSource<TRequest>
additionalFilters?: TRequest
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
idField?: string
labelField?: string
filterFields?: string[]
value?: string | number
onChange: (e: ChangeEvent<HTMLInputElement>) => void
placeholder?: string
readOnly?: boolean
}
const RemoteSelectBoxComponent = <TRequest extends PagedRequest>(props: RemoteSelectBoxProps<TRequest>) => {
const {
dataSource,
additionalFilters,
label,
colspan = 12,
errorText,
idField = 'id',
labelField = 'name',
filterFields = ['name'],
value = '',
onChange,
placeholder,
readOnly = false,
} = props
const [options, setOptions] = useState<SearchResponseBase[]>([])
const prevPagedRequest = useRef<TRequest | null>(null)
const dataSourceRef = useRef(dataSource)
dataSourceRef.current = dataSource
const handleFilterChange = useCallback((filters?: string, showLoader: boolean = false) => {
const pagedRequest = {
pageSize: 10,
filters,
...additionalFilters,
} as TRequest
if (deepEqual(pagedRequest, prevPagedRequest.current))
return
prevPagedRequest.current = pagedRequest
void dataSourceRef.current(pagedRequest, { showLoader })
.then((items) => {
if (!items)
return
setOptions(items)
})
.catch((error) => {
console.error('RemoteSelectBox fetch error:', error)
})
}, [additionalFilters])
useEffect(() => {
handleFilterChange(undefined, true)
}, [handleFilterChange])
return (
<SelectBoxComponent
colspan={colspan}
label={label}
placeholder={placeholder}
options={options?.map((item) => {
const row = item as unknown as Record<string, unknown>
const labelRaw = row[labelField] ?? row.name ?? row.id
return {
value: item.id,
label: labelRaw != null ? String(labelRaw) : item.id,
}
})}
idField={idField}
filterFields={filterFields}
onFilterChange={(text) => handleFilterChange(text, false)}
value={value}
onChange={onChange}
errorText={errorText}
readOnly={readOnly}
/>
)
}
export { RemoteSelectBoxComponent }

View File

@ -0,0 +1,106 @@
import { Copy, Dices, Eye, EyeOff } from 'lucide-react'
import { ChangeEvent, FC, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
export type SecretDataSource = () => Promise<string | undefined>
export interface SecretComponentProps {
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
value?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
placeholder?: string
readOnly?: boolean
enableCopy?: boolean
enableGenerate?: boolean
/** Fetches a new secret value when the generate button is used. */
dataSource?: SecretDataSource
}
const SecretComponent: FC<SecretComponentProps> = ({
label,
colspan = 12,
errorText,
value = '',
onChange,
placeholder,
readOnly = false,
enableCopy = false,
enableGenerate = false,
dataSource,
}) => {
const prevValue = useRef<string>(value)
const [showPassword, setShowPassword] = useState(false)
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
if (prevValue.current === e.target.value)
return
prevValue.current = e.target.value
onChange?.(e)
}
const handleGenerateSecret = () => {
if (!dataSource)
return
void dataSource().then((secret) => {
if (!secret)
return
handleOnChange({
target: { value: secret },
} as ChangeEvent<HTMLInputElement>)
})
}
const handleCopy = async () => {
if (value)
await navigator.clipboard.writeText(value)
}
const hasContent = String(value).length > 0
const actionButtonClass = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<div className={'relative'}>
<input
type={showPassword ? 'text' : 'password'}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={getInputClasses({ errorText, readOnly, extra: 'pr-20' })}
readOnly={readOnly}
/>
<div className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}>
{hasContent && (
<button
type={'button'}
onClick={() => setShowPassword((prev) => !prev)}
className={actionButtonClass}
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff /> : <Eye />}
</button>
)}
{enableGenerate && !readOnly && dataSource && (
<button type={'button'} onClick={handleGenerateSecret} className={actionButtonClass} tabIndex={-1} aria-label={'Generate secret'}>
<Dices />
</button>
)}
{enableCopy && hasContent && (
<button type={'button'} onClick={handleCopy} className={actionButtonClass} tabIndex={-1} aria-label={'Copy secret'}>
<Copy />
</button>
)}
</div>
</div>
</FieldContainer>
)
}
export { SecretComponent }

View File

@ -0,0 +1,204 @@
import { debounce } from 'lodash'
import { CircleX } from 'lucide-react'
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
export interface SelectBoxComponentOption {
value: string | number
label: string
}
interface SelectBoxComponentProps {
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
options?: SelectBoxComponentOption[]
// Field used to compare with the value
idField?: string
// Fields to search against when filtering options
filterFields?: string[]
// Callback function called with a filter string, debounced
onFilterChange?: (filters: string) => void
value?: string | number
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
placeholder?: string
readOnly?: boolean
disabled?: boolean
}
const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
const {
label,
colspan = 12,
errorText,
options = [],
idField = 'id',
filterFields,
onFilterChange,
value = '',
onChange,
placeholder,
readOnly = false,
disabled = false,
} = props
// Local state to control dropdown visibility and current filter text
const [showDropdown, setShowDropdown] = useState(false)
const [filterValue, setFilterValue] = useState<string>('')
// Memoized debounced callback for filter changes.
const debounceOnFilterChange = useMemo(() => {
return onFilterChange ? debounce(onFilterChange, 500) : undefined
}, [onFilterChange])
// Refs to store previous values to detect changes
const initRef = useRef(false)
const prevFilterValue = useRef(filterValue)
// Update the selected value and notify parent via onValueChange callback.
const handleValueChange = useCallback(
(newValue: string | number) => {
// Simulate a ChangeEvent with the new value
onChange?.({ target: { value: newValue } } as ChangeEvent<HTMLInputElement>)
},
[onChange]
)
// Handle input changes for filtering options.
const handleFilterChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const newFilter = e.target.value
setFilterValue(newFilter)
// If filter value hasn't changed, exit early.
if (prevFilterValue.current === newFilter) {
return
}
// Build a filter query string based on the filterFields.
const query = filterFields
?.map((field) => `${field}.Contains("${newFilter}")`)
.filter(Boolean)
.join(' || ') ?? ''
// If debounced filter callback is provided, invoke it.
if (debounceOnFilterChange) {
debounceOnFilterChange(query)
}
// Clear the selected value when user types in filter.
if (showDropdown) {
handleValueChange('')
}
prevFilterValue.current = newFilter
},
[filterFields, debounceOnFilterChange, showDropdown, handleValueChange, disabled]
)
const selectedOption = options.find((option) => option.value === value)
const inputValue = showDropdown ? filterValue : (selectedOption?.label ?? '')
// Fetch the selected option when the parent provides only the id.
useEffect(() => {
if (value === '' || selectedOption) {
return
}
if (debounceOnFilterChange && !initRef.current) {
debounceOnFilterChange(`${idField} == "${value}"`)
initRef.current = true
}
}, [value, selectedOption, idField, debounceOnFilterChange])
// Handle click on an option from the dropdown.
const handleOptionClick = (optionValue: string | number) => {
if (disabled) return
// Update the selected value.
handleValueChange(optionValue)
// Update the input to display the selected option's label.
const selectedOption = options.find((option) => option.value === optionValue)
setFilterValue(selectedOption?.label ?? '')
// Close the dropdown.
setShowDropdown(false)
}
const actionButtons = () => {
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
if (disabled) return null
return [
!!filterValue && !readOnly && (
<button
key={'clear'}
type={'button'}
onClick={() => {
setFilterValue('')
handleValueChange('')
if (debounceOnFilterChange) debounceOnFilterChange('')
}}
className={className}
tabIndex={-1}
aria-label={'Clear'}
>
<CircleX />
</button>
),
].filter(Boolean)
}
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<div className={'relative'}>
<div className={'relative'}>
<input
type={'text'}
value={inputValue}
onChange={handleFilterChange}
placeholder={placeholder}
className={getInputClasses({ errorText, disabled, readOnly })}
disabled={readOnly || disabled}
// Open dropdown when input is focused.
onFocus={() => { if (!disabled) setShowDropdown(true) }}
// Delay closing dropdown to allow click events on options.
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
/>
{/* Action Buttons */}
<div
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
>
{actionButtons()}
</div>
</div>
{showDropdown && !disabled && (
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
{options.length > 0 ? (
options.map((option) => (
<div
key={option.value}
className={'px-4 py-2 cursor-pointer hover:bg-gray-200'}
onMouseDown={() => handleOptionClick(option.value)}
>
{option.label}
</div>
))
) : (
<div className={'px-4 py-2 text-gray-500'}>No options found</div>
)}
</div>
)}
</div>
</FieldContainer>
)
}
export { SelectBoxComponent }

View File

@ -0,0 +1,110 @@
import { Eye, EyeOff } from 'lucide-react'
import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'
import { FieldContainer } from './FieldContainer'
import { getInputClasses } from './editorStyles'
interface TextBoxComponentProps {
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
value?: string | number
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
type?: 'text' | 'password' | 'textarea' | 'number' | 'email' | 'time'
placeholder?: string
readOnly?: boolean
disabled?: boolean
}
const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
const {
label,
colspan = 12,
errorText,
value = '',
onChange,
type = 'text',
placeholder,
readOnly = false,
disabled = false,
} = props
const prevValue = useRef<string | number>(value)
useEffect(() => {
prevValue.current = value
}, [value])
// Stato locale per gestire la visibilità della password
const [showPassword, setShowPassword] = useState(false)
const handleOnChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (prevValue.current === e.target.value)
return
prevValue.current = e.target.value
onChange?.(e)
}
// Se il type è "textarea", comportamento invariato
if (type === 'textarea') {
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
<textarea
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={getInputClasses({ errorText, disabled, readOnly })}
readOnly={readOnly}
disabled={disabled}
/>
</FieldContainer>
)
}
// Verifica se il valore non è vuoto (per tipo "password" useremo questa condizione)
const hasContent = String(value).length > 0
return (
<FieldContainer colspan={colspan} label={label} errorText={errorText}>
{type === 'password' ? (
// Wrapper che contiene input e bottone show/hide, ma bottone solo se c'è contenuto
<div className={'relative'}>
<input
type={showPassword ? 'text' : 'password'}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={getInputClasses({ errorText, disabled, readOnly, extra: 'pr-10' })}
readOnly={readOnly}
disabled={disabled}
/>
{hasContent && (
<button
type={'button'}
onClick={() => setShowPassword(prev => !prev)}
className={'absolute inset-y-0 right-0 pr-3 flex items-center text-gray-600'}
tabIndex={-1} // Non interferisce con l'ordine di tabulazione
>
{showPassword ? <Eye /> : <EyeOff />}
</button>
)}
</div>
) : (
// Input normale per tutti gli altri tipi (text, number, email, time)
<input
type={type}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={getInputClasses({ errorText, disabled, readOnly })}
readOnly={readOnly}
disabled={disabled}
/>
)}
</FieldContainer>
)
}
export { TextBoxComponent }

View File

@ -0,0 +1,85 @@
import React, { useState, ReactNode } from 'react'
interface TreeNode {
id: string;
name: string;
content?: ReactNode | ((ids: string[]) => ReactNode); // Custom content at each node
children?: TreeNode[]; // Nested nodes for infinite depth
defaultCollapsed?: boolean; // Default collapse/expand state
}
interface TreeViewProps {
data: TreeNode[];
label?: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
}
const TreeViewComponent: React.FC<TreeViewProps> = (props) => {
const { data, label, colspan = 6 } = props
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>(
() =>
data.reduce((acc, node) => {
const initCollapse = (node: TreeNode, collapsed: Record<string, boolean>): Record<string, boolean> => {
collapsed[node.id] = node.defaultCollapsed ?? true
if (node.children) {
node.children.forEach((child) => initCollapse(child, collapsed))
}
return collapsed
}
return initCollapse(node, acc)
}, {})
)
const toggleCollapse = (id: string) => {
setCollapsedItems((prev) => ({
...prev,
[id]: !prev[id],
}))
}
const renderTree = (nodes: TreeNode[], parentIds: string[] = []) => {
return nodes.map((node) => {
const nodeIds = [...parentIds, node.id]
return (
<div key={node.id} className={'mb-2 ml-4'}>
{/* Node Header */}
<div className={'flex items-center justify-between'}>
<div className={'flex items-center'}>
<span className={'font-bold'}>{node.name}</span>
</div>
<button
onClick={() => toggleCollapse(node.id)}
className={'text-sm text-blue-500 focus:outline-none'}
>
{collapsedItems[node.id] ? 'Expand' : 'Collapse'}
</button>
</div>
{/* Node Content */}
{!collapsedItems[node.id] && (
<>
{node.content && (
<div className={'ml-4 mt-2'}>
{typeof node.content === 'function' ? node.content(nodeIds) : node.content}
</div>
)}
{node.children && <div className={'ml-4'}>{renderTree(node.children, nodeIds)}</div>}
</>
)}
</div>
)
})
}
return (
<div className={`col-span-${colspan}`}>
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
<div className={'border p-4 rounded-md'}>{renderTree(data)}</div>
</div>
)
}
export { TreeViewComponent }

View File

@ -0,0 +1,25 @@
/**
* Shared Tailwind classes for editor components (TextBox, SelectBox, DateTimePicker, Secret).
* Keeps input styling uniform and avoids drift.
*/
export const inputBaseClasses =
'border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500/30'
export interface InputClassOptions {
errorText?: string
disabled?: boolean
readOnly?: boolean
/** Extra classes (e.g. pr-10 for input with trailing button) */
extra?: string
}
export function getInputClasses(options: InputClassOptions): string {
const { errorText, disabled = false, readOnly = false, extra = '' } = options
const border = errorText ? 'border-red-500' : 'border-gray-300'
const state =
disabled
? 'bg-gray-100 text-gray-500 cursor-default'
: 'bg-white' + (readOnly ? ' text-gray-500 cursor-text select-text' : '')
return [inputBaseClasses, border, state, extra].filter(Boolean).join(' ')
}

View File

@ -0,0 +1,40 @@
import {
FieldContainer,
} from './FieldContainer'
import { ButtonComponent } from './ButtonComponent'
import { CheckBoxComponent } from './CheckBoxComponent'
import { TextBoxComponent } from './TextBoxComponent'
import { DateTimePickerComponent } from './DateTimePickerComponent'
import { DualListboxComponent } from './DualListboxComponent'
import { TreeViewComponent } from './TreeViewComponent'
import { ListboxComponent } from './ListBoxComponent'
import { SelectBoxComponent } from './SelectBoxComponent'
import { RadioGroupComponent } from './RadioGroupComponent'
import { FileUploadComponent } from './FileUploadComponent'
import { SecretComponent } from './SecretComponent'
import { RemoteSelectBoxComponent } from './RemoteSelectBoxComponent'
export {
FieldContainer,
FieldContainer as EditorWrapper,
ButtonComponent,
CheckBoxComponent,
DateTimePickerComponent,
TextBoxComponent,
DualListboxComponent,
TreeViewComponent,
ListboxComponent,
SelectBoxComponent,
RadioGroupComponent,
FileUploadComponent,
SecretComponent,
RemoteSelectBoxComponent,
}
export type { SecretDataSource, SecretComponentProps } from './SecretComponent'
export type {
RemoteSelectBoxProps,
RemoteSelectSearchDataSource,
} from './RemoteSelectBoxComponent'

View File

@ -0,0 +1,80 @@
import { ReactNode } from 'react'
/** Column definition for dense list tables (sortable headers, optional filters). */
export interface VaultStyleColumn<T> {
id: string
header: string
/** Optional width hint, e.g. w-24, w-1/4 */
widthClass?: string
headerClassName?: string
cellClassName?: string
cell: (row: T) => ReactNode
}
export interface VaultStyleDataTableProps<T> {
columns: VaultStyleColumn<T>[]
rows: T[]
rowKey: (row: T) => string
loading?: boolean
emptyMessage: string
/** When true, no outer card chrome (use inside {@link VaultStyleListSection}). */
embedded?: boolean
}
/**
* Dense bordered table: neutral header bar, row dividers, hover.
*/
function VaultStyleDataTable<T>(props: VaultStyleDataTableProps<T>) {
const { columns, rows, rowKey, loading, emptyMessage, embedded } = props
const wrapClass = embedded
? 'overflow-x-auto bg-white'
: 'overflow-x-auto overflow-y-hidden rounded-md border border-neutral-200 bg-white shadow-sm'
return (
<div className={wrapClass}>
<table className={'w-full min-w-[32rem] table-fixed border-collapse text-left text-sm'}>
<thead>
<tr className={'border-b border-neutral-200 bg-neutral-100'}>
{columns.map((col) => (
<th
key={col.id}
scope={'col'}
className={`px-3 py-2.5 text-xs font-semibold uppercase tracking-wide text-neutral-600 ${col.widthClass ?? ''} ${col.headerClassName ?? ''}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className={'divide-y divide-neutral-200'}>
{loading ? (
<tr>
<td colSpan={columns.length} className={'px-3 py-10 text-center text-neutral-500'}>
Loading
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className={'px-3 py-10 text-center text-neutral-500'}>
{emptyMessage}
</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)} className={'transition-colors hover:bg-neutral-50'}>
{columns.map((col) => (
<td key={col.id} className={`px-3 py-2.5 align-middle text-neutral-900 ${col.cellClassName ?? ''}`}>
{col.cell(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
export { VaultStyleDataTable }

View File

@ -0,0 +1,55 @@
import { FC } from 'react'
import { ButtonComponent } from '../editors'
export interface VaultStyleListFooterProps {
pageNumber: number
pageSize: number
totalRecords: number
loading: boolean
onPrevious: () => void
onNext: () => void
}
/** Footer: “Showing ab of n” + prev/next for paged lists. */
const VaultStyleListFooter: FC<VaultStyleListFooterProps> = (props) => {
const { pageNumber, pageSize, totalRecords, loading, onPrevious, onNext } = props
const size = Math.max(1, pageSize)
const total = Math.max(0, totalRecords)
const from = total === 0 ? 0 : (pageNumber - 1) * size + 1
const to = total === 0 ? 0 : Math.min(pageNumber * size, total)
const totalPages = Math.max(1, Math.ceil(total / size))
return (
<div
className={
'mt-0 flex flex-col gap-3 border-t border-neutral-200 bg-neutral-50 px-3 py-3 sm:flex-row sm:items-center sm:justify-between'
}
>
<p className={'text-sm text-neutral-600'}>
Showing <span className={'font-medium text-neutral-800'}>{from}</span>
<span className={'font-medium text-neutral-800'}>{to}</span> of{' '}
<span className={'font-medium text-neutral-800'}>{total}</span>
<span className={'ml-2 text-neutral-400'}>
(page {pageNumber} of {totalPages})
</span>
</p>
<div className={'flex flex-wrap items-center gap-2'}>
<ButtonComponent
label={'Previous'}
buttonHierarchy={'secondary'}
disabled={loading || pageNumber <= 1}
onClick={onPrevious}
/>
<ButtonComponent
label={'Next'}
buttonHierarchy={'secondary'}
disabled={loading || pageNumber >= totalPages || total === 0}
onClick={onNext}
/>
</div>
</div>
)
}
export { VaultStyleListFooter }

View File

@ -0,0 +1,33 @@
import { FC, ReactNode } from 'react'
/** Wraps filter toolbar + table + footer in one bordered panel (dense admin list layout). */
interface VaultStyleListSectionProps {
title?: string
description?: string
toolbar: ReactNode
/** Optional second strip (e.g. create form) below filters. */
secondaryToolbar?: ReactNode
children: ReactNode
}
const VaultStyleListSection: FC<VaultStyleListSectionProps> = (props) => {
const { title, description, toolbar, secondaryToolbar, children } = props
return (
<div className={'rounded-lg border border-neutral-200 bg-white shadow-sm'}>
{(title || description) && (
<div className={'border-b border-neutral-200 px-4 py-3'}>
{title ? <h2 className={'text-base font-semibold text-neutral-900'}>{title}</h2> : null}
{description ? <p className={'mt-1 text-sm text-neutral-600'}>{description}</p> : null}
</div>
)}
<div className={'border-b border-neutral-200 bg-neutral-50 px-4 py-3'}>{toolbar}</div>
{secondaryToolbar ? (
<div className={'border-b border-neutral-200 bg-white px-4 py-3'}>{secondaryToolbar}</div>
) : null}
{children}
</div>
)
}
export { VaultStyleListSection }

View File

@ -0,0 +1,4 @@
export { VaultStyleDataTable } from './VaultStyleDataTable'
export type { VaultStyleColumn } from './VaultStyleDataTable'
export { VaultStyleListFooter } from './VaultStyleListFooter'
export { VaultStyleListSection } from './VaultStyleListSection'

View File

@ -0,0 +1,34 @@
export * from './components/editors'
export { FieldContainer } from './components/editors/FieldContainer'
export { SecretComponent } from './components/editors/SecretComponent'
export type { SecretDataSource, SecretComponentProps } from './components/editors/SecretComponent'
export { FormContainer, FormContent, FormFooter, FormHeader } from './components/FormLayout'
export { Offcanvas } from './components/Offcanvas'
export { LazyLoadTable } from './components/LazyLoadTable'
export {
DataTable,
DataTableFilter,
DataTableLabel,
createColumn,
createColumns,
} from './components/DataTable'
export type {
DataTableColumn,
DataTableFilterProps,
DataTableFilterState,
DataTableLabelProps,
DataTableRemoteFilterDataSource,
DataTableRemoteLabelDataSource,
} from './components/DataTable'
export { RemoteSelectBoxComponent } from './components/editors/RemoteSelectBoxComponent'
export type {
RemoteSelectBoxProps,
RemoteSelectSearchDataSource,
} from './components/editors/RemoteSelectBoxComponent'
export { addToast } from './components/Toast/addToast'
export { Toast as ToastContainer } from './components/Toast'
export { VaultStyleDataTable, VaultStyleListFooter, VaultStyleListSection } from './components/list'
export type { VaultStyleColumn } from './components/list'
export { EntityScopesSummary } from './components/Scopes'
export type { EntityScopesSummaryProps } from './components/Scopes'
export { Layout } from './components/Layout'

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,43 @@
{
"name": "@maksit/webui-contracts",
"version": "0.1.0",
"description": "Shared TypeScript contracts for MaksIT WebUI apps",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"prepublishOnly": "npm run build"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/MAKS-IT-COM/maksit-webui.git",
"directory": "src/packages/contracts"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,23 @@
import { boolean, number, object, record, string, type ZodType } from 'zod'
import type { RequestModelBase } from './RequestModelBase'
import { RequestModelBaseSchema } from './RequestModelBase'
export interface PagedRequest extends RequestModelBase {
pageSize?: number
pageNumber?: number
filters?: string
collectionFilters?: Record<string, string>
sortBy?: string
isAscending?: boolean
}
export const PagedRequestSchema: ZodType<PagedRequest> = RequestModelBaseSchema.and(
object({
pageSize: number().optional(),
pageNumber: number().optional(),
filters: string().optional(),
collectionFilters: record(string(), string()).optional(),
sortBy: string().optional(),
isAscending: boolean().optional(),
})
)

View File

@ -0,0 +1,12 @@
import type { ResponseModelBase } from './ResponseModelBase'
/** Matches server <see cref="MaksIT.Core.Webapi.Models.PagedResponse{T}" /> (camelCase JSON). */
export interface PagedResponse<T> extends ResponseModelBase {
items: T[]
pageNumber: number
pageSize: number
totalCount: number
totalPages: number
hasPreviousPage: boolean
hasNextPage: boolean
}

View File

@ -0,0 +1,25 @@
export enum PatchOperation {
/// <summary>
/// When you need to set or replace a normal field
/// </summary>
SetField,
/// <summary>
/// When you need to set a normal field to null
/// </summary>
RemoveField,
/// <summary>
/// When you need to add an item to a collection
/// </summary>
AddToCollection,
/// <summary>
/// When you need to remove an item from a collection
/// </summary>
RemoveFromCollection,
}
/** Key for per-item collection operations in PATCH payloads. Must match backend Constants.CollectionItemOperation. */
export const COLLECTION_ITEM_OPERATION = 'collectionItemOperation'

View File

@ -0,0 +1,15 @@
import z, { object, record, string, type ZodType } from 'zod'
import { PatchOperation } from './PatchOperation'
import { RequestModelBase, RequestModelBaseSchema } from './RequestModelBase'
export interface PatchRequestModelBase extends RequestModelBase {
operations?: { [key: string]: PatchOperation }
/** Required so PATCH payloads are assignable to diff helpers (e.g. deepDelta). */
[key: string]: unknown
}
export const PatchRequestModelBaseSchema: ZodType<PatchRequestModelBase> = RequestModelBaseSchema.and(
object({
operations: record(string(), z.enum(PatchOperation)).optional(),
})
)

View File

@ -0,0 +1,21 @@
/**
* JSON shape for `MaksIT.Results.Mvc.ProblemDetails` (RFC 7807).
*
* `Extensions` is `[JsonExtensionData]` in the library: extra members serialize as **sibling**
* properties on the same object (`traceId`, custom `id`, etc.), not under a nested `extensions` key.
*
* @see `MaksIT.Results.Mvc.ProblemDetails` in the **maksit-results** repository (same contract as the **MaksIT.Results** NuGet package).
*/
export interface ProblemDetails {
type?: string
title?: string
status?: number
detail?: string
instance?: string
/** Validation failures when the API puts `errors` in extension data (ValidationProblemDetails-style). */
errors?: Record<string, string[]>
/** Often emitted by ASP.NET (`traceId` in extension data). */
traceId?: string
/** Any other extension member the server attaches (correlation id, etc.). */
[key: string]: unknown
}

View File

@ -0,0 +1,7 @@
import { object, type ZodType } from 'zod'
export interface RequestModelBase {
[key: string]: unknown
}
export const RequestModelBaseSchema: ZodType<RequestModelBase> = object({})

View File

@ -0,0 +1,3 @@
export interface ResponseModelBase {
[key: string]: unknown
}

View File

@ -0,0 +1,6 @@
import { ResponseModelBase } from './ResponseModelBase'
export interface SearchResponseBase extends ResponseModelBase {
id: string;
name: string;
}

View File

@ -0,0 +1,5 @@
import { ResponseModelBase } from './ResponseModelBase'
export interface TrngResponse extends ResponseModelBase {
secret: string;
}

View File

@ -0,0 +1,239 @@
export enum Claims {
//
// Summary:
// The URI for a claim that specifies the actor, http://schemas.xmlsoap.org/ws/2009/09/identity/claims/actor.
Actor = 'http://schemas.xmlsoap.org/ws/2009/09/identity/claims/actor',
//
// Summary:
// The URI for a claim that specifies the postal code of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/postalcode.
PostalCode = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/postalcode',
//
// Summary:
// The URI for a claim that specifies the primary group SID of an entity, http://schemas.microsoft.com/ws/2008/06/identity/claims/primarygroupsid.
PrimaryGroupSid = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/primarygroupsid',
//
// Summary:
// The URI for a claim that specifies the primary SID of an entity, http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid.
PrimarySid = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid',
//
// Summary:
// The URI for a claim that specifies the role of an entity, http://schemas.microsoft.com/ws/2008/06/identity/claims/role.
Role = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
//
// Summary:
// The URI for a claim that specifies an RSA key, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/rsa.
Rsa = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/rsa',
//
// Summary:
// The URI for a claim that specifies a serial number, http://schemas.microsoft.com/ws/2008/06/identity/claims/serialnumber.
SerialNumber = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/serialnumber',
//
// Summary:
// The URI for a claim that specifies a security identifier (SID), http://schemas.xmlsoap.org/ws/2005/05/identity/claims/sid.
Sid = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/sid',
//
// Summary:
// The URI for a claim that specifies a service principal name (SPN) claim, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/spn.
Spn = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/spn',
//
// Summary:
// The URI for a claim that specifies the state or province in which an entity resides,
// http://schemas.xmlsoap.org/ws/2005/05/identity/claims/stateorprovince.
StateOrProvince = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/stateorprovince',
//
// Summary:
// The URI for a claim that specifies the street address of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/streetaddress.
StreetAddress = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/streetaddress',
//
// Summary:
// The URI for a claim that specifies the surname of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname.
Surname = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
//
// Summary:
// The URI for a claim that identifies the system entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system.
System = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/system',
//
// Summary:
// The URI for a claim that specifies a thumbprint, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/thumbprint.
// A thumbprint is a globally unique SHA-1 hash of an X.509 certificate.
Thumbprint = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/thumbprint',
//
// Summary:
// The URI for a claim that specifies a user principal name (UPN), http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn.
Upn = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn',
//
// Summary:
// The URI for a claim that specifies a URI, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/uri.
Uri = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/uri',
//
// Summary:
// The URI for a claim that specifies the user data, http://schemas.microsoft.com/ws/2008/06/identity/claims/userdata.
UserData = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/userdata',
//
// Summary:
// The URI for a claim that specifies the version, http://schemas.microsoft.com/ws/2008/06/identity/claims/version.
Version = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/version',
//
// Summary:
// The URI for a claim that specifies the webpage of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/webpage.
Webpage = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/webpage',
//
// Summary:
// The URI for a claim that specifies the Windows domain account name of an entity,
// http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname.
WindowsAccountName = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsdeviceclaim.
WindowsDeviceClaim = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsdeviceclaim',
//
// Summary:
// The URI for a claim that specifies the Windows group SID of the device, http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsdevicegroup.
WindowsDeviceGroup = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsdevicegroup',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsfqbnversion.
WindowsFqbnVersion = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsfqbnversion',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/windowssubauthority.
WindowsSubAuthority = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/windowssubauthority',
//
// Summary:
// The URI for a claim that specifies the alternative phone number of an entity,
// http://schemas.xmlsoap.org/ws/2005/05/identity/claims/otherphone.
OtherPhone = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/otherphone',
//
// Summary:
// The URI for a claim that specifies the name of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier.
NameIdentifier = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
//
// Summary:
// The URI for a claim that specifies the name of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name.
Name = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
//
// Summary:
// The URI for a claim that specifies the mobile phone number of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone.
MobilePhone = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone',
//
// Summary:
// The URI for a claim that specifies the anonymous user, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/anonymous.
Anonymous = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/anonymous',
//
// Summary:
// The URI for a claim that specifies details about whether an identity is authenticated,
// http://schemas.xmlsoap.org/ws/2005/05/identity/claims/authenticated.
Authentication = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/authentication',
//
// Summary:
// The URI for a claim that specifies the instant at which an entity was authenticated,
// http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationinstant.
AuthenticationInstant = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationinstant',
//
// Summary:
// The URI for a claim that specifies the method with which an entity was authenticated,
// http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod.
AuthenticationMethod = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationmethod',
//
// Summary:
// The URI for a claim that specifies an authorization decision on an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/authorizationdecision.
AuthorizationDecision = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/authorizationdecision',
//
// Summary:
// The URI for a claim that specifies the cookie path, http://schemas.microsoft.com/ws/2008/06/identity/claims/cookiepath.
CookiePath = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/cookiepath',
//
// Summary:
// The URI for a claim that specifies the country/region in which an entity resides,
// http://schemas.xmlsoap.org/ws/2005/05/identity/claims/country.
Country = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/country',
//
// Summary:
// The URI for a claim that specifies the date of birth of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth.
DateOfBirth = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dateofbirth',
//
// Summary:
// The URI for a claim that specifies the deny-only primary group SID on an entity,
// http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlyprimarygroupsid.
// A deny-only SID denies the specified entity to a securable object.
DenyOnlyPrimaryGroupSid = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlyprimarygroupsid',
//
// Summary:
// The URI for a claim that specifies the deny-only primary SID on an entity, http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlyprimarysid.
// A deny-only SID denies the specified entity to a securable object.
DenyOnlyPrimarySid = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlyprimarysid',
//
// Summary:
// The URI for a claim that specifies a deny-only security identifier (SID) for
// an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/denyonlysid.
// A deny-only SID denies the specified entity to a securable object.
DenyOnlySid = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/denyonlysid',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsuserclaim.
WindowsUserClaim = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsuserclaim',
//
// Summary:
// The URI for a claim that specifies the Windows deny-only group SID of the device,
// http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlywindowsdevicegroup.
DenyOnlyWindowsDeviceGroup = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/denyonlywindowsdevicegroup',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/dsa.
Dsa = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/dsa',
//
// Summary:
// The URI for a claim that specifies the email address of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress.
Email = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/expiration.
Expiration = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/expiration',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/expired.
Expired = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/expired',
//
// Summary:
// The URI for a claim that specifies the gender of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/gender.
Gender = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/gender',
//
// Summary:
// The URI for a claim that specifies the given name of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname.
GivenName = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
//
// Summary:
// The URI for a claim that specifies the SID for the group of an entity, http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid.
GroupSid = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid',
//
// Summary:
// The URI for a claim that specifies a hash value, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/hash.
Hash = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/hash',
//
// Summary:
// The URI for a claim that specifies the home phone number of an entity, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone.
HomePhone = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/homephone',
//
// Summary:
// http://schemas.microsoft.com/ws/2008/06/identity/claims/ispersistent.
IsPersistent = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/ispersistent',
//
// Summary:
// The URI for a claim that specifies the locale in which an entity resides, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/locality.
Locality = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/locality',
//
// Summary:
// The URI for a claim that specifies the DNS name associated with the computer
// name or with the alternative name of either the subject or issuer of an X.509
// certificate, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dns.
Dns = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/dns',
//
// Summary:
// The URI for an X.500 distinguished name claim, such as the subject of an X.509
// Public Key Certificate or an entry identifier in a directory services Directory
// Information Tree, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/x500distinguishedname.
X500DistinguishedName = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/x500distinguishedname',
AclEntry = 'acl_entry'
}

View File

@ -0,0 +1,41 @@
import { object, RefinementCtx, string, ZodIssueCode, type ZodType } from 'zod'
export interface LoginRequest {
username: string
password: string
twoFactorCode?: string
twoFactorRecoveryCode?: string
}
const loginRequestSchemaRefine = (data: LoginRequest, ctx: RefinementCtx) => {
if (data.username === '') {
ctx.addIssue({
code: ZodIssueCode.custom,
message: 'Username cannot be empty',
path: ['username'],
})
}
if (data.password === '') {
ctx.addIssue({
code: ZodIssueCode.custom,
message: 'Password cannot be empty',
path: ['password'],
})
}
if (data.twoFactorCode && data.twoFactorRecoveryCode) {
ctx.addIssue({
code: ZodIssueCode.custom,
message: 'Cannot have both twoFactorCode and twoFactorRecoveryCode',
path: ['twoFactorCode', 'twoFactorRecoveryCode'],
})
}
}
export const LoginRequestSchema: ZodType<LoginRequest> = object({
username: string(),
password: string(),
twoFactorCode: string().optional(),
twoFactorRecoveryCode: string().optional(),
}).superRefine(loginRequestSchemaRefine)

View File

@ -0,0 +1,9 @@
export interface LoginResponse {
tokenType: string
token: string
expiresAt: string
refreshToken: string
refreshTokenExpiresAt: string
/** Actual login username from the server; use for display so it is not replaced by a display name (e.g. "Organization Admin") from the JWT. */
username?: string
}

View File

@ -0,0 +1,11 @@
import { boolean, object, string, type ZodType } from 'zod'
export interface RefreshTokenRequest {
refreshToken: string
force?: boolean
}
export const RefreshTokenRequestSchema: ZodType<RefreshTokenRequest> = object({
refreshToken: string().min(1),
force: boolean().optional(),
})

View File

@ -0,0 +1,12 @@
import { boolean, object, string, type ZodType } from 'zod'
export interface LogoutRequest {
logoutFromAllDevices?: boolean
/** Optional; some clients send the access token in the body for reference. */
token?: string
}
export const LogoutRequestSchema: ZodType<LogoutRequest> = object({
logoutFromAllDevices: boolean().optional(),
token: string().optional(),
})

View File

@ -0,0 +1,3 @@
export interface LogoutResponse {
}

View File

@ -0,0 +1,34 @@
export {
PatchOperation,
COLLECTION_ITEM_OPERATION,
} from './PatchOperation'
export type { RequestModelBase } from './RequestModelBase'
export { RequestModelBaseSchema } from './RequestModelBase'
export type { PatchRequestModelBase } from './PatchRequestModelBase'
export { PatchRequestModelBaseSchema } from './PatchRequestModelBase'
export type { PagedRequest } from './PagedRequest'
export { PagedRequestSchema } from './PagedRequest'
export type { ResponseModelBase } from './ResponseModelBase'
export type { PagedResponse } from './PagedResponse'
export type { ProblemDetails } from './ProblemDetails'
export type { SearchResponseBase } from './SearchResponseBase'
export type { TrngResponse } from './TrngResponse'
export type { SearchEntityScopeEntry } from './shared/search/SearchEntityScopeEntry'
export { Claims } from './identity/Claims'
export type { LoginRequest } from './identity/login/LoginRequest'
export { LoginRequestSchema } from './identity/login/LoginRequest'
export type { LoginResponse } from './identity/login/LoginResponse'
export type { RefreshTokenRequest } from './identity/login/RefreshTokenRequest'
export { RefreshTokenRequestSchema } from './identity/login/RefreshTokenRequest'
export type { LogoutRequest } from './identity/logout/LogoutRequest'
export { LogoutRequestSchema } from './identity/logout/LogoutRequest'
export type { LogoutResponse } from './identity/logout/LogoutResponse'

View File

@ -0,0 +1,10 @@
/** One entity scope line in search results (entity + CRUD permission flags). */
export interface SearchEntityScopeEntry<TScopeEntityType = number> {
scopeEntityType: TScopeEntityType
entityId: string
entityName?: string
read: boolean
write: boolean
delete: boolean
create: boolean
}

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,52 @@
{
"name": "@maksit/webui-core",
"version": "0.1.0",
"description": "Shared utilities and hooks for MaksIT WebUI apps",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"prepublishOnly": "npm run build"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"repository": {
"type": "git",
"url": "git+https://github.com/MAKS-IT-COM/maksit-webui.git",
"directory": "src/packages/core"
},
"dependencies": {
"@maksit/webui-contracts": "^0.1.0",
"date-fns": "^4.1.0"
},
"peerDependencies": {
"axios": "^1.7.0",
"react": "^18.0.0 || ^19.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"axios": "^1.13.2",
"react": "^19.2.4",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,2 @@
export type { AclEntry } from './parseAclEntry'
export { parseAclEntry, parseAclEntries } from './parseAclEntry'

View File

@ -0,0 +1,63 @@
import { ScopePermission } from '../../types/ScopePermissions'
export interface AclEntry<TEntityType extends number = number> {
/** Numeric entity type resolved from the type-code prefix. */
entityType: TEntityType
/** Opaque entity identifier from the middle segment. */
entityId: string
/** Permission bitmask parsed from the trailing hex segment. */
scope: ScopePermission
}
/**
* Parses a single ACL entry string into a typed {@link AclEntry}.
*
* Expected format: `{entityTypeCode}:{entityId}:{scopeHex}` where `entityTypeCode`
* is resolved via `entityTypeMap` and `scopeHex` is a hexadecimal {@link ScopePermission} value.
*
* @param aclEntry - Raw ACL string from the API or storage.
* @param entityTypeMap - Maps single-character (or short) type codes to numeric entity types.
* @returns Parsed entry, or `null` when the string is malformed or the type code is unknown.
*/
const parseAclEntry = <TEntityType extends number>(
aclEntry: string,
entityTypeMap: Record<string, TEntityType>
): AclEntry<TEntityType> | null => {
if (typeof aclEntry !== 'string')
return null
const parts = aclEntry.split(':')
if (parts.length !== 3)
return null
const entityType = entityTypeMap[parts[0]]
if (entityType === undefined)
return null
const entityId = parts[1]
const scopePermission = parseInt(parts[2], 16) as ScopePermission
return {
entityType,
entityId,
scope: scopePermission,
}
}
/**
* Parses an array of ACL entry strings, dropping entries that fail {@link parseAclEntry}.
*
* @param aclEntries - List of raw ACL strings.
* @param entityTypeMap - Maps type codes to numeric entity types (same as {@link parseAclEntry}).
* @returns Only successfully parsed {@link AclEntry} items, in original order.
*/
const parseAclEntries = <TEntityType extends number>(
aclEntries: string[],
entityTypeMap: Record<string, TEntityType>
): AclEntry<TEntityType>[] => {
return aclEntries
.map((entry) => parseAclEntry(entry, entityTypeMap))
.filter((entry): entry is AclEntry<TEntityType> => entry !== null)
}
export { parseAclEntry, parseAclEntries }

View File

@ -0,0 +1,14 @@
/**
* Reads the first `Prop.Contains|StartsWith|EndsWith("…")` value from a combined
* LINQ-style filter string (e.g. Certs sends the extracted substring as `PagedRequest.filters`).
*
* @param combined - Full filter expression from the DataTable, or `undefined` when empty.
* @param propName - Property name to match (e.g. `"CommonName"`).
* @returns The captured substring inside the first matching predicate, or `undefined`.
*/
export function extractPropFilter(combined: string | undefined, propName: string): string | undefined {
if (!combined?.trim()) return undefined
const re = new RegExp(`${propName}\\.(?:Contains|StartsWith|EndsWith)\\("([^"]*)"`, 'i')
const m = combined.match(re)
return m?.[1]
}

View File

@ -0,0 +1,49 @@
import type { PagedResponse } from '@maksit/webui-contracts'
/**
* Virtualized DataTable view model used by client paging and search helpers.
*
* Mirrors {@link PagedResponse} fields with UI-friendly defaults so table
* components can bind directly without adapter logic.
*/
export interface DataTablePageView<T> {
items: T[]
pageNumber: number
pageSize: number
totalCount: number
totalPages: number
hasPreviousPage: boolean
hasNextPage: boolean
}
/**
* Maps a contract {@link PagedResponse} into a {@link DataTablePageView} for UI tables.
*
* Returns a safe empty page when `raw` is missing or has no `items` array, so
* DataTable consumers never need null checks on the response envelope.
*
* @param raw - Paged API payload, or `undefined` when the request has not completed.
* @returns Normalized page view with defaults for optional paging metadata.
*/
export function mapPagedToDataTable<T>(raw: PagedResponse<T> | undefined): DataTablePageView<T> {
if (raw == null || !Array.isArray(raw.items)) {
return {
items: [],
pageNumber: 1,
pageSize: 0,
totalCount: 0,
totalPages: 1,
hasPreviousPage: false,
hasNextPage: false,
}
}
return {
items: raw.items,
pageNumber: raw.pageNumber ?? 1,
pageSize: raw.pageSize ?? 0,
totalCount: raw.totalCount ?? 0,
totalPages: raw.totalPages ?? 1,
hasPreviousPage: raw.hasPreviousPage ?? false,
hasNextPage: raw.hasNextPage ?? false,
}
}

View File

@ -0,0 +1,9 @@
import { mapPagedToDataTable } from './dataTablePaged'
import { extractPropFilter } from './dataTableFilters'
export {
mapPagedToDataTable,
extractPropFilter,
}
export type { DataTablePageView } from './dataTablePaged'

View File

@ -0,0 +1,25 @@
import { parseISO, isValid } from 'date-fns'
import { z } from 'zod'
const INVALID_DATE_MESSAGE = 'Invalid date/time'
/**
* Zod schema that accepts a local datetime string (e.g. from `<input type="datetime-local">`)
* or an ISO string with or without offset, and transforms it to a UTC ISO string for the API.
*
* Uses date-fns for parsing and validation, consistent with {@link isValidISODateString}
* and {@link formatISODateString}. Emits `"Invalid date/time"` when parsing fails.
*/
export const dateTimeToUtcIsoSchema = z
.string()
.refine(
(value) => {
const parsed = parseISO(value)
return isValid(parsed)
},
{ message: INVALID_DATE_MESSAGE }
)
.transform((value) => {
const parsed = parseISO(value)
return parsed.toISOString()
})

View File

@ -0,0 +1,28 @@
import { parseISO, isValid, format } from 'date-fns'
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
/**
* Formats an ISO-8601 date/time string for display in the UI.
*
* Uses `yyyy-MM-dd HH:mm` in local time via date-fns. Returns an empty string for
* falsy input and a fixed error message when the string cannot be parsed.
*
* @param isoString - ISO date string from the API or form state.
* @returns Formatted display string, empty string, or an invalid-date message.
*/
const formatISODateString = (isoString: string): string => {
if (!isoString)
return ''
const parsed = parseISO(isoString)
if (!isValid(parsed))
return 'ISO Date String is invalid'
return format(parsed, DISPLAY_FORMAT)
}
export {
formatISODateString
}

View File

@ -0,0 +1,9 @@
import { isValidISODateString } from './isValidDateString'
import { formatISODateString } from './formatISODateString'
export { dateTimeToUtcIsoSchema } from './dateTimeToUtcIsoSchema'
export {
isValidISODateString,
formatISODateString
}

View File

@ -0,0 +1,23 @@
import { parseISO, isValid } from 'date-fns'
/**
* Returns whether a string is a parseable ISO-8601 date/time value.
*
* Uses date-fns `parseISO` and `isValid`. Empty strings are treated as invalid.
*
* @param dateString - Candidate ISO date string.
* @returns `true` when the string parses to a valid date.
*/
const isValidISODateString = (dateString: string): boolean => {
if (!dateString) return false
const parsed = parseISO(dateString)
return isValid(parsed)
}
export {
isValidISODateString
}

View File

@ -0,0 +1,45 @@
/**
* Creates a deep clone of a value, preserving structure for objects and arrays.
*
* Primitives, `Date`, `RegExp`, and functions are returned as-is. Circular references
* are handled via a `WeakMap` so the same object graph is not copied infinitely.
*
* @param obj - Value to clone.
* @param seen - Internal map of already-cloned objects (used for cycle detection).
* @returns A deep copy of `obj`.
*/
const deepCopy = <T>(obj: T, seen = new WeakMap<object, unknown>()): T =>{
if (
obj === null ||
typeof obj !== 'object' ||
obj instanceof Date ||
obj instanceof RegExp ||
obj instanceof Function
) {
return obj
}
if (seen.has(obj as object)) {
return seen.get(obj as object) as T
}
if (Array.isArray(obj)) {
const arrCopy: unknown[] = []
seen.set(obj, arrCopy)
for (const item of obj) {
arrCopy.push(deepCopy(item, seen))
}
return arrCopy as T
}
const objCopy = {} as { [K in keyof T]: T[K] }
seen.set(obj, objCopy)
for (const key of Object.keys(obj) as Array<keyof T>) {
objCopy[key] = deepCopy(obj[key], seen)
}
return objCopy
}
export { deepCopy }

View File

@ -0,0 +1,514 @@
import { COLLECTION_ITEM_OPERATION, PatchOperation } from '@maksit/webui-contracts'
import { deepCopy } from './deepCopy'
import { deepEqual } from './deepEqual'
type IdLike = string | number | null | undefined
export type Identifiable<I extends string | number = string | number> = {
id?: I | null
}
type OperationBag<K extends string = string> = {
operations?: Partial<Record<K | typeof COLLECTION_ITEM_OPERATION, PatchOperation>>
}
type EnsureId<T extends Identifiable> = { id?: T['id'] }
type PlainObject = Record<string, unknown>
type DeltaArrayItem<T extends Identifiable> = Partial<T> & EnsureId<T> & OperationBag
/**
* Policy that controls how object arrays behave.
*
* - Arrays with identifiable items (id or identityKey) get per-item Add/Remove/Update logic.
* - Arrays without identity fall back to "full replace" semantics.
*/
export type ArrayPolicy = {
/** Name of the "root" field that implies re-parenting (e.g. 'organizationId') */
rootKey?: string
/** Child array field names to process on re-parenting (e.g. ['applicationRoles']) */
childArrayKeys?: string[]
/** If true, children are cleared on root change (default TRUE) */
dropChildrenOnRootChange?: boolean
/** Name of the role field (default 'role') */
roleFieldKey?: string
/** If true, when role becomes null the entire item is removed (default TRUE) */
deleteItemWhenRoleRemoved?: boolean
/**
* Stable identity for items that do not have an `id`.
* Can be:
* - a property name (e.g. "hostname")
* - a function that extracts a unique value
*
* Without identityKey AND without item.id, the array falls back to full replace.
*/
identityKey?: string | ((item: Record<string, unknown>) => string | number)
/**
* Name of the field used in the delta payload to carry the identity.
* Defaults to:
* - identityKey (if it is a string), otherwise
* - "id".
*
* Example:
* { identityKey: "hostname", idFieldKey: "hostname" }
* will emit { hostname: "...", operations: {...} } instead of { id: "..." }.
*/
idFieldKey?: string
}
export type DeepDeltaOptions<T> = {
/**
* Optional per-array rules.
* Example:
* {
* hostnames: { identityKey: "hostname" }
* }
*/
arrays?: Partial<Record<Extract<keyof T, string>, ArrayPolicy>>
}
/**
* Delta<T> represents:
* - T fields that changed (primitives, objects, arrays)
* - "operations" dictionary describing what type of change (SetField, RemoveField, AddToCollection, etc.)
* - For primitive arrays: delta contains the full new array + SetField.
* - For identifiable object arrays: delta contains per-item changes.
*/
export type Delta<T> =
Partial<{
[K in keyof T]:
T[K] extends (infer U)[]
? (U extends object
? DeltaArrayItem<(U & Identifiable)>[] // object arrays → itemized
: U[]) // primitive arrays → full array
: T[K] extends object
? Delta<T[K] & OperationBag<Extract<keyof T, string>>>
: T[K]
}> & OperationBag<Extract<keyof T, string>>
/**
* Looks up the {@link ArrayPolicy} for a given array field on the delta options.
*
* @param options - Per-array policy map from {@link DeepDeltaOptions}.
* @param key - Array property name on the form model.
* @returns Matching policy, or `undefined` when none is configured.
*/
const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string): ArrayPolicy | undefined => {
const arrays = options?.arrays as Partial<Record<string, ArrayPolicy>> | undefined
return arrays?.[key]
}
/**
* Type guard for non-null plain objects (excludes arrays).
*
* @param value - Value to test.
* @returns `true` when `value` is a plain object record.
*/
const isPlainObject = (value: unknown): value is PlainObject =>
typeof value === 'object' && value !== null && !Array.isArray(value)
/**
* Computes a deep "delta" object between formState and backupState.
*
* Rules:
* - Primitive fields SetField / RemoveField
* - Primitive arrays full replace (SetField)
* - Object arrays:
* * if items have id or identityKey itemized collection diff
* * otherwise full replace (SetField)
*
* @param formState Current form state.
* @param backupState Original/backup state to diff against.
* @param options Optional per-array policies.
* @returns Delta<T> structure with changes and operations.
*/
export const deepDelta = <T extends Record<string, unknown>>(
formState: T,
backupState: T,
options?: DeepDeltaOptions<T>
): Delta<T> => {
const delta = {} as Delta<T>
/**
* Records a {@link PatchOperation} on the operations bag for a given field key.
*
* @param bag - Object or nested delta node carrying an `operations` dictionary.
* @param key - Field name the operation applies to.
* @param op - Patch operation to assign (`SetField`, `RemoveField`, etc.).
*/
const setOp = (bag: OperationBag, key: string, op: PatchOperation) => {
const ops = (bag.operations ??= {} as Record<string, PatchOperation>)
ops[key] = op
}
/**
* Recursively diffs two plain objects and writes changes into `parentDelta`.
*
* Handles primitives, nested objects, and arrays (delegates array logic to
* {@link calculateArrayDelta} when items are identifiable).
*
* @param form - Current form-side object subtree.
* @param backup - Original backup subtree to compare against.
* @param parentDelta - Delta node that receives field changes and operations.
*/
const calculateDelta = (
form: PlainObject,
backup: PlainObject,
parentDelta: PlainObject & OperationBag
) => {
const keys = Array.from(new Set([...Object.keys(form), ...Object.keys(backup)]))
for (const rawKey of keys) {
const key = rawKey as keyof T & string
const formValue = form[key]
const backupValue = backup[key]
// --- ARRAY ---
if (Array.isArray(formValue) && Array.isArray(backupValue)) {
const bothPrimitive =
(formValue as unknown[]).every(v => typeof v !== 'object' || v === null) &&
(backupValue as unknown[]).every(v => typeof v !== 'object' || v === null)
/**
* Detect primitive arrays (string[], number[], primitive unions).
* Primitive arrays have no identity always full replace.
*/
if (bothPrimitive) {
if (!deepEqual(formValue, backupValue)) {
;(parentDelta as Delta<T>)[key] = deepCopy(formValue) as unknown as Delta<T>[typeof key]
setOp(parentDelta, key, PatchOperation.SetField)
}
continue
}
// Object collections
const policy = getArrayPolicy(options, key)
/**
* If items have neither `id` nor `identityKey`, they cannot be diffed.
* => treat array as a scalar and replace entirely.
*/
const lacksIdentity =
!(policy?.identityKey) &&
(formValue as Identifiable[]).every(x => (x?.id ?? null) == null) &&
(backupValue as Identifiable[]).every(x => (x?.id ?? null) == null)
if (lacksIdentity) {
if (!deepEqual(formValue, backupValue)) {
;(parentDelta as Delta<T>)[key] = deepCopy(formValue) as unknown as Delta<T>[typeof key]
setOp(parentDelta, key, PatchOperation.SetField)
}
continue
}
/**
* Identifiable arrays => itemized delta with Add/Remove/Update
*/
const arrayDelta = calculateArrayDelta(
formValue as Identifiable[],
backupValue as Identifiable[],
policy
)
if (arrayDelta.length > 0) {
;(parentDelta as Delta<T>)[key] = arrayDelta as unknown as Delta<T>[typeof key]
}
continue
}
// --- OBJECT ---
if (isPlainObject(formValue) && isPlainObject(backupValue)) {
if (!deepEqual(formValue, backupValue)) {
const nestedDelta: PlainObject & OperationBag = {}
calculateDelta(
formValue as PlainObject,
(backupValue as PlainObject) ?? {},
nestedDelta
)
if (Object.keys(nestedDelta).length > 0) {
;(parentDelta as Delta<T>)[key] = nestedDelta as unknown as Delta<T>[typeof key]
}
}
continue
}
// --- PRIMITIVE / TYPE CHANGED ---
if (!deepEqual(formValue, backupValue)) {
const isNullish = formValue === null || formValue === undefined
if (!isNullish) {
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
}
setOp(parentDelta, key, isNullish ? PatchOperation.RemoveField : PatchOperation.SetField)
}
}
}
/**
* Computes itemized add/remove/update deltas for an identifiable object array.
*
* Supports re-parenting via `rootKey`, role-based removal, and synthetic
* identities from {@link ArrayPolicy.identityKey}.
*
* @param formArray - Current form array.
* @param backupArray - Backup array to diff against.
* @param policy - Optional per-array rules from {@link DeepDeltaOptions.arrays}.
* @returns Per-item delta entries with collection operations.
*/
const calculateArrayDelta = <U extends Identifiable>(
formArray: U[],
backupArray: U[],
policy?: ArrayPolicy
): DeltaArrayItem<U>[] => {
const arrayDelta: DeltaArrayItem<U>[] = []
/**
* Identity resolution order:
* 1. If item has `.id` use it.
* 2. Else if identityKey is provided use that to extract a unique key.
* 3. Else: return null item will be treated as new.
*/
const resolveId = (item?: U): IdLike => {
if (!item) return null
const directId = (item as Identifiable).id
if (directId !== null && directId !== undefined) return directId
if (!policy?.identityKey) return null
if (typeof policy.identityKey === 'function') {
try { return policy.identityKey(item as unknown as Record<string, unknown>) }
catch { return null }
}
const k = policy.identityKey as string
const v = (item as unknown as Record<string, unknown>)[k]
return (typeof v === 'string' || typeof v === 'number') ? v : null
}
const childrenKeys = policy?.childArrayKeys ?? []
const dropChildren = policy?.dropChildrenOnRootChange ?? true
const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string
const rootKey = policy?.rootKey
const identityKey = policy?.identityKey
const idFieldKey =
(policy?.idFieldKey ??
(typeof identityKey === 'string' ? identityKey : 'id')) as keyof U & string
/**
* Decides which field to use for the identity in the delta payload.
*
* Rules:
* - If the item has a real `id` (server-assigned), always emit it as `id`
* so the backend can match and update/remove correctly.
* - Otherwise fall back to the policy's idFieldKey (e.g. "_deltaId") so
* synthetic identities never go into `id` where a Guid is expected.
*/
const getIdFieldForItem = (item: U): keyof U & string => {
const directId = (item as Identifiable).id
if (directId !== null && directId !== undefined && String(directId).length > 0) {
return 'id' as keyof U & string
}
return idFieldKey
}
/**
* Returns whether two array items share the same `rootKey` parent value.
*
* When no `rootKey` is configured, all items are treated as having the same root.
*/
const sameRoot = (f: U, b: U): boolean => {
if (!rootKey) return true
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
}
// id → item maps for O(1) lookups
const formMap = new Map<string | number, U>()
const backupMap = new Map<string | number, U>()
for (const item of formArray) {
const id = resolveId(item)
if (id !== null && id !== undefined) formMap.set(id as string | number, item)
}
for (const item of backupArray) {
const id = resolveId(item)
if (id !== null && id !== undefined) backupMap.set(id as string | number, item)
}
// 1) Items present in the form array
for (const formItem of formArray) {
const fid = resolveId(formItem)
// 1.a) New item (no identity)
if (fid === null || fid === undefined) {
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
// normalize children as AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
}
}
arrayDelta.push(addItem)
continue
}
// 1.b) Has identity but not in backup ⇒ AddToCollection
const backupItem = backupMap.get(fid as string | number)
if (!backupItem) {
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
;(addItem as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike // store identity
addItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
}
}
arrayDelta.push(addItem)
continue
}
// 1.c) Re-parenting: root changed
if (!sameRoot(formItem, backupItem)) {
const removeItem = {} as DeltaArrayItem<U>
;(removeItem as PlainObject)[getIdFieldForItem(backupItem)] = fid as IdLike
removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
;(addItem as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike
addItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
if (dropChildren) {
for (const ck of childrenKeys) {
if (ck in (addItem as PlainObject)) {
;(addItem as PlainObject)[ck] = []
}
}
} else {
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
}
}
}
arrayDelta.push(addItem)
continue
}
// 1.d) Role → null ⇒ remove item (if enabled)
const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true
if (deleteOnRoleNull) {
const formRole = (formItem as PlainObject)[roleKey]
const backupRole = (backupItem as PlainObject)[roleKey]
const roleBecameNull = backupRole !== null && formRole === null
if (roleBecameNull) {
const removeItem = {} as DeltaArrayItem<U>
;(removeItem as PlainObject)[idFieldKey] = fid as IdLike
removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
continue
}
}
// 1.e) Field-level diff
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
;(itemDeltaBase as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike
calculateDelta(
formItem as PlainObject,
backupItem as PlainObject,
itemDeltaBase
)
const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== idFieldKey)
if (hasMeaningfulChanges) {
arrayDelta.push(itemDeltaBase as DeltaArrayItem<U>)
}
}
// 2) Items removed
for (const backupItem of backupArray) {
const bid = resolveId(backupItem)
if (bid === null || bid === undefined) continue
if (!formMap.has(bid as string | number)) {
const removeItem = {} as DeltaArrayItem<U>
;(removeItem as PlainObject)[getIdFieldForItem(backupItem)] = bid as IdLike
removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
}
}
return arrayDelta
}
calculateDelta(
deepCopy(formState) as PlainObject,
deepCopy(backupState) as PlainObject,
delta as PlainObject & OperationBag
)
return delta
}
/**
* Checks whether any operations exist inside the delta.
*
* A delta has operations if:
* - parent-level operations exist, or
* - nested object deltas contain operations, or
* - any array item contains operations.
*
* @param delta Delta object to inspect.
* @returns True if any operations are present, otherwise false.
*/
export const deltaHasOperations = <T extends Record<string, unknown>>(delta: Delta<T>): boolean => {
if (!isPlainObject(delta)) return false
if ('operations' in delta && isPlainObject(delta.operations)) return true
for (const key in delta) {
const v = (delta as PlainObject)[key]
if (isPlainObject(v) && deltaHasOperations(v as Delta<{}>)) return true
if (Array.isArray(v)) {
for (const item of v) {
if (isPlainObject(item) && deltaHasOperations(item as Delta<{}>)) return true
}
}
}
return false
}

View File

@ -0,0 +1,91 @@
import { deepCopy } from './deepCopy.js'
/**
* Recursively compares two values for deep structural equality.
*
* Arrays are compared order-independently via {@link deepEqualArrays}. Objects are
* compared by key set and recursive value equality. Primitives use strict equality
* after a defensive deep copy.
*
* @param objA - First value.
* @param objB - Second value.
* @returns `true` when both values are deeply equal.
*/
const deepEqual = (objA: unknown, objB: unknown): boolean => {
const copyA = deepCopy(objA)
const copyB = deepCopy(objB)
if (copyA === copyB) {
return true
}
if (Array.isArray(copyA) && Array.isArray(copyB)) {
return deepEqualArrays(copyA, copyB)
}
if (
typeof copyA !== 'object' ||
typeof copyB !== 'object' ||
copyA === null ||
copyB === null
) {
return false
}
const keysA = Object.keys(copyA)
const keysB = Object.keys(copyB)
if (keysA.length !== keysB.length) {
return false
}
for (const key of keysA) {
if (!keysB.includes(key)) {
return false
}
const valA = (copyA as Record<string, unknown>)[key]
const valB = (copyB as Record<string, unknown>)[key]
if (!deepEqual(valA, valB)) {
return false
}
}
return true
}
/**
* Compares two arrays for deep equality regardless of element order.
*
* Each element in `arrA` must match exactly one unmatched element in `arrB`.
* Empty arrays are equal. Uses {@link deepEqual} for element comparison.
*
* @param arrA - First array.
* @param arrB - Second array.
* @returns `true` when both arrays contain the same elements (multiset equality).
*/
const deepEqualArrays = (arrA: unknown[], arrB: unknown[]): boolean => {
const copyA = deepCopy(arrA)
const copyB = deepCopy(arrB)
if (copyA.length !== copyB.length) {
return false
}
if (copyA.length === 0 && copyB.length === 0) {
return true
}
for (const itemA of copyA) {
const matchIndex = copyB.findIndex((itemB) => deepEqual(itemA, itemB))
if (matchIndex === -1) {
return false
}
copyB.splice(matchIndex, 1)
}
return true
}
export { deepEqual, deepEqualArrays }

View File

@ -0,0 +1,47 @@
/**
* Deep-merges `source` into `target`, recursively combining nested objects and arrays.
*
* `undefined` values in `source` are skipped so they do not overwrite existing target
* fields. Array indices are merged element-wise up to the longer array length.
* Circular references are tracked via `seen` to avoid infinite recursion.
*
* @param target - Base object or array to merge into.
* @param source - Values to merge; wins for defined leaf values.
* @param seen - Internal map for cycle detection.
* @returns A new merged structure (does not mutate `target` or `source`).
*/
const deepMerge = <T>(target: T, source: T, seen = new WeakMap<object, unknown>()): T => {
if (target === null || typeof target !== 'object') return source
if (source === null || typeof source !== 'object') return target
if (seen.has(target as object)) {
return seen.get(target as object) as T
}
if (Array.isArray(target) && Array.isArray(source)) {
const mergedArray: unknown[] = []
seen.set(target, mergedArray)
const maxLength = Math.max(target.length, source.length)
for (let i = 0; i < maxLength; i++) {
mergedArray[i] = deepMerge(target[i], source[i], seen)
}
return mergedArray as T
}
const mergedObject = { ...target } as Record<string | number | symbol, unknown>
seen.set(target as object, mergedObject)
for (const key of Object.keys(source) as Array<keyof T>) {
const sourceValue = source[key]
if (sourceValue !== undefined) {
const targetValue = target[key]
mergedObject[key] = deepMerge(targetValue, sourceValue, seen)
}
}
return mergedObject as T
}
export { deepMerge }

View File

@ -0,0 +1,27 @@
/**
* Checks whether `obj` is a structural subset of `pattern` (same keys, compatible types).
*
* `obj` may have fewer keys than `pattern`, but must not introduce extra keys or
* mismatch value types for keys present in both. Useful for partial object matching
* in guards and filters.
*
* @param pattern - Expected shape (keys and value types).
* @param obj - Candidate object to test.
* @returns `true` when `obj` matches the pattern shape.
*/
const deepPatternMatch = <T extends object>(pattern: T, obj: unknown): boolean => {
if (typeof obj !== 'object' || obj === null) return false
const objKeys = Object.keys(obj as object)
const patternKeys = Object.keys(pattern)
// obj must not have more keys than pattern
if (objKeys.length > patternKeys.length) return false
for (const key of objKeys) {
if (!(key in pattern)) return false
if (typeof (obj as T)[key as keyof T] !== typeof pattern[key as keyof T]) return false
}
return true
}
export {
deepPatternMatch
}

View File

@ -0,0 +1,25 @@
import { deepCopy } from './deepCopy'
import {
deepDelta,
deltaHasOperations
} from './deepDelta'
import {
ENTITY_SCOPES_ARRAY_POLICY,
VERSIONS_ARRAY_POLICY,
} from './patchCollectionPolicies'
import { deepEqualArrays, deepEqual } from './deepEqual'
import { deepMerge } from './deepMerge'
import { deepPatternMatch } from './deepPatternMatch'
export {
deepCopy,
deepDelta,
deltaHasOperations,
ENTITY_SCOPES_ARRAY_POLICY,
VERSIONS_ARRAY_POLICY,
deepEqualArrays,
deepEqual,
deepMerge,
deepPatternMatch
}

View File

@ -0,0 +1,72 @@
import type { ArrayPolicy } from './deepDelta'
/**
* Resolves a stable string identity for collection items during delta computation.
*
* Prefers a non-empty server-assigned `id`; otherwise delegates to `fallback`.
*
* @param item - Collection item being diffed.
* @param fallback - Synthetic identity when `id` is absent.
* @returns String identity used for map lookups.
*/
function preferIdElse(
item: Record<string, unknown>,
fallback: (item: Record<string, unknown>) => string
): string {
const id = item.id
if (id !== null && id !== undefined && String(id).length > 0)
return String(id)
return fallback(item)
}
/**
* Builds a synthetic identity for entity-scope rows without a server `id`.
*
* Combines `entityId`, `entityType`, and `scope` so duplicate scopes on the same
* entity remain distinguishable during {@link deepDelta} array diffing.
*/
function entityScopeFallback(item: Record<string, unknown>): string {
const entityId = item.entityId ?? ''
const entityType = item.entityType ?? ''
const scope = item.scope ?? ''
return `${entityId}-${entityType}-${scope}`
}
/**
* Array policy for user/API key `entityScopes` payloads passed to {@link deepDelta}.
*
* Uses `_deltaId` in the delta payload when items lack a server `id`, avoiding
* invalid Guid values in the `id` field expected by the API.
*/
export const ENTITY_SCOPES_ARRAY_POLICY: ArrayPolicy = {
identityKey: (item) => preferIdElse(item, entityScopeFallback),
idFieldKey: '_deltaId',
}
/**
* Builds a synthetic identity for secret version rows without a server `id`.
*
* Falls back to the `version` field, or an empty string when neither is present.
*/
function versionFallback(item: Record<string, unknown>): string {
const version = item.version
if (version !== null && version !== undefined && String(version).length > 0)
return String(version)
return ''
}
/**
* Array policy for secret `versions` collections passed to {@link deepDelta}.
*
* Mirrors {@link ENTITY_SCOPES_ARRAY_POLICY}: synthetic identities are emitted as
* `_deltaId` rather than `id` when no server identifier exists.
*/
export const VERSIONS_ARRAY_POLICY: ArrayPolicy = {
identityKey: (item) => preferIdElse(item, versionFallback),
idFieldKey: '_deltaId',
}

View File

@ -0,0 +1,49 @@
/** `{ value, displayValue }` pair for populating select/dropdown options from a TypeScript enum. */
export interface EnumArrayProps {
value: number | string;
displayValue: string;
}
/**
* Converts a TypeScript enum into a sorted array of `{ value, displayValue }` options.
*
* Skips numeric reverse-mapping keys for numeric enums and deduplicates string enums
* that emit duplicate values. Results are sorted by `displayValue` (enum key name).
*
* @param enumType - Runtime enum object (numeric or string enum).
* @returns Dropdown-ready options, or an empty array when `enumType` is falsy.
*/
const enumToArr = (enumType: unknown): EnumArrayProps[] => {
if (!enumType) return []
const enumEntries = Object.entries(enumType)
const addedValues = new Set()
const result: EnumArrayProps[] = []
enumEntries.forEach(([key, value]) => {
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
if (!isNaN(Number(key))) return
// Skip already added values for string enums with reverse mapping
if (addedValues.has(value)) return
addedValues.add(value)
result.push({
value: value,
displayValue: key,
})
})
// Sort the result array by displayValue (key)
result.sort((a, b) => {
if (typeof a.displayValue === 'string' && typeof b.displayValue === 'string') {
return a.displayValue.localeCompare(b.displayValue)
}
return 0
})
return result
}
export { enumToArr }

View File

@ -0,0 +1,25 @@
/**
* Converts a TypeScript enum into a plain key value record.
*
* Numeric reverse-mapping keys are omitted so each logical member appears once.
*
* @param enumType - Runtime enum object.
* @returns Object map of enum member names to values, or `{}` when `enumType` is falsy.
*/
const enumToObj = (enumType: unknown) => {
if (!enumType) return {}
const enumEntries = Object.entries(enumType)
const result: { [key: string]: number | string } = {}
enumEntries.forEach(([key, value]) => {
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
if (!isNaN(Number(key))) return
result[key] = value
})
return result
}
export { enumToObj }

View File

@ -0,0 +1,39 @@
import { EnumArrayProps, enumToArr } from './enumToArr'
/**
* Resolves a numeric enum member to its {@link EnumArrayProps} metadata entry.
*
* @param enumType - Runtime enum object.
* @param enumValue - Numeric enum value to look up.
* @returns Matching option metadata, or `undefined` when not found.
*/
const getEnumValue = <T>(enumType: T, enumValue: number) : EnumArrayProps | undefined => {
return enumToArr(enumType).find((item) => item.value == enumValue)
}
/**
* Formats a numeric enum value as `"value - DisplayName"` for read-only UI labels.
*
* Returns an empty string when `enumValue` is `null`/`undefined` or not found in the enum.
*
* @param enumType - Runtime enum object.
* @param enumValue - Numeric value to format.
* @returns Human-readable label, or `''` when the value cannot be resolved.
*/
const enumToString = <T>(enumType: T, enumValue?: number | null): string => {
if (enumValue === undefined || enumValue === null) {
return ''
}
const enumVal = getEnumValue(enumType, enumValue)
if (!enumVal)
return ''
return `${enumVal.value} - ${enumVal.displayValue}`
}
export {
enumToString
}

View File

@ -0,0 +1,19 @@
import { enumToArr } from './enumToArr'
/**
* Formats a bit-flag enum value as a comma-separated list of set flag names.
*
* Flag value `0` is excluded from the output. Returns `"None"` when no flags are set.
*
* @param enumType - Runtime flags enum (each member is a power-of-two bit).
* @param flags - Combined bitmask to decode.
* @returns Comma-separated display names, or `"None"`.
*/
const flagsToString = <T>(enumType: T, flags: number): string => {
return enumToArr(enumType)
.filter(opt => (flags & opt.value as number) === opt.value && opt.value !== 0)
.map(opt => opt.displayValue)
.join(', ') || 'None'
}
export { flagsToString }

View File

@ -0,0 +1,12 @@
/**
* Tests whether any bit in `flags` is set in `current`.
*
* @param current - Current bitmask (defaults to `0`).
* @param flags - Flag mask; any overlapping bit satisfies the check.
* @returns `true` when `(current & flags) !== 0`.
*/
const hasAnyFlag = <T extends number>(current: T = 0 as T, flags: T): boolean => {
return (current & flags) !== 0
}
export { hasAnyFlag }

View File

@ -0,0 +1,12 @@
/**
* Tests whether all bits in `flag` are set in `current` (exact flag match).
*
* @param current - Current bitmask (defaults to `0`).
* @param flag - Flag or combined flags that must all be present.
* @returns `true` when `(current & flag) === flag`.
*/
const hasFlag = <T extends number>(current: T = 0 as T, flag: T): boolean => {
return (current & flag) === flag
}
export { hasFlag }

View File

@ -0,0 +1,37 @@
import {
enumToArr
} from './enumToArr'
import {
enumToObj
} from './enumToObj'
import {
enumToString
} from './enumToString'
import {
flagsToString
} from './flagsToString'
import {
toggleFlag
} from './toggleFlag'
import {
hasFlag
} from './hasFlag'
import {
hasAnyFlag
} from './hasAnyFlag'
export {
enumToArr,
enumToObj,
enumToString,
flagsToString,
toggleFlag,
hasFlag,
hasAnyFlag
}

Some files were not shown because too many files have changed in this diff Show More