mirror of
https://github.com/MAKS-IT-COM/maksit-webui.git
synced 2026-06-30 20:06:43 +02:00
(feature): init
This commit is contained in:
commit
2c18605699
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal 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
11
CHANGELOG.md
Normal 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
51
README.md
Normal 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
|
||||
47
assets/docs/NPM_CONSUMPTION.md
Normal file
47
assets/docs/NPM_CONSUMPTION.md
Normal 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`.
|
||||
84
assets/docs/NPM_PUBLISH.md
Normal file
84
assets/docs/NPM_PUBLISH.md
Normal 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
52
scripts/publish-npm.ps1
Normal 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
2216
src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
src/package.json
Normal file
22
src/package.json
Normal 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"
|
||||
}
|
||||
66
src/packages/components/package.json
Normal file
66
src/packages/components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
476
src/packages/components/src/components/DataTable/DataTable.tsx
Normal file
476
src/packages/components/src/components/DataTable/DataTable.tsx
Normal 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 }
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
12
src/packages/components/src/components/DataTable/helpers.ts
Normal file
12
src/packages/components/src/components/DataTable/helpers.ts
Normal 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 }
|
||||
28
src/packages/components/src/components/DataTable/index.ts
Normal file
28
src/packages/components/src/components/DataTable/index.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
11
src/packages/components/src/components/FormLayout/index.ts
Normal file
11
src/packages/components/src/components/FormLayout/index.ts
Normal 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
|
||||
}
|
||||
17
src/packages/components/src/components/Layout/Container.tsx
Normal file
17
src/packages/components/src/components/Layout/Container.tsx
Normal 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
|
||||
}
|
||||
17
src/packages/components/src/components/Layout/Content.tsx
Normal file
17
src/packages/components/src/components/Layout/Content.tsx
Normal 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
|
||||
}
|
||||
16
src/packages/components/src/components/Layout/Footer.tsx
Normal file
16
src/packages/components/src/components/Layout/Footer.tsx
Normal 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
|
||||
}
|
||||
19
src/packages/components/src/components/Layout/Header.tsx
Normal file
19
src/packages/components/src/components/Layout/Header.tsx
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
40
src/packages/components/src/components/Layout/index.tsx
Normal file
40
src/packages/components/src/components/Layout/index.tsx
Normal 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
|
||||
}
|
||||
86
src/packages/components/src/components/LazyLoadTable.tsx
Normal file
86
src/packages/components/src/components/LazyLoadTable.tsx
Normal 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 }
|
||||
56
src/packages/components/src/components/Offcanvas.tsx
Normal file
56
src/packages/components/src/components/Offcanvas.tsx
Normal 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 }
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
2
src/packages/components/src/components/Scopes/index.ts
Normal file
2
src/packages/components/src/components/Scopes/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { EntityScopesSummary } from './EntityScopesSummary'
|
||||
export type { EntityScopesSummaryProps } from './EntityScopesSummary'
|
||||
13
src/packages/components/src/components/Toast/addToast.ts
Normal file
13
src/packages/components/src/components/Toast/addToast.ts
Normal 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)
|
||||
}
|
||||
75
src/packages/components/src/components/Toast/index.tsx
Normal file
75
src/packages/components/src/components/Toast/index.tsx
Normal 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'}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Toast
|
||||
}
|
||||
@ -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 }
|
||||
@ -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
|
||||
}
|
||||
@ -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'}>
|
||||
<
|
||||
</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'}>
|
||||
>
|
||||
</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 }
|
||||
@ -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'}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
onClick={moveToAvailable}
|
||||
className={'border px-4 py-2 bg-red-500 text-white hover:bg-red-600'}
|
||||
>
|
||||
<
|
||||
</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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 }
|
||||
@ -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
|
||||
}
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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 }
|
||||
@ -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(' ')
|
||||
}
|
||||
40
src/packages/components/src/components/editors/index.ts
Normal file
40
src/packages/components/src/components/editors/index.ts
Normal 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'
|
||||
@ -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 }
|
||||
@ -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 a–b 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 }
|
||||
@ -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 }
|
||||
4
src/packages/components/src/components/list/index.ts
Normal file
4
src/packages/components/src/components/list/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { VaultStyleDataTable } from './VaultStyleDataTable'
|
||||
export type { VaultStyleColumn } from './VaultStyleDataTable'
|
||||
export { VaultStyleListFooter } from './VaultStyleListFooter'
|
||||
export { VaultStyleListSection } from './VaultStyleListSection'
|
||||
34
src/packages/components/src/index.ts
Normal file
34
src/packages/components/src/index.ts
Normal 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'
|
||||
8
src/packages/components/tsconfig.json
Normal file
8
src/packages/components/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
43
src/packages/contracts/package.json
Normal file
43
src/packages/contracts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
src/packages/contracts/src/PagedRequest.ts
Normal file
23
src/packages/contracts/src/PagedRequest.ts
Normal 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(),
|
||||
})
|
||||
)
|
||||
12
src/packages/contracts/src/PagedResponse.ts
Normal file
12
src/packages/contracts/src/PagedResponse.ts
Normal 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
|
||||
}
|
||||
25
src/packages/contracts/src/PatchOperation.ts
Normal file
25
src/packages/contracts/src/PatchOperation.ts
Normal 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'
|
||||
15
src/packages/contracts/src/PatchRequestModelBase.ts
Normal file
15
src/packages/contracts/src/PatchRequestModelBase.ts
Normal 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(),
|
||||
})
|
||||
)
|
||||
21
src/packages/contracts/src/ProblemDetails.ts
Normal file
21
src/packages/contracts/src/ProblemDetails.ts
Normal 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
|
||||
}
|
||||
7
src/packages/contracts/src/RequestModelBase.ts
Normal file
7
src/packages/contracts/src/RequestModelBase.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { object, type ZodType } from 'zod'
|
||||
|
||||
export interface RequestModelBase {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export const RequestModelBaseSchema: ZodType<RequestModelBase> = object({})
|
||||
3
src/packages/contracts/src/ResponseModelBase.ts
Normal file
3
src/packages/contracts/src/ResponseModelBase.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface ResponseModelBase {
|
||||
[key: string]: unknown
|
||||
}
|
||||
6
src/packages/contracts/src/SearchResponseBase.ts
Normal file
6
src/packages/contracts/src/SearchResponseBase.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { ResponseModelBase } from './ResponseModelBase'
|
||||
|
||||
export interface SearchResponseBase extends ResponseModelBase {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
5
src/packages/contracts/src/TrngResponse.ts
Normal file
5
src/packages/contracts/src/TrngResponse.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { ResponseModelBase } from './ResponseModelBase'
|
||||
|
||||
export interface TrngResponse extends ResponseModelBase {
|
||||
secret: string;
|
||||
}
|
||||
239
src/packages/contracts/src/identity/Claims.ts
Normal file
239
src/packages/contracts/src/identity/Claims.ts
Normal 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'
|
||||
}
|
||||
41
src/packages/contracts/src/identity/login/LoginRequest.ts
Normal file
41
src/packages/contracts/src/identity/login/LoginRequest.ts
Normal 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)
|
||||
@ -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
|
||||
}
|
||||
@ -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(),
|
||||
})
|
||||
12
src/packages/contracts/src/identity/logout/LogoutRequest.ts
Normal file
12
src/packages/contracts/src/identity/logout/LogoutRequest.ts
Normal 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(),
|
||||
})
|
||||
@ -0,0 +1,3 @@
|
||||
export interface LogoutResponse {
|
||||
|
||||
}
|
||||
34
src/packages/contracts/src/index.ts
Normal file
34
src/packages/contracts/src/index.ts
Normal 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'
|
||||
@ -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
|
||||
}
|
||||
8
src/packages/contracts/tsconfig.json
Normal file
8
src/packages/contracts/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
52
src/packages/core/package.json
Normal file
52
src/packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
src/packages/core/src/functions/acl/index.ts
Normal file
2
src/packages/core/src/functions/acl/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { AclEntry } from './parseAclEntry'
|
||||
export { parseAclEntry, parseAclEntries } from './parseAclEntry'
|
||||
63
src/packages/core/src/functions/acl/parseAclEntry.ts
Normal file
63
src/packages/core/src/functions/acl/parseAclEntry.ts
Normal 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 }
|
||||
@ -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]
|
||||
}
|
||||
49
src/packages/core/src/functions/dataTable/dataTablePaged.ts
Normal file
49
src/packages/core/src/functions/dataTable/dataTablePaged.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
9
src/packages/core/src/functions/dataTable/index.ts
Normal file
9
src/packages/core/src/functions/dataTable/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { mapPagedToDataTable } from './dataTablePaged'
|
||||
import { extractPropFilter } from './dataTableFilters'
|
||||
|
||||
export {
|
||||
mapPagedToDataTable,
|
||||
extractPropFilter,
|
||||
}
|
||||
|
||||
export type { DataTablePageView } from './dataTablePaged'
|
||||
@ -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()
|
||||
})
|
||||
28
src/packages/core/src/functions/date/formatISODateString.ts
Normal file
28
src/packages/core/src/functions/date/formatISODateString.ts
Normal 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
|
||||
}
|
||||
9
src/packages/core/src/functions/date/index.ts
Normal file
9
src/packages/core/src/functions/date/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { isValidISODateString } from './isValidDateString'
|
||||
import { formatISODateString } from './formatISODateString'
|
||||
|
||||
export { dateTimeToUtcIsoSchema } from './dateTimeToUtcIsoSchema'
|
||||
|
||||
export {
|
||||
isValidISODateString,
|
||||
formatISODateString
|
||||
}
|
||||
23
src/packages/core/src/functions/date/isValidDateString.ts
Normal file
23
src/packages/core/src/functions/date/isValidDateString.ts
Normal 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
|
||||
}
|
||||
45
src/packages/core/src/functions/deep/deepCopy.ts
Normal file
45
src/packages/core/src/functions/deep/deepCopy.ts
Normal 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 }
|
||||
514
src/packages/core/src/functions/deep/deepDelta.ts
Normal file
514
src/packages/core/src/functions/deep/deepDelta.ts
Normal 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
|
||||
}
|
||||
91
src/packages/core/src/functions/deep/deepEqual.ts
Normal file
91
src/packages/core/src/functions/deep/deepEqual.ts
Normal 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 }
|
||||
47
src/packages/core/src/functions/deep/deepMerge.ts
Normal file
47
src/packages/core/src/functions/deep/deepMerge.ts
Normal 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 }
|
||||
27
src/packages/core/src/functions/deep/deepPatternMatch.ts
Normal file
27
src/packages/core/src/functions/deep/deepPatternMatch.ts
Normal 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
|
||||
}
|
||||
25
src/packages/core/src/functions/deep/index.ts
Normal file
25
src/packages/core/src/functions/deep/index.ts
Normal 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
|
||||
}
|
||||
@ -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',
|
||||
}
|
||||
49
src/packages/core/src/functions/enum/enumToArr.ts
Normal file
49
src/packages/core/src/functions/enum/enumToArr.ts
Normal 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 }
|
||||
25
src/packages/core/src/functions/enum/enumToObj.ts
Normal file
25
src/packages/core/src/functions/enum/enumToObj.ts
Normal 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 }
|
||||
39
src/packages/core/src/functions/enum/enumToString.ts
Normal file
39
src/packages/core/src/functions/enum/enumToString.ts
Normal 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
|
||||
}
|
||||
19
src/packages/core/src/functions/enum/flagsToString.ts
Normal file
19
src/packages/core/src/functions/enum/flagsToString.ts
Normal 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 }
|
||||
12
src/packages/core/src/functions/enum/hasAnyFlag.ts
Normal file
12
src/packages/core/src/functions/enum/hasAnyFlag.ts
Normal 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 }
|
||||
12
src/packages/core/src/functions/enum/hasFlag.ts
Normal file
12
src/packages/core/src/functions/enum/hasFlag.ts
Normal 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 }
|
||||
37
src/packages/core/src/functions/enum/index.ts
Normal file
37
src/packages/core/src/functions/enum/index.ts
Normal 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
Loading…
Reference in New Issue
Block a user