snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
43
lib/Makefile
Normal file
43
lib/Makefile
Normal file
@@ -0,0 +1,43 @@
|
||||
.PHONY: build dev test test-js test-vue lint fresh audit
|
||||
|
||||
build:
|
||||
@echo "---Building js---"
|
||||
@(cd $(CURDIR)/js && yarn && yarn build) || (echo "Failed to build js"; exit 1)
|
||||
@echo "---Building vue---"
|
||||
@(cd $(CURDIR)/vue && yarn && yarn build) || (echo "Failed to build vue"; exit 1)
|
||||
|
||||
dev: build
|
||||
@echo "---Creating sym link for js---"
|
||||
@(cd $(CURDIR)/js && yarn && yarn link && yarn build) || (echo "Failed to link js"; exit 1)
|
||||
@echo "---Building vue and creating sym link---"
|
||||
@(cd $(CURDIR)/vue && yarn && yarn cdeps && yarn build && yarn link) || (echo "Failed to process vue lib"; exit 1)
|
||||
|
||||
test: test-js test-vue
|
||||
|
||||
test-js:
|
||||
@echo "---Testing js---"
|
||||
@(cd $(CURDIR)/js && yarn test) || (echo "Failed to test js"; exit 1)
|
||||
|
||||
test-vue:
|
||||
@echo "---Testing vue---"
|
||||
@(cd $(CURDIR)/vue && yarn test) || (echo "Failed to test vue"; exit 1)
|
||||
|
||||
lint:
|
||||
@echo "---Linting js---"
|
||||
@(cd $(CURDIR)/js && yarn lint) || (echo "Failed to lint js"; exit 1)
|
||||
@echo "---Linting vue---"
|
||||
@(cd $(CURDIR)/vue && yarn lint) || (echo "Failed to lint vue"; exit 1)
|
||||
|
||||
fresh:
|
||||
@echo "---Fresh js---"
|
||||
@(cd $(CURDIR)/js && rm -rf node_modules yarn.lock && yarn && yarn link && yarn build) || (echo "Failed to fresh js"; exit 1)
|
||||
@echo "---Fresh vue---"
|
||||
@(cd $(CURDIR)/vue && rm -rf node_modules yarn.lock && yarn && yarn cdeps && yarn build && yarn link) || (echo "Failed to fresh vue"; exit 1)
|
||||
|
||||
audit:
|
||||
@echo "---Audit js---"
|
||||
@(cd $(CURDIR)/js && yarn audit) || true
|
||||
@echo "---Audit vue---"
|
||||
@(cd $(CURDIR)/vue && yarn audit) || true
|
||||
|
||||
.DEFAULT_GOAL := dev
|
||||
18
lib/eslint-client/.gitignore
vendored
Normal file
18
lib/eslint-client/.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
coverage
|
||||
.nyc_output
|
||||
package-lock.json
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
53
lib/eslint-client/index.js
Normal file
53
lib/eslint-client/index.js
Normal file
@@ -0,0 +1,53 @@
|
||||
module.exports = {
|
||||
root: false,
|
||||
env: {
|
||||
es6: true,
|
||||
node: true,
|
||||
mocha: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/recommended',
|
||||
'@vue/standard',
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'import/no-named-default': 'off',
|
||||
'new-cap': 'off',
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'no-misleading-character-class': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'no-async-promise-executor': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'vue/no-v-model-argument': 'off',
|
||||
|
||||
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
|
||||
'vue/order-in-components': ['error'],
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-lone-template': 'off',
|
||||
'vue/no-mutating-props': 'off',
|
||||
'vue/component-definition-name-casing': 'off',
|
||||
'vue/no-v-text-v-html-on-component': 'off',
|
||||
'vue/attributes-order': ['error', {
|
||||
order: [
|
||||
'DEFINITION',
|
||||
'LIST_RENDERING',
|
||||
'CONDITIONALS',
|
||||
'RENDER_MODIFIERS',
|
||||
'GLOBAL',
|
||||
'UNIQUE',
|
||||
'TWO_WAY_BINDING',
|
||||
'SLOT',
|
||||
'OTHER_DIRECTIVES',
|
||||
'OTHER_ATTR',
|
||||
'EVENTS',
|
||||
'CONTENT',
|
||||
],
|
||||
alphabetical: false,
|
||||
}],
|
||||
},
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
},
|
||||
}
|
||||
14
lib/eslint-client/package.json
Normal file
14
lib/eslint-client/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "eslint-config-corteza-client",
|
||||
"description": "Eslint config for corteza client",
|
||||
"version": "2024.9.10",
|
||||
"license": "Apache-2.0",
|
||||
"main": "./index.js",
|
||||
"peerDependencies": {
|
||||
"@vue/cli-plugin-eslint": "5.0.8",
|
||||
"@vue/eslint-config-standard": "^8.0.1",
|
||||
"@babel/eslint-parser": "^7.26.5",
|
||||
"eslint": "^8.7.2",
|
||||
"eslint-plugin-vue": "^9.32.0"
|
||||
}
|
||||
}
|
||||
4
lib/eslint-client/yarn.lock
Normal file
4
lib/eslint-client/yarn.lock
Normal file
@@ -0,0 +1,4 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
7
lib/js/.eslintignore
Normal file
7
lib/js/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
src/api-clients/automation.ts
|
||||
src/api-clients/compose.ts
|
||||
src/api-clients/federation.ts
|
||||
src/api-clients/system.ts
|
||||
**/*.test.ts
|
||||
38
lib/js/.eslintrc.cjs
Normal file
38
lib/js/.eslintrc.cjs
Normal file
@@ -0,0 +1,38 @@
|
||||
module.exports = {
|
||||
root: false,
|
||||
env: {
|
||||
node: true,
|
||||
es6: true,
|
||||
mocha: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'comma-spacing': ['error', { 'before': false, 'after': true }],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/ban-ts-ignore': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/camelcase': 'off',
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@typescript-eslint/eslint-plugin',
|
||||
],
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': [
|
||||
'.ts',
|
||||
],
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
18
lib/js/.gitignore
vendored
Normal file
18
lib/js/.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
coverage
|
||||
.nyc_output
|
||||
package-lock.json
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
11
lib/js/.mocharc.cjs
Normal file
11
lib/js/.mocharc.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
require: [
|
||||
'tsx/cjs'
|
||||
],
|
||||
'full-trace': true,
|
||||
bail: true,
|
||||
recursive: true,
|
||||
extension: ['ts'],
|
||||
spec: ['src/**/*.test.ts'],
|
||||
timeout: 5000,
|
||||
}
|
||||
78
lib/js/package.json
Normal file
78
lib/js/package.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@cortezaproject/corteza-js",
|
||||
"description": "Corteza JavaScript/TypeScript libs",
|
||||
"version": "2024.9.10",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"author": "Denis Arh <denis.arh@crust.tech>",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rollup -c --environment BUILD:production",
|
||||
"build:dev": "rollup -c",
|
||||
"lint": "eslint src --ext .js,.ts",
|
||||
"test:unit": "mocha",
|
||||
"test": "yarn test:unit",
|
||||
"codegen": "node tools/codegen/corteza-api-client.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.8.3",
|
||||
"hex-rgb": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimalist": "^1.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"moment": "2.30.1",
|
||||
"numeral": "^2.0.6",
|
||||
"pino": "^9.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.2",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/chai-as-promised": "^8.0.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/numeral": "^2.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"chai": "^5.1.2",
|
||||
"chai-as-promised": "^8.0.1",
|
||||
"eslint": "^8.7.2",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-json": "^4.0.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"js-yaml": "^4.1.0",
|
||||
"mocha": "^11.1.0",
|
||||
"node-watch": "^0.7.4",
|
||||
"nodemon": "^3.1.9",
|
||||
"rollup": "^4.32.1",
|
||||
"rollup-plugin-typescript2": "^0.36.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-sinon": "2.0.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"browser": {
|
||||
"fs": false,
|
||||
"path": false
|
||||
}
|
||||
}
|
||||
50
lib/js/rollup.config.js
Normal file
50
lib/js/rollup.config.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import typescript from 'rollup-plugin-typescript2'
|
||||
import { nodeResolve } from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import json from '@rollup/plugin-json'
|
||||
import { readFileSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)))
|
||||
const ts = require('typescript')
|
||||
|
||||
export default {
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: 'dist/index.cjs',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
},
|
||||
{
|
||||
file: 'dist/index.js',
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
},
|
||||
],
|
||||
|
||||
external: [
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
'fs',
|
||||
'path',
|
||||
],
|
||||
|
||||
plugins: [
|
||||
typescript({
|
||||
typescript: ts,
|
||||
}),
|
||||
|
||||
nodeResolve({
|
||||
browser: true,
|
||||
preferBuiltins: true,
|
||||
}),
|
||||
|
||||
commonjs({
|
||||
include: /node_modules/,
|
||||
}),
|
||||
|
||||
json(),
|
||||
],
|
||||
}
|
||||
1301
lib/js/src/api-clients/automation.ts
Normal file
1301
lib/js/src/api-clients/automation.ts
Normal file
File diff suppressed because it is too large
Load Diff
4569
lib/js/src/api-clients/compose.ts
Normal file
4569
lib/js/src/api-clients/compose.ts
Normal file
File diff suppressed because it is too large
Load Diff
119
lib/js/src/api-clients/discovery.ts
Normal file
119
lib/js/src/api-clients/discovery.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
interface KV {
|
||||
[header: string]: unknown;
|
||||
}
|
||||
|
||||
interface Headers {
|
||||
[header: string]: string;
|
||||
}
|
||||
|
||||
interface Ctor {
|
||||
baseURL?: string;
|
||||
accessTokenFn?: () => string | undefined;
|
||||
headers?: Headers;
|
||||
}
|
||||
|
||||
interface CortezaResponse {
|
||||
error?: string;
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
function stdResolve (response: AxiosResponse<CortezaResponse>): KV|Promise<never> {
|
||||
if (response.data.error) {
|
||||
return Promise.reject(response.data.error)
|
||||
} else {
|
||||
return response.data.response as KV
|
||||
}
|
||||
}
|
||||
|
||||
export default class Discovery {
|
||||
protected baseURL?: string;
|
||||
protected accessTokenFn?: () => (string | undefined);
|
||||
protected headers: Headers = {};
|
||||
|
||||
constructor ({ baseURL, headers, accessTokenFn }: Ctor) {
|
||||
this.baseURL = baseURL
|
||||
this.accessTokenFn = accessTokenFn
|
||||
this.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
this.setHeaders(headers)
|
||||
}
|
||||
|
||||
setAccessTokenFn (fn: () => string | undefined): Discovery {
|
||||
this.accessTokenFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
setHeaders (headers?: Headers): Discovery {
|
||||
if (typeof headers === 'object') {
|
||||
this.headers = headers
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
setHeader (name: string, value: string | undefined): Discovery {
|
||||
if (value === undefined) {
|
||||
delete this.headers[name]
|
||||
} else {
|
||||
this.headers[name] = value
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
api (): AxiosInstance {
|
||||
const headers = { ...this.headers }
|
||||
const accessToken = this.accessTokenFn ? this.accessTokenFn() : undefined
|
||||
if (accessToken) {
|
||||
headers.Authorization = 'Bearer ' + accessToken
|
||||
}
|
||||
|
||||
return axios.create({
|
||||
withCredentials: true,
|
||||
baseURL: this.baseURL,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
async query (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
|
||||
const {
|
||||
query = '',
|
||||
from,
|
||||
size,
|
||||
resourceTypes,
|
||||
} = a || {}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (resourceTypes && Array.isArray(resourceTypes)) {
|
||||
resourceTypes.forEach(t => params.append('resourceTypes', t))
|
||||
}
|
||||
|
||||
if (from) params.append('from', from.toString())
|
||||
if (size) params.append('size', size.toString())
|
||||
|
||||
const cfg: AxiosRequestConfig = {
|
||||
...extra,
|
||||
method: 'get',
|
||||
url: `/?q=${query}`,
|
||||
params,
|
||||
}
|
||||
|
||||
return this.api().request(cfg).then(result => stdResolve(result))
|
||||
}
|
||||
|
||||
queryCancellable (a: KV, extra: AxiosRequestConfig = {}): { response: () => Promise<KV>; cancel: () => void } {
|
||||
const cancelTokenSource = axios.CancelToken.source()
|
||||
const options = { ...extra, cancelToken: cancelTokenSource.token }
|
||||
|
||||
return {
|
||||
response: () => this.query(a, options),
|
||||
cancel: () => {
|
||||
cancelTokenSource.cancel()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
1433
lib/js/src/api-clients/federation.ts
Normal file
1433
lib/js/src/api-clients/federation.ts
Normal file
File diff suppressed because it is too large
Load Diff
5
lib/js/src/api-clients/index.ts
Normal file
5
lib/js/src/api-clients/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as System } from './system'
|
||||
export { default as Compose } from './compose'
|
||||
export { default as Federation } from './federation'
|
||||
export { default as Automation } from './automation'
|
||||
export { default as Discovery } from './discovery'
|
||||
5
lib/js/src/automation/index.ts
Normal file
5
lib/js/src/automation/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Workflow } from './types/workflow'
|
||||
export { Param } from './types/param'
|
||||
export { Function } from './types/function'
|
||||
export { Prompt } from './types/prompt'
|
||||
export { Typed, IsTyped, Vars, Encode } from './types/values'
|
||||
48
lib/js/src/automation/types/function.ts
Normal file
48
lib/js/src/automation/types/function.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Apply } from '../../cast'
|
||||
import { Param } from './param'
|
||||
|
||||
export interface FunctionMeta {
|
||||
short: string;
|
||||
description: string;
|
||||
visual: { [_: string]: any };
|
||||
// List of webapps where function can be used, if omitted it can be used everywhere
|
||||
webapps: Array<string>;
|
||||
}
|
||||
|
||||
interface FunctionCtr extends Partial<Omit<Function, 'parameters' | 'results'>> {
|
||||
parameters?: Array<Partial<Param>>;
|
||||
results?: Array<Partial<Param>>;
|
||||
}
|
||||
|
||||
export class Function {
|
||||
public ref = ''
|
||||
public kind = ''
|
||||
public meta: Partial<FunctionMeta> = {}
|
||||
public parameters: Array<Param> = []
|
||||
public results: Array<Param> = []
|
||||
public labels: { [_: string]: string } = {}
|
||||
|
||||
constructor (u?: FunctionCtr) {
|
||||
this.apply(u)
|
||||
}
|
||||
|
||||
apply (u?: FunctionCtr): void {
|
||||
Apply(this, u, String, 'ref', 'kind')
|
||||
|
||||
if (u?.parameters) {
|
||||
this.parameters = u.parameters.map(p => new Param(p))
|
||||
}
|
||||
|
||||
if (u?.results) {
|
||||
this.results = u.results.map(p => new Param(p))
|
||||
}
|
||||
|
||||
if (u?.meta) {
|
||||
this.meta = { ...u.meta }
|
||||
}
|
||||
|
||||
if (u?.labels) {
|
||||
this.labels = { ...u.labels }
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/js/src/automation/types/param.ts
Normal file
32
lib/js/src/automation/types/param.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Apply } from '../../cast'
|
||||
|
||||
export interface ParamMeta {
|
||||
label: string;
|
||||
description: string;
|
||||
visual: { [_: string]: any };
|
||||
}
|
||||
|
||||
export class Param {
|
||||
public name = ''
|
||||
public types: Array<string> = []
|
||||
public required = false
|
||||
public isArray = false
|
||||
public meta: Partial<ParamMeta> = {}
|
||||
|
||||
constructor (u?: Partial<Param>) {
|
||||
this.apply(u)
|
||||
}
|
||||
|
||||
apply (u?: Partial<Param>): void {
|
||||
Apply(this, u, String, 'name')
|
||||
Apply(this, u, Boolean, 'required', 'isArray')
|
||||
|
||||
if (u?.types) {
|
||||
this.types = u.types
|
||||
}
|
||||
|
||||
if (u?.meta) {
|
||||
this.meta = { ...u.meta }
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/js/src/automation/types/prompt.ts
Normal file
23
lib/js/src/automation/types/prompt.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
|
||||
|
||||
export class Prompt {
|
||||
public ref = ''
|
||||
public sessionID = NoID
|
||||
public stateID = NoID
|
||||
public createdAt?: Date = undefined
|
||||
public payload: any = undefined
|
||||
|
||||
constructor (u?: Partial<Prompt>) {
|
||||
this.apply(u)
|
||||
}
|
||||
|
||||
apply (u?: Partial<Prompt>): void {
|
||||
Apply(this, u, CortezaID, 'sessionID', 'stateID')
|
||||
Apply(this, u, String, 'ref')
|
||||
Apply(this, u, ISO8601Date, 'createdAt')
|
||||
|
||||
if (u?.payload) {
|
||||
this.payload = u.payload
|
||||
}
|
||||
}
|
||||
}
|
||||
58
lib/js/src/automation/types/values.ts
Normal file
58
lib/js/src/automation/types/values.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import lodash from 'lodash'
|
||||
const { capitalize } = lodash
|
||||
|
||||
export interface Typed {
|
||||
'@type': string;
|
||||
'@value': any;
|
||||
}
|
||||
|
||||
export type Vars = { [_: string]: Typed }
|
||||
|
||||
export function IsTyped (a: unknown): a is Typed {
|
||||
return typeof a &&
|
||||
a === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(a, '@type') &&
|
||||
Object.prototype.hasOwnProperty.call(a, '@value')
|
||||
}
|
||||
|
||||
function unwrap (v: unknown): any {
|
||||
return IsTyped(v) ? v['@value'] : v
|
||||
}
|
||||
|
||||
function cast (v: unknown): Typed {
|
||||
return { '@value': unwrap(v), '@type': guessType(v) }
|
||||
}
|
||||
|
||||
function guessType (v: any): string {
|
||||
switch (typeof v) {
|
||||
case 'boolean':
|
||||
return 'Boolean'
|
||||
case 'string':
|
||||
return 'String'
|
||||
case 'number':
|
||||
return Number(v) === v && v % 1 === 0 ? 'Float' : 'Integer'
|
||||
case 'object':
|
||||
if (v.resourceType) {
|
||||
// converts foo:bar into FooBar
|
||||
return v.resourceType.split(':').map(capitalize).join('')
|
||||
}
|
||||
return 'Any'
|
||||
default:
|
||||
return 'Any'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param any
|
||||
* @constructor
|
||||
*/
|
||||
export function Encode (input: {[_: string]: any}): Vars {
|
||||
const output: Vars = {}
|
||||
|
||||
for (const key in input) {
|
||||
output[key] = IsTyped(input[key]) ? input[key] : cast(input[key])
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
106
lib/js/src/automation/types/workflow.ts
Normal file
106
lib/js/src/automation/types/workflow.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
|
||||
import { IsOf } from '../../guards'
|
||||
|
||||
interface Meta {
|
||||
name?: string;
|
||||
description?: string;
|
||||
visual?: Record<string, unknown>;
|
||||
subWorkflow?: boolean;
|
||||
}
|
||||
|
||||
interface PartialWorkflow extends Partial<Omit<Workflow, 'createdAt' | 'updatedAt' | 'deletedAt' | 'suspendedAt'>> {
|
||||
meta?: Partial<Meta>;
|
||||
createdAt?: string|number|Date;
|
||||
updatedAt?: string|number|Date;
|
||||
deletedAt?: string|number|Date;
|
||||
}
|
||||
|
||||
export class Workflow {
|
||||
public workflowID = NoID
|
||||
public handle = ''
|
||||
public enabled = false
|
||||
public trace = false
|
||||
public keepSessions = 0
|
||||
public labels: Record<string, string> = {}
|
||||
public meta: Meta = {
|
||||
name: '',
|
||||
description: '',
|
||||
visual: {},
|
||||
subWorkflow: false,
|
||||
};
|
||||
|
||||
public scope?: Record<string, unknown> = undefined
|
||||
public steps?: unknown[] = undefined
|
||||
public paths?: unknown[] = undefined
|
||||
public issues?: unknown[] = undefined
|
||||
|
||||
public runAs = NoID
|
||||
public ownedBy = NoID;
|
||||
public createdBy = NoID;
|
||||
public updatedBy = NoID;
|
||||
public deletedBy = NoID;
|
||||
public createdAt?: Date = undefined
|
||||
public updatedAt?: Date = undefined
|
||||
public deletedAt?: Date = undefined
|
||||
|
||||
public canGrant = false
|
||||
public canUpdateWorkflow = false
|
||||
public canDeleteWorkflow = false
|
||||
public canExecuteWorkflow = false
|
||||
public canManageWorkflowSessions = false
|
||||
|
||||
constructor (w?: PartialWorkflow) {
|
||||
this.apply(w)
|
||||
}
|
||||
|
||||
apply (w?: PartialWorkflow): void {
|
||||
Apply(this, w, CortezaID, 'workflowID')
|
||||
Apply(this, w, String, 'handle')
|
||||
|
||||
Apply(this, w, Boolean, 'enabled', 'trace')
|
||||
Apply(this, w, Number, 'keepSessions')
|
||||
|
||||
Apply(this, w, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
Apply(this, w, CortezaID, 'runAs', 'ownedBy', 'createdBy', 'updatedBy', 'deletedBy')
|
||||
|
||||
Apply(this, w, Boolean, 'canGrant', 'canUpdateWorkflow', 'canDeleteWorkflow', 'canExecuteWorkflow', 'canManageWorkflowSessions')
|
||||
|
||||
if (IsOf(w, 'meta')) {
|
||||
this.meta = { ...w.meta }
|
||||
}
|
||||
|
||||
if (IsOf(w, 'labels')) {
|
||||
this.labels = { ...w.labels }
|
||||
}
|
||||
|
||||
if (IsOf(w, 'scope')) {
|
||||
this.scope = w.scope
|
||||
}
|
||||
|
||||
if (IsOf(w, 'steps')) {
|
||||
this.steps = w.steps
|
||||
}
|
||||
|
||||
if (IsOf(w, 'paths')) {
|
||||
this.paths = w.paths
|
||||
}
|
||||
|
||||
if (IsOf(w, 'issues')) {
|
||||
this.issues = w.issues
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.workflowID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'automation:workflow'
|
||||
}
|
||||
}
|
||||
70
lib/js/src/cast.test.ts
Normal file
70
lib/js/src/cast.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect } from 'chai'
|
||||
import { Apply } from './cast'
|
||||
class Foo {
|
||||
baz = ''
|
||||
bool = false
|
||||
arr = []
|
||||
ff: Foo[] = []
|
||||
}
|
||||
|
||||
describe('cast', () => {
|
||||
describe('ensure that applying actually works', () => {
|
||||
let foo: Foo
|
||||
|
||||
beforeEach(() => {
|
||||
foo = new Foo()
|
||||
})
|
||||
|
||||
it('should handle simple assignment', () => {
|
||||
Apply(foo, { baz: 'value' }, String, 'baz')
|
||||
expect(foo.baz).to.equal('value')
|
||||
})
|
||||
|
||||
it('should assign number to string', () => {
|
||||
Apply(foo, { baz: 42 }, String, 'baz')
|
||||
expect(foo.baz).to.equal('42')
|
||||
})
|
||||
|
||||
it('should assign number to string using default (string) caster', () => {
|
||||
Apply(foo, { baz: 84 }, 'baz')
|
||||
expect(foo.baz).to.equal('84')
|
||||
})
|
||||
|
||||
it('should assign boolean (empty string)', () => {
|
||||
Apply(foo, { bool: '' }, Boolean, 'bool')
|
||||
expect(foo.bool).to.equal(false)
|
||||
})
|
||||
|
||||
it('should assign boolean (non-empty string)', () => {
|
||||
Apply(foo, { bool: 'true' }, Boolean, 'bool')
|
||||
expect(foo.bool).to.equal(true)
|
||||
})
|
||||
|
||||
it('should assign boolean (number, 1)', () => {
|
||||
Apply(foo, { bool: 1 }, Boolean, 'bool')
|
||||
expect(foo.bool).to.equal(true)
|
||||
})
|
||||
|
||||
it('should assign boolean (number, 0)', () => {
|
||||
Apply(foo, { bool: 0 }, Boolean, 'bool')
|
||||
expect(foo.bool).to.equal(false)
|
||||
})
|
||||
|
||||
it('should assign simple array', () => {
|
||||
Apply(foo, { arr: [42, '42'] }, (o) => o, 'arr')
|
||||
expect(foo.arr).to.deep.equal([42, '42'])
|
||||
})
|
||||
|
||||
it('should assign array of Foos', () => {
|
||||
// just a primitive check, not actually casting
|
||||
// to Foo[] so we're not actually checking for that
|
||||
Apply(foo, { ff: [{ baz: 'one' }, { baz: 'two' }] }, (o) => o, 'ff')
|
||||
expect(foo.ff).to.deep.equal([{ baz: 'one' }, { baz: 'two' }])
|
||||
})
|
||||
|
||||
it('should cast null to empty string', () => {
|
||||
Apply(foo, { baz: null }, String, 'baz')
|
||||
expect(foo.baz).to.equal('')
|
||||
})
|
||||
})
|
||||
})
|
||||
188
lib/js/src/cast.ts
Normal file
188
lib/js/src/cast.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// const iso8601check = /^([\\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([.,]\d+(?!:))?)?(\17[0-5]\d([.,]\d+)?)?([zZ]|([\\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?$/
|
||||
|
||||
const uint64zeropad = '00000000000000000000'
|
||||
|
||||
/**
|
||||
* Reasons behind this small snippet:
|
||||
* - backend is using uint64 as prefered type for handling CortezaID (of all things)
|
||||
* - JavaScript can not (without external help) deal with uint64
|
||||
* - Backend's JSON marshaller converts uint64 to string
|
||||
* - Backend's JSON unmarshaller raises error when given anything but string with a number inside
|
||||
*
|
||||
* Until this last thing is fixed or has a proper workaround, we're stuck with this.
|
||||
*/
|
||||
export const NoID = '0'
|
||||
|
||||
export function ISO8601Date (ts?: unknown): Date|undefined {
|
||||
if (ts instanceof Date) {
|
||||
return ts
|
||||
}
|
||||
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
return new Date(ts)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface Caster<T> {
|
||||
(input: unknown): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is native class?
|
||||
*
|
||||
* @param thing
|
||||
* @returns {boolean}
|
||||
*/
|
||||
// function isNativeClass (o: unknown): boolean {
|
||||
// return typeof o === 'function' &&
|
||||
// Object.prototype.hasOwnProperty.call(o, 'prototype') &&
|
||||
// !Object.prototype.hasOwnProperty.call(o, 'arguments')
|
||||
// }
|
||||
|
||||
/**
|
||||
* Casts value to <type> or returns default
|
||||
*/
|
||||
export function PropCast<T> (type: Caster<T>, o: {[_: string]: unknown}|undefined, prop: string): T|undefined {
|
||||
if (o === undefined || Object.prototype.hasOwnProperty.call(o, prop)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return type(o.prop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a given value looks like corteza ID
|
||||
* @param ID
|
||||
* @constructor
|
||||
*/
|
||||
export function IsCortezaID (ID: unknown): boolean {
|
||||
if (typeof ID !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(ID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
export function CortezaID (value: unknown): string {
|
||||
if (!value) {
|
||||
return NoID
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value.toString()
|
||||
}
|
||||
|
||||
if (IsCortezaID(value)) {
|
||||
return value as string
|
||||
}
|
||||
|
||||
throw new Error('Invalid CortezaID value')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply caster interface that satisfies basic casting functions + String, Number etc...
|
||||
*/
|
||||
interface ApplyCaster {
|
||||
(val: unknown): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply takes all given props, their values (from src) and assignes them to props (on dst)
|
||||
*
|
||||
* A casting function can be used (see ApplyCaster) to modify the values before assigning them
|
||||
*/
|
||||
export function Apply<DST, SRC, T extends keyof DST> (dst: DST, src: SRC, cast: ApplyCaster|keyof DST, ...props: (keyof DST)[]): void {
|
||||
if (typeof cast !== 'function') {
|
||||
// Handle case where we do not use caster
|
||||
props.unshift(cast)
|
||||
|
||||
// and use String as a caster, effectively forcing
|
||||
// cast-to-string on all applied values to
|
||||
cast = String
|
||||
}
|
||||
|
||||
if (typeof src !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
props.forEach(prop => {
|
||||
// prop must exist on dst
|
||||
if (!Object.prototype.hasOwnProperty.call(dst, prop)) {
|
||||
return
|
||||
}
|
||||
|
||||
// prop must exist on src
|
||||
if (!Object.prototype.hasOwnProperty.call(src, prop)) {
|
||||
return
|
||||
}
|
||||
|
||||
// sProp is prop from source
|
||||
const sProp = (prop as unknown) as keyof SRC
|
||||
|
||||
// value on src should be defined
|
||||
if (!src || src[sProp] === undefined || src[sProp] === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cast value from src to type of value from prop on dst
|
||||
let val = (src[sProp] as unknown) as DST[T]
|
||||
|
||||
// Run value through cast fn
|
||||
val = ((cast as ApplyCaster)(val) as unknown) as DST[T]
|
||||
if (val === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// Assign (valid ony) value to dst
|
||||
dst[prop] = val
|
||||
})
|
||||
}
|
||||
|
||||
export function ApplyWhitelisted<DST, SRC, WL, T extends keyof DST> (dst: DST, src: SRC, whitelist: (DST[T])[], ...props: (keyof DST)[]): void {
|
||||
if (typeof src !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
props.forEach(prop => {
|
||||
// prop must exist on dst
|
||||
if (!Object.prototype.hasOwnProperty.call(dst, prop)) {
|
||||
return
|
||||
}
|
||||
|
||||
// prop must exist on src
|
||||
if (!Object.prototype.hasOwnProperty.call(src, prop)) {
|
||||
return
|
||||
}
|
||||
|
||||
// sProp is prop from source
|
||||
const sProp = (prop as unknown) as keyof SRC
|
||||
|
||||
// value on src should be defined
|
||||
if (!src || src[sProp] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cast value from src to type of value from prop on dst
|
||||
const val = (src[sProp] as unknown) as DST[T]
|
||||
|
||||
if (whitelist.includes(val)) {
|
||||
dst[prop] = val
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function makeIDSortable (ID?: string): string {
|
||||
// We're using uint64 for CortezaID and JavaScript does not know how to handle this type
|
||||
// natively. We get the value from backend as string anyway and we need to prefix
|
||||
// it with '0' to ensure string sorting does what we need it to.
|
||||
return uint64zeropad.substr((ID || '').length) + (ID || '')
|
||||
}
|
||||
179
lib/js/src/compose/events.ts
Normal file
179
lib/js/src/compose/events.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Event, onManual } from '../eventbus/shared'
|
||||
import { Module } from './types/module'
|
||||
import { Page } from './types/page'
|
||||
import { Record, Values } from './types/record'
|
||||
import { Namespace } from './types/namespace'
|
||||
import { ConstraintMatcher } from '../eventbus/constraints'
|
||||
import { IsOf } from '../guards'
|
||||
|
||||
interface TriggerEndpoints {
|
||||
automationTriggerScript (params: { script: string }): Promise<object>;
|
||||
namespaceTriggerScript (params: { namespaceID: string; script: string }): Promise<object>;
|
||||
moduleTriggerScript (params: { namespaceID: string; moduleID: string; script: string }): Promise<object>;
|
||||
recordTriggerScript (params: { namespaceID: string; moduleID: string; recordID: string; values: Values; script: string }): Promise<object>;
|
||||
}
|
||||
|
||||
function namespaceMatcher (r: Namespace, c: ConstraintMatcher, def: boolean): boolean {
|
||||
if (!r) {
|
||||
throw new Error('can not run namespace matcher on undefined/null namespace')
|
||||
}
|
||||
|
||||
// keep in sync with server/compose/service/event/namespace.go
|
||||
switch (c.Name()) {
|
||||
case 'namespace':
|
||||
case 'namespace.slug':
|
||||
return c.Match(r.slug)
|
||||
case 'namespace.name':
|
||||
return c.Match(r.name)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
function moduleMatcher (r: Module, c: ConstraintMatcher, def: boolean): boolean {
|
||||
if (!r) {
|
||||
throw new Error('can not run module matcher on undefined/null module')
|
||||
}
|
||||
|
||||
// keep in sync with server/compose/service/event/module.go
|
||||
switch (c.Name()) {
|
||||
case 'module':
|
||||
case 'module.handle':
|
||||
return c.Match(r.handle)
|
||||
case 'module.name':
|
||||
return c.Match(r.name)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates event for compose resource with ready-to-go-defaults
|
||||
*/
|
||||
export function ComposeEvent (event?: Partial<Event>): Event {
|
||||
return {
|
||||
eventType: onManual,
|
||||
resourceType: 'compose',
|
||||
match: (): boolean => true,
|
||||
...event,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates namespace event with ready-to-go-defaults
|
||||
*/
|
||||
export function NamespaceEvent (res: Namespace, event?: Partial<Event>): Event {
|
||||
return {
|
||||
eventType: onManual,
|
||||
resourceType: res.resourceType,
|
||||
match: (c): boolean => namespaceMatcher(res, c, false),
|
||||
...event,
|
||||
|
||||
// Override the arguments at the end
|
||||
args: { namespace: res, ...event?.args },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates module event with ready-to-go-defaults
|
||||
*/
|
||||
export function ModuleEvent (res: Module, event?: Partial<Event>): Event {
|
||||
return {
|
||||
eventType: onManual,
|
||||
resourceType: res.resourceType,
|
||||
match: (c): boolean => namespaceMatcher(res.namespace, c, moduleMatcher(res, c, false)),
|
||||
...event,
|
||||
|
||||
// Override the arguments at the end
|
||||
args: { module: res, namespace: res.namespace, ...event?.args },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates record event with ready-to-go-defaults
|
||||
*/
|
||||
export function RecordEvent (res: Record, event?: Partial<Event>): Event {
|
||||
return {
|
||||
eventType: onManual,
|
||||
resourceType: res.resourceType,
|
||||
match: (c): boolean => namespaceMatcher(res.namespace, c, moduleMatcher(res.module, c, false)),
|
||||
...event,
|
||||
|
||||
// Override the arguments at the end
|
||||
args: { record: res, module: res.module, namespace: res.namespace, ...event?.args },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates record event with ready-to-go-defaults
|
||||
*/
|
||||
export function PageEvent (res: Page, event?: Partial<Event>): Event {
|
||||
return {
|
||||
eventType: onManual,
|
||||
resourceType: 'compose:page',
|
||||
match: (): boolean => true,
|
||||
...event,
|
||||
|
||||
// Override the arguments at the end
|
||||
args: { page: res, ...event?.args },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns handler that routes onManual events for server script to the compose API
|
||||
*
|
||||
* See makeAutomationScriptsRegistrator
|
||||
*
|
||||
* @param api
|
||||
* @return function
|
||||
*/
|
||||
export function TriggerComposeServerScriptOnManual (api: TriggerEndpoints) {
|
||||
return (ev: Event, script: string): Promise<unknown> => {
|
||||
const params = { script, args: ev.args }
|
||||
|
||||
if (ev.resourceType === 'compose') {
|
||||
return api
|
||||
.automationTriggerScript({ ...params })
|
||||
}
|
||||
|
||||
if (!ev.args) {
|
||||
throw new Error('expecting args prop in event')
|
||||
}
|
||||
|
||||
if (ev.resourceType === 'compose:namespace') {
|
||||
if (!IsOf<Namespace>(ev.args.namespace, 'namespaceID')) {
|
||||
throw new Error('expecting args.namespace in event arguments')
|
||||
}
|
||||
|
||||
const { namespaceID } = ev.args.namespace as Namespace
|
||||
return api
|
||||
.namespaceTriggerScript({ namespaceID, ...params })
|
||||
}
|
||||
|
||||
if (ev.resourceType === 'compose:module') {
|
||||
if (!IsOf<Module>(ev.args.module, 'namespaceID', 'moduleID')) {
|
||||
throw new Error('expecting args.module in event arguments')
|
||||
}
|
||||
|
||||
const { namespaceID, moduleID } = ev.args.module as Module
|
||||
|
||||
return api
|
||||
.moduleTriggerScript({ namespaceID, moduleID, ...params })
|
||||
}
|
||||
|
||||
if (ev.resourceType === 'compose:record') {
|
||||
if (!IsOf<Record>(ev.args.record, 'namespaceID', 'moduleID', 'recordID')) {
|
||||
throw new Error('expecting args.record in event arguments')
|
||||
}
|
||||
|
||||
const record = ev.args.record as Record
|
||||
const { namespaceID, moduleID, recordID, values } = record
|
||||
|
||||
return api
|
||||
.recordTriggerScript({ namespaceID, moduleID, recordID, values, ...params })
|
||||
.then(rval => record.apply(rval))
|
||||
}
|
||||
|
||||
throw Error(`cannot trigger server script: unknown resource type '${ev.resourceType}'`)
|
||||
}
|
||||
}
|
||||
4
lib/js/src/compose/helpers/idgen.ts
Normal file
4
lib/js/src/compose/helpers/idgen.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function generateUID (): string {
|
||||
const uid = Math.random().toString(36).substring(2) + (new Date()).getTime().toString(36)
|
||||
return `tempID-${uid}`
|
||||
}
|
||||
15
lib/js/src/compose/helpers/index.ts
Normal file
15
lib/js/src/compose/helpers/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import yaml from 'js-yaml'
|
||||
import fs from 'fs'
|
||||
import { Module } from '../types/module'
|
||||
|
||||
export function getModuleFromYaml (moduleName: string, yamlPath: string): Module|undefined {
|
||||
const data = yaml.loadAll(fs.readFileSync(yamlPath, 'utf8')) as Array<{ modules: { [key: string]: any } }>
|
||||
const mod = data[0].modules[moduleName]
|
||||
if (mod) {
|
||||
// Convert fields from object to array
|
||||
mod.fields = Object.keys(mod.fields).map((k) => mod.fields[k])
|
||||
return new Module(mod)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
21
lib/js/src/compose/index.ts
Normal file
21
lib/js/src/compose/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export { Record } from './types/record'
|
||||
export { Module } from './types/module'
|
||||
export * from './types/revision'
|
||||
export * from './types/module-field'
|
||||
export { Namespace } from './types/namespace'
|
||||
export { Page } from './types/page'
|
||||
export { PageLayout } from './types/page-layout'
|
||||
export * from './types/page-block'
|
||||
export { RecordValidator } from './validators/record'
|
||||
export { getModuleFromYaml } from './helpers'
|
||||
|
||||
export * from './types/chart'
|
||||
|
||||
export {
|
||||
ComposeEvent,
|
||||
NamespaceEvent,
|
||||
ModuleEvent,
|
||||
RecordEvent,
|
||||
PageEvent,
|
||||
TriggerComposeServerScriptOnManual,
|
||||
} from './events'
|
||||
410
lib/js/src/compose/types/chart/base.ts
Normal file
410
lib/js/src/compose/types/chart/base.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import _ from 'lodash'
|
||||
import {
|
||||
ChartConfig,
|
||||
Dimension,
|
||||
Metric,
|
||||
Report,
|
||||
dimensionFunctions,
|
||||
makeAlias,
|
||||
TemporalDataPoint,
|
||||
defFormatData,
|
||||
} from './util'
|
||||
|
||||
import {
|
||||
CortezaID,
|
||||
NoID,
|
||||
ISO8601Date,
|
||||
Apply,
|
||||
} from '../../../cast'
|
||||
|
||||
export type PartialChart = Partial<BaseChart>
|
||||
|
||||
// The default dataset post processing function to use.
|
||||
// This one simply returns the current value.
|
||||
const defaultFx = 'n'
|
||||
|
||||
/**
|
||||
* BaseChart represents a structure that stores any configuration data.
|
||||
* Any display and data rendering operations should be handled by any sub classes.
|
||||
*/
|
||||
export class BaseChart {
|
||||
public chartID = NoID
|
||||
public namespaceID = NoID
|
||||
public name = ''
|
||||
public handle = ''
|
||||
|
||||
public createdAt?: Date = undefined
|
||||
public updatedAt?: Date = undefined
|
||||
public deletedAt?: Date = undefined
|
||||
|
||||
public canUpdateChart = false
|
||||
public canDeleteChart = false
|
||||
public canGrant = false
|
||||
|
||||
public config: ChartConfig = {}
|
||||
|
||||
constructor (def: PartialChart = {}) {
|
||||
this.merge(def)
|
||||
}
|
||||
|
||||
/**
|
||||
* The method performs post processing for each value in the given dataset.
|
||||
* It works with a simple equation written in javascript (example: n + m).
|
||||
* Available variables to use:
|
||||
* * n - current value
|
||||
* * m - previous value (undefined in case of the first element)
|
||||
* * r - entire data array.
|
||||
*
|
||||
* @param data Array of values in the given data set
|
||||
* @param m Metric for the given dataset
|
||||
*/
|
||||
datasetPostProc (data: Array<number|TemporalDataPoint>, m: Metric): Array<number|TemporalDataPoint> {
|
||||
// Define a valid function to evaluate
|
||||
let fxRaw = (m.fx || defaultFx).trim()
|
||||
if (!fxRaw.startsWith('return')) {
|
||||
fxRaw = 'return ' + fxRaw
|
||||
}
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fx = new Function('n', 'm', 'r', fxRaw)
|
||||
|
||||
// Define a new array, so we don't alter the original one.
|
||||
const r = [...data]
|
||||
|
||||
// Run postprocessing for all data in the given data set
|
||||
// There is a slight difference between temporal data points and categorical data points.
|
||||
if (data[0] instanceof Object) {
|
||||
// Temporal
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const a = data[i] as TemporalDataPoint
|
||||
const b = data[i - 1] as TemporalDataPoint|undefined
|
||||
|
||||
const n = a.y
|
||||
let m: number|undefined
|
||||
if (i > 0) {
|
||||
m = b?.y
|
||||
}
|
||||
|
||||
a.y = fx(n, m, r)
|
||||
}
|
||||
} else {
|
||||
// Categorical
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const n = data[i] as number
|
||||
let m: number|undefined
|
||||
if (i > 0) {
|
||||
m = data[i - 1] as number
|
||||
}
|
||||
data[i] = fx(n, m, r)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
merge (c: PartialChart) {
|
||||
let conf = { ...(c.config || {}) }
|
||||
Apply(this, c, CortezaID, 'chartID', 'namespaceID')
|
||||
Apply(this, c, String, 'name', 'handle')
|
||||
Apply(this, c, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
Apply(this, c, Boolean, 'canUpdateChart', 'canDeleteChart', 'canGrant')
|
||||
Apply(this, c, Object, 'config')
|
||||
|
||||
if (typeof c.config === 'object') {
|
||||
// Verify & normalize
|
||||
const { reports = [], ...rest } = c.config
|
||||
|
||||
conf = { reports: reports || [], ...rest }
|
||||
}
|
||||
|
||||
this.config = (conf ? _.merge(this.defConfig(), conf) : false) || this.config || this.defConfig()
|
||||
|
||||
this.config.reports?.forEach(report => {
|
||||
const { dimensions = [], metrics = [] } = report || {}
|
||||
|
||||
report.dimensions = dimensions.map(d => {
|
||||
// Legacy support
|
||||
if (d.modifier === 'auto') {
|
||||
d.timeLabels = true
|
||||
d.modifier = '(no grouping / buckets)'
|
||||
}
|
||||
|
||||
if (d.field === 'created_at') {
|
||||
d.field = 'createdAt'
|
||||
}
|
||||
|
||||
return _.merge(this.defDimension(), d)
|
||||
})
|
||||
|
||||
report.metrics = metrics.map(m => _.merge(this.defMetric(), m))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks reports validity.
|
||||
* Validates dimensions and metrics.
|
||||
* If invalid it throws an error.
|
||||
*/
|
||||
isValid () {
|
||||
if (!this.config.reports || !this.config.reports.length) {
|
||||
throw new Error('notification.chart.invalidConfig.missingReports')
|
||||
}
|
||||
|
||||
this.config.reports.forEach(({ moduleID, dimensions, metrics }) => {
|
||||
if (!moduleID) {
|
||||
throw new Error('notification.chart.invalidConfig.missingModuleID')
|
||||
}
|
||||
|
||||
// Expecting all dimensions to have defined fields
|
||||
dimensions?.forEach(this.dimCheck)
|
||||
|
||||
// Expecting all metrics to have defined fields
|
||||
metrics?.forEach(this.mtrCheck)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks validity of dimensions.
|
||||
* If invalid it throws an error
|
||||
*/
|
||||
dimCheck ({ field, modifier }: Dimension) {
|
||||
if (!field) {
|
||||
throw new Error('notification.chart.invalidConfig.missingDimensionsField')
|
||||
}
|
||||
if (!modifier) {
|
||||
throw new Error('notification.chart.invalidConfig.missingDimensionsModifier')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks validity of metrics.
|
||||
* If invalid it throws an error
|
||||
*/
|
||||
mtrCheck ({ field, aggregate, type }: Metric) {
|
||||
if (!field) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsField')
|
||||
}
|
||||
if (field !== 'count' && !aggregate) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsAggregate')
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsType')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares params that the reporter can use for querying.
|
||||
*/
|
||||
formatReporterParams ({ moduleID, metrics, dimensions, filter }: Report) {
|
||||
return {
|
||||
moduleID,
|
||||
filter,
|
||||
|
||||
// Remove count (we'll get it anyway) and construct FUNC(ARG) params
|
||||
metrics: metrics?.filter((m: Metric) => m.field !== 'count').map((m: Metric) => `${m.aggregate}(${m.field}) AS ${makeAlias(m)}`).join(','),
|
||||
|
||||
// Construct dimensions \w modifiers...
|
||||
dimensions: dimensions?.map(d => ({ field: 'createdAt', ...d })).map((d: Dimension) => dimensionFunctions.convert(d))[0],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher reports defined in the given configuration with the help of the provided
|
||||
* reporter.
|
||||
*/
|
||||
async fetchReports ({ reporter }: { reporter(p: any): Promise<any> }) {
|
||||
const out: Array<any> = []
|
||||
|
||||
// Prepare params & filter out invalid combos (formatReporterParams will return null on invalid params)
|
||||
const reports: any = this.config.reports?.map(this.formatReporterParams)
|
||||
// Send requests to reporter (API caller)
|
||||
.map(params => reporter(params))
|
||||
// Process each result
|
||||
.map((p: any, index: number) => p.then((results: any) => {
|
||||
results = results || []
|
||||
out[index] = this.processReporterResults(results, (this.config.reports || [])[index])
|
||||
}))
|
||||
|
||||
// Wait for all requests to finish and return new promise, with results
|
||||
return Promise.all(reports).then(() => new Promise(resolve => {
|
||||
resolve(out)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes provided report with it's results:
|
||||
* * skip missing values, if so requested,
|
||||
* * generate labels,
|
||||
* * creates dataset for the chart.
|
||||
*/
|
||||
private processReporterResults (results: Array<object> = [], report: Report): object {
|
||||
const dLabel = 'dimension_0'
|
||||
const { dimensions: [dimension] = [] } = report
|
||||
let labels: Array<string> = []
|
||||
|
||||
// helper to choose between eight the provided value, default value or a generic 'undefined'
|
||||
const pickValue = (val: unknown, { default: dDft }: Dimension): unknown => {
|
||||
return val || val === 0 ? val : dDft || 'undefined'
|
||||
}
|
||||
|
||||
// Skip missing values; if so requested
|
||||
if (dimension.skipMissing) {
|
||||
results = results.filter((r: any) => r[dLabel] || r[dLabel] === 0)
|
||||
}
|
||||
|
||||
// Not a time dimensions, build set of labels
|
||||
labels = results.map((r: any) => pickValue(r[dLabel], dimension)) as Array<string>
|
||||
|
||||
// Build data sets
|
||||
const datasets = report.metrics?.map(m => {
|
||||
const alias = makeAlias({ field: m.field, aggregate: m.aggregate })
|
||||
const data = results.map((r: any) => {
|
||||
return pickValue(r[m.field === 'count' ? m.field : alias], dimension)
|
||||
})
|
||||
|
||||
// Any sub class has the ability to define how the dataset looks like.
|
||||
// this comes in handy when we want to support charts with different definitions.
|
||||
return this.makeDataset(m, dimension, data, alias)
|
||||
})
|
||||
|
||||
return {
|
||||
labels: this.processLabels(labels, dimension),
|
||||
datasets,
|
||||
dimension,
|
||||
}
|
||||
}
|
||||
|
||||
processLabels (ll: Array<string>, d: Dimension) {
|
||||
return ll
|
||||
}
|
||||
|
||||
makeDataset (m: Metric, d: Dimension, data: Array<number|any>, alias: string) {
|
||||
throw new Error('method.makeDataset.notImplemented')
|
||||
}
|
||||
|
||||
makeOptions (data?: any) {
|
||||
throw new Error('method.makeOptions.notImplemented')
|
||||
}
|
||||
|
||||
plugins (mm: Array<Metric>) {
|
||||
throw new Error('method.plugins.notImplemented')
|
||||
}
|
||||
|
||||
baseChartType (datasets: Array<any>) {
|
||||
throw new Error('method.baseChartType.notImplemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs chart export; used by exporter feature.
|
||||
*/
|
||||
async export (findModuleByID: ({ namespaceID, moduleID }: { namespaceID: string; moduleID: string }) => Promise<any>) {
|
||||
const { namespaceID } = this
|
||||
const copy = new BaseChart(this)
|
||||
if (copy.config?.reports) {
|
||||
await Promise.all(copy.config.reports.map(async (r: any) => {
|
||||
const { moduleID } = r
|
||||
if (moduleID) {
|
||||
const module = await findModuleByID({ namespaceID, moduleID })
|
||||
r.moduleID = module.name
|
||||
return r
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})).then((a: any) => {
|
||||
return a
|
||||
})
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs import; used by importer feature
|
||||
*/
|
||||
import (getModuleID: (moduleID: string) => string) {
|
||||
const copy = new BaseChart(this)
|
||||
copy.config.reports = copy.config?.reports?.map(r => {
|
||||
const { moduleID } = r
|
||||
if (moduleID) {
|
||||
r.moduleID = getModuleID(moduleID)
|
||||
}
|
||||
return r
|
||||
})
|
||||
return copy
|
||||
}
|
||||
|
||||
defDimension (): Dimension {
|
||||
return Object.assign({}, {
|
||||
conditions: {},
|
||||
meta: {},
|
||||
rotateLabel: 0,
|
||||
})
|
||||
}
|
||||
|
||||
defMetric (): Metric {
|
||||
return Object.assign({}, {
|
||||
formatting: defFormatData(),
|
||||
})
|
||||
}
|
||||
|
||||
defReport (): Report {
|
||||
return Object.assign({}, {
|
||||
moduleID: undefined,
|
||||
filter: '',
|
||||
dimensions: [this.defDimension()],
|
||||
metrics: [this.defMetric()],
|
||||
yAxis: {
|
||||
axisType: 'linear',
|
||||
axisPosition: 'left',
|
||||
labelPosition: 'end',
|
||||
rotateLabel: 0,
|
||||
formatting: defFormatData(),
|
||||
},
|
||||
tooltip: {},
|
||||
legend: {
|
||||
isScrollable: true,
|
||||
orientation: 'horizontal',
|
||||
align: 'center',
|
||||
position: {
|
||||
top: undefined,
|
||||
right: undefined,
|
||||
bottom: undefined,
|
||||
left: undefined,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
offset: {
|
||||
top: '50',
|
||||
right: '30',
|
||||
bottom: '20',
|
||||
left: '30',
|
||||
isDefault: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
defConfig (): ChartConfig {
|
||||
return Object.assign({}, {
|
||||
colorScheme: '',
|
||||
reports: [this.defReport()],
|
||||
noAnimation: false,
|
||||
toolbox: {
|
||||
saveAsImage: false,
|
||||
timeline: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:chart'
|
||||
}
|
||||
|
||||
clone (): BaseChart {
|
||||
return new BaseChart(JSON.parse(JSON.stringify(this)))
|
||||
}
|
||||
}
|
||||
|
||||
export { chartUtil } from './util'
|
||||
374
lib/js/src/compose/types/chart/chart.ts
Normal file
374
lib/js/src/compose/types/chart/chart.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { BaseChart } from './base'
|
||||
import {
|
||||
Dimension,
|
||||
Metric,
|
||||
TemporalDataPoint,
|
||||
formatChartValue,
|
||||
formatChartTooltip,
|
||||
TooltipParams,
|
||||
} from './util'
|
||||
import { getColorschemeColors } from '../../../shared'
|
||||
|
||||
/**
|
||||
* Chart represents a generic chart, such as a bar chart, line chart, ...
|
||||
*/
|
||||
export default class Chart extends BaseChart {
|
||||
// Generic charts (at the moment) support only 1 report per chart
|
||||
async fetchReports (a: any) {
|
||||
return super.fetchReports(a).then((rr: any) => {
|
||||
return rr[0]
|
||||
})
|
||||
}
|
||||
|
||||
makeDataset (m: Metric, d: Dimension, data: Array<number|TemporalDataPoint>, alias: string) {
|
||||
data = this.datasetPostProc(data, m)
|
||||
|
||||
return {
|
||||
type: m.type,
|
||||
label: m.label || m.field,
|
||||
data,
|
||||
fill: m.fill,
|
||||
smooth: m.smooth,
|
||||
step: m.step ? 'middle' : undefined,
|
||||
roseType: m.rose ? 'radius' : undefined,
|
||||
symbol: m.symbol,
|
||||
stack: m.stack,
|
||||
tooltip: {
|
||||
fixed: m.fixTooltips,
|
||||
relative: m.relativeValue && !['bar', 'line'].includes(m.type as string),
|
||||
},
|
||||
formatting: m.formatting,
|
||||
}
|
||||
}
|
||||
|
||||
makeOptions (data: any): any {
|
||||
const { reports = [], colorScheme, noAnimation = false, toolbox } = this.config
|
||||
const { saveAsImage, timeline = '' } = toolbox || {}
|
||||
|
||||
const options: any = {
|
||||
animation: !noAnimation,
|
||||
series: [],
|
||||
xAxis: [],
|
||||
yAxis: [],
|
||||
tooltip: {
|
||||
show: true,
|
||||
appendToBody: true,
|
||||
position: 'inside',
|
||||
},
|
||||
}
|
||||
|
||||
const { labels, datasets = [], themeVariables = {} } = data
|
||||
const {
|
||||
dimensions: [dimension] = [],
|
||||
yAxis,
|
||||
offset,
|
||||
tooltip: t,
|
||||
legend: l,
|
||||
} = reports[0] || {}
|
||||
|
||||
const hasAxis = datasets.some(({ type }: any) => ['bar', 'line', 'scatter'].includes(type))
|
||||
let horizontal = false
|
||||
|
||||
if (hasAxis) {
|
||||
if (yAxis) {
|
||||
const {
|
||||
label: yLabel,
|
||||
axisType: yType = 'linear',
|
||||
axisPosition: position = 'left',
|
||||
labelPosition = 'end',
|
||||
beginAtZero,
|
||||
min,
|
||||
max,
|
||||
} = yAxis
|
||||
|
||||
horizontal = !!yAxis.horizontal
|
||||
|
||||
const xAxis = {
|
||||
nameLocation: 'center',
|
||||
type: dimension.timeLabels ? 'time' : 'category',
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
overflow: 'break',
|
||||
hideOverlap: true,
|
||||
rotate: dimension.rotateLabel,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
}
|
||||
|
||||
const tempYAxis = {
|
||||
name: yLabel,
|
||||
type: yType === 'linear' ? 'value' : 'log',
|
||||
position,
|
||||
nameLocation: labelPosition,
|
||||
min: beginAtZero ? 0 : Number(min) || undefined,
|
||||
max: Number(max) || undefined,
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
overflow: 'break',
|
||||
hideOverlap: true,
|
||||
rotate: yAxis.rotateLabel,
|
||||
formatter: (value: string | number): string => formatChartValue(value, yAxis.formatting),
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
onZero: false,
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: [themeVariables['extra-light']],
|
||||
},
|
||||
},
|
||||
nameTextStyle: {
|
||||
align: labelPosition === 'center' ? 'center' : position,
|
||||
},
|
||||
}
|
||||
|
||||
// If we provide undefined, log scale breaks
|
||||
if (tempYAxis.type === 'log') {
|
||||
delete tempYAxis.min
|
||||
delete tempYAxis.max
|
||||
}
|
||||
|
||||
if (horizontal) {
|
||||
options.xAxis = [tempYAxis]
|
||||
options.yAxis = [xAxis]
|
||||
} else {
|
||||
options.xAxis = [xAxis]
|
||||
options.yAxis = [tempYAxis]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
options.series = datasets.map(({ formatting, type, label, data, stack, tooltip, fill, smooth, step, roseType, symbol }: any, index: number) => {
|
||||
const { fixed, relative } = tooltip
|
||||
|
||||
// We should render the first metric in the dataset as the last
|
||||
const z = (datasets.length - 1) - index
|
||||
|
||||
if (['pie', 'doughnut'].includes(type)) {
|
||||
const startRadius = type === 'doughnut' ? 40 : 0
|
||||
const endRadius = 80
|
||||
const radiusLength = (endRadius - startRadius) / (datasets.length || 1)
|
||||
|
||||
const sr = startRadius + (index * radiusLength)
|
||||
const er = startRadius + ((index + 1) * radiusLength)
|
||||
|
||||
options.tooltip.trigger = 'item'
|
||||
|
||||
let lbl :any = {
|
||||
rotate: dimension.rotateLabel ? +dimension.rotateLabel : 0,
|
||||
}
|
||||
|
||||
if (t?.labelsNextToPartition) {
|
||||
lbl = {
|
||||
...lbl,
|
||||
show: true,
|
||||
overflow: 'truncate',
|
||||
}
|
||||
} else {
|
||||
lbl = {
|
||||
...lbl,
|
||||
show: fixed,
|
||||
position: 'inside',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
z,
|
||||
stack,
|
||||
name: label,
|
||||
type: 'pie',
|
||||
roseType,
|
||||
radius: [`${sr}%`, `${er}%`],
|
||||
center: ['50%', '55%'],
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
appendToBody: true,
|
||||
formatter: (params: TooltipParams): string => {
|
||||
const v = formatChartValue(params.value || '', formatting)
|
||||
|
||||
if (t?.formatting) {
|
||||
return formatChartTooltip(t?.formatting, params)
|
||||
}
|
||||
|
||||
return `${params.seriesName}<br>${params.marker}${params.name}<span style="float: right; margin-left: 20px">${v}${relative ? ' (' + params.percent + '%)' : ''}</span>`
|
||||
},
|
||||
},
|
||||
label: {
|
||||
...lbl,
|
||||
formatter: (params: TooltipParams): string => formatChartValue(params.value || '', formatting),
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 5,
|
||||
borderColor: themeVariables.white,
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
data: labels.map((name: string, i: number) => {
|
||||
return { name, value: data[i] }
|
||||
}),
|
||||
top: offset?.isDefault ? undefined : offset?.top,
|
||||
right: offset?.isDefault ? undefined : offset?.right,
|
||||
bottom: offset?.isDefault ? undefined : offset?.bottom,
|
||||
left: offset?.isDefault ? undefined : offset?.left,
|
||||
}
|
||||
} else if (['bar', 'line', 'scatter'].includes(type)) {
|
||||
options.tooltip.trigger = 'axis'
|
||||
|
||||
const defaultOffset = {
|
||||
top: 65,
|
||||
right: timeline.includes('x') ? 40 : 30,
|
||||
bottom: timeline.includes('x') ? 60 : 20,
|
||||
left: 30,
|
||||
}
|
||||
|
||||
options.grid = {
|
||||
top: offset?.isDefault ? defaultOffset.top : offset?.top,
|
||||
right: offset?.isDefault ? defaultOffset.right : offset?.right,
|
||||
bottom: offset?.isDefault ? defaultOffset.bottom : offset?.bottom,
|
||||
left: offset?.isDefault ? defaultOffset.left : offset?.left,
|
||||
containLabel: true,
|
||||
}
|
||||
|
||||
if (horizontal) {
|
||||
data = labels.map((name: string, i: number) => {
|
||||
return [data[i], name]
|
||||
})
|
||||
} else {
|
||||
data = labels.map((name: string, i: number) => {
|
||||
return [name, data[i]]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
z,
|
||||
stack,
|
||||
name: label,
|
||||
type: type,
|
||||
smooth,
|
||||
step,
|
||||
areaStyle: {
|
||||
opacity: fill ? 0.7 : 0,
|
||||
},
|
||||
symbol,
|
||||
symbolSize: type === 'scatter' ? 16 : 10,
|
||||
tooltip: {
|
||||
appendToBody: true,
|
||||
// pass trigger type to determine if valueFormatter or formatter will be used
|
||||
trigger: t?.formatting ? 'item' : 'axis',
|
||||
// we can either
|
||||
// add formatting to the value and apply tooltip if trigger: 'item'
|
||||
// display the same tooltip format name <br/> seriesName value if trigger: 'axis'
|
||||
|
||||
// works when trigger is set to axis
|
||||
valueFormatter: (value: string | number): string => formatChartValue(value, formatting),
|
||||
// works when trigger is set to item
|
||||
formatter: (params: { seriesName?: string; name?: string;value: Array<any>, percent: string | number, marker: string;}): string => {
|
||||
const { value = [], percent = '' } = params
|
||||
|
||||
const formattedValue = formatChartValue(value[1], formatting)
|
||||
|
||||
if (t?.formatting) {
|
||||
return formatChartTooltip(t?.formatting, { ...params, value: value[1], percent })
|
||||
}
|
||||
|
||||
return `${params.seriesName}<br>${params.marker}${params.name}<span style="float: right; margin-left: 20px">${formattedValue}${relative ? ' (' + params.percent + '%)' : ''}</span>`
|
||||
},
|
||||
},
|
||||
label: {
|
||||
show: fixed,
|
||||
position: 'inside',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
formatter: (params: { seriesName: string, name: string, value: Array<any>, percent: string | number }): string => {
|
||||
const { value = [], percent = '' } = params
|
||||
|
||||
return `${formatChartValue(value[1], formatting)}${relative ? ` (${percent}%)` : ''}`
|
||||
},
|
||||
},
|
||||
data,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dataZoom = timeline ? [
|
||||
{
|
||||
show: timeline.includes('x'),
|
||||
type: 'slider',
|
||||
height: 30,
|
||||
},
|
||||
{
|
||||
show: timeline.includes('y'),
|
||||
type: 'slider',
|
||||
width: 15,
|
||||
yAxisIndex: 0,
|
||||
},
|
||||
] : undefined
|
||||
|
||||
return {
|
||||
color: getColorschemeColors(colorScheme, data.customColorSchemes),
|
||||
textStyle: {
|
||||
fontFamily: themeVariables['font-regular'],
|
||||
overflow: 'break',
|
||||
color: themeVariables.black,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: saveAsImage ? {
|
||||
name: this.name,
|
||||
} : undefined,
|
||||
},
|
||||
top: 23,
|
||||
right: 2,
|
||||
},
|
||||
dataZoom,
|
||||
legend: {
|
||||
show: !l?.isHidden,
|
||||
type: l?.isScrollable ? 'scroll' : 'plain',
|
||||
top: (l?.position?.isDefault ? undefined : l?.position?.top) || undefined,
|
||||
right: (l?.position?.isDefault ? undefined : l?.position?.right) || undefined,
|
||||
bottom: (l?.position?.isDefault ? undefined : l?.position?.bottom) || undefined,
|
||||
left: (l?.position?.isDefault ? l?.align || 'center' : l?.position?.left) || 'auto',
|
||||
orient: l?.orientation || 'horizontal',
|
||||
textStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
pageTextStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
pageIconColor: themeVariables.black,
|
||||
pageIconInactiveColor: themeVariables.light,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
defMetric (): Metric {
|
||||
return Object.assign(super.defMetric(), {
|
||||
smooth: true,
|
||||
fill: false,
|
||||
rose: false,
|
||||
symbol: 'circle',
|
||||
})
|
||||
}
|
||||
|
||||
baseChartType (datasets: Array<any>): string {
|
||||
return datasets[0].type
|
||||
}
|
||||
}
|
||||
1
lib/js/src/compose/types/chart/common.ts
Normal file
1
lib/js/src/compose/types/chart/common.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const defaultBGColor = 'rgba(165, 165, 165, 1)'
|
||||
299
lib/js/src/compose/types/chart/funnel.ts
Normal file
299
lib/js/src/compose/types/chart/funnel.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { BaseChart, PartialChart } from './base'
|
||||
import {
|
||||
Dimension,
|
||||
Metric,
|
||||
Report,
|
||||
ChartType,
|
||||
formatChartValue,
|
||||
formatChartTooltip,
|
||||
TooltipParams,
|
||||
} from './util'
|
||||
import { getColorschemeColors } from '../../../shared'
|
||||
|
||||
export default class FunnelChart extends BaseChart {
|
||||
constructor (def: PartialChart = {}) {
|
||||
super(def)
|
||||
|
||||
// Assure required fields; this helps with backwards compatibility
|
||||
for (const v of (this.config.reports || []) as Array<Report>) {
|
||||
for (const d of (v.dimensions || []) as Array<Dimension>) {
|
||||
if (!d.meta) {
|
||||
d.meta = {}
|
||||
}
|
||||
|
||||
if (!d.meta.fields) {
|
||||
d.meta.fields = []
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of (v.metrics || []) as Array<Metric>) {
|
||||
if (m.cumulative === undefined) {
|
||||
m.cumulative = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since funnel charts always define one type, this check can be simplified
|
||||
*/
|
||||
mtrCheck ({ field, aggregate }: Metric) {
|
||||
if (!field) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsField')
|
||||
}
|
||||
if (field !== 'count' && !aggregate) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsAggregate')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend this method to include filtering for just specific values.
|
||||
* For example:
|
||||
* We wish to show only new and converted leads.
|
||||
*/
|
||||
formatReporterParams (r: Report) {
|
||||
const base = super.formatReporterParams(r)
|
||||
const ff = base.filter
|
||||
|
||||
let df = ''
|
||||
if (r.dimensions && r.dimensions[0]) {
|
||||
const rd = r.dimensions[0]
|
||||
if (r.dimensions[0].meta) {
|
||||
const fields = r.dimensions[0].meta.fields || []
|
||||
df = fields.map(({ value }: any) => `${rd.field || ''}='${value}'`)
|
||||
.join(' OR ')
|
||||
}
|
||||
}
|
||||
|
||||
if (ff && df) {
|
||||
base.filter = `(${base.filter}) AND (${df})`
|
||||
} else if (!ff && df) {
|
||||
base.filter = df
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
// Funnel chart creates a metric including all reports, so this step is deferred to there
|
||||
makeDataset (m: Metric, d: Dimension, data: Array<number|any>, alias: string) {
|
||||
return {
|
||||
type: m.type,
|
||||
label: m.label || m.field,
|
||||
data,
|
||||
tooltip: {
|
||||
fixed: !!m.fixTooltips,
|
||||
relative: !!m.relativeValue,
|
||||
},
|
||||
formatting: m.formatting,
|
||||
}
|
||||
}
|
||||
|
||||
makeOptions (data: any) {
|
||||
const { reports = [], colorScheme, noAnimation = false, toolbox } = this.config
|
||||
const { saveAsImage } = toolbox || {}
|
||||
|
||||
const { labels, datasets = [], tooltip, themeVariables = {} } = data
|
||||
const { fixed, relative } = tooltip
|
||||
|
||||
const { legend: l } = reports[0] || {}
|
||||
const { formatting } = datasets[0] || {}
|
||||
|
||||
const colors = getColorschemeColors(colorScheme, data.customColorSchemes)
|
||||
|
||||
return {
|
||||
animation: !noAnimation,
|
||||
textStyle: {
|
||||
fontFamily: themeVariables['font-regular'],
|
||||
overflow: 'break',
|
||||
color: themeVariables.black,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: saveAsImage ? {
|
||||
name: this.name,
|
||||
} : undefined,
|
||||
},
|
||||
top: 23,
|
||||
right: 2,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: TooltipParams): string => {
|
||||
const { value = '', percent = '' } = params
|
||||
|
||||
const v = formatChartValue(value, formatting)
|
||||
|
||||
return `${params.seriesName}<br>${params.marker}${params.name}<span style="float: right; margin-left: 20px">${v}${relative ? ' (' + percent + '%)' : ''}</span>`
|
||||
},
|
||||
appendToBody: true,
|
||||
},
|
||||
legend: {
|
||||
show: !l?.isHidden,
|
||||
type: l?.isScrollable ? 'scroll' : 'plain',
|
||||
top: (l?.position?.isDefault ? undefined : l?.position?.top) || undefined,
|
||||
right: (l?.position?.isDefault ? undefined : l?.position?.right) || undefined,
|
||||
bottom: (l?.position?.isDefault ? undefined : l?.position?.bottom) || undefined,
|
||||
left: (l?.position?.isDefault ? l?.align || 'center' : l?.position?.left) || 'auto',
|
||||
orient: l?.orientation || 'horizontal',
|
||||
textStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
pageTextStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
pageIconColor: themeVariables.black,
|
||||
pageIconInactiveColor: themeVariables.light,
|
||||
},
|
||||
series: datasets.map(({ data, label, formatting }: any) => {
|
||||
return {
|
||||
name: label,
|
||||
type: 'funnel',
|
||||
sort: 'descending',
|
||||
top: 45,
|
||||
bottom: 10,
|
||||
left: '5%',
|
||||
width: '90%',
|
||||
label: {
|
||||
show: fixed,
|
||||
position: 'inside',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
formatter: (params: TooltipParams): string => {
|
||||
const { value = '', percent = '' } = params
|
||||
const formattedValue = formatChartValue(value, formatting)
|
||||
|
||||
return `${formattedValue}${relative ? ' (' + percent + '%)' : ''}`
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: fixed,
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
data: labels.map((name: string, i: number) => {
|
||||
return { name, value: data[i], itemStyle: { color: colors[i] } }
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
baseChartType (): string {
|
||||
return 'funnel'
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes a few additional post processing steps:
|
||||
* * generate a set of labels based on all reports, all data sets,
|
||||
* * generates a set of data based on all reports, all data sets,
|
||||
*/
|
||||
async fetchReports (a: any) {
|
||||
const rr = await super.fetchReports(a) as any
|
||||
const values = []
|
||||
|
||||
let tooltip = {}
|
||||
let label = ''
|
||||
let formatting = {}
|
||||
|
||||
// Above provided data sets might not have their labels/values ordered
|
||||
// correctly
|
||||
const valMap: any = {}
|
||||
// Map values to their labels
|
||||
for (let ri = 0; ri < rr.length; ri++) {
|
||||
const r = rr[ri]
|
||||
|
||||
r.labels.forEach((l: string, i: number) => {
|
||||
valMap[l] = r.datasets[0].data[i]
|
||||
})
|
||||
|
||||
tooltip = { ...tooltip, ...r.datasets[0].tooltip }
|
||||
label = r.datasets[0].label
|
||||
formatting = r.datasets[0].formatting
|
||||
|
||||
// Construct labels & data based on provided reports
|
||||
const report = this.config.reports?.[ri]
|
||||
const d = report?.dimensions?.[0] as Dimension
|
||||
|
||||
let { fields = [] } = d.meta || {}
|
||||
fields = fields.length ? fields : r.labels
|
||||
|
||||
for (const label of fields) {
|
||||
const value = typeof label === 'object' ? label.value : label
|
||||
values.push({
|
||||
// Use value for label and resolve it on FE (i18n)
|
||||
label: value,
|
||||
data: valMap[value] || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// We are rendering the chart upside down
|
||||
// (by default it renders in ASC, but we want DESC)
|
||||
const labels: any[] = []
|
||||
const data: any[] = []
|
||||
|
||||
values.sort((a, b) => a.data - b.data).forEach(v => {
|
||||
labels.push(v.label)
|
||||
data.push(v.data)
|
||||
})
|
||||
|
||||
// Determine color to render for specific value
|
||||
const colorMap: { [_: string]: string } = {}
|
||||
this.config.reports?.forEach(r => {
|
||||
const dimension = r.dimensions?.[0]
|
||||
if (dimension?.meta?.fields) {
|
||||
for (const { value, color } of dimension.meta.fields) {
|
||||
colorMap[value] = color
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Get cumulative data but also keep original for tooltips
|
||||
if (this.isCumulative()) {
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
data[i] += data[i - 1]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [{
|
||||
label,
|
||||
data,
|
||||
formatting,
|
||||
}],
|
||||
tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
isCumulative (): boolean {
|
||||
// Cumulative true by default
|
||||
// Find false value
|
||||
let cumulative = true
|
||||
const { reports = [] } = this.config
|
||||
|
||||
reports.forEach(({ metrics = [] }) => {
|
||||
if (cumulative && !metrics[0].cumulative) {
|
||||
cumulative = false
|
||||
}
|
||||
})
|
||||
|
||||
return cumulative
|
||||
}
|
||||
|
||||
defMetric (): Metric {
|
||||
return Object.assign(super.defMetric(), {
|
||||
type: ChartType.funnel,
|
||||
fixTooltips: false,
|
||||
relativeValue: true,
|
||||
})
|
||||
}
|
||||
|
||||
defDimension (): Dimension {
|
||||
return Object.assign({}, {
|
||||
conditions: {},
|
||||
meta: { fields: [] },
|
||||
})
|
||||
}
|
||||
}
|
||||
214
lib/js/src/compose/types/chart/gauge.ts
Normal file
214
lib/js/src/compose/types/chart/gauge.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { BaseChart, PartialChart } from './base'
|
||||
import {
|
||||
Metric,
|
||||
Report,
|
||||
Dimension,
|
||||
ChartType,
|
||||
TemporalDataPoint,
|
||||
formatChartValue,
|
||||
} from './util'
|
||||
import { getColorschemeColors } from '../../../shared'
|
||||
|
||||
export default class GaugeChart extends BaseChart {
|
||||
constructor (def: PartialChart = {}) {
|
||||
super(def)
|
||||
|
||||
// Assure required fields
|
||||
for (const v of (this.config.reports || []) as Array<Report>) {
|
||||
for (const d of (v.dimensions || []) as Array<Dimension>) {
|
||||
// Since gauge produces one value we want one dataset, deletedAt is the same for all existing records
|
||||
d.field = 'deletedAt'
|
||||
|
||||
if (!d.meta) {
|
||||
d.meta = {}
|
||||
}
|
||||
|
||||
if (!d.meta.steps) {
|
||||
d.meta.steps = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gauge charts (at the moment) support only 1 report per chart
|
||||
async fetchReports (a: any) {
|
||||
return super.fetchReports(a).then((rr: any) => {
|
||||
return rr[0]
|
||||
})
|
||||
}
|
||||
|
||||
processLabels (ll: Array<string>, d: Dimension) {
|
||||
return (d.meta?.steps || []).map(({ label }: any) => label)
|
||||
}
|
||||
|
||||
makeDataset (m: Metric, d: Dimension, data: Array<number|TemporalDataPoint>, alias: string) {
|
||||
const steps = (d.meta?.steps || [])
|
||||
|
||||
data = this.datasetPostProc(data, m)
|
||||
|
||||
const value = data.reduce((acc: any, cur: any) => {
|
||||
return !isNaN(cur) ? acc + parseFloat(cur) : acc
|
||||
}, 0).toFixed(3)
|
||||
|
||||
const max = Math.max(...steps.map(({ value }: any) => parseFloat(value)))
|
||||
|
||||
const sortedSteps = [...steps].sort((a: any, b: any) => {
|
||||
return parseFloat(b.value) - parseFloat(a.value)
|
||||
})
|
||||
|
||||
const { label: name } = sortedSteps.reduce((acc: any, cur: any) => {
|
||||
const curValue = parseFloat(cur.value)
|
||||
return value < curValue ? cur : acc
|
||||
}, sortedSteps[0] || {})
|
||||
|
||||
return {
|
||||
steps,
|
||||
name,
|
||||
max,
|
||||
value,
|
||||
startAngle: m.startAngle,
|
||||
endAngle: m.endAngle,
|
||||
tooltip: {
|
||||
fixed: m.fixTooltips,
|
||||
},
|
||||
formatting: m.formatting,
|
||||
}
|
||||
}
|
||||
|
||||
makeOptions (data: any) {
|
||||
const { reports = [], colorScheme, noAnimation = false, toolbox } = this.config
|
||||
const { saveAsImage } = toolbox || {}
|
||||
const { datasets = [], themeVariables = {} } = data
|
||||
const {
|
||||
steps = [],
|
||||
name,
|
||||
value,
|
||||
max,
|
||||
tooltip,
|
||||
startAngle,
|
||||
endAngle,
|
||||
formatting,
|
||||
} = datasets.find(({ value }: any) => value) || datasets[0]
|
||||
|
||||
const colors = getColorschemeColors(colorScheme, data.customColorSchemes)
|
||||
|
||||
const color = steps.map((s: any, i: number) => {
|
||||
return [s.value / max, colors[i]]
|
||||
})
|
||||
|
||||
return {
|
||||
animation: !noAnimation,
|
||||
textStyle: {
|
||||
fontFamily: themeVariables['font-regular'],
|
||||
overflow: 'break',
|
||||
color: themeVariables.black,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: saveAsImage ? {
|
||||
name: this.name,
|
||||
} : undefined,
|
||||
},
|
||||
top: 23,
|
||||
right: 2,
|
||||
},
|
||||
grid: {
|
||||
bottom: 0,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'gauge',
|
||||
startAngle,
|
||||
endAngle,
|
||||
min: 0,
|
||||
max,
|
||||
splitNumber: 5,
|
||||
radius: '100%',
|
||||
center: ['50%', '50%'],
|
||||
pointer: {
|
||||
width: 5,
|
||||
length: '75%',
|
||||
itemStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
},
|
||||
splitLine: {
|
||||
distance: 0,
|
||||
length: 0,
|
||||
lineStyle: {
|
||||
color: themeVariables.white,
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 30,
|
||||
color,
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
distance: -30,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
distance: 60,
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
show: tooltip.fixed,
|
||||
offsetCenter: [0, '30%'],
|
||||
color: themeVariables.black,
|
||||
},
|
||||
detail: {
|
||||
fontSize: 13,
|
||||
offsetCenter: [0, '55%'],
|
||||
valueAnimation: true,
|
||||
color: themeVariables.black,
|
||||
formatter: (value: string | number): string => formatChartValue(value, formatting),
|
||||
},
|
||||
data: [
|
||||
{
|
||||
name,
|
||||
value,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
baseChartType (): string {
|
||||
return 'gauge'
|
||||
}
|
||||
|
||||
defMetric (): Metric {
|
||||
return Object.assign(super.defMetric(), {
|
||||
type: ChartType.gauge,
|
||||
fixTooltips: true,
|
||||
startAngle: 200,
|
||||
endAngle: -20,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks validity of dimensions.
|
||||
* If invalid it throws an error
|
||||
*/
|
||||
dimCheck ({ meta }: Dimension): void | Error {
|
||||
if ((meta?.steps || []).length === 0) {
|
||||
throw new Error('notification.chart.invalidConfig.missingDimensionsSteps')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since gauge charts always define one type, this check can be simplified
|
||||
*/
|
||||
mtrCheck ({ field, aggregate }: Metric): void | Error {
|
||||
if (!field) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsField')
|
||||
}
|
||||
if (field !== 'count' && !aggregate) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsAggregate')
|
||||
}
|
||||
}
|
||||
}
|
||||
5
lib/js/src/compose/types/chart/index.ts
Normal file
5
lib/js/src/compose/types/chart/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { chartUtil } from './util'
|
||||
export { default as Chart } from './chart'
|
||||
export { default as FunnelChart } from './funnel'
|
||||
export { default as GaugeChart } from './gauge'
|
||||
export { default as RadarChart } from './radar'
|
||||
139
lib/js/src/compose/types/chart/radar.ts
Normal file
139
lib/js/src/compose/types/chart/radar.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { BaseChart } from './base'
|
||||
import {
|
||||
Dimension,
|
||||
Metric,
|
||||
ChartType,
|
||||
formatChartValue,
|
||||
TooltipParams,
|
||||
} from './util'
|
||||
import { getColorschemeColors } from '../../../shared'
|
||||
|
||||
export default class RadarChart extends BaseChart {
|
||||
mtrCheck ({ field, aggregate }: Metric) {
|
||||
if (!field) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsField')
|
||||
}
|
||||
if (field !== 'count' && !aggregate) {
|
||||
throw new Error('notification.chart.invalidConfig.missingMetricsAggregate')
|
||||
}
|
||||
}
|
||||
|
||||
makeDataset (m: Metric, d: Dimension, data: Array<number|any>) {
|
||||
return {
|
||||
type: m.type,
|
||||
label: m.label || m.field,
|
||||
data,
|
||||
formatting: m.formatting,
|
||||
}
|
||||
}
|
||||
|
||||
makeOptions (data: any) {
|
||||
const { reports = [], colorScheme, noAnimation = false, toolbox } = this.config
|
||||
const { saveAsImage } = toolbox || {}
|
||||
const { labels, datasets = [], dimension = {}, themeVariables = {} } = data
|
||||
|
||||
const {
|
||||
legend: l,
|
||||
} = reports[0] || {}
|
||||
|
||||
const { formatting } = datasets[0] || {}
|
||||
|
||||
let min: number = 0
|
||||
let max: number = Math.max()
|
||||
const seriesData: any[] = []
|
||||
|
||||
datasets.forEach(({ data: value, label: name }: any) => {
|
||||
value.forEach((v: number) => {
|
||||
if (v < min) min = v
|
||||
if (v > max) max = v
|
||||
})
|
||||
|
||||
seriesData.push({ value, name })
|
||||
})
|
||||
|
||||
return {
|
||||
color: getColorschemeColors(colorScheme, data.customColorSchemes),
|
||||
animation: !noAnimation,
|
||||
textStyle: {
|
||||
fontFamily: themeVariables['font-regular'],
|
||||
overflow: 'break',
|
||||
color: themeVariables.black,
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: saveAsImage ? {
|
||||
name: this.name,
|
||||
} : undefined,
|
||||
},
|
||||
top: 23,
|
||||
right: 2,
|
||||
},
|
||||
legend: {
|
||||
show: !l?.isHidden,
|
||||
type: l?.isScrollable ? 'scroll' : 'plain',
|
||||
top: (l?.position?.isDefault ? undefined : l?.position?.top) || undefined,
|
||||
right: (l?.position?.isDefault ? undefined : l?.position?.right) || undefined,
|
||||
bottom: (l?.position?.isDefault ? undefined : l?.position?.bottom) || undefined,
|
||||
left: (l?.position?.isDefault ? l?.align || 'center' : l?.position?.left) || 'auto',
|
||||
orient: l?.orientation || 'horizontal',
|
||||
textStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
pageTextStyle: {
|
||||
color: themeVariables.black,
|
||||
},
|
||||
pageIconColor: themeVariables.black,
|
||||
pageIconInactiveColor: themeVariables.light,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
appendToBody: true,
|
||||
valueFormatter: (value: string | number): string => formatChartValue(value, formatting),
|
||||
},
|
||||
radar: {
|
||||
shape: dimension.shape,
|
||||
indicator: labels.map((name: string) => {
|
||||
return { name, min, max }
|
||||
}),
|
||||
center: ['50%', '55%'],
|
||||
},
|
||||
series: {
|
||||
type: 'radar',
|
||||
label: {
|
||||
show: dimension.fixTooltips,
|
||||
formatter: (params: { value: string | number }): string => {
|
||||
const { value = '' } = params
|
||||
return formatChartValue(value, formatting)
|
||||
},
|
||||
},
|
||||
data: seriesData,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
baseChartType (): string {
|
||||
return 'radar'
|
||||
}
|
||||
|
||||
async fetchReports (a: any) {
|
||||
return super.fetchReports(a).then((rr: any) => {
|
||||
return rr[0]
|
||||
})
|
||||
}
|
||||
|
||||
defMetric (): Metric {
|
||||
return Object.assign(super.defMetric(), {
|
||||
type: ChartType.radar,
|
||||
})
|
||||
}
|
||||
|
||||
defDimension (): Dimension {
|
||||
return Object.assign({}, {
|
||||
shape: 'polygon',
|
||||
fixTooltips: false,
|
||||
conditions: {},
|
||||
meta: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
326
lib/js/src/compose/types/chart/util.ts
Normal file
326
lib/js/src/compose/types/chart/util.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import numeral from 'numeral'
|
||||
import * as fmt from '../../../formatting'
|
||||
|
||||
export const rgbaRegex = /^rgba\((\d+),.*?(\d+),.*?(\d+),.*?(\d*\.?\d*)\)$/
|
||||
|
||||
const ln = (n: number) => Math.round(n < 0 ? 255 + n : (n > 255) ? n - 255 : n)
|
||||
export const toRGBA = ([r, g, b, a]: Array<number>) =>
|
||||
`rgba(${ln(r)}, ${ln(g)}, ${ln(b)}, ${a})`
|
||||
|
||||
export enum ChartType {
|
||||
pie = 'pie',
|
||||
bar = 'bar',
|
||||
line = 'line',
|
||||
doughnut = 'doughnut',
|
||||
funnel = 'funnel',
|
||||
gauge = 'gauge',
|
||||
radar = 'radar',
|
||||
scatter = 'scatter',
|
||||
}
|
||||
|
||||
export interface TemporalDataPoint {
|
||||
t: Date;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface KV {
|
||||
[_: string]: any;
|
||||
}
|
||||
|
||||
export interface FormatData {
|
||||
format?: string,
|
||||
prefix?: string,
|
||||
suffix?: string,
|
||||
presetFormat?: string,
|
||||
}
|
||||
|
||||
export interface Tooltip {
|
||||
formatting?: string;
|
||||
labelsNextToPartition?: boolean;
|
||||
}
|
||||
|
||||
export interface TooltipParams {
|
||||
seriesName?: string;
|
||||
name?: string;
|
||||
value?: string | number;
|
||||
percent?: string | number;
|
||||
marker?: string;
|
||||
}
|
||||
|
||||
export interface Dimension {
|
||||
meta?: KV;
|
||||
conditions: object;
|
||||
field?: string;
|
||||
modifier?: string;
|
||||
default?: string;
|
||||
skipMissing?: boolean;
|
||||
timeLabels?: boolean;
|
||||
autoSkip?: boolean;
|
||||
rotateLabel?: number;
|
||||
}
|
||||
|
||||
export interface Metric {
|
||||
axisType?: string;
|
||||
field?: string;
|
||||
fixTooltips?: boolean;
|
||||
relativeValue?: boolean;
|
||||
cumulative?: boolean;
|
||||
type?: ChartType;
|
||||
alias?: string;
|
||||
aggregate?: string;
|
||||
modifier?: string;
|
||||
fx?: string;
|
||||
backgroundColor?: string;
|
||||
symbol?: string;
|
||||
formatting: FormatData;
|
||||
[_: string]: any;
|
||||
}
|
||||
|
||||
export interface YAxis {
|
||||
axisPosition?: string;
|
||||
axisType?: string;
|
||||
beginAtZero?: boolean;
|
||||
label?: string;
|
||||
labelPosition?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
rotateLabel?: number;
|
||||
horizontal?: boolean;
|
||||
formatting: FormatData;
|
||||
}
|
||||
|
||||
export interface ChartOffset {
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
isDefault?: boolean;
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
}
|
||||
|
||||
export interface Legend {
|
||||
isHidden?: boolean;
|
||||
orientation?: string;
|
||||
align?: string;
|
||||
isScrollable?: boolean;
|
||||
isDefault?: boolean;
|
||||
position?: Position;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
moduleID?: string|null;
|
||||
filter?: string|null;
|
||||
dimensions?: Array<Dimension>;
|
||||
metrics?: Array<Metric>;
|
||||
yAxis?: YAxis;
|
||||
tooltip?: Tooltip;
|
||||
legend?: Legend;
|
||||
offset?: ChartOffset;
|
||||
}
|
||||
|
||||
export interface ChartToolbox {
|
||||
saveAsImage: boolean;
|
||||
timeline: string;
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
reports?: Array<Report>;
|
||||
colorScheme?: string;
|
||||
noAnimation?: boolean;
|
||||
toolbox?: ChartToolbox;
|
||||
}
|
||||
|
||||
export const aggregateFunctions = [
|
||||
{
|
||||
value: 'SUM',
|
||||
text: 'sum',
|
||||
},
|
||||
{
|
||||
value: 'MAX',
|
||||
text: 'max',
|
||||
},
|
||||
{
|
||||
value: 'MIN',
|
||||
text: 'min',
|
||||
},
|
||||
{
|
||||
value: 'AVG',
|
||||
text: 'avg',
|
||||
},
|
||||
{
|
||||
value: 'STD',
|
||||
text: 'std',
|
||||
},
|
||||
]
|
||||
|
||||
interface DimensionFunction {
|
||||
text: string;
|
||||
value: string;
|
||||
convert: (f: string) => string;
|
||||
}
|
||||
|
||||
export class DimensionFunctions<T> extends Array<T> {
|
||||
private constructor (items?: Array<T>) {
|
||||
super(...(items || []))
|
||||
}
|
||||
|
||||
static create<T> (): DimensionFunctions<T> {
|
||||
return Object.create(DimensionFunctions.prototype)
|
||||
}
|
||||
|
||||
public lookup (d: any): any {
|
||||
return this.find((f: any) => d.modifier === f.value)
|
||||
}
|
||||
|
||||
public convert (d: any): any {
|
||||
return (this.lookup(d) || {}).convert(d.field)
|
||||
}
|
||||
}
|
||||
|
||||
export const dimensionFunctions: DimensionFunctions<DimensionFunction> = DimensionFunctions.create<DimensionFunction>()
|
||||
dimensionFunctions.push(...[
|
||||
{
|
||||
text: 'none',
|
||||
value: '(no grouping / buckets)',
|
||||
convert: (f: string) => f,
|
||||
},
|
||||
|
||||
{
|
||||
text: 'date',
|
||||
value: 'DATE',
|
||||
convert: (f: string) => `DATE(${f})`,
|
||||
},
|
||||
|
||||
{
|
||||
text: 'week',
|
||||
value: 'WEEK',
|
||||
convert: (f: string) => `WEEK(${f})`,
|
||||
},
|
||||
|
||||
{
|
||||
text: 'month',
|
||||
value: 'MONTH',
|
||||
convert: (f: string) => `DATE_FORMAT(${f}, '%Y-%m-01')`,
|
||||
},
|
||||
|
||||
{
|
||||
text: 'quarter',
|
||||
value: 'QUARTER',
|
||||
convert: (f: string) => `QUARTER(${f})`,
|
||||
},
|
||||
|
||||
{
|
||||
text: 'year',
|
||||
value: 'YEAR',
|
||||
convert: (f: string) => `DATE_FORMAT(${f}, '%Y-01-01')`,
|
||||
},
|
||||
])
|
||||
|
||||
export const predefinedFilters = [
|
||||
{
|
||||
value: 'YEAR(createdAt) = YEAR(NOW())',
|
||||
text: 'recordsCreatedThisYear',
|
||||
},
|
||||
{
|
||||
value: 'YEAR(createdAt) = YEAR(NOW()) - 1',
|
||||
text: 'recordsCreatedLastYear',
|
||||
},
|
||||
|
||||
{
|
||||
value: 'YEAR(createdAt) = YEAR(NOW()) AND QUARTER(createdAt) = QUARTER(NOW())',
|
||||
text: 'recordsCreatedThisQuarter',
|
||||
},
|
||||
{
|
||||
value: 'YEAR(createdAt) = YEAR(NOW()) AND QUARTER(createdAt) = QUARTER(DATE_SUB(NOW(), INTERVAL 3 MONTH)',
|
||||
text: 'recordsCreatedLastQuarter',
|
||||
},
|
||||
|
||||
{
|
||||
value: 'DATE_FORMAT(createdAt, \'%Y-%m\') = DATE_FORMAT(NOW(), \'%Y-%m\')',
|
||||
text: 'recordsCreatedThisMonth',
|
||||
},
|
||||
{
|
||||
value: 'DATE_FORMAT(createdAt, \'%Y-%m\') = DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), \'%Y-%m\')',
|
||||
text: 'recordsCreatedLastMonth',
|
||||
},
|
||||
]
|
||||
|
||||
dimensionFunctions.lookup = d => dimensionFunctions.find(f => d.modifier === f.value) || dimensionFunctions[0]
|
||||
dimensionFunctions.convert = d => dimensionFunctions.lookup(d).convert(d.field)
|
||||
|
||||
export const isRadialChart = ({ type }: KV) => type === 'doughnut' || type === 'pie'
|
||||
export const hasRelativeDisplay = ({ type }: KV) => isRadialChart({ type })
|
||||
|
||||
// Makes a standardized alias from modifier or dimension report option
|
||||
export const makeAlias = ({ alias, aggregate, modifier, field }: Partial<Metric>) => alias || `${aggregate || modifier || 'none'}_${field}`.toLocaleLowerCase()
|
||||
|
||||
export function formatChartValue (value: string | number, formatting?: FormatData): string {
|
||||
let n: number | string = 0 || ''
|
||||
// if value contains alphabetic chars parseFloat() will return NaN
|
||||
// and n will equal 0
|
||||
const containsAlphabeticChars = isNaN(Number(value))
|
||||
let result = ''
|
||||
|
||||
if (!containsAlphabeticChars) {
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
n = parseFloat(value)
|
||||
break
|
||||
case 'number':
|
||||
n = value
|
||||
break
|
||||
default:
|
||||
n = 0
|
||||
}
|
||||
|
||||
if (formatting?.format) {
|
||||
result = numeral(n).format(formatting.format)
|
||||
} else {
|
||||
result = fmt.number(n)
|
||||
}
|
||||
}
|
||||
|
||||
if (formatting?.presetFormat === 'accounting') {
|
||||
result = fmt.accountingNumber(Number(n))
|
||||
}
|
||||
|
||||
return ` ${formatting?.prefix ?? ''} ${result || value} ${formatting?.suffix ?? ''}`
|
||||
}
|
||||
|
||||
export function formatChartTooltip (tooltip: string, params: TooltipParams): string {
|
||||
const { seriesName = '', name = '', value = '', percent = '' } = params
|
||||
|
||||
return tooltip
|
||||
.replace('{a}', seriesName)
|
||||
.replace('{b}', name)
|
||||
.replace('{c}', value.toString())
|
||||
.replace('{d}', percent.toString())
|
||||
}
|
||||
|
||||
export function defFormatData (): FormatData {
|
||||
return Object.assign({}, {
|
||||
presetFormat: 'custom',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
format: '',
|
||||
})
|
||||
}
|
||||
|
||||
const chartUtil = {
|
||||
dimensionFunctions,
|
||||
hasRelativeDisplay,
|
||||
aggregateFunctions,
|
||||
predefinedFilters,
|
||||
ChartType,
|
||||
}
|
||||
|
||||
export {
|
||||
chartUtil,
|
||||
}
|
||||
13
lib/js/src/compose/types/module-field/base.test.ts
Normal file
13
lib/js/src/compose/types/module-field/base.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { expect } from 'chai'
|
||||
import { ModuleField } from './base'
|
||||
|
||||
describe('check module field casting', () => {
|
||||
it('simple assignment', () => {
|
||||
const f = new ModuleField({
|
||||
name: 'fname',
|
||||
kind: 'number',
|
||||
})
|
||||
|
||||
expect(f.name).to.equal('fname')
|
||||
})
|
||||
})
|
||||
250
lib/js/src/compose/types/module-field/base.ts
Normal file
250
lib/js/src/compose/types/module-field/base.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
import { IsOf } from '../../../guards'
|
||||
import { Apply, CortezaID, NoID } from '../../../cast'
|
||||
|
||||
export const FieldNameValidator = /^[A-Za-z][0-9A-Za-z_-]*[A-Za-z0-9]$/
|
||||
|
||||
const unsortableFieldKinds = ['File', 'Geometry']
|
||||
const unsortableSysFields = ['recordID']
|
||||
|
||||
const unfilterableFieldKinds = ['File', 'Geometry']
|
||||
const nonQueryableFieldKinds = ['Number', 'Record', 'User', 'Bool', 'DateTime', 'File', 'Geometry']
|
||||
const nonQueryableFieldNames = ['recordID']
|
||||
|
||||
type fieldEncoding = null | { omit: true } | { ident: string }
|
||||
|
||||
export interface Capabilities {
|
||||
configurable: true;
|
||||
multi: boolean;
|
||||
writable: boolean;
|
||||
required: boolean;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
description: {
|
||||
view: string;
|
||||
edit: string | undefined;
|
||||
};
|
||||
hint: {
|
||||
view: string;
|
||||
edit: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultOptions = (): Readonly<Options> => Object.freeze({
|
||||
description: {
|
||||
view: '',
|
||||
edit: undefined,
|
||||
},
|
||||
hint: {
|
||||
view: '',
|
||||
edit: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
interface Config {
|
||||
dal: {
|
||||
encodingStrategy: fieldEncoding;
|
||||
};
|
||||
|
||||
privacy: {
|
||||
sensitivityLevelID: string;
|
||||
usageDisclosure: string;
|
||||
};
|
||||
|
||||
recordRevisions: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Expressions {
|
||||
value?: string;
|
||||
|
||||
sanitizers?: Array<string>;
|
||||
|
||||
validators?: Array<Validator>;
|
||||
disableDefaultValidators?: boolean;
|
||||
|
||||
formatters?: Array<string>;
|
||||
disableDefaultFormatters?: boolean;
|
||||
}
|
||||
|
||||
interface Validator {
|
||||
validatorID: string;
|
||||
test: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DefaultValue {
|
||||
name?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class ModuleField {
|
||||
public fieldID = NoID
|
||||
public name = ''
|
||||
public kind = ''
|
||||
public label = ''
|
||||
|
||||
public defaultValue: Array<DefaultValue> = []
|
||||
public maxLength = 0
|
||||
|
||||
public isRequired = false
|
||||
public isMulti = false
|
||||
public isSystem = false
|
||||
public isSortable = true
|
||||
public isFilterable = true
|
||||
public isQueryable = true
|
||||
public options: Options = { ...defaultOptions() }
|
||||
public expressions: Expressions = {}
|
||||
|
||||
public config: Partial<Config> = {
|
||||
dal: {
|
||||
encodingStrategy: null,
|
||||
},
|
||||
|
||||
privacy: {
|
||||
sensitivityLevelID: NoID,
|
||||
usageDisclosure: '',
|
||||
},
|
||||
|
||||
recordRevisions: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
public canUpdateRecordValue = false
|
||||
public canReadRecordValue = false
|
||||
|
||||
constructor (f?: Partial<ModuleField>) {
|
||||
this.apply(f)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
if (o.description) {
|
||||
this.options.description = {
|
||||
...this.options.description,
|
||||
...o.description,
|
||||
}
|
||||
|
||||
this.options.description.edit = this.options.description.edit || undefined
|
||||
}
|
||||
|
||||
if (o.hint) {
|
||||
this.options.hint = {
|
||||
...this.options.hint,
|
||||
...o.hint,
|
||||
}
|
||||
|
||||
this.options.hint.edit = this.options.hint.edit || undefined
|
||||
}
|
||||
}
|
||||
|
||||
clone (): ModuleField {
|
||||
return new ModuleField(JSON.parse(JSON.stringify(this)))
|
||||
}
|
||||
|
||||
public apply (f?: Partial<ModuleField>): void {
|
||||
if (!f) return
|
||||
|
||||
Apply(this, f, CortezaID, 'fieldID')
|
||||
Apply(this, f, String, 'name', 'label', 'kind')
|
||||
Apply(this, f, Number, 'maxLength')
|
||||
Apply(this, f, Boolean, 'isRequired', 'isMulti', 'isSystem')
|
||||
|
||||
// Make sure field is align with it's capabilities
|
||||
if (!this.cap.multi) this.isMulti = false
|
||||
if (!this.cap.required) this.isRequired = false
|
||||
|
||||
// Check if kind sortable
|
||||
if (unsortableFieldKinds.includes(this.kind)) {
|
||||
this.isSortable = false
|
||||
}
|
||||
|
||||
if (unfilterableFieldKinds.includes(this.kind)) {
|
||||
this.isFilterable = false
|
||||
}
|
||||
|
||||
if (nonQueryableFieldKinds.includes(this.kind)) {
|
||||
this.isQueryable = false
|
||||
}
|
||||
|
||||
if (nonQueryableFieldNames.includes(this.name)) {
|
||||
this.isQueryable = false
|
||||
}
|
||||
|
||||
if (f.defaultValue && Array.isArray(f.defaultValue)) {
|
||||
/**
|
||||
* Converting default value into proper format
|
||||
* so we can use it without conversion
|
||||
* false boolean values are represented only by the name, in all other cases the value is also present
|
||||
*/
|
||||
this.defaultValue = f.defaultValue.filter(({ name, value }) => name !== undefined || (value !== undefined && value !== null))
|
||||
}
|
||||
|
||||
if (this.isSystem) {
|
||||
this.canUpdateRecordValue = true
|
||||
this.canReadRecordValue = true
|
||||
|
||||
if (unsortableSysFields.includes(this.name)) {
|
||||
this.isSortable = false
|
||||
}
|
||||
} else {
|
||||
Apply(this, f, Boolean, 'canUpdateRecordValue', 'canReadRecordValue')
|
||||
}
|
||||
|
||||
if (IsOf(f, 'config')) {
|
||||
this.config = merge({}, this.config, f.config)
|
||||
}
|
||||
|
||||
if (IsOf(f, 'kind')) {
|
||||
this.kind = f.kind
|
||||
}
|
||||
|
||||
if (IsOf(f, 'expressions')) {
|
||||
this.expressions = f.expressions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test field validity
|
||||
*
|
||||
* Expecting valid name
|
||||
*/
|
||||
public get isValid (): boolean {
|
||||
return this.name.length > 0 && FieldNameValidator.test(this.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per module field type capabilities
|
||||
*/
|
||||
public get cap (): Readonly<Capabilities> {
|
||||
return {
|
||||
configurable: true,
|
||||
multi: true,
|
||||
writable: true,
|
||||
required: true,
|
||||
private: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.fieldID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:module-field'
|
||||
}
|
||||
}
|
||||
|
||||
export const Registry = new Map<string, typeof ModuleField>()
|
||||
48
lib/js/src/compose/types/module-field/bool.ts
Normal file
48
lib/js/src/compose/types/module-field/bool.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Capabilities, ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Bool'
|
||||
|
||||
interface BoolOptions extends Options {
|
||||
trueLabel: string;
|
||||
falseLabel: string;
|
||||
switch: boolean;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<BoolOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
trueLabel: '',
|
||||
falseLabel: '',
|
||||
switch: false,
|
||||
})
|
||||
|
||||
export class ModuleFieldBool extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: BoolOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldBool>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<BoolOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'trueLabel', 'falseLabel')
|
||||
Apply(this.options, o, Boolean, 'switch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Per module field type capabilities
|
||||
*/
|
||||
public get cap (): Readonly<Capabilities> {
|
||||
return {
|
||||
...super.cap,
|
||||
multi: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldBool)
|
||||
128
lib/js/src/compose/types/module-field/datetime.ts
Normal file
128
lib/js/src/compose/types/module-field/datetime.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// @todo option to allow only time entry
|
||||
// @todo option to allow multiple entries
|
||||
// @todo option to allow duplicates
|
||||
import moment, { Moment } from 'moment'
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
import * as fmt from '../../../formatting'
|
||||
|
||||
const kind = 'DateTime'
|
||||
|
||||
interface DateTimeOptions extends Options {
|
||||
format: string;
|
||||
onlyDate: boolean;
|
||||
onlyTime: boolean;
|
||||
onlyPastValues: boolean;
|
||||
onlyFutureValues: boolean;
|
||||
outputRelative: boolean;
|
||||
multiDelimiter: string;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<DateTimeOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
format: '',
|
||||
multiDelimiter: '\n',
|
||||
onlyDate: false,
|
||||
onlyFutureValues: false,
|
||||
onlyPastValues: false,
|
||||
onlyTime: false,
|
||||
outputRelative: false,
|
||||
})
|
||||
|
||||
export class ModuleFieldDateTime extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: DateTimeOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldDateTime>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<DateTimeOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'format', 'multiDelimiter')
|
||||
Apply(this.options, o, Boolean, 'onlyDate', 'onlyTime', 'onlyPastValues', 'onlyFutureValues', 'outputRelative')
|
||||
}
|
||||
|
||||
formatValue (value: string|Moment|Date): string|null {
|
||||
if (value === 'Invalid date') {
|
||||
return null
|
||||
}
|
||||
|
||||
const o = this.options
|
||||
const m = moment(value, ['YYYY-MM-DDTHH:mm:ssZ', 'YYYY-MM-DD HH:mm', 'YYYY-MM-DD', 'HH:mm'])
|
||||
|
||||
if (o.outputRelative) {
|
||||
return m.fromNow()
|
||||
} else if (o.format.length > 0) {
|
||||
return m.format(o.format)
|
||||
} else if (o.onlyTime) {
|
||||
return fmt.time(m)
|
||||
} else if (o.onlyDate) {
|
||||
return fmt.date(moment(value, 'YYYY-MM-DD'))
|
||||
} else {
|
||||
return fmt.fullDateTime(m)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given value is in the future
|
||||
* @param {String|Array<String>} v Value (in DateTime) to check
|
||||
* @param {Moment} now Time reference
|
||||
* @returns {undefined|String} undefined if valid, Error string if invalid
|
||||
*/
|
||||
checkFuture (v: string|string[], now = moment()): undefined|string {
|
||||
if (!this.options.onlyFutureValues) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!Array.isArray(v)) {
|
||||
v = [v]
|
||||
}
|
||||
if (v.find(v => moment(v) < now)) {
|
||||
return 'notification.field-datetime.valueNotFuture'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given value is in the past
|
||||
* @param {String|Array<String>} v Value (in DateTime) to check
|
||||
* @param {Moment} now Time reference
|
||||
* @returns {undefined|String} undefined if valid, Error string if invalid
|
||||
*/
|
||||
checkPast (v: string|string[], now = moment()): undefined|string {
|
||||
if (!this.options.onlyPastValues) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!Array.isArray(v)) {
|
||||
v = [v]
|
||||
}
|
||||
if (v.find(v => moment(v) > now)) {
|
||||
return 'notification.field-datetime.valueNotPast'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given value is valid for this field
|
||||
* @param {String} v Value (in DateTime) to check
|
||||
* @param {Moment} now Reference time used to compare
|
||||
* @returns {Array<>} Array of issues; empty if none
|
||||
*/
|
||||
validate (v: string|string[], now = moment()): string[] {
|
||||
let err = this.checkFuture(v, now)
|
||||
err = err || this.checkPast(v, now)
|
||||
|
||||
if (err) {
|
||||
return [err]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldDateTime)
|
||||
39
lib/js/src/compose/types/module-field/email.ts
Normal file
39
lib/js/src/compose/types/module-field/email.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// @todo option to allow multiple entries
|
||||
// @todo option to allow duplicates
|
||||
// @todo option to allow only whitelisted domains
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Email'
|
||||
|
||||
interface EmailOptions extends Options {
|
||||
outputPlain: boolean;
|
||||
multiDelimiter: string;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<EmailOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
outputPlain: true,
|
||||
multiDelimiter: '\n',
|
||||
})
|
||||
|
||||
export class ModuleFieldEmail extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: EmailOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldEmail>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<EmailOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'multiDelimiter')
|
||||
Apply(this.options, o, Boolean, 'outputPlain')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldEmail)
|
||||
85
lib/js/src/compose/types/module-field/file.ts
Normal file
85
lib/js/src/compose/types/module-field/file.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply, ApplyWhitelisted } from '../../../cast'
|
||||
|
||||
const kind = 'File'
|
||||
|
||||
export const modes = [
|
||||
// list of attachments, no preview
|
||||
'list',
|
||||
// list of all images/files, show preview
|
||||
'gallery',
|
||||
]
|
||||
|
||||
interface FileOptions extends Options {
|
||||
allowImages: boolean;
|
||||
allowDocuments: boolean;
|
||||
maxSize: number;
|
||||
mode: string;
|
||||
inline: boolean;
|
||||
hideFileName: boolean;
|
||||
mimetypes?: string;
|
||||
height?: string;
|
||||
width?: string;
|
||||
maxHeight?: string;
|
||||
maxWidth?: string;
|
||||
borderRadius?: string;
|
||||
margin?: string;
|
||||
backgroundColor?: string;
|
||||
clickToView?: boolean;
|
||||
enableDownload?: boolean;
|
||||
multiDelimiter: string;
|
||||
enableWebcam?: boolean
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<FileOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
allowImages: true,
|
||||
allowDocuments: true,
|
||||
maxSize: 0,
|
||||
mode: 'list',
|
||||
inline: true,
|
||||
hideFileName: false,
|
||||
mimetypes: '',
|
||||
height: '',
|
||||
width: '',
|
||||
maxHeight: '',
|
||||
maxWidth: '',
|
||||
borderRadius: '',
|
||||
margin: 'auto',
|
||||
backgroundColor: '#FFFFFF00',
|
||||
clickToView: true,
|
||||
enableDownload: true,
|
||||
multiDelimiter: '\n',
|
||||
enableWebcam: false,
|
||||
})
|
||||
|
||||
export class ModuleFieldFile extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: FileOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldFile>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<FileOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, Number, 'maxSize')
|
||||
Apply(this.options, o, Boolean, 'allowImages', 'allowDocuments', 'inline', 'hideFileName', 'clickToView', 'enableDownload', 'enableWebcam')
|
||||
Apply(this.options, o, String, 'mimetypes', 'height', 'width', 'maxHeight', 'maxWidth', 'borderRadius', 'margin', 'backgroundColor')
|
||||
|
||||
// Legacy
|
||||
if (o.mode === 'single') {
|
||||
o.mode = 'gallery'
|
||||
} else if (o.mode === 'grid') {
|
||||
o.mode = 'list'
|
||||
}
|
||||
|
||||
ApplyWhitelisted(this.options, o, modes, 'mode')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldFile)
|
||||
59
lib/js/src/compose/types/module-field/geometry.ts
Normal file
59
lib/js/src/compose/types/module-field/geometry.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Capabilities, ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Geometry'
|
||||
|
||||
interface GeometryOptions extends Options {
|
||||
center: number[];
|
||||
zoom: number;
|
||||
multiDelimiter: string;
|
||||
prefillWithCurrentLocation: boolean;
|
||||
hideCurrentLocationButton: boolean;
|
||||
hideGeoSearch: boolean,
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<GeometryOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
center: [30, 30],
|
||||
zoom: 3,
|
||||
multiDelimiter: '\n',
|
||||
prefillWithCurrentLocation: false,
|
||||
hideCurrentLocationButton: false,
|
||||
hideGeoSearch: false,
|
||||
})
|
||||
|
||||
export class ModuleFieldGeometry extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: GeometryOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldGeometry>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<GeometryOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'multiDelimiter')
|
||||
Apply(this.options, o, Number, 'zoom')
|
||||
Apply(this.options, o, Boolean, 'prefillWithCurrentLocation', 'hideCurrentLocationButton', 'hideGeoSearch')
|
||||
|
||||
if (o.center) {
|
||||
this.options.center = o.center
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per module field type capabilities
|
||||
*/
|
||||
public get cap (): Readonly<Capabilities> {
|
||||
return {
|
||||
...super.cap,
|
||||
multi: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldGeometry)
|
||||
29
lib/js/src/compose/types/module-field/index.ts
Normal file
29
lib/js/src/compose/types/module-field/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ModuleField, Registry } from './base'
|
||||
export { ModuleFieldBool } from './bool'
|
||||
export { ModuleFieldDateTime } from './datetime'
|
||||
export { ModuleFieldEmail } from './email'
|
||||
export { ModuleFieldFile } from './file'
|
||||
export { ModuleFieldSelect } from './select'
|
||||
export { ModuleFieldNumber } from './number'
|
||||
export { ModuleFieldRecord } from './record'
|
||||
export { ModuleFieldString } from './string'
|
||||
export { ModuleFieldUrl } from './url'
|
||||
export { ModuleFieldUser } from './user'
|
||||
export { ModuleFieldGeometry } from './geometry'
|
||||
|
||||
export function ModuleFieldMaker (i: { kind?: string }): ModuleField {
|
||||
if (!i.kind) {
|
||||
return new ModuleField(i)
|
||||
}
|
||||
|
||||
if (!Registry.has(i.kind)) {
|
||||
throw new Error(`unknown module field kind '${i.kind}'`)
|
||||
}
|
||||
|
||||
return new (Registry.get(i.kind) as typeof ModuleField)(i)
|
||||
}
|
||||
|
||||
export {
|
||||
Registry as ModuleFieldRegistry,
|
||||
ModuleField,
|
||||
}
|
||||
108
lib/js/src/compose/types/module-field/number.ts
Normal file
108
lib/js/src/compose/types/module-field/number.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import numeral from 'numeral'
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
import * as fmt from '../../../formatting'
|
||||
|
||||
const kind = 'Number'
|
||||
|
||||
interface Threshold {
|
||||
value: number;
|
||||
variant: string;
|
||||
}
|
||||
|
||||
interface NumberOptions extends Options {
|
||||
presetFormat: string;
|
||||
format: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
precision: number;
|
||||
multiDelimiter: string;
|
||||
display: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
showValue: boolean;
|
||||
showRelative: boolean;
|
||||
showProgress: boolean;
|
||||
animated: boolean;
|
||||
variant: string;
|
||||
thresholds: Threshold[];
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<NumberOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
presetFormat: 'custom',
|
||||
precision: 3,
|
||||
multiDelimiter: '\n',
|
||||
display: 'number', // Either number or progress (progress bar)
|
||||
// Number display options
|
||||
format: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
// Progress bar display options
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
showValue: true,
|
||||
showRelative: true,
|
||||
showProgress: false,
|
||||
animated: false,
|
||||
variant: 'success',
|
||||
thresholds: [],
|
||||
})
|
||||
|
||||
export class ModuleFieldNumber extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: NumberOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldNumber>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<NumberOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'format', 'prefix', 'suffix', 'multiDelimiter', 'display', 'variant', 'presetFormat')
|
||||
Apply(this.options, o, Number, 'precision', 'min', 'max', 'step')
|
||||
Apply(this.options, o, Boolean, 'showValue', 'showRelative', 'showProgress', 'animated')
|
||||
|
||||
if (o.thresholds) {
|
||||
this.options.thresholds = o.thresholds
|
||||
}
|
||||
}
|
||||
|
||||
formatValue (value: string, format: string): string {
|
||||
const o = this.options
|
||||
let n: number
|
||||
|
||||
format = o.presetFormat === 'custom' ? o.format : o.presetFormat
|
||||
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
n = parseFloat(value)
|
||||
break
|
||||
case 'number':
|
||||
n = value
|
||||
break
|
||||
default:
|
||||
n = 0
|
||||
}
|
||||
|
||||
let out = `${n}`
|
||||
|
||||
if (format === 'accounting') {
|
||||
out = fmt.accountingNumber(n)
|
||||
} else if (format && format.length > 0) {
|
||||
out = numeral(n).format(format)
|
||||
} else {
|
||||
out = fmt.number(n)
|
||||
}
|
||||
|
||||
return '' + o.prefix + (out || n) + o.suffix
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldNumber)
|
||||
60
lib/js/src/compose/types/module-field/record.ts
Normal file
60
lib/js/src/compose/types/module-field/record.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// @todo option to allow multiple entries
|
||||
// @todo option to allow duplicates
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply, CortezaID, NoID } from '../../../cast'
|
||||
|
||||
const kind = 'Record'
|
||||
|
||||
interface RecordOptions extends Options {
|
||||
moduleID: string;
|
||||
labelField: string;
|
||||
recordLabelField: string;
|
||||
queryFields: Array<string>;
|
||||
selectType: string;
|
||||
multiDelimiter: string;
|
||||
isUniqueMultiValue: boolean;
|
||||
prefilter?: string;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<RecordOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
moduleID: NoID,
|
||||
labelField: '',
|
||||
recordLabelField: '',
|
||||
queryFields: [],
|
||||
selectType: '',
|
||||
multiDelimiter: '\n',
|
||||
isUniqueMultiValue: false,
|
||||
prefilter: undefined,
|
||||
})
|
||||
|
||||
export class ModuleFieldRecord extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: RecordOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldRecord>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<RecordOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, CortezaID, 'moduleID')
|
||||
Apply(this.options, o, String, 'labelField', 'recordLabelField', 'selectType', 'multiDelimiter', 'prefilter')
|
||||
Apply(this.options, o, Boolean, 'isUniqueMultiValue')
|
||||
Apply(this.options, o, (o) => {
|
||||
if (!o) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(o)) {
|
||||
return [o]
|
||||
}
|
||||
return o
|
||||
}, 'queryFields')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldRecord)
|
||||
23
lib/js/src/compose/types/module-field/select.test.ts
Normal file
23
lib/js/src/compose/types/module-field/select.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect } from 'chai'
|
||||
import { ModuleFieldSelect } from './select'
|
||||
|
||||
describe('select module field options', () => {
|
||||
it('uses value as label when label is empty', () => {
|
||||
const f = new ModuleFieldSelect({
|
||||
options: {
|
||||
options: [
|
||||
{ value: 'one' },
|
||||
{ value: 'two', text: 'Two' },
|
||||
],
|
||||
},
|
||||
} as never)
|
||||
|
||||
expect(f.options.options.map(({ text }) => text)).to.deep.equal(['one', 'Two'])
|
||||
})
|
||||
|
||||
it('creates an empty option without a value', () => {
|
||||
const f = new ModuleFieldSelect()
|
||||
|
||||
expect(f.createSelectOption()).to.deep.include({ value: '', text: '' })
|
||||
})
|
||||
})
|
||||
78
lib/js/src/compose/types/module-field/select.ts
Normal file
78
lib/js/src/compose/types/module-field/select.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
import { AreStrings } from '../../../guards'
|
||||
|
||||
const kind = 'Select'
|
||||
|
||||
interface SelectOptionStyle {
|
||||
textColor?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
text: string;
|
||||
style: SelectOptionStyle;
|
||||
}
|
||||
|
||||
interface SelectOptions extends Options {
|
||||
options: Array<SelectOption>;
|
||||
selectType: string;
|
||||
displayType: 'text' | 'badge';
|
||||
multiDelimiter: string;
|
||||
isUniqueMultiValue: boolean;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<SelectOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
options: [],
|
||||
selectType: 'default',
|
||||
multiDelimiter: '\n',
|
||||
isUniqueMultiValue: false,
|
||||
displayType: 'text',
|
||||
})
|
||||
|
||||
export class ModuleFieldSelect extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: SelectOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldSelect>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<SelectOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'selectType', 'multiDelimiter', 'displayType')
|
||||
Apply(this.options, o, Boolean, 'isUniqueMultiValue')
|
||||
|
||||
if (o.options) {
|
||||
let opt: Array<SelectOption> = []
|
||||
|
||||
if (AreStrings(o.options)) {
|
||||
opt = o.options.map((value: string) => this.createSelectOption({ value, text: value }))
|
||||
} else {
|
||||
opt = o.options.map(o => this.createSelectOption(o))
|
||||
}
|
||||
|
||||
this.options.options = opt
|
||||
}
|
||||
}
|
||||
|
||||
createSelectOption ({ value = '', text = '', style = {} }: Partial<SelectOption> = {}): SelectOption {
|
||||
const { textColor = '', backgroundColor = '' } = style || {}
|
||||
return {
|
||||
value,
|
||||
text: text || value,
|
||||
style: {
|
||||
textColor,
|
||||
backgroundColor,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldSelect)
|
||||
39
lib/js/src/compose/types/module-field/string.ts
Normal file
39
lib/js/src/compose/types/module-field/string.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'String'
|
||||
|
||||
interface StringOptions extends Options {
|
||||
multiLine: boolean;
|
||||
useRichTextEditor: boolean;
|
||||
multiDelimiter: string;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<StringOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
multiLine: false,
|
||||
useRichTextEditor: false,
|
||||
multiDelimiter: '\n',
|
||||
})
|
||||
|
||||
export class ModuleFieldString extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: StringOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldString>) {
|
||||
super(i)
|
||||
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<StringOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'multiDelimiter')
|
||||
Apply(this.options, o, Boolean, 'multiLine', 'useRichTextEditor')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldString)
|
||||
47
lib/js/src/compose/types/module-field/url.ts
Normal file
47
lib/js/src/compose/types/module-field/url.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// @todo option to allow multiple entries
|
||||
// @todo option to allow duplicates
|
||||
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Url'
|
||||
|
||||
interface UrlOptions extends Options {
|
||||
trimFragment: boolean;
|
||||
trimQuery: boolean;
|
||||
trimPath: boolean;
|
||||
onlySecure: boolean;
|
||||
outputPlain: boolean;
|
||||
multiDelimiter: string;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<UrlOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
trimFragment: false,
|
||||
trimQuery: false,
|
||||
trimPath: false,
|
||||
onlySecure: false,
|
||||
outputPlain: false,
|
||||
multiDelimiter: '\n',
|
||||
})
|
||||
|
||||
export class ModuleFieldUrl extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: UrlOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldUrl>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<UrlOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, String, 'multiDelimiter')
|
||||
Apply(this.options, o, Boolean, 'trimFragment', 'trimQuery', 'trimPath', 'onlySecure', 'outputPlain')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldUrl)
|
||||
58
lib/js/src/compose/types/module-field/user.ts
Normal file
58
lib/js/src/compose/types/module-field/user.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// @todo option to allow multiple entries
|
||||
// @todo option to allow duplicates
|
||||
import { ModuleField, Registry, Options, defaultOptions } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
import { User } from '../../../system'
|
||||
|
||||
const kind = 'User'
|
||||
|
||||
interface UserOptions extends Options {
|
||||
roles: Array<string>;
|
||||
presetWithAuthenticated: boolean;
|
||||
selectType: string;
|
||||
multiDelimiter: string;
|
||||
isUniqueMultiValue: boolean;
|
||||
}
|
||||
|
||||
const defaults = (): Readonly<UserOptions> => Object.freeze({
|
||||
...defaultOptions(),
|
||||
roles: [],
|
||||
presetWithAuthenticated: false,
|
||||
selectType: 'default',
|
||||
multiDelimiter: '\n',
|
||||
isUniqueMultiValue: false,
|
||||
})
|
||||
|
||||
export class ModuleFieldUser extends ModuleField {
|
||||
readonly kind = kind
|
||||
|
||||
options: UserOptions = { ...defaults() }
|
||||
|
||||
constructor (i?: Partial<ModuleFieldUser>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<UserOptions>): void {
|
||||
if (!o) return
|
||||
super.applyOptions(o)
|
||||
|
||||
Apply(this.options, o, Boolean, 'presetWithAuthenticated', 'isUniqueMultiValue')
|
||||
Apply(this.options, o, String, 'selectType', 'multiDelimiter')
|
||||
Apply(this.options, o, (o) => {
|
||||
if (!o) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(o)) {
|
||||
return [o]
|
||||
}
|
||||
return o
|
||||
}, 'roles')
|
||||
}
|
||||
|
||||
formatter ({ userID, name, username, email, handle }: Partial<User> = {}): string {
|
||||
return name || username || email || handle || userID || ''
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, ModuleFieldUser)
|
||||
54
lib/js/src/compose/types/module.test.ts
Normal file
54
lib/js/src/compose/types/module.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { expect } from 'chai'
|
||||
import { Module } from './module'
|
||||
import { AreObjectsOf } from '../../guards'
|
||||
import { ModuleField } from './module-field/base'
|
||||
|
||||
const mod = new Module({
|
||||
name: 'modName',
|
||||
|
||||
fields: [
|
||||
{ name: 'f0' },
|
||||
{ name: 'f1s', kind: 'String' },
|
||||
{ name: 'f2b', kind: 'Bool' },
|
||||
{ name: 'f3n', kind: 'Number' },
|
||||
],
|
||||
})
|
||||
|
||||
describe('module', () => {
|
||||
describe('check module casting', () => {
|
||||
it('simple assignment', () => {
|
||||
expect(mod.name).to.equal('modName')
|
||||
expect(mod.fields).to.be.lengthOf(4)
|
||||
expect(AreObjectsOf<ModuleField>(mod.fields, 'isSystem')).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('check field creation', () => {
|
||||
it('simple assignment', () => {
|
||||
const mod = new Module({
|
||||
name: 'modName',
|
||||
fields: [
|
||||
{ name: 'f001', kind: 'Bool' },
|
||||
{ name: 'f002', kind: 'DateTime' },
|
||||
{ name: 'f003', kind: 'Email' },
|
||||
{ name: 'f004', kind: 'File' },
|
||||
{ name: 'f005', kind: 'Select' },
|
||||
{ name: 'f006', kind: 'Number' },
|
||||
{ name: 'f007', kind: 'Record' },
|
||||
{ name: 'f008', kind: 'String' },
|
||||
{ name: 'f009', kind: 'Url' },
|
||||
{ name: 'f010', kind: 'User' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(mod.fields).lengthOf(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('field operations', () => {
|
||||
it('simple search', () => {
|
||||
expect(mod.findField('f1s')).to.not.equal(undefined)
|
||||
expect(mod.findField('foo')).to.equal(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
356
lib/js/src/compose/types/module.ts
Normal file
356
lib/js/src/compose/types/module.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
|
||||
import { ModuleField, ModuleFieldMaker } from './module-field'
|
||||
import { CortezaID, NoID, ISO8601Date, Apply } from '../../cast'
|
||||
import { AreObjects, AreStrings, IsOf } from '../../guards'
|
||||
import { Namespace } from './namespace'
|
||||
|
||||
const propNamespace = Symbol('namespace')
|
||||
|
||||
interface MetaAdmin {
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
interface MetaUi {
|
||||
admin: MetaAdmin;
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
ui: MetaUi;
|
||||
}
|
||||
|
||||
type systemFieldEncoding = null | { omit: true } | { ident: string }
|
||||
|
||||
interface Config {
|
||||
dal: {
|
||||
connectionID: string;
|
||||
// operations
|
||||
// constraints
|
||||
ident: string;
|
||||
systemFieldEncoding: {
|
||||
id: systemFieldEncoding;
|
||||
revision: systemFieldEncoding;
|
||||
moduleID: systemFieldEncoding;
|
||||
namespaceID: systemFieldEncoding;
|
||||
ownedBy: systemFieldEncoding;
|
||||
createdBy: systemFieldEncoding;
|
||||
createdAt: systemFieldEncoding;
|
||||
updatedBy: systemFieldEncoding;
|
||||
updatedAt: systemFieldEncoding;
|
||||
deletedBy: systemFieldEncoding;
|
||||
deletedAt: systemFieldEncoding;
|
||||
};
|
||||
};
|
||||
|
||||
privacy: {
|
||||
sensitivityLevelID: string;
|
||||
usageDisclosure: string;
|
||||
};
|
||||
|
||||
discovery: {
|
||||
public: ConfigDiscoveryAccess;
|
||||
private: ConfigDiscoveryAccess;
|
||||
protected: ConfigDiscoveryAccess;
|
||||
};
|
||||
|
||||
recordRevisions: {
|
||||
enabled: boolean;
|
||||
ident: string;
|
||||
};
|
||||
|
||||
recordDeDup: {
|
||||
rules: RecordDeDupRule[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfigDiscoveryAccess {
|
||||
result: {
|
||||
lang: string;
|
||||
fields: string[];
|
||||
}[]
|
||||
}
|
||||
|
||||
interface Constraint {
|
||||
attribute: string;
|
||||
modifier: string;
|
||||
multiValue: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface RecordDeDupRule {
|
||||
name?: string;
|
||||
strict: boolean;
|
||||
constraints: Constraint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* System fields that are present in every record.
|
||||
*/
|
||||
export const systemFields = Object.freeze([
|
||||
{ isSystem: true, name: 'recordID', label: 'Record ID', kind: 'String' },
|
||||
{ isSystem: true, name: 'ownedBy', label: 'Owned by', kind: 'User' },
|
||||
{ isSystem: true, name: 'createdBy', label: 'Created by', kind: 'User' },
|
||||
{ isSystem: true, name: 'createdAt', label: 'Created at', kind: 'DateTime' },
|
||||
{ isSystem: true, name: 'updatedBy', label: 'Updated by', kind: 'User' },
|
||||
{ isSystem: true, name: 'updatedAt', label: 'Updated at', kind: 'DateTime' },
|
||||
{ isSystem: true, name: 'revision', label: 'Revision', kind: 'Number' },
|
||||
{ isSystem: true, name: 'deletedBy', label: 'Deleted by', kind: 'User' },
|
||||
{ isSystem: true, name: 'deletedAt', label: 'Deleted at', kind: 'DateTime' },
|
||||
].map(f => ModuleFieldMaker(f)))
|
||||
|
||||
interface PartialModule extends Partial<Omit<Module, 'fields' | 'meta' | 'labels' | 'createdAt' | 'updatedAt' | 'deletedAt'>> {
|
||||
fields?: Array<Partial<ModuleField>> | Array<ModuleField>;
|
||||
meta?: Partial<Meta>;
|
||||
config?: Partial<Config>;
|
||||
issues?: Array<string>;
|
||||
labels?: Partial<object>;
|
||||
|
||||
createdAt?: string|number|Date;
|
||||
updatedAt?: string|number|Date;
|
||||
deletedAt?: string|number|Date;
|
||||
}
|
||||
|
||||
export class Module {
|
||||
public moduleID = NoID;
|
||||
public namespaceID = NoID;
|
||||
public name = '';
|
||||
public handle = '';
|
||||
public fields: Array<ModuleField> = [];
|
||||
public issues: Array<string> = [];
|
||||
|
||||
public config: Partial<Config> = {
|
||||
dal: {
|
||||
connectionID: NoID,
|
||||
ident: '',
|
||||
systemFieldEncoding: {
|
||||
id: null,
|
||||
revision: null,
|
||||
moduleID: null,
|
||||
namespaceID: null,
|
||||
ownedBy: null,
|
||||
createdBy: null,
|
||||
createdAt: null,
|
||||
updatedBy: null,
|
||||
updatedAt: null,
|
||||
deletedBy: null,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
|
||||
privacy: {
|
||||
sensitivityLevelID: NoID,
|
||||
usageDisclosure: '',
|
||||
},
|
||||
|
||||
discovery: {
|
||||
public: {
|
||||
result: [
|
||||
{
|
||||
lang: '',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
private: {
|
||||
result: [
|
||||
{
|
||||
lang: '',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
protected: {
|
||||
result: [
|
||||
{
|
||||
lang: '',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
recordRevisions: {
|
||||
enabled: false,
|
||||
ident: '',
|
||||
},
|
||||
|
||||
recordDeDup: {
|
||||
rules: [],
|
||||
},
|
||||
}
|
||||
|
||||
public meta: Meta = {
|
||||
ui: {
|
||||
admin: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public labels: object = {};
|
||||
|
||||
public createdAt?: Date = undefined;
|
||||
public updatedAt?: Date = undefined;
|
||||
public deletedAt?: Date = undefined;
|
||||
|
||||
public canUpdateModule = false;
|
||||
public canDeleteModule = false;
|
||||
public canCreateRecord = false;
|
||||
public canCreateOwnedRecord = false;
|
||||
public canGrant = false;
|
||||
|
||||
private [propNamespace]?: Namespace
|
||||
|
||||
constructor (i?: PartialModule, ns?: Namespace) {
|
||||
if (ns) {
|
||||
this.namespace = ns
|
||||
}
|
||||
|
||||
this.apply(i)
|
||||
}
|
||||
|
||||
clone (): Module {
|
||||
return new Module(JSON.parse(JSON.stringify(this)), this.namespace)
|
||||
}
|
||||
|
||||
apply (m?: PartialModule): void {
|
||||
if (!m) return
|
||||
|
||||
if (this.namespace && m.namespaceID && m.namespaceID !== this.namespace.namespaceID) {
|
||||
throw new Error('module can not change namespace')
|
||||
}
|
||||
|
||||
Apply(this, m, CortezaID, 'moduleID', 'namespaceID')
|
||||
Apply(this, m, String, 'name', 'handle')
|
||||
|
||||
if (IsOf(m, 'fields')) {
|
||||
this.fields = []
|
||||
if (AreObjects(m.fields)) {
|
||||
// We're very permissive here -- array of (empty) objects is all we need
|
||||
// to create fields.
|
||||
this.fields = m.fields.map((b: { kind?: string }) => ModuleFieldMaker(b))
|
||||
}
|
||||
}
|
||||
|
||||
if (IsOf(m, 'meta')) {
|
||||
if (m.meta.ui && m.meta.ui.admin && m.meta.ui.admin.fields) {
|
||||
if (!AreStrings(m.meta.ui.admin.fields)) {
|
||||
const fields = m.meta.ui.admin.fields || []
|
||||
m.meta.ui.admin.fields = fields.map((f: any) => f.fieldID && f.fieldID !== NoID ? f.fieldID : f.name).filter((f: any) => !!f)
|
||||
}
|
||||
}
|
||||
|
||||
this.meta = merge({}, this.meta, m.meta)
|
||||
}
|
||||
|
||||
if (IsOf(m, 'config')) {
|
||||
this.config = merge({}, this.config, m.config)
|
||||
}
|
||||
|
||||
if (IsOf(m, 'labels')) {
|
||||
this.labels = { ...m.labels }
|
||||
}
|
||||
|
||||
if (IsOf(m, 'issues')) {
|
||||
this.issues = m.issues
|
||||
}
|
||||
|
||||
Apply(this, m, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
Apply(this, m, Boolean,
|
||||
'canUpdateModule',
|
||||
'canDeleteModule',
|
||||
'canCreateRecord',
|
||||
'canCreateOwnedRecord',
|
||||
'canGrant',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.moduleID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:module'
|
||||
}
|
||||
|
||||
public get namespace (): Namespace {
|
||||
return this[propNamespace] as Namespace
|
||||
}
|
||||
|
||||
public set namespace (ns: Namespace) {
|
||||
if (this[propNamespace]) {
|
||||
if ((this[propNamespace] as Namespace).namespaceID !== ns.namespaceID) {
|
||||
throw new Error('namespace for this module already set')
|
||||
}
|
||||
}
|
||||
|
||||
this.namespaceID = ns.namespaceID
|
||||
|
||||
if (Object.isFrozen(ns)) {
|
||||
this[propNamespace] = ns
|
||||
} else {
|
||||
// Making a copy and freezing it
|
||||
this[propNamespace] = Object.freeze(new Namespace(ns))
|
||||
}
|
||||
|
||||
this[propNamespace] = ns
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns fields from module, filtered and order as requested
|
||||
*/
|
||||
filterFields (requested?: string[] | Array<ModuleField>): Array<ModuleField> {
|
||||
if (!requested || requested.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!AreStrings(requested)) {
|
||||
requested = (requested as ModuleField[]).map((f: ModuleField) => f.name || f.fieldID)
|
||||
}
|
||||
|
||||
const out: ModuleField[] = []
|
||||
|
||||
for (const r of requested) {
|
||||
const sf = this.systemFields().find(f => r === f.name || r === f.fieldID)
|
||||
if (sf) {
|
||||
out.push(sf)
|
||||
continue
|
||||
}
|
||||
|
||||
const mf = this.fields.find(f => r === f.name || r === f.fieldID)
|
||||
if (mf) {
|
||||
out.push(mf)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
public findField (name: string): ModuleField|undefined {
|
||||
const r = this.filterFields([name])
|
||||
return r && r.length > 0 ? r[0] : undefined
|
||||
}
|
||||
|
||||
fieldNames (): readonly string[] {
|
||||
return this.fields.map(f => f.name)
|
||||
}
|
||||
|
||||
systemFields (): readonly ModuleField[] {
|
||||
return systemFields
|
||||
}
|
||||
|
||||
export (): Module {
|
||||
return this
|
||||
}
|
||||
|
||||
import (): Module {
|
||||
return this
|
||||
}
|
||||
}
|
||||
119
lib/js/src/compose/types/namespace.test.ts
Normal file
119
lib/js/src/compose/types/namespace.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { NoID } from '../../cast'
|
||||
import { expect } from 'chai'
|
||||
import { Namespace } from './namespace'
|
||||
|
||||
describe('namespace', () => {
|
||||
describe('initialization', () => {
|
||||
// it('requires name and slug', () => {
|
||||
// expect(() => new Namespace({})).to.throw('field name is empty')
|
||||
// expect(() => new Namespace({ name: 'name' })).to.throw('field slug is empty')
|
||||
// })
|
||||
|
||||
it('simple', () => {
|
||||
const ns = new Namespace({})
|
||||
|
||||
expect(ns).to.have.property('namespaceID').and.to.eq(NoID)
|
||||
expect(ns).to.have.property('name').and.to.eq('')
|
||||
expect(ns).to.have.property('slug').and.to.eq('')
|
||||
|
||||
expect(ns).to.have.property('enabled').and.to.eq(false)
|
||||
|
||||
expect(ns).to.have.property('labels').and.to.deep.eq({})
|
||||
expect(ns).to.have.property('meta').and.to.deep.eq({})
|
||||
|
||||
expect(ns).to.have.property('createdAt').and.to.eq(undefined)
|
||||
expect(ns).to.have.property('updatedAt').and.to.eq(undefined)
|
||||
expect(ns).to.have.property('deletedAt').and.to.eq(undefined)
|
||||
|
||||
expect(ns).to.have.property('canCreateChart').and.to.eq(false)
|
||||
expect(ns).to.have.property('canCreateModule').and.to.eq(false)
|
||||
expect(ns).to.have.property('canCreatePage').and.to.eq(false)
|
||||
expect(ns).to.have.property('canDeleteNamespace').and.to.eq(false)
|
||||
expect(ns).to.have.property('canUpdateNamespace').and.to.eq(false)
|
||||
expect(ns).to.have.property('canManageNamespace').and.to.eq(false)
|
||||
expect(ns).to.have.property('canCloneNamespace').and.to.eq(false)
|
||||
expect(ns).to.have.property('canGrant').and.to.eq(false)
|
||||
})
|
||||
|
||||
it('apply', () => {
|
||||
const dateNow = new Date()
|
||||
|
||||
const ns = new Namespace({
|
||||
namespaceID: '42',
|
||||
name: 'name',
|
||||
slug: 'slug',
|
||||
|
||||
enabled: true,
|
||||
|
||||
labels: {
|
||||
foo: 'foo',
|
||||
},
|
||||
|
||||
meta: {
|
||||
subtitle: 'subtitle',
|
||||
description: 'description',
|
||||
icon: 'icon',
|
||||
logo: 'logo',
|
||||
logoEnabled: true,
|
||||
},
|
||||
|
||||
createdAt: dateNow,
|
||||
updatedAt: dateNow,
|
||||
deletedAt: dateNow,
|
||||
|
||||
canCreateChart: true,
|
||||
canCreateModule: true,
|
||||
canCreatePage: true,
|
||||
canDeleteNamespace: true,
|
||||
canUpdateNamespace: true,
|
||||
canManageNamespace: true,
|
||||
canCloneNamespace: true,
|
||||
canGrant: true,
|
||||
})
|
||||
|
||||
expect(ns).to.have.property('namespaceID').and.to.eq('42')
|
||||
expect(ns).to.have.property('name').and.to.eq('name')
|
||||
expect(ns).to.have.property('slug').and.to.eq('slug')
|
||||
|
||||
expect(ns).to.have.property('enabled').and.to.eq(true)
|
||||
|
||||
expect(ns).to.have.property('labels').and.to.deep.eq({
|
||||
foo: 'foo',
|
||||
})
|
||||
expect(ns).to.have.property('meta').and.to.deep.eq({
|
||||
subtitle: 'subtitle',
|
||||
description: 'description',
|
||||
icon: 'icon',
|
||||
logo: 'logo',
|
||||
logoEnabled: true,
|
||||
})
|
||||
|
||||
expect(ns).to.have.property('createdAt').and.to.eq(dateNow)
|
||||
expect(ns).to.have.property('updatedAt').and.to.eq(dateNow)
|
||||
expect(ns).to.have.property('deletedAt').and.to.eq(dateNow)
|
||||
|
||||
expect(ns).to.have.property('canCreateChart').and.to.eq(true)
|
||||
expect(ns).to.have.property('canCreateModule').and.to.eq(true)
|
||||
expect(ns).to.have.property('canCreatePage').and.to.eq(true)
|
||||
expect(ns).to.have.property('canDeleteNamespace').and.to.eq(true)
|
||||
expect(ns).to.have.property('canUpdateNamespace').and.to.eq(true)
|
||||
expect(ns).to.have.property('canManageNamespace').and.to.eq(true)
|
||||
expect(ns).to.have.property('canCloneNamespace').and.to.eq(true)
|
||||
expect(ns).to.have.property('canGrant').and.to.eq(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getters', () => {
|
||||
const ns = new Namespace({
|
||||
namespaceID: '42',
|
||||
})
|
||||
|
||||
it('resourceType', () => {
|
||||
expect(ns.resourceType).to.eq('compose:namespace')
|
||||
})
|
||||
|
||||
it('resourceID', () => {
|
||||
expect(ns.resourceID).to.eq('compose:namespace:42')
|
||||
})
|
||||
})
|
||||
})
|
||||
130
lib/js/src/compose/types/namespace.ts
Normal file
130
lib/js/src/compose/types/namespace.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
|
||||
import { IsOf } from '../../guards'
|
||||
|
||||
interface MetaAdminRecordList {
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
interface MetaAdmin {
|
||||
recordList: MetaAdminRecordList;
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
subtitle: string;
|
||||
description: string;
|
||||
hideSidebar: boolean;
|
||||
// Temporary icon & logo URLs
|
||||
// @todo rework this when we rework attachment management
|
||||
icon: string;
|
||||
logo: string;
|
||||
logoEnabled: boolean;
|
||||
}
|
||||
|
||||
interface PartialNamespace extends Partial<Omit<Namespace, 'meta' | 'createdAt' | 'updatedAt' | 'deletedAt'>> {
|
||||
meta?: Partial<Meta>;
|
||||
createdAt?: string|number|Date;
|
||||
updatedAt?: string|number|Date;
|
||||
deletedAt?: string|number|Date;
|
||||
}
|
||||
|
||||
export class Namespace {
|
||||
public namespaceID = NoID
|
||||
public name = ''
|
||||
public slug = ''
|
||||
|
||||
public enabled = false
|
||||
|
||||
public labels: object = {}
|
||||
|
||||
public meta: object = {}
|
||||
|
||||
public createdAt?: Date = undefined
|
||||
public updatedAt?: Date = undefined
|
||||
public deletedAt?: Date = undefined
|
||||
|
||||
public canCreateChart = false
|
||||
public canCreateModule = false
|
||||
public canCreatePage = false
|
||||
public canDeleteNamespace = false
|
||||
public canUpdateNamespace = false
|
||||
public canManageNamespace = false
|
||||
public canCloneNamespace = false
|
||||
public canExportNamespace = false
|
||||
public canGrant = false
|
||||
public canExportCharts = false
|
||||
public canExportModules = false
|
||||
|
||||
constructor (i?: PartialNamespace) {
|
||||
this.apply(i)
|
||||
}
|
||||
|
||||
clone (): Namespace {
|
||||
return new Namespace(JSON.parse(JSON.stringify(this)))
|
||||
}
|
||||
|
||||
apply (n?: PartialNamespace | Namespace): void {
|
||||
if (!n) return
|
||||
|
||||
Apply(this, n, CortezaID, 'namespaceID')
|
||||
Apply(this, n, String, 'name', 'slug')
|
||||
|
||||
Apply(this, n, Boolean, 'enabled')
|
||||
|
||||
if (IsOf(n, 'meta')) {
|
||||
this.meta = { ...n.meta }
|
||||
}
|
||||
|
||||
if (IsOf(n, 'labels')) {
|
||||
this.labels = { ...n.labels }
|
||||
}
|
||||
|
||||
Apply(this, n, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
Apply(this, n, Boolean,
|
||||
'canDeleteNamespace',
|
||||
'canUpdateNamespace',
|
||||
'canManageNamespace',
|
||||
'canCloneNamespace',
|
||||
'canExportNamespace',
|
||||
'canGrant',
|
||||
'canCreateModule',
|
||||
'canExportModules',
|
||||
'canCreatePage',
|
||||
'canCreateChart',
|
||||
'canExportCharts',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.namespaceID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:namespace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate namespace initials
|
||||
*/
|
||||
get initials (): string {
|
||||
let base = this.name || this.slug
|
||||
|
||||
// if length is shorter than 3 letters, use that
|
||||
if (base.length <= 3) {
|
||||
return base
|
||||
}
|
||||
|
||||
// split by space and take first letter of each word
|
||||
base = base.split(/\s+/).map(w => w[0]).filter(c => /[a-zA-Z]/.test(c)).join('')
|
||||
if (base.length > 3) {
|
||||
base = base.slice(0, 3)
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
}
|
||||
67
lib/js/src/compose/types/page-block/automation.ts
Normal file
67
lib/js/src/compose/types/page-block/automation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Button } from './types'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Automation'
|
||||
|
||||
interface Options {
|
||||
// Ordered list of buttons to display in the block
|
||||
buttons: Array<Button>;
|
||||
|
||||
// When true, new compatible buttons (ui-hooks) are NOT
|
||||
// added automatically to the block
|
||||
//
|
||||
// Default behaviour is to add new buttons automatically.
|
||||
sealed: boolean;
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
buttons: [],
|
||||
sealed: false,
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockAutomation extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, String, 'magnifyOption')
|
||||
|
||||
if (o.buttons) {
|
||||
this.options.buttons = o.buttons.map(b => new Button(b))
|
||||
}
|
||||
}
|
||||
|
||||
// Validates Page Block configuration
|
||||
validate (): Array<string> {
|
||||
const ee = super.validate()
|
||||
|
||||
this.options.buttons.forEach(b => {
|
||||
if (b.workflowID) {
|
||||
// workflow defined
|
||||
return
|
||||
}
|
||||
|
||||
if (b.script) {
|
||||
// script defined
|
||||
return
|
||||
}
|
||||
|
||||
ee.push('Automation button without configured script or workflow')
|
||||
})
|
||||
|
||||
return ee
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockAutomation)
|
||||
126
lib/js/src/compose/types/page-block/base.ts
Normal file
126
lib/js/src/compose/types/page-block/base.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
import { Apply, NoID } from '../../../cast'
|
||||
import { generateUID } from '../../helpers/idgen'
|
||||
|
||||
interface PageBlockStyleVariants {
|
||||
[_: string]: string;
|
||||
}
|
||||
|
||||
interface PageBlockStyleWrap {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface PageBlockStyleBorder {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface PageBlockStyle {
|
||||
variants: PageBlockStyleVariants;
|
||||
wrap?: PageBlockStyleWrap;
|
||||
border?: PageBlockStyleBorder;
|
||||
}
|
||||
|
||||
interface Visibility {
|
||||
expression: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface PageBlockMeta {
|
||||
hidden?: boolean;
|
||||
tempID?: string;
|
||||
customID?: string;
|
||||
customCSSClass?: string;
|
||||
visibility: Visibility;
|
||||
}
|
||||
|
||||
export type PageBlockInput = PageBlock | Partial<PageBlock>
|
||||
|
||||
const defaultXYWH = [0, 0, 20, 15]
|
||||
|
||||
export class PageBlock {
|
||||
// blockID is auto generated by the server in order to support resource translations
|
||||
public blockID = NoID;
|
||||
public kind = ''
|
||||
|
||||
public title = '';
|
||||
public description = '';
|
||||
|
||||
public xywh: number[] = defaultXYWH
|
||||
|
||||
public options = {}
|
||||
|
||||
public meta: PageBlockMeta = {
|
||||
hidden: false,
|
||||
tempID: undefined,
|
||||
customID: undefined,
|
||||
customCSSClass: undefined,
|
||||
visibility: {
|
||||
expression: '',
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
|
||||
public style: PageBlockStyle = {
|
||||
variants: {
|
||||
headerText: 'dark',
|
||||
},
|
||||
wrap: {
|
||||
kind: 'card',
|
||||
},
|
||||
border: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
this.apply(i)
|
||||
this.setTempID()
|
||||
}
|
||||
|
||||
apply (i?: PageBlockInput): void {
|
||||
if (!i) return
|
||||
|
||||
Apply(this, i, String, 'title', 'description', 'blockID')
|
||||
|
||||
if (i.xywh) {
|
||||
if (!Array.isArray(i.xywh)) {
|
||||
throw new Error('xywh must be an array')
|
||||
}
|
||||
|
||||
if (i.xywh.length !== 4) {
|
||||
throw new Error('xywh must have 4 elements')
|
||||
}
|
||||
|
||||
// by default, park 3x3 block in upper left corner
|
||||
this.xywh = i.xywh || defaultXYWH
|
||||
}
|
||||
|
||||
if (i.options) {
|
||||
this.options = merge({}, this.options, i.options)
|
||||
}
|
||||
|
||||
if (i.style) {
|
||||
this.style = merge({}, this.style, i.style)
|
||||
}
|
||||
|
||||
if (i.meta) {
|
||||
this.meta = merge({}, this.meta, i.meta)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns Page Block configuration errors
|
||||
validate (): Array<string> {
|
||||
return []
|
||||
}
|
||||
|
||||
setTempID (): void {
|
||||
this.meta.tempID = this.meta.tempID || generateUID()
|
||||
}
|
||||
|
||||
clone (): this {
|
||||
return new (this.constructor as new (i?: PageBlockInput) => this)({ ...JSON.parse(JSON.stringify(this)), blockID: NoID, meta: { ...this.meta, tempID: '' } })
|
||||
}
|
||||
}
|
||||
|
||||
export const Registry = new Map<string, typeof PageBlock>()
|
||||
148
lib/js/src/compose/types/page-block/calendar/feed-record.ts
Normal file
148
lib/js/src/compose/types/page-block/calendar/feed-record.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Record } from '../../record'
|
||||
import { Namespace } from '../../namespace'
|
||||
import { Module } from '../../module'
|
||||
import { Compose as ComposeAPI } from '../../../../api-clients'
|
||||
import { makeColors, Event } from './shared'
|
||||
|
||||
interface FeedOptions {
|
||||
color: string;
|
||||
prefilter: string;
|
||||
}
|
||||
|
||||
interface Feed {
|
||||
startField: string;
|
||||
endField: string;
|
||||
titleField: string;
|
||||
options: FeedOptions;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
interface Range {
|
||||
end: Date;
|
||||
start: Date;
|
||||
}
|
||||
|
||||
function getRecordValue (record: Readonly<Record>, field: string): (string|undefined)[] {
|
||||
const ef = record.module.fields.find(({ name }) => name === field)
|
||||
if (ef) {
|
||||
return ef.isMulti ? record.values[field] as string[] : [(record.values[field] as string) || undefined]
|
||||
} else {
|
||||
switch (field) {
|
||||
case 'recordID':
|
||||
case 'moduleID':
|
||||
case 'namespaceID':
|
||||
return [record[field]]
|
||||
case 'createdAt':
|
||||
case 'updatedAt':
|
||||
case 'deletedAt':
|
||||
if (record[field] !== undefined) {
|
||||
return [(record[field] as Date).toISOString()]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return [undefined]
|
||||
}
|
||||
|
||||
/**
|
||||
* Method expands the given record in a (set) of FC event objects.
|
||||
* Handles basic recurrence -- multiple date fields.
|
||||
* @param {Record} record Record to expand
|
||||
* @param {Feed} feed Feed, this record belongs to
|
||||
* @returns {Array} A set of expanded events
|
||||
*/
|
||||
function expandRecord (record: Readonly<Record>, feed: Feed): Event[] {
|
||||
const events: Event[] = []
|
||||
|
||||
const starts = getRecordValue(record, feed.startField)
|
||||
const ends = getRecordValue(record, feed.endField)
|
||||
const title = getRecordValue(record, feed.titleField).shift() || record.recordID
|
||||
|
||||
// Make sure ends is at least as long as starts, to avoid length checks
|
||||
ends.push(...(new Array(Math.max(starts.length - ends.length, 0)).fill(undefined)))
|
||||
|
||||
const classNames = ['event', 'event-record']
|
||||
const { backgroundColor, borderColor, textColor } = makeColors(feed.options.color)
|
||||
|
||||
starts.forEach((start, i) => {
|
||||
events.push({
|
||||
// So FC knows how to group these expanded events
|
||||
groupId: record.recordID,
|
||||
id: record.recordID,
|
||||
title,
|
||||
start: start,
|
||||
end: ends[i],
|
||||
allDay: feed.allDay,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
classNames,
|
||||
textColor,
|
||||
|
||||
extendedProps: {
|
||||
moduleID: record.module.moduleID,
|
||||
recordID: record.recordID,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given field can be used with the given record.
|
||||
* A field can be used if it's either defined as a record value OR it's a system field.
|
||||
*
|
||||
* @param r The record to check
|
||||
* @param field The field we wish to use
|
||||
*/
|
||||
function recordFeedFilter (r: Readonly<Record>, field: string): boolean {
|
||||
if (r.values[field]) {
|
||||
return true
|
||||
}
|
||||
|
||||
return ['createdAt', 'updatedAt', 'deletedAt'].includes(field)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads & converts module resource into FC events
|
||||
* @param {ComposeAPI} $ComposeAPI ComposeAPI provider
|
||||
* @param {Module} module Current module
|
||||
* @param {Namespace} namespace Current namespace
|
||||
* @param {Feed} feed Current feed
|
||||
* @param {Object} range Current date range
|
||||
* @returns {Promise<Array>} Resolves to a set of FC events to display
|
||||
*/
|
||||
export async function RecordFeed ($ComposeAPI: ComposeAPI, module: Module, namespace: Namespace, feed: Feed, range: Range, options = {}): Promise<Event[]> {
|
||||
// Params for record fetching
|
||||
const params = {
|
||||
namespaceID: namespace.namespaceID,
|
||||
moduleID: module.moduleID,
|
||||
query: `(date(${
|
||||
feed.startField
|
||||
}) <= '${range.end.toISOString()}' AND '${range.start.toISOString()}' <= date(${
|
||||
feed.endField || feed.startField
|
||||
}))`,
|
||||
}
|
||||
|
||||
if (feed.options.prefilter) {
|
||||
params.query += ` AND (${feed.options.prefilter})`
|
||||
}
|
||||
|
||||
const events: Array<Event> = []
|
||||
return $ComposeAPI.recordList(params, options).then(({ set }) => {
|
||||
(set as Array<{ recordID: string }>)
|
||||
|
||||
// Removes all duplicates
|
||||
.filter(({ recordID }, index, set) => set.findIndex((r) => recordID === r.recordID) === index)
|
||||
|
||||
// cast & freeze
|
||||
.map(r => Object.freeze(new Record(module, r)))
|
||||
|
||||
// drop record w/o proper values
|
||||
.filter(r => recordFeedFilter(r, feed.startField))
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
.forEach(r => events.push(...expandRecord(r, feed)))
|
||||
return events
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { System as SystemAPI } from '../../../../api-clients'
|
||||
import { User, Reminder } from '../../../../system'
|
||||
import { makeColors, Event } from './shared'
|
||||
import { AreObjectsOf } from '../../../../guards'
|
||||
|
||||
interface FeedOptions {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Feed {
|
||||
options: FeedOptions;
|
||||
}
|
||||
|
||||
interface Range {
|
||||
end: Date;
|
||||
start: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads & converts reminder resource into FC events
|
||||
* @param {SystemAPI} $SystemAPI SystemAPI provider
|
||||
* @param {User} user Current user
|
||||
* @param {Feed} feed Current feed
|
||||
* @param {Object} range Current date range
|
||||
* @returns {Promise<Array>} Resolves to a set of FC events to display
|
||||
*/
|
||||
export async function ReminderFeed ($SystemAPI: SystemAPI, user: User, feed: Feed, range: Range, options = {}): Promise<Event[]> {
|
||||
return $SystemAPI.reminderList({
|
||||
scheduledFrom: range.start.toISOString(),
|
||||
scheduledUntil: range.end.toISOString(),
|
||||
scheduledOnly: true,
|
||||
excludeDismissed: true,
|
||||
assignedTo: user.userID,
|
||||
}, options).then(({ set }) => {
|
||||
const { backgroundColor, borderColor, textColor } = makeColors(feed.options.color)
|
||||
|
||||
if (!AreObjectsOf<Reminder>(set, 'reminderID', 'assignedTo', 'remindAt', 'payload')) {
|
||||
return []
|
||||
}
|
||||
|
||||
return set.map(r => {
|
||||
r = new Reminder(r)
|
||||
|
||||
const classNames = ['event', 'event-reminder']
|
||||
if (r.assignedTo !== user.userID) {
|
||||
classNames.push('event-not-owner')
|
||||
}
|
||||
|
||||
const e: Event = {
|
||||
id: r.reminderID,
|
||||
title: (r.payload.title as string) ?? r.reminderID ?? '-',
|
||||
start: r.remindAt?.toISOString(),
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor,
|
||||
classNames,
|
||||
allDay: false,
|
||||
extendedProps: {
|
||||
reminderID: r.reminderID,
|
||||
},
|
||||
}
|
||||
|
||||
return e
|
||||
})
|
||||
})
|
||||
}
|
||||
80
lib/js/src/compose/types/page-block/calendar/feed.ts
Normal file
80
lib/js/src/compose/types/page-block/calendar/feed.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { feedResources } from './resources'
|
||||
import { Apply, NoID } from '../../../../cast'
|
||||
import { IsOf } from '../../../../guards'
|
||||
|
||||
interface FeedOptions {
|
||||
moduleID: string;
|
||||
color: string;
|
||||
prefilter: string;
|
||||
}
|
||||
|
||||
interface LegacyFeed {
|
||||
moduleID?: string;
|
||||
startField?: string;
|
||||
endField?: string;
|
||||
titleField?: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
export type FeedInput = Partial<Feed> | Feed | LegacyFeed
|
||||
|
||||
const defOptions = {
|
||||
moduleID: NoID,
|
||||
color: '#FF9661',
|
||||
prefilter: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed class represents an event feed for the given calendar
|
||||
*/
|
||||
export default class Feed {
|
||||
public resource = 'compose:record'
|
||||
public startField = ''
|
||||
public endField = ''
|
||||
public titleField = ''
|
||||
public options: FeedOptions = { ...defOptions }
|
||||
|
||||
public allDay = false
|
||||
|
||||
constructor (i?: FeedInput) {
|
||||
this.apply(i)
|
||||
}
|
||||
|
||||
apply (i?: FeedInput): void {
|
||||
if (!i) return
|
||||
|
||||
if (!IsOf<Feed>(i, 'resource') && IsOf<LegacyFeed>(i, 'moduleID')) {
|
||||
i = Feed.fromLegacy(i)
|
||||
}
|
||||
|
||||
if (IsOf<Feed>(i, 'resource')) {
|
||||
Apply(this, i, String, 'resource', 'startField', 'endField', 'titleField')
|
||||
Apply(this, i, Boolean, 'allDay')
|
||||
|
||||
if (i.options) {
|
||||
this.options = { ...this.options, ...i.options }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static fromLegacy (legacy: LegacyFeed): Partial<Feed> {
|
||||
const p: Partial<Feed> = {
|
||||
// legacy does not have resource,
|
||||
// we've used it with records only
|
||||
resource: feedResources.record,
|
||||
|
||||
...legacy,
|
||||
}
|
||||
|
||||
if (legacy.moduleID) {
|
||||
if (!p.options) {
|
||||
p.options = { ...defOptions }
|
||||
}
|
||||
|
||||
// module was moved under options
|
||||
p.options.moduleID = legacy.moduleID
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
}
|
||||
1
lib/js/src/compose/types/page-block/calendar/index.ts
Normal file
1
lib/js/src/compose/types/page-block/calendar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PageBlockCalendar } from './page-block'
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect } from 'chai'
|
||||
import { PageBlockCalendar } from './page-block'
|
||||
|
||||
describe('calendar', () => {
|
||||
describe('check namespace casting', () => {
|
||||
it('simple assignment', () => {
|
||||
const cal = new PageBlockCalendar({
|
||||
title: 'My Calendar',
|
||||
options: {
|
||||
defaultView: 'month',
|
||||
feeds: [
|
||||
{ endField: 'EndDateTime', moduleID: '69055788747849745', startField: 'ActivityDate', titleField: 'Subject' },
|
||||
{ endField: null, moduleID: '69055789049839633', startField: 'ActivityDate', titleField: 'Subject' },
|
||||
],
|
||||
header: { views: ['timeGridWeek', 'dayGridMonth', 'timeGridDay', 'dayGridMonth', 'month'] },
|
||||
},
|
||||
style: { variants: { bodyBg: 'white', border: 'dark', headerBg: 'white', headerText: 'dark' } },
|
||||
kind: 'Calendar',
|
||||
xywh: [0, 0, 6, 14],
|
||||
},
|
||||
)
|
||||
|
||||
expect(cal.getHeader()).to.have.property('center').equal('title')
|
||||
expect(cal.getHeader()).to.have.property('right').equal('dayGridMonth,timeGridWeek,timeGridDay')
|
||||
})
|
||||
})
|
||||
})
|
||||
167
lib/js/src/compose/types/page-block/calendar/page-block.ts
Normal file
167
lib/js/src/compose/types/page-block/calendar/page-block.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
import { PageBlock, Registry } from '../base'
|
||||
import Feed, { FeedInput } from './feed'
|
||||
import { ReminderFeed } from './feed-reminder'
|
||||
import { RecordFeed } from './feed-record'
|
||||
import { Apply } from '../../../../cast'
|
||||
|
||||
const kind = 'Calendar'
|
||||
|
||||
// Map of < V4 view names to >= V4 view names
|
||||
const legacyViewMapping: {[old: string]: string} = {
|
||||
month: 'dayGridMonth',
|
||||
agendaMonth: 'dayGridMonth',
|
||||
agendaWeek: 'timeGridWeek',
|
||||
agendaDay: 'timeGridDay',
|
||||
listMonth: 'listMonth',
|
||||
}
|
||||
|
||||
interface Header {
|
||||
left: string;
|
||||
center: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
interface CalendarOptionsHeader {
|
||||
hide: boolean;
|
||||
views: string[];
|
||||
hidePrevNext: boolean;
|
||||
hideToday: boolean;
|
||||
hideTitle: boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
defaultView: string;
|
||||
feeds: Array<Feed>;
|
||||
header: Partial<CalendarOptionsHeader>;
|
||||
locale: string;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
eventDisplayOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
defaultView: '',
|
||||
feeds: [],
|
||||
header: {},
|
||||
locale: 'en-gb',
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
eventDisplayOption: 'sameTab',
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper class to help define calendar's functionality
|
||||
*/
|
||||
export class PageBlockCalendar extends PageBlock {
|
||||
readonly kind = kind
|
||||
public options: Options = { ...defaults }
|
||||
|
||||
static feedResources = Object.freeze({
|
||||
record: 'compose:record',
|
||||
reminder: 'system:reminder',
|
||||
})
|
||||
|
||||
constructor (i?: PageBlock | Partial<PageBlock>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
|
||||
Apply(this.options, o, String, 'magnifyOption', 'eventDisplayOption')
|
||||
|
||||
this.options.defaultView = PageBlockCalendar.handleLegacyView(o.defaultView) || 'dayGridMonth'
|
||||
this.options.feeds = (o.feeds || []).map(f => new Feed(f))
|
||||
this.options.header = merge(
|
||||
{},
|
||||
this.options.header,
|
||||
o.header,
|
||||
{ views: PageBlockCalendar.handleLegacyViews(o.header?.views || ['dayGridMonth', 'timeGridWeek', 'timeGridDay', 'listMonth']) },
|
||||
)
|
||||
|
||||
this.options.locale = o.locale || 'en-gb'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a header object of fullcalendar
|
||||
* @returns {Object}
|
||||
*/
|
||||
getHeader (): Header|undefined {
|
||||
const h = this.options.header
|
||||
if (h.hide) {
|
||||
return
|
||||
}
|
||||
|
||||
// Show view buttons only when 2 or more are selected
|
||||
let right = ''
|
||||
|
||||
if (h.views && h.views.length >= 2) {
|
||||
right = this.reorderViews(h.views).join(',')
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${h.hidePrevNext ? '' : 'prev,next'} ${h.hideToday ? '' : 'today'}`.trim(),
|
||||
center: `${h.hideTitle ? '' : 'title'}`,
|
||||
right,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a list of available views.
|
||||
* @note When adding new ones, make sure included plugins support it.
|
||||
* @returns {Array}
|
||||
*/
|
||||
static availableViews (): Array<string> {
|
||||
return [
|
||||
'dayGridMonth',
|
||||
'timeGridWeek',
|
||||
'timeGridDay',
|
||||
'listMonth',
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder views according to available views array order.
|
||||
* @param {Array} views Array of views to filter & sort
|
||||
*/
|
||||
reorderViews (views: string[] = []): Array<string> {
|
||||
return PageBlockCalendar.availableViews()
|
||||
.filter(v => views.find(fv => fv === v))
|
||||
.map(v => v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts old < V4 view names to >= V4 view names.
|
||||
* @note It wil preserve fields that don't need to/can't be converted
|
||||
* @param {string} views converted view name
|
||||
*/
|
||||
static handleLegacyView (views = 'dayGridMonth'): string {
|
||||
return legacyViewMapping[views] || views
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts old < V4 view names to >= V4 view names.
|
||||
* @note It wil preserve fields that don't need to/can't be converted
|
||||
* @param {string[]} views converted view names
|
||||
*/
|
||||
static handleLegacyViews (views: string[]): string[] {
|
||||
return views.map(v => legacyViewMapping[v] || v)
|
||||
}
|
||||
|
||||
static makeFeed (f?: FeedInput): Feed {
|
||||
return new Feed(f)
|
||||
}
|
||||
|
||||
static ReminderFeed = ReminderFeed
|
||||
static RecordFeed = RecordFeed
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockCalendar)
|
||||
@@ -0,0 +1,4 @@
|
||||
export const feedResources = {
|
||||
record: 'compose:record',
|
||||
reminder: 'system:reminder',
|
||||
}
|
||||
47
lib/js/src/compose/types/page-block/calendar/shared.ts
Normal file
47
lib/js/src/compose/types/page-block/calendar/shared.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import hr from 'hex-rgb'
|
||||
|
||||
const isLightThreshold = 100
|
||||
const bgAlpha = 1
|
||||
|
||||
export const rgbaRegex = /^rgba\((\d+),.*?(\d+),.*?(\d+),.*?(\d*\.?\d*)\)$/
|
||||
|
||||
const ln = (n: number): number => Math.round(n < 0 ? 255 + n : (n > 255) ? n - 255 : n)
|
||||
export const toRGBA = ([r, g, b, a]: number[]): string =>
|
||||
`rgba(${ln(r)}, ${ln(g)}, ${ln(b)}, ${a})`
|
||||
|
||||
interface Colors {
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine event's colors
|
||||
* @param {String} hex Base color in HEX format
|
||||
* @returns {Object} { backgroundColor: String, borderColor: String, isLight: Boolean }
|
||||
*/
|
||||
export function makeColors (hex: string): Colors {
|
||||
const bg = hr(hex, { format: 'array' })
|
||||
const br = [...bg]
|
||||
const isLight = (bg.slice(0, 3).reduce((acc, cur) => acc + cur, 0) / (bg.length - 1)) > isLightThreshold
|
||||
|
||||
return {
|
||||
textColor: isLight ? '#000' : '#fff',
|
||||
backgroundColor: `rgba(${bg.join(',')})`,
|
||||
borderColor: `rgba(${br.join(',')})`,
|
||||
}
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
groupId?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
allDay: boolean;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
textColor: string;
|
||||
classNames: string[];
|
||||
extendedProps: object;
|
||||
}
|
||||
68
lib/js/src/compose/types/page-block/chart.ts
Normal file
68
lib/js/src/compose/types/page-block/chart.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply, NoID } from '../../../cast'
|
||||
import { Options as PageBlockRecordListOptions } from './record-list'
|
||||
import lodash from 'lodash'
|
||||
const { cloneDeep, merge } = lodash
|
||||
|
||||
const kind = 'Chart'
|
||||
|
||||
interface DrillDown {
|
||||
enabled: boolean;
|
||||
blockID: string;
|
||||
recordListOptions: Partial<PageBlockRecordListOptions>;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
chartID: string;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
drillDown: DrillDown;
|
||||
liveFilterEnabled: boolean;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
chartID: '',
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
liveFilterEnabled: false,
|
||||
drillDown: {
|
||||
enabled: false,
|
||||
blockID: '',
|
||||
recordListOptions: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export class PageBlockChart extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
o.chartID = o.chartID === NoID ? '' : o.chartID
|
||||
|
||||
Apply(this.options, o, String, 'chartID', 'magnifyOption')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh', 'liveFilterEnabled')
|
||||
|
||||
if (o.drillDown) {
|
||||
this.options.drillDown = merge({}, defaults.drillDown, o.drillDown)
|
||||
}
|
||||
}
|
||||
|
||||
resetDrillDown (): void {
|
||||
this.options.drillDown = cloneDeep(defaults.drillDown)
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockChart)
|
||||
54
lib/js/src/compose/types/page-block/comment.ts
Normal file
54
lib/js/src/compose/types/page-block/comment.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Apply, CortezaID, NoID } from '../../../cast';
|
||||
import { PageBlock, PageBlockInput, Registry } from './base';
|
||||
|
||||
const kind = 'Comment'
|
||||
interface Options {
|
||||
moduleID: string;
|
||||
filter: string;
|
||||
titleField: string;
|
||||
contentField: string;
|
||||
replyField: string;
|
||||
referenceField: string;
|
||||
sortDirection: string;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
attachmentField: string;
|
||||
reactionsField: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
moduleID: NoID,
|
||||
filter: '',
|
||||
titleField: '',
|
||||
contentField: '',
|
||||
replyField: '',
|
||||
sortDirection: 'asc',
|
||||
referenceField: '',
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
attachmentField: '',
|
||||
reactionsField: '',
|
||||
})
|
||||
|
||||
export class PageBlockComment extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
Apply(this.options, o, CortezaID, 'moduleID')
|
||||
Apply(this.options, o, String, 'titleField', 'contentField', 'replyField', 'referenceField', 'attachmentField', 'reactionsField', 'filter', 'sortDirection', 'magnifyOption')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockComment)
|
||||
33
lib/js/src/compose/types/page-block/content.ts
Normal file
33
lib/js/src/compose/types/page-block/content.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Content'
|
||||
|
||||
interface Options {
|
||||
body: string;
|
||||
magnifyOption: string
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
body: '',
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockContent extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, String, 'body', 'magnifyOption')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockContent)
|
||||
83
lib/js/src/compose/types/page-block/file.ts
Normal file
83
lib/js/src/compose/types/page-block/file.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'File'
|
||||
|
||||
interface Options {
|
||||
mode: string;
|
||||
attachments: string[];
|
||||
hideFileName: boolean;
|
||||
height: string;
|
||||
width: string;
|
||||
maxHeight: string;
|
||||
maxWidth: string;
|
||||
borderRadius: string;
|
||||
margin: string;
|
||||
backgroundColor: string;
|
||||
magnifyOption: string;
|
||||
clickToView?: boolean;
|
||||
enableDownload?: boolean;
|
||||
}
|
||||
|
||||
const PageBlockFileDefaultMode = 'list'
|
||||
const PageBlockFileModes = [
|
||||
// list of attachments, no preview
|
||||
'list',
|
||||
// list of all images/files, show preview
|
||||
'gallery',
|
||||
]
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
mode: PageBlockFileDefaultMode,
|
||||
attachments: [],
|
||||
hideFileName: false,
|
||||
height: '',
|
||||
width: '',
|
||||
maxHeight: '',
|
||||
maxWidth: '',
|
||||
borderRadius: '',
|
||||
margin: 'auto',
|
||||
backgroundColor: '#FFFFFF00',
|
||||
magnifyOption: '',
|
||||
clickToView: true,
|
||||
enableDownload: true,
|
||||
})
|
||||
|
||||
export class PageBlockFile extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
if (o.attachments) {
|
||||
this.options.attachments = o.attachments
|
||||
}
|
||||
|
||||
Apply(this.options, o, Boolean, 'hideFileName', 'clickToView', 'enableDownload')
|
||||
Apply(this.options, o, String, 'height', 'width', 'maxHeight', 'maxWidth', 'borderRadius', 'margin', 'backgroundColor', 'magnifyOption')
|
||||
|
||||
if (o.mode) {
|
||||
// Legacy
|
||||
if (o.mode === 'single') {
|
||||
o.mode = 'gallery'
|
||||
} else if (o.mode === 'grid') {
|
||||
o.mode = 'list'
|
||||
}
|
||||
|
||||
if (PageBlockFileModes.includes(o.mode)) {
|
||||
this.options.mode = o.mode
|
||||
} else {
|
||||
o.mode = PageBlockFileDefaultMode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockFile)
|
||||
35
lib/js/src/compose/types/page-block/geometry/feed-record.ts
Normal file
35
lib/js/src/compose/types/page-block/geometry/feed-record.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Record } from '../../record'
|
||||
import { Namespace } from '../../namespace'
|
||||
import { Module } from '../../module'
|
||||
import { Compose as ComposeAPI } from '../../../../api-clients'
|
||||
|
||||
interface FeedOptions {
|
||||
color: string;
|
||||
prefilter: string;
|
||||
}
|
||||
|
||||
interface Feed {
|
||||
titleField: string;
|
||||
options: FeedOptions;
|
||||
}
|
||||
|
||||
interface Range {
|
||||
end: Date;
|
||||
start: Date;
|
||||
}
|
||||
|
||||
export async function RecordFeed ($ComposeAPI: ComposeAPI, module: Module, namespace: Namespace, feed: Feed, options = {}): Promise<any[]> {
|
||||
// Params for record fetching
|
||||
const params = {
|
||||
namespaceID: namespace.namespaceID,
|
||||
moduleID: module.moduleID,
|
||||
query: feed.options.prefilter,
|
||||
}
|
||||
|
||||
const events: Array<any> = []
|
||||
return $ComposeAPI.recordList(params, options).then(({ set }) => {
|
||||
return (set as Array<{ recordID: string }>)
|
||||
// cast & freeze
|
||||
.map(r => Object.freeze(new Record(module, r)))
|
||||
})
|
||||
}
|
||||
49
lib/js/src/compose/types/page-block/geometry/feed.ts
Normal file
49
lib/js/src/compose/types/page-block/geometry/feed.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Apply, NoID } from '../../../../cast'
|
||||
import { IsOf } from '../../../../guards'
|
||||
|
||||
interface FeedOptions {
|
||||
color: string;
|
||||
prefilter: string;
|
||||
moduleID: string;
|
||||
}
|
||||
|
||||
export type FeedInput = Partial<Feed> | Feed
|
||||
|
||||
const defOptions = {
|
||||
moduleID: NoID,
|
||||
color: '#FF9661',
|
||||
prefilter: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed class represents an event feed for the given calendar
|
||||
*/
|
||||
export default class Feed {
|
||||
public resource = 'compose:record'
|
||||
public titleField = ''
|
||||
public geometryField = ''
|
||||
public displayMarker = true
|
||||
public displayPolygon = false
|
||||
public options: FeedOptions = { ...defOptions }
|
||||
|
||||
constructor (i?: FeedInput) {
|
||||
this.apply(i)
|
||||
}
|
||||
|
||||
apply (i?: FeedInput): void {
|
||||
if (!i) return
|
||||
|
||||
if (IsOf<Feed>(i, 'resource')) {
|
||||
Apply(this, i, String, 'resource', 'titleField', 'geometryField')
|
||||
Apply(this, i, Boolean, 'displayMarker', 'displayPolygon')
|
||||
|
||||
if (i.options) {
|
||||
this.options = { ...this.options, ...i.options }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isValid (): boolean {
|
||||
return this.options.moduleID !== NoID && !!this.geometryField
|
||||
}
|
||||
}
|
||||
1
lib/js/src/compose/types/page-block/geometry/index.ts
Normal file
1
lib/js/src/compose/types/page-block/geometry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PageBlockGeometry } from './page-block'
|
||||
74
lib/js/src/compose/types/page-block/geometry/page-block.ts
Normal file
74
lib/js/src/compose/types/page-block/geometry/page-block.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Apply } from '../../../../cast'
|
||||
import { PageBlock, Registry } from '../base'
|
||||
import Feed, { FeedInput } from './feed'
|
||||
import { RecordFeed } from './feed-record'
|
||||
|
||||
const kind = 'Geometry'
|
||||
|
||||
type Bounds = number[][]
|
||||
|
||||
interface Options {
|
||||
defaultView: string;
|
||||
center: Array<number>;
|
||||
feeds: Array<Feed>;
|
||||
zoomStarting: number;
|
||||
zoomMin: number;
|
||||
zoomMax: number;
|
||||
bounds: Bounds | null;
|
||||
lockBounds: boolean;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
displayOption: string;
|
||||
hideGeoSearch: boolean;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
defaultView: '',
|
||||
center: [35, -30],
|
||||
feeds: [],
|
||||
zoomStarting: 2,
|
||||
zoomMin: 1,
|
||||
zoomMax: 18,
|
||||
bounds: null,
|
||||
lockBounds: false,
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
displayOption: 'sameTab',
|
||||
hideGeoSearch: true,
|
||||
})
|
||||
|
||||
export class PageBlockGeometry extends PageBlock {
|
||||
readonly kind = kind
|
||||
options: Options = { ...defaults }
|
||||
|
||||
static feedResources = Object.freeze({
|
||||
record: 'compose:record',
|
||||
})
|
||||
|
||||
constructor (i?: PageBlock | Partial<PageBlock>) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
this.options.feeds = (o.feeds || []).map(f => new Feed(f))
|
||||
this.options.center = (o.center || [])
|
||||
this.options.bounds = (o.bounds || null)
|
||||
|
||||
Apply(this.options, o, String, 'magnifyOption', 'displayOption')
|
||||
Apply(this.options, o, Number, 'zoomStarting', 'zoomMin', 'zoomMax', 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'lockBounds', 'showRefresh', 'hideGeoSearch')
|
||||
}
|
||||
|
||||
static makeFeed (f?: FeedInput): Feed {
|
||||
return new Feed(f)
|
||||
}
|
||||
|
||||
static RecordFeed = RecordFeed
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockGeometry)
|
||||
@@ -0,0 +1,3 @@
|
||||
export const feedResources = {
|
||||
record: 'compose:record',
|
||||
}
|
||||
43
lib/js/src/compose/types/page-block/iframe.ts
Normal file
43
lib/js/src/compose/types/page-block/iframe.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
import { PageBlockWrap } from './types'
|
||||
|
||||
const kind = 'IFrame'
|
||||
interface Options {
|
||||
srcField: string;
|
||||
src: string;
|
||||
wrap: PageBlockWrap;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
srcField: '',
|
||||
src: '',
|
||||
wrap: 'Plain',
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockIFrame extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, String, 'srcField', 'src', 'wrap', 'magnifyOption')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockIFrame)
|
||||
38
lib/js/src/compose/types/page-block/index.ts
Normal file
38
lib/js/src/compose/types/page-block/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PageBlock, Registry } from './base'
|
||||
export { PageBlockAutomation } from './automation'
|
||||
export { PageBlockChart } from './chart'
|
||||
export { PageBlockContent } from './content'
|
||||
export { PageBlockFile } from './file'
|
||||
export { PageBlockIFrame } from './iframe'
|
||||
export { PageBlockRecord } from './record'
|
||||
export { PageBlockRecordList } from './record-list'
|
||||
export { PageBlockRecordRevisions } from './record-revisions'
|
||||
export { PageBlockRecordOrganizer } from './record-organizer'
|
||||
export { PageBlockSocialFeed } from './social-feed'
|
||||
export { PageBlockCalendar } from './calendar'
|
||||
export { PageBlockMetric } from './metric'
|
||||
export { PageBlockComment } from './comment'
|
||||
export { PageBlockReport } from './report'
|
||||
export { PageBlockProgress } from './progress'
|
||||
export { PageBlockNavigation } from './navigation'
|
||||
export { PageBlockTab } from './tabs'
|
||||
export { PageBlockGeometry } from './geometry'
|
||||
|
||||
export function PageBlockMaker<T extends PageBlock> (i: { kind: string }): T {
|
||||
const PageBlockTemp = Registry.get(i.kind)
|
||||
if (PageBlockTemp === undefined) {
|
||||
throw new Error(`unknown block kind '${i.kind}'`)
|
||||
}
|
||||
|
||||
if (i instanceof PageBlock) {
|
||||
// Get rid of the references
|
||||
i = JSON.parse(JSON.stringify(i))
|
||||
}
|
||||
|
||||
return new PageBlockTemp(i) as T
|
||||
}
|
||||
|
||||
export {
|
||||
Registry as PageBlockRegistry,
|
||||
PageBlock,
|
||||
}
|
||||
161
lib/js/src/compose/types/page-block/metric.ts
Normal file
161
lib/js/src/compose/types/page-block/metric.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
import { Apply } from '../../../cast'
|
||||
import { Options as PageBlockRecordListOptions } from './record-list'
|
||||
const kind = 'Metric'
|
||||
|
||||
type Reporter = (p: ReporterParams) => Promise<any>
|
||||
|
||||
interface DrillDown {
|
||||
enabled: boolean;
|
||||
blockID: string;
|
||||
recordListOptions: Partial<PageBlockRecordListOptions>;
|
||||
}
|
||||
|
||||
interface ReporterParams {
|
||||
moduleID: string;
|
||||
filter?: string;
|
||||
metrics?: string;
|
||||
dimensions: string;
|
||||
}
|
||||
|
||||
interface Style {
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
label: string;
|
||||
moduleID: string;
|
||||
dimensionField: string;
|
||||
dateFormat?: string;
|
||||
filter?: string;
|
||||
bucketSize?: string;
|
||||
metricField: string;
|
||||
operation: string;
|
||||
numberFormat?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
transformFx?: string;
|
||||
|
||||
// @todo allow conditional styles; eg. if value is < 10 render with bold red text
|
||||
valueStyle?: Style;
|
||||
drillDown: DrillDown;
|
||||
}
|
||||
|
||||
const defaultMetric: Readonly<Metric> = Object.freeze({
|
||||
label: '',
|
||||
moduleID: '',
|
||||
dimensionField: '',
|
||||
dateFormat: '',
|
||||
filter: '',
|
||||
bucketSize: '',
|
||||
metricField: '',
|
||||
operation: '',
|
||||
numberFormat: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
transformFx: '',
|
||||
|
||||
valueStyle: {
|
||||
backgroundColor: '#FFFFFF00',
|
||||
color: '#000000',
|
||||
fontSize: undefined,
|
||||
},
|
||||
|
||||
drillDown: {
|
||||
enabled: false,
|
||||
blockID: '',
|
||||
recordListOptions: {
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
interface Options {
|
||||
metrics: Array<Metric>;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
metrics: [],
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockMetric extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
Apply(this.options, o, String, 'magnifyOption')
|
||||
if (o.metrics) {
|
||||
this.options.metrics = o.metrics.map((m) => merge({}, defaultMetric, m))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to fetch and parse reporter's reports.
|
||||
*/
|
||||
async fetch ({ m }: { m: Metric }, reporter: Reporter): Promise<object> {
|
||||
const w = await reporter(this.formatParams(m))
|
||||
const datasets = w.map((r: any) => r.rp !== undefined ? r.rp : r.count)
|
||||
|
||||
let rtr: number
|
||||
if (m.operation === 'max') {
|
||||
rtr = datasets.sort((a: number, b: number) => b - a)[0]
|
||||
} else if (m.operation === 'min') {
|
||||
rtr = datasets.sort((a: number, b: number) => a - b)[0]
|
||||
} else if (m.operation === 'avg') {
|
||||
rtr = datasets.reduce((acc: number, cur: number) => acc + cur, 0) / datasets.length
|
||||
} else {
|
||||
rtr = datasets.reduce((acc: number, cur: number) => acc + cur, 0)
|
||||
}
|
||||
|
||||
if (m.transformFx) {
|
||||
// eslint-disable-next-line no-new-func
|
||||
rtr = (new Function('v', `return ${m.transformFx}`))(rtr)
|
||||
}
|
||||
|
||||
return [{ value: rtr }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to construct reporter's params
|
||||
*/
|
||||
private formatParams ({ moduleID, filter, metricField, operation = '' }: Metric): ReporterParams {
|
||||
let metrics = ''
|
||||
|
||||
if (operation && metricField && metricField !== 'count') {
|
||||
metrics = `${operation}(${metricField}) AS rp`
|
||||
}
|
||||
|
||||
return {
|
||||
moduleID,
|
||||
filter,
|
||||
metrics,
|
||||
// Since metric produces one value we want one dataset, deletedAt is the same for all existing records
|
||||
dimensions: 'deletedAt',
|
||||
}
|
||||
}
|
||||
|
||||
makeMetric () {
|
||||
return merge({}, defaultMetric)
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockMetric)
|
||||
54
lib/js/src/compose/types/page-block/navigation/index.ts
Normal file
54
lib/js/src/compose/types/page-block/navigation/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from '../base'
|
||||
import NavigationItem, { NavigationItemInput } from './navigation-item'
|
||||
import { Apply } from '../../../../cast'
|
||||
|
||||
const kind = 'Navigation'
|
||||
|
||||
interface DisplayOptions {
|
||||
appearance: string;
|
||||
alignment: string;
|
||||
justify: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
display: DisplayOptions;
|
||||
navigationItems: NavigationItem[];
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
display: {
|
||||
appearance: 'pills',
|
||||
alignment: 'center',
|
||||
justify: 'justify',
|
||||
},
|
||||
navigationItems: [],
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockNavigation extends PageBlock {
|
||||
readonly kind = kind;
|
||||
|
||||
options: Options = { ...defaults };
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, String, 'magnifyOption')
|
||||
|
||||
this.options.navigationItems = (o.navigationItems || []).map(f => new NavigationItem(f))
|
||||
|
||||
this.options.display = { ...this.options.display, ...o.display }
|
||||
}
|
||||
|
||||
static makeNavigationItem (item?: NavigationItemInput): NavigationItem {
|
||||
return new NavigationItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockNavigation)
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Apply } from '../../../../cast'
|
||||
import { PageBlock } from '../base'
|
||||
|
||||
interface DropdownItem {
|
||||
label: string;
|
||||
url: string;
|
||||
delimiter: boolean;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface Dropdown {
|
||||
label: string;
|
||||
items: DropdownItem[]
|
||||
}
|
||||
|
||||
interface ItemOptions {
|
||||
label: string;
|
||||
url: string;
|
||||
target: string;
|
||||
delimiter: boolean;
|
||||
pageID: string;
|
||||
pageLayoutID: string;
|
||||
moduleID: string;
|
||||
displaySubPages: boolean;
|
||||
dropdown: Dropdown;
|
||||
align: string;
|
||||
}
|
||||
|
||||
interface NavigationItemOptions {
|
||||
enabled: boolean;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
item: ItemOptions;
|
||||
}
|
||||
|
||||
export type NavigationItemInput = Partial<NavigationItem> | NavigationItem
|
||||
|
||||
const defOptions = {
|
||||
enabled: true,
|
||||
textColor: '#FF9661',
|
||||
backgroundColor: '',
|
||||
item: {
|
||||
label: '',
|
||||
url: '',
|
||||
target: '',
|
||||
delimiter: false,
|
||||
pageID: '',
|
||||
pageLayoutID: '',
|
||||
moduleID: '',
|
||||
displaySubPages: false,
|
||||
align: 'bottom',
|
||||
dropdown: {
|
||||
label: '',
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default class NavigationItem {
|
||||
public type = ''
|
||||
|
||||
public options: NavigationItemOptions = { ...defOptions }
|
||||
|
||||
constructor (i?: NavigationItemInput) {
|
||||
this.apply(i)
|
||||
}
|
||||
|
||||
apply (i?: NavigationItemInput): void {
|
||||
if (!i) return
|
||||
|
||||
Apply(this, i, String, 'type')
|
||||
|
||||
if (i.options) {
|
||||
this.options = { ...this.options, ...i.options }
|
||||
}
|
||||
}
|
||||
}
|
||||
208
lib/js/src/compose/types/page-block/progress.ts
Normal file
208
lib/js/src/compose/types/page-block/progress.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import _ from 'lodash'
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { dimensionFunctions } from '../chart/util'
|
||||
import { Compose as ComposeAPI } from '../../../api-clients'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Progress'
|
||||
|
||||
interface ValueOptions {
|
||||
default: number;
|
||||
moduleID: string;
|
||||
filter: string;
|
||||
field: string;
|
||||
operation: string;
|
||||
}
|
||||
|
||||
interface Threshold {
|
||||
value: number;
|
||||
variant: string;
|
||||
}
|
||||
|
||||
interface DisplayOptions {
|
||||
showValue: boolean;
|
||||
showRelative: boolean;
|
||||
showProgress: boolean;
|
||||
animated: boolean;
|
||||
variant: string;
|
||||
thresholds: Threshold[];
|
||||
}
|
||||
|
||||
interface Options {
|
||||
value: ValueOptions;
|
||||
minValue: ValueOptions;
|
||||
maxValue: ValueOptions;
|
||||
display: DisplayOptions;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
value: {
|
||||
default: 0,
|
||||
moduleID: '',
|
||||
filter: '',
|
||||
field: '',
|
||||
operation: '',
|
||||
},
|
||||
|
||||
minValue: {
|
||||
default: 0,
|
||||
moduleID: '',
|
||||
filter: '',
|
||||
field: '',
|
||||
operation: '',
|
||||
},
|
||||
|
||||
maxValue: {
|
||||
default: 100,
|
||||
moduleID: '',
|
||||
filter: '',
|
||||
field: '',
|
||||
operation: '',
|
||||
},
|
||||
|
||||
display: {
|
||||
showValue: true,
|
||||
showRelative: true,
|
||||
showProgress: false,
|
||||
animated: false,
|
||||
variant: 'success',
|
||||
thresholds: [],
|
||||
},
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockProgress extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
Apply(this.options, o, String, 'magnifyOption')
|
||||
|
||||
if (o.value) {
|
||||
this.options.value = { ...this.options.value, ...o.value }
|
||||
}
|
||||
|
||||
if (o.minValue) {
|
||||
this.options.minValue = { ...this.options.minValue, ...o.minValue }
|
||||
}
|
||||
|
||||
if (o.maxValue) {
|
||||
this.options.maxValue = { ...this.options.maxValue, ...o.maxValue }
|
||||
}
|
||||
|
||||
if (o.display) {
|
||||
this.options.display = { ...this.options.display, ...o.display }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to fetch and parse reporter's reports.
|
||||
*/
|
||||
fetch (additionalOptions: Options, api: ComposeAPI, namespaceID: string): Promise<object> {
|
||||
const reports = []
|
||||
const dimensions = dimensionFunctions.convert({ modifier: 'YEAR', field: 'createdAt' })
|
||||
|
||||
let metrics = ''
|
||||
|
||||
// Construct value report
|
||||
const { field: valueField, operation: valueOperation = '' } = this.options.value
|
||||
|
||||
if (this.options.value.moduleID && valueField) {
|
||||
if (valueOperation && valueField !== 'count') {
|
||||
metrics = `${valueOperation}(${valueField}) AS rp`
|
||||
}
|
||||
|
||||
reports.push(api.recordReport({ namespaceID, metrics, dimensions, ...this.options.value, ...additionalOptions.value }))
|
||||
} else {
|
||||
reports.push(new Promise(resolve => resolve(this.options.value.default)))
|
||||
}
|
||||
|
||||
// Construct minValue report
|
||||
const { field: minValueField, operation: minValueOperation = '' } = this.options.minValue
|
||||
|
||||
if (this.options.minValue.moduleID && minValueField) {
|
||||
metrics = ''
|
||||
if (minValueOperation && minValueField !== 'count') {
|
||||
metrics = `${minValueOperation}(${minValueField}) AS rp`
|
||||
}
|
||||
|
||||
reports.push(api.recordReport({ namespaceID, metrics, dimensions, ...this.options.minValue, ...additionalOptions.minValue }))
|
||||
} else {
|
||||
reports.push(new Promise(resolve => resolve(this.options.minValue.default)))
|
||||
}
|
||||
|
||||
// Construct minValue report
|
||||
const { field: maxValueField, operation: maxValueOperation = '' } = this.options.maxValue
|
||||
|
||||
if (this.options.maxValue.moduleID && maxValueField) {
|
||||
metrics = ''
|
||||
if (maxValueOperation && maxValueField !== 'count') {
|
||||
metrics = `${maxValueOperation}(${maxValueField}) AS rp`
|
||||
}
|
||||
|
||||
reports.push(api.recordReport({ namespaceID, metrics, dimensions, ...this.options.maxValue, ...additionalOptions.maxValue }))
|
||||
} else {
|
||||
reports.push(new Promise(resolve => resolve(this.options.maxValue.default)))
|
||||
}
|
||||
|
||||
return Promise.all(reports).then(([value, min, max]: Array<any>) => {
|
||||
if (Array.isArray(value)) {
|
||||
const datasets = value.map((r: any) => r.rp !== undefined ? r.rp : r.count)
|
||||
if (valueOperation === 'max') {
|
||||
value = datasets.sort((a: number, b: number) => b - a)[0]
|
||||
} else if (valueOperation === 'min') {
|
||||
value = datasets.sort((a: number, b: number) => a - b)[0]
|
||||
} else if (valueOperation === 'avg') {
|
||||
value = datasets.reduce((acc: number, cur: number) => acc + cur, 0) / datasets.length
|
||||
} else {
|
||||
value = datasets.reduce((acc: number, cur: number) => acc + cur, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(min)) {
|
||||
const datasets = min.map((r: any) => r.rp !== undefined ? r.rp : r.count)
|
||||
if (minValueOperation === 'max') {
|
||||
min = datasets.sort((a: number, b: number) => b - a)[0]
|
||||
} else if (minValueOperation === 'min') {
|
||||
min = datasets.sort((a: number, b: number) => a - b)[0]
|
||||
} else if (minValueOperation === 'avg') {
|
||||
min = datasets.reduce((acc: number, cur: number) => acc + cur, 0) / datasets.length
|
||||
} else {
|
||||
min = datasets.reduce((acc: number, cur: number) => acc + cur, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(max)) {
|
||||
const datasets = max.map((r: any) => r.rp !== undefined ? r.rp : r.count)
|
||||
if (maxValueOperation === 'max') {
|
||||
max = datasets.sort((a: number, b: number) => b - a)[0]
|
||||
} else if (maxValueOperation === 'min') {
|
||||
max = datasets.sort((a: number, b: number) => a - b)[0]
|
||||
} else if (maxValueOperation === 'avg') {
|
||||
max = datasets.reduce((acc: number, cur: number) => acc + cur, 0) / datasets.length
|
||||
} else {
|
||||
max = datasets.reduce((acc: number, cur: number) => acc + cur, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return { value, min, max }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockProgress)
|
||||
297
lib/js/src/compose/types/page-block/record-list.ts
Normal file
297
lib/js/src/compose/types/page-block/record-list.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply, CortezaID, NoID } from '../../../cast'
|
||||
import { Compose as ComposeAPI } from '../../../api-clients'
|
||||
import { Module } from '../module'
|
||||
import { Button } from './types'
|
||||
|
||||
const kind = 'RecordList'
|
||||
|
||||
interface FilterPreset {
|
||||
name: string;
|
||||
filter: unknown[];
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
enum SummaryMetric {
|
||||
Min = 'min',
|
||||
Max = 'max',
|
||||
Avg = 'avg',
|
||||
Sum = 'sum',
|
||||
EmptyCount = 'emptyCount',
|
||||
NotEmptyCount = 'notEmptyCount',
|
||||
UniqueCount = 'uniqueCount',
|
||||
Earliest = 'earliest',
|
||||
Latest = 'latest',
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
label: string;
|
||||
field: string[];
|
||||
metric: SummaryMetric;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
moduleID: string;
|
||||
prefilter: string;
|
||||
presort: string;
|
||||
fields: unknown[];
|
||||
inlineEditFields: unknown[];
|
||||
hideHeader: boolean;
|
||||
hideAddButton: boolean;
|
||||
hideImportButton: boolean;
|
||||
hideConfigureFieldsButton: boolean;
|
||||
hideSearch: boolean;
|
||||
hidePaging: boolean;
|
||||
hideSorting: boolean;
|
||||
hideFiltering: boolean;
|
||||
hideRecordReminderButton: boolean;
|
||||
hideRecordCloneButton: boolean;
|
||||
hideRecordEditButton: boolean;
|
||||
hideRecordViewButton: boolean;
|
||||
hideRecordPermissionsButton: boolean;
|
||||
hideRecordDeleteButton: boolean;
|
||||
enableRecordPageNavigation: boolean;
|
||||
allowExport: boolean;
|
||||
perPage: number;
|
||||
recordDisplayOption: string;
|
||||
recordSelectorDisplayOption: string;
|
||||
addRecordDisplayOption: string;
|
||||
magnifyOption: string;
|
||||
searchableFields: string[];
|
||||
searchSubmitMode: string;
|
||||
|
||||
fullPageNavigation: boolean;
|
||||
showTotalCount: boolean;
|
||||
showDeletedRecordsOption: boolean;
|
||||
customFilterPresets: boolean;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
|
||||
// Record-lines
|
||||
editable: boolean;
|
||||
draggable?: boolean;
|
||||
positionField?: string;
|
||||
refField?: string; // When adding a new record, prefill refField value with parent record ID
|
||||
editFields?: unknown[];
|
||||
linkToParent: boolean; // Legacy
|
||||
|
||||
// Should records be opened in a new tab
|
||||
// legacy field that has been removed but we keep it for backwards compatibility
|
||||
openInNewTab: boolean;
|
||||
|
||||
// Are table rows selectable
|
||||
selectable: boolean;
|
||||
selectMode: 'multi' | 'single' | 'range';
|
||||
|
||||
// Ordered list of buttons to display in the block
|
||||
selectionButtons: Array<Button>;
|
||||
|
||||
bulkRecordEditEnabled: boolean;
|
||||
inlineRecordEditEnabled: boolean;
|
||||
inlineRecordEditAllowAddField: boolean;
|
||||
inlineValueFiltering: boolean;
|
||||
filterPresets: FilterPreset[];
|
||||
showRecordPerPageOption: boolean;
|
||||
openRecordInEditMode: boolean;
|
||||
|
||||
// Summaries
|
||||
customSummaries: boolean;
|
||||
summaries: Summary[];
|
||||
|
||||
textStyles: {
|
||||
wrappedFields: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
moduleID: NoID,
|
||||
prefilter: '',
|
||||
presort: 'createdAt DESC',
|
||||
fields: [],
|
||||
inlineEditFields: [],
|
||||
hideHeader: false,
|
||||
hideAddButton: false,
|
||||
hideImportButton: false,
|
||||
hideConfigureFieldsButton: true,
|
||||
hideSearch: false,
|
||||
hidePaging: false,
|
||||
hideSorting: false,
|
||||
hideFiltering: false,
|
||||
hideRecordReminderButton: false,
|
||||
hideRecordCloneButton: false,
|
||||
hideRecordEditButton: false,
|
||||
hideRecordViewButton: false,
|
||||
hideRecordPermissionsButton: false,
|
||||
hideRecordDeleteButton: false,
|
||||
enableRecordPageNavigation: true,
|
||||
allowExport: true,
|
||||
perPage: 20,
|
||||
recordDisplayOption: 'sameTab',
|
||||
recordSelectorDisplayOption: 'sameTab',
|
||||
addRecordDisplayOption: 'sameTab',
|
||||
magnifyOption: '',
|
||||
searchableFields: [],
|
||||
searchSubmitMode: 'submit',
|
||||
|
||||
fullPageNavigation: false,
|
||||
showTotalCount: true,
|
||||
showDeletedRecordsOption: false,
|
||||
customFilterPresets: false,
|
||||
|
||||
editable: false,
|
||||
draggable: false,
|
||||
positionField: undefined,
|
||||
refField: undefined,
|
||||
editFields: [],
|
||||
|
||||
linkToParent: false,
|
||||
|
||||
openInNewTab: false,
|
||||
|
||||
selectable: true,
|
||||
selectMode: 'multi',
|
||||
|
||||
selectionButtons: [],
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
|
||||
bulkRecordEditEnabled: true,
|
||||
inlineRecordEditEnabled: false,
|
||||
inlineRecordEditAllowAddField: false,
|
||||
inlineValueFiltering: false,
|
||||
filterPresets: [],
|
||||
showRecordPerPageOption: false,
|
||||
openRecordInEditMode: false,
|
||||
|
||||
customSummaries: false,
|
||||
summaries: [],
|
||||
|
||||
textStyles: {
|
||||
wrappedFields: [],
|
||||
},
|
||||
})
|
||||
|
||||
export class PageBlockRecordList extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, CortezaID, 'moduleID')
|
||||
|
||||
Apply(this.options, o, String,
|
||||
'prefilter',
|
||||
'presort',
|
||||
'selectMode',
|
||||
'positionField',
|
||||
'refField',
|
||||
'recordDisplayOption',
|
||||
'magnifyOption',
|
||||
'recordSelectorDisplayOption',
|
||||
'addRecordDisplayOption',
|
||||
'searchSubmitMode',
|
||||
)
|
||||
|
||||
Apply(this.options, o, Number, 'perPage', 'refreshRate')
|
||||
|
||||
if (o.fields) {
|
||||
this.options.fields = o.fields
|
||||
}
|
||||
|
||||
if (o.searchableFields) {
|
||||
this.options.searchableFields = o.searchableFields
|
||||
}
|
||||
|
||||
if (o.inlineEditFields) {
|
||||
this.options.inlineEditFields = o.inlineEditFields
|
||||
}
|
||||
|
||||
if (o.filterPresets) {
|
||||
this.options.filterPresets = o.filterPresets
|
||||
}
|
||||
|
||||
if (o.editFields) {
|
||||
this.options.editFields = o.editFields
|
||||
}
|
||||
|
||||
if (o.openInNewTab) {
|
||||
this.options.recordDisplayOption = 'newTab'
|
||||
}
|
||||
|
||||
Apply(this.options, o, Boolean,
|
||||
'hideHeader',
|
||||
'hideAddButton',
|
||||
'hideImportButton',
|
||||
'hideConfigureFieldsButton',
|
||||
'hideSearch',
|
||||
'hidePaging',
|
||||
'hideFiltering',
|
||||
'fullPageNavigation',
|
||||
'showTotalCount',
|
||||
'showDeletedRecordsOption',
|
||||
'customFilterPresets',
|
||||
'hideSorting',
|
||||
'allowExport',
|
||||
'selectable',
|
||||
'hideRecordReminderButton',
|
||||
'hideRecordCloneButton',
|
||||
'hideRecordEditButton',
|
||||
'hideRecordViewButton',
|
||||
'hideRecordPermissionsButton',
|
||||
'hideRecordDeleteButton',
|
||||
'enableRecordPageNavigation',
|
||||
'editable',
|
||||
'draggable',
|
||||
'linkToParent',
|
||||
'showRefresh',
|
||||
'bulkRecordEditEnabled',
|
||||
'inlineRecordEditEnabled',
|
||||
'inlineRecordEditAllowAddField',
|
||||
'inlineValueFiltering',
|
||||
'showRecordPerPageOption',
|
||||
'openRecordInEditMode',
|
||||
'customSummaries',
|
||||
)
|
||||
|
||||
if (o.selectionButtons) {
|
||||
this.options.selectionButtons = o.selectionButtons.map(b => new Button(b))
|
||||
}
|
||||
|
||||
if (o.summaries) {
|
||||
this.options.summaries = o.summaries
|
||||
}
|
||||
|
||||
if (o.textStyles) {
|
||||
this.options.textStyles = {
|
||||
...this.options.textStyles,
|
||||
...o.textStyles,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetch (api: ComposeAPI, recordListModule: Module, filter: {[_: string]: unknown}): Promise<object> {
|
||||
if (recordListModule.moduleID !== this.options.moduleID) {
|
||||
throw Error('Module incompatible, module mismatch')
|
||||
}
|
||||
|
||||
filter.moduleID = this.options.moduleID
|
||||
filter.namespaceID = recordListModule.namespaceID
|
||||
|
||||
return api
|
||||
.recordList(filter)
|
||||
.then(r => {
|
||||
const { set: records, filter } = r as { filter: object; set: object[] }
|
||||
return { records, filter }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockRecordList)
|
||||
53
lib/js/src/compose/types/page-block/record-organizer.ts
Normal file
53
lib/js/src/compose/types/page-block/record-organizer.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply, CortezaID, NoID } from '../../../cast'
|
||||
|
||||
const kind = 'RecordOrganizer'
|
||||
interface Options {
|
||||
moduleID: string;
|
||||
labelField: string;
|
||||
descriptionField: string;
|
||||
filter: string;
|
||||
positionField: string;
|
||||
groupField: string;
|
||||
group: string;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
displayOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
moduleID: NoID,
|
||||
labelField: '',
|
||||
descriptionField: '',
|
||||
filter: '',
|
||||
positionField: '',
|
||||
groupField: '',
|
||||
group: '',
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
displayOption: 'sameTab',
|
||||
})
|
||||
|
||||
export class PageBlockRecordOrganizer extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, CortezaID, 'moduleID')
|
||||
Apply(this.options, o, String, 'labelField', 'descriptionField', 'filter', 'positionField', 'groupField', 'group', 'magnifyOption', 'displayOption')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockRecordOrganizer)
|
||||
83
lib/js/src/compose/types/page-block/record-revisions.ts
Normal file
83
lib/js/src/compose/types/page-block/record-revisions.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
import { Compose as ComposeAPI, System as SystemAPI } from '../../../api-clients'
|
||||
import { Record, Module } from '../../'
|
||||
import { User } from '../../../system'
|
||||
import { convertRevisionPayloadToRevision, RawRevisionPayload, Revision } from '../revision'
|
||||
|
||||
const kind = 'RecordRevisions'
|
||||
interface Options {
|
||||
// do we preload changes or not
|
||||
preload: boolean;
|
||||
|
||||
// what fields do we want to display
|
||||
// empty array means all fields
|
||||
displayedFields: string[];
|
||||
|
||||
// referenced fields (records, users) we want to expand
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
sortDirection: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
preload: false,
|
||||
displayedFields: [],
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
sortDirection: 'desc',
|
||||
})
|
||||
|
||||
export class PageBlockRecordRevisions extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, Boolean, 'preload', 'showRefresh')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, String, 'magnifyOption', 'sortDirection')
|
||||
|
||||
// set new values to displayed fields
|
||||
if (Array.isArray(o?.displayedFields)) {
|
||||
this.options.displayedFields = o.displayedFields.map(String)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch is a utility method on record revision page block
|
||||
* that fetches revisions for a record and converts them to RevisionPayload class
|
||||
*
|
||||
* this function also strips out all fields that should not be dispalyed
|
||||
* (as per displayedFields option)
|
||||
*
|
||||
* @param api Compose API to be used
|
||||
* @param record Record to fetch revisions for
|
||||
* @param sortDirection Sort direction ('asc' for oldest first, 'desc' for newest first)
|
||||
*/
|
||||
async fetch (api: ComposeAPI, record: Record, sortDirection?: string): Promise<Array<Revision>> {
|
||||
const { namespaceID, moduleID, recordID } = record
|
||||
|
||||
// Build sort parameter based on sortDirection
|
||||
// Default to 'desc' (newest first) if not specified
|
||||
const sort = sortDirection === 'asc' ? 'revision ASC' : 'revision DESC'
|
||||
|
||||
return api
|
||||
.recordRevisions({ namespaceID, moduleID, recordID, sort })
|
||||
.then(payload => convertRevisionPayloadToRevision(
|
||||
(payload as unknown) as RawRevisionPayload,
|
||||
this.options.displayedFields,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockRecordRevisions)
|
||||
70
lib/js/src/compose/types/page-block/record.ts
Normal file
70
lib/js/src/compose/types/page-block/record.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Record'
|
||||
|
||||
interface FieldCondition {
|
||||
field: string;
|
||||
condition: string;
|
||||
clearOnHide?: boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
fields: unknown[];
|
||||
fieldConditions: FieldCondition[];
|
||||
clearConditionalFieldsOnHide: boolean;
|
||||
recordSelectorShowAddRecordButton: boolean;
|
||||
magnifyOption: string;
|
||||
recordSelectorDisplayOption: string;
|
||||
recordSelectorAddRecordDisplayOption: string;
|
||||
referenceField?: string;
|
||||
referenceModuleID?: string;
|
||||
inlineRecordEditEnabled: boolean;
|
||||
horizontalFieldLayoutEnabled: boolean;
|
||||
recordFieldLayoutOption: string;
|
||||
inlineRecordEditAllowAddField: boolean;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
fields: [],
|
||||
fieldConditions: [],
|
||||
clearConditionalFieldsOnHide: false,
|
||||
recordSelectorShowAddRecordButton: false,
|
||||
magnifyOption: '',
|
||||
recordSelectorDisplayOption: 'sameTab',
|
||||
recordSelectorAddRecordDisplayOption: 'sameTab',
|
||||
referenceField: '',
|
||||
referenceModuleID: undefined,
|
||||
inlineRecordEditEnabled: false,
|
||||
inlineRecordEditAllowAddField: false,
|
||||
horizontalFieldLayoutEnabled: false,
|
||||
recordFieldLayoutOption: 'default',
|
||||
})
|
||||
|
||||
export class PageBlockRecord extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, String, 'magnifyOption', 'recordSelectorDisplayOption', 'recordSelectorAddRecordDisplayOption', 'referenceField', 'referenceModuleID', 'recordFieldLayoutOption')
|
||||
Apply(this.options, o, Boolean, 'recordSelectorShowAddRecordButton', 'inlineRecordEditEnabled', 'horizontalFieldLayoutEnabled', 'inlineRecordEditAllowAddField', 'clearConditionalFieldsOnHide')
|
||||
|
||||
if (o.fields) {
|
||||
this.options.fields = o.fields
|
||||
}
|
||||
|
||||
if (o.fieldConditions) {
|
||||
this.options.fieldConditions = o.fieldConditions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockRecord)
|
||||
44
lib/js/src/compose/types/page-block/report.ts
Normal file
44
lib/js/src/compose/types/page-block/report.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply, CortezaID, NoID } from '../../../cast'
|
||||
|
||||
const kind = 'Report'
|
||||
|
||||
interface Options {
|
||||
reportID: string;
|
||||
scenarioID: string;
|
||||
elementID: string;
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
reportID: NoID,
|
||||
scenarioID: NoID,
|
||||
elementID: NoID,
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockReport extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, CortezaID, 'reportID', 'scenarioID', 'elementID')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
Apply(this.options, o, String, 'magnifyOption')
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockReport)
|
||||
49
lib/js/src/compose/types/page-block/social-feed.ts
Normal file
49
lib/js/src/compose/types/page-block/social-feed.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply, CortezaID, NoID } from '../../../cast'
|
||||
|
||||
const kind = 'SocialFeed'
|
||||
interface Options {
|
||||
moduleID: string;
|
||||
fields: unknown[];
|
||||
profileSourceField: string;
|
||||
profileUrl: string;
|
||||
showRefresh: boolean;
|
||||
refreshRate: number;
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
moduleID: NoID,
|
||||
fields: [],
|
||||
profileSourceField: '',
|
||||
profileUrl: '',
|
||||
showRefresh: false,
|
||||
refreshRate: 0,
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockSocialFeed extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, CortezaID, 'moduleID')
|
||||
Apply(this.options, o, String, 'profileSourceField', 'profileUrl', 'magnifyOption')
|
||||
Apply(this.options, o, Number, 'refreshRate')
|
||||
Apply(this.options, o, Boolean, 'showRefresh')
|
||||
|
||||
if (o.fields) {
|
||||
this.options.fields = o.fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockSocialFeed)
|
||||
66
lib/js/src/compose/types/page-block/tabs.ts
Normal file
66
lib/js/src/compose/types/page-block/tabs.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
import { Apply } from '../../../cast'
|
||||
|
||||
const kind = 'Tabs'
|
||||
|
||||
interface Style {
|
||||
appearance: string;
|
||||
alignment: string;
|
||||
justify: string;
|
||||
orientation: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
blockID: string;
|
||||
title: string;
|
||||
lazy: boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
style: Style;
|
||||
tabs: Tab[];
|
||||
magnifyOption: string;
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
style: {
|
||||
appearance: 'tabs',
|
||||
alignment: 'center',
|
||||
justify: 'justify',
|
||||
orientation: 'horizontal',
|
||||
position: 'start',
|
||||
},
|
||||
tabs: [],
|
||||
magnifyOption: '',
|
||||
})
|
||||
|
||||
export class PageBlockTab extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
Apply(this.options, o, String, 'magnifyOption')
|
||||
|
||||
if (o.tabs) {
|
||||
this.options.tabs = o.tabs.map(tab => ({
|
||||
...tab,
|
||||
lazy: tab.lazy !== false,
|
||||
}))
|
||||
}
|
||||
|
||||
if (o.style) {
|
||||
this.options.style = { ...this.options.style, ...o.style }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockTab)
|
||||
31
lib/js/src/compose/types/page-block/types.ts
Normal file
31
lib/js/src/compose/types/page-block/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Apply, CortezaID } from '../../../cast'
|
||||
|
||||
export class Button {
|
||||
// Used when referring to Corredor automation script
|
||||
public script?: string = undefined
|
||||
|
||||
// Used when referring to workflow with onManual trigger
|
||||
public workflowID?: string = undefined
|
||||
|
||||
// Used when referring to a specific step (triggered by onManual trigger)
|
||||
public stepID?: string = undefined
|
||||
|
||||
// resource type (copied from ui hook or from trigger)
|
||||
public resourceType?: string = undefined;
|
||||
|
||||
// Can override hook's label
|
||||
public label?: string = undefined;
|
||||
|
||||
// can override hook's variant
|
||||
public variant?: string = 'primary';
|
||||
|
||||
public enabled = true;
|
||||
|
||||
constructor (b: Partial<Button>) {
|
||||
Apply(this, b, Boolean, 'enabled')
|
||||
Apply(this, b, String, 'label', 'variant', 'script', 'resourceType')
|
||||
Apply(this, b, CortezaID, 'workflowID', 'stepID')
|
||||
}
|
||||
}
|
||||
|
||||
export type PageBlockWrap = 'Plain' | 'Card'
|
||||
164
lib/js/src/compose/types/page-layout.ts
Normal file
164
lib/js/src/compose/types/page-layout.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
|
||||
import { PageBlock } from './page-block/base'
|
||||
import { Button } from './page-block/types'
|
||||
|
||||
export type PageLayoutInput = PageLayout | Partial<PageLayout>
|
||||
|
||||
interface PageLayoutConfig {
|
||||
visibility: Visibility;
|
||||
buttons: {
|
||||
back: Button;
|
||||
delete: Button;
|
||||
new: Button;
|
||||
clone: Button;
|
||||
edit: Button;
|
||||
submit: Button;
|
||||
};
|
||||
actions: Action[];
|
||||
// Only used for record pages
|
||||
useTitle: boolean;
|
||||
validation: Validation;
|
||||
}
|
||||
|
||||
interface RequiredField {
|
||||
field: string;
|
||||
condition: string;
|
||||
}
|
||||
|
||||
interface Validation {
|
||||
requiredFields: RequiredField[];
|
||||
}
|
||||
|
||||
interface Action {
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
placement: string;
|
||||
params: unknown;
|
||||
meta: ActionMeta;
|
||||
}
|
||||
|
||||
interface ActionMeta {
|
||||
label: string;
|
||||
style: {
|
||||
variant: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface Visibility {
|
||||
expression: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface Meta {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class PageLayout {
|
||||
public pageLayoutID = NoID;
|
||||
public namespaceID = NoID;
|
||||
public pageID = NoID
|
||||
public handle = '';
|
||||
|
||||
public weight = 0;
|
||||
|
||||
public blocks: (Partial<PageBlock>)[] = [];
|
||||
|
||||
public config: PageLayoutConfig = {
|
||||
visibility: {
|
||||
expression: '',
|
||||
roles: [],
|
||||
},
|
||||
buttons: {
|
||||
back: { enabled: true },
|
||||
delete: { enabled: true },
|
||||
clone: { enabled: true },
|
||||
new: { enabled: true },
|
||||
edit: { enabled: true },
|
||||
submit: { enabled: true },
|
||||
},
|
||||
actions: [],
|
||||
useTitle: false,
|
||||
validation: {
|
||||
requiredFields: [],
|
||||
},
|
||||
}
|
||||
|
||||
public meta: Meta = {
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
public createdAt?: Date = undefined;
|
||||
public updatedAt?: Date = undefined;
|
||||
public deletedAt?: Date = undefined;
|
||||
|
||||
public ownedBy = NoID;
|
||||
|
||||
constructor (pl?: PageLayoutInput) {
|
||||
this.apply(pl)
|
||||
}
|
||||
|
||||
apply (pl?: PageLayoutInput): void {
|
||||
if (!pl) return
|
||||
|
||||
Apply(this, pl, CortezaID, 'pageLayoutID', 'namespaceID', 'pageID', 'ownedBy')
|
||||
Apply(this, pl, String, 'handle')
|
||||
Apply(this, pl, Number, 'weight')
|
||||
Apply(this, pl, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
|
||||
this.blocks = (pl.blocks || []).map(({ blockID, xywh, meta }) => ({ blockID, xywh, meta }))
|
||||
|
||||
if (pl.meta) {
|
||||
this.meta = { ...this.meta, ...pl.meta }
|
||||
}
|
||||
|
||||
if (pl.config) {
|
||||
this.config = merge({}, this.config, pl.config)
|
||||
}
|
||||
}
|
||||
|
||||
clone (): PageLayout {
|
||||
return new PageLayout(JSON.parse(JSON.stringify(this)))
|
||||
}
|
||||
|
||||
addAction () {
|
||||
this.config.actions.push({
|
||||
kind: 'toLayout',
|
||||
placement: 'end',
|
||||
enabled: true,
|
||||
params: {
|
||||
pageLayoutID: '',
|
||||
},
|
||||
meta: {
|
||||
label: '',
|
||||
style: {
|
||||
variant: 'primary',
|
||||
},
|
||||
},
|
||||
} as Action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.pageLayoutID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:page-layout'
|
||||
}
|
||||
|
||||
export (): PageLayoutInput {
|
||||
return {
|
||||
blocks: this.blocks,
|
||||
meta: this.meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
176
lib/js/src/compose/types/page.test.ts
Normal file
176
lib/js/src/compose/types/page.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NoID } from '../../cast'
|
||||
import { expect } from 'chai'
|
||||
import { Page } from './page'
|
||||
import { PageBlock } from './page-block'
|
||||
|
||||
describe('page', () => {
|
||||
describe('initialization', () => {
|
||||
it('simple', () => {
|
||||
const page = new Page({})
|
||||
|
||||
expect(page).to.have.property('pageID').and.to.eq(NoID)
|
||||
expect(page).to.have.property('selfID').and.to.eq(NoID)
|
||||
expect(page).to.have.property('moduleID').and.to.eq(NoID)
|
||||
expect(page).to.have.property('namespaceID').and.to.eq(NoID)
|
||||
|
||||
expect(page).to.have.property('title').and.to.eq('')
|
||||
expect(page).to.have.property('handle').and.to.eq('')
|
||||
expect(page).to.have.property('description').and.to.eq('')
|
||||
expect(page).to.have.property('weight').and.to.eq(0)
|
||||
|
||||
expect(page).to.have.property('labels').and.to.deep.eq({})
|
||||
|
||||
expect(page).to.have.property('visible').and.to.eq(false)
|
||||
|
||||
expect(page).to.have.property('blocks').and.to.deep.eq([])
|
||||
|
||||
expect(page).to.have.property('createdAt').and.to.eq(undefined)
|
||||
expect(page).to.have.property('updatedAt').and.to.eq(undefined)
|
||||
expect(page).to.have.property('deletedAt').and.to.eq(undefined)
|
||||
|
||||
expect(page).to.have.property('canUpdatePage').and.to.eq(false)
|
||||
expect(page).to.have.property('canDeletePage').and.to.eq(false)
|
||||
expect(page).to.have.property('canGrant').and.to.eq(false)
|
||||
})
|
||||
|
||||
it('complex', () => {
|
||||
const dateNow = new Date()
|
||||
|
||||
const page = new Page({
|
||||
pageID: '42',
|
||||
selfID: '42',
|
||||
moduleID: '42',
|
||||
namespaceID: '42',
|
||||
|
||||
title: 'title',
|
||||
handle: 'handle',
|
||||
description: 'description',
|
||||
weight: 1,
|
||||
|
||||
labels: {
|
||||
foo: 'foo',
|
||||
},
|
||||
|
||||
visible: true,
|
||||
|
||||
blocks: [
|
||||
{ kind: 'RecordList', xywh: [0, 0, 3, 3] },
|
||||
] as PageBlock[],
|
||||
|
||||
createdAt: dateNow,
|
||||
updatedAt: dateNow,
|
||||
deletedAt: dateNow,
|
||||
|
||||
canDeletePage: true,
|
||||
canUpdatePage: true,
|
||||
canGrant: true,
|
||||
})
|
||||
|
||||
expect(page).to.have.property('pageID').and.to.eq('42')
|
||||
expect(page).to.have.property('selfID').and.to.eq('42')
|
||||
expect(page).to.have.property('moduleID').and.to.eq('42')
|
||||
expect(page).to.have.property('namespaceID').and.to.eq('42')
|
||||
|
||||
expect(page).to.have.property('title').and.to.eq('title')
|
||||
expect(page).to.have.property('handle').and.to.eq('handle')
|
||||
expect(page).to.have.property('description').and.to.eq('description')
|
||||
expect(page).to.have.property('weight').and.to.eq(1)
|
||||
|
||||
expect(page).to.have.property('labels').and.to.deep.eq({
|
||||
foo: 'foo',
|
||||
})
|
||||
|
||||
expect(page).to.have.property('visible').and.to.eq(true)
|
||||
|
||||
expect(page).to.have.property('blocks').and.lengthOf(1)
|
||||
|
||||
expect(page).to.have.property('createdAt').and.to.eq(dateNow)
|
||||
expect(page).to.have.property('updatedAt').and.to.eq(dateNow)
|
||||
expect(page).to.have.property('deletedAt').and.to.eq(dateNow)
|
||||
|
||||
expect(page).to.have.property('canUpdatePage').and.to.eq(true)
|
||||
expect(page).to.have.property('canDeletePage').and.to.eq(true)
|
||||
expect(page).to.have.property('canGrant').and.to.eq(true)
|
||||
})
|
||||
}),
|
||||
|
||||
describe('getters', () => {
|
||||
const page = new Page({
|
||||
pageID: '42',
|
||||
selfID: '42',
|
||||
moduleID: '42',
|
||||
namespaceID: '42',
|
||||
})
|
||||
|
||||
const pg = new Page({})
|
||||
|
||||
it('resourceType', () => {
|
||||
expect(page.resourceType).to.eq('compose:page')
|
||||
})
|
||||
|
||||
it('resourceID', () => {
|
||||
expect(page.resourceID).to.eq('compose:page:42')
|
||||
})
|
||||
|
||||
it('isRecordPage', () => {
|
||||
expect(page.isRecordPage).to.eq(true)
|
||||
expect(pg.isRecordPage).to.eq(false)
|
||||
})
|
||||
|
||||
it('firstLevel', () => {
|
||||
expect(page.firstLevel).to.eq(false)
|
||||
expect(pg.firstLevel).to.eq(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('methods', () => {
|
||||
it('export', () => {
|
||||
const page = new Page({
|
||||
title: 'title',
|
||||
handle: 'handle',
|
||||
description: 'description',
|
||||
visible: true,
|
||||
blocks: [
|
||||
{ kind: 'RecordList', xywh: [0, 0, 3, 3] },
|
||||
] as PageBlock[],
|
||||
})
|
||||
|
||||
const pageExport = page.export()
|
||||
expect(pageExport).to.have.property('title').and.to.eq('title')
|
||||
expect(pageExport).to.have.property('handle').and.to.eq('handle')
|
||||
expect(pageExport).to.have.property('description').and.to.eq('description')
|
||||
expect(pageExport).to.have.property('visible').and.to.eq(true)
|
||||
expect(pageExport).to.have.property('blocks').and.lengthOf(1)
|
||||
})
|
||||
|
||||
it('validate', () => {
|
||||
const page = new Page({
|
||||
blocks: [
|
||||
{ kind: 'RecordList', xywh: [0, 0, 3, 3] },
|
||||
] as PageBlock[],
|
||||
})
|
||||
|
||||
expect(page.validate()).to.deep.eq([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('blocks', () => {
|
||||
it('should make all kinds of block', () => {
|
||||
const p = new Page({
|
||||
title: 'test page',
|
||||
blocks: [
|
||||
{ kind: 'Automation', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'Chart', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'Content', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'File', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'Record', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'RecordList', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'RecordOrganizer', xywh: [0, 0, 3, 3] },
|
||||
{ kind: 'SocialFeed', xywh: [0, 0, 3, 3] },
|
||||
] as PageBlock[],
|
||||
})
|
||||
|
||||
expect(p.blocks).lengthOf(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
173
lib/js/src/compose/types/page.ts
Normal file
173
lib/js/src/compose/types/page.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
|
||||
import { IsOf, AreObjectsOf } from '../../guards'
|
||||
import { PageBlock, PageBlockMaker } from './page-block'
|
||||
import lodash from 'lodash'
|
||||
const { merge } = lodash
|
||||
|
||||
interface PartialPage extends Partial<Omit<Page, 'children' | 'meta' | 'blocks' |'createdAt' | 'updatedAt' | 'deletedAt'>> {
|
||||
children?: Array<PartialPage>;
|
||||
|
||||
blocks?: PageBlock[];
|
||||
|
||||
meta?: PageMeta;
|
||||
|
||||
createdAt?: string|number|Date;
|
||||
updatedAt?: string|number|Date;
|
||||
deletedAt?: string|number|Date;
|
||||
}
|
||||
|
||||
interface PageMeta {
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface PageConfig {
|
||||
navItem: {
|
||||
icon: {
|
||||
type: string;
|
||||
src: string;
|
||||
};
|
||||
expanded: false;
|
||||
};
|
||||
}
|
||||
|
||||
export class Page {
|
||||
public pageID = NoID;
|
||||
public selfID = NoID;
|
||||
public moduleID = NoID;
|
||||
public namespaceID = NoID;
|
||||
|
||||
public title = '';
|
||||
public handle = '';
|
||||
public description = '';
|
||||
public weight = 0;
|
||||
|
||||
public labels: object = {}
|
||||
|
||||
public visible = false;
|
||||
|
||||
public children?: Page[];
|
||||
|
||||
public blocks: PageBlock[] = [];
|
||||
|
||||
public config: PageConfig = {
|
||||
navItem: {
|
||||
icon: {
|
||||
type: '',
|
||||
src: '',
|
||||
},
|
||||
expanded: false,
|
||||
},
|
||||
}
|
||||
|
||||
public meta: PageMeta = {
|
||||
notifications: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
public createdAt?: Date = undefined;
|
||||
public updatedAt?: Date = undefined;
|
||||
public deletedAt?: Date = undefined;
|
||||
|
||||
public canUpdatePage = false;
|
||||
public canDeletePage = false;
|
||||
public canGrant = false;
|
||||
|
||||
constructor (i?: PartialPage) {
|
||||
this.apply(i)
|
||||
}
|
||||
|
||||
clone (): Page {
|
||||
return new Page(JSON.parse(JSON.stringify(this)))
|
||||
}
|
||||
|
||||
apply (i?: PartialPage): void {
|
||||
if (!i) return
|
||||
|
||||
Apply(this, i, CortezaID, 'pageID', 'selfID', 'moduleID', 'namespaceID')
|
||||
Apply(this, i, String, 'title', 'handle', 'description')
|
||||
Apply(this, i, Number, 'weight')
|
||||
Apply(this, i, Boolean, 'visible')
|
||||
|
||||
if (i.blocks) {
|
||||
this.blocks = i.blocks.map(block => PageBlockMaker(block))
|
||||
}
|
||||
|
||||
if (i.children) {
|
||||
this.children = []
|
||||
if (AreObjectsOf<Page>(i.children, 'pageID')) {
|
||||
this.children = i.children.map(c => new Page(c))
|
||||
}
|
||||
}
|
||||
|
||||
if (IsOf(i, 'config')) {
|
||||
this.config = merge({}, this.config, i.config)
|
||||
}
|
||||
|
||||
if (IsOf(i, 'meta')) {
|
||||
this.meta = merge({}, this.meta, i.meta)
|
||||
}
|
||||
|
||||
if (IsOf(i, 'labels')) {
|
||||
this.labels = { ...i.labels }
|
||||
}
|
||||
|
||||
Apply(this, i, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
Apply(this, i, Boolean,
|
||||
'canUpdatePage',
|
||||
'canDeletePage',
|
||||
'canGrant',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.pageID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:page'
|
||||
}
|
||||
|
||||
get isRecordPage (): boolean {
|
||||
return this.moduleID !== NoID
|
||||
}
|
||||
|
||||
get firstLevel (): boolean {
|
||||
return this.selfID === NoID
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates page & it's blocks
|
||||
*/
|
||||
validate (): Array<string> {
|
||||
const ee: Array<string> = []
|
||||
|
||||
if (this.blocks.length === 0) {
|
||||
ee.push('blocks missing')
|
||||
} else {
|
||||
this.blocks.forEach(b => {
|
||||
ee.push(...b.validate())
|
||||
})
|
||||
}
|
||||
|
||||
return ee
|
||||
}
|
||||
|
||||
export (): PartialPage {
|
||||
return {
|
||||
title: this.title,
|
||||
handle: this.handle,
|
||||
description: this.description,
|
||||
visible: this.visible,
|
||||
blocks: this.blocks,
|
||||
}
|
||||
}
|
||||
}
|
||||
250
lib/js/src/compose/types/record.test.ts
Normal file
250
lib/js/src/compose/types/record.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-ignore */
|
||||
import { expect } from 'chai'
|
||||
import { Record, Values } from './record'
|
||||
import { Module } from './module'
|
||||
|
||||
const m = Object.freeze(new Module({
|
||||
fields: [
|
||||
{ name: 'bool', kind: 'Bool' },
|
||||
{ name: 'simple', kind: 'String' },
|
||||
{ name: 'required', isRequired: true },
|
||||
{ name: 'multi', isMulti: true },
|
||||
{ name: 'multiRequired', isRequired: true, isMulti: true },
|
||||
],
|
||||
}))
|
||||
|
||||
describe('record', () => {
|
||||
describe('record creation', () => {
|
||||
const assertAllUndefined = function (r: Record): void {
|
||||
expect(r.module).to.eq(m)
|
||||
/**
|
||||
* It's extremely important that properties are set
|
||||
* even if to undefined. Without this we will have problems
|
||||
* with Vue reactivity
|
||||
*/
|
||||
expect(r.values).to.have.property('bool').and.to.be.undefined
|
||||
expect(r.values).to.have.property('simple').and.to.be.undefined
|
||||
expect(r.values).to.have.property('required').to.be.undefined
|
||||
expect(r.values).to.have.property('multi').to.be.deep.eq([])
|
||||
expect(r.values).to.have.property('multiRequired').to.be.deep.eq([])
|
||||
}
|
||||
|
||||
const assertSimpleSet = function (r: Record): void {
|
||||
expect(r.module).to.eq(m)
|
||||
expect(r.values.bool).to.eq('0')
|
||||
expect(r.values.simple).to.eq('foo')
|
||||
expect(r.values.multi).to.deep.eq(['bar'])
|
||||
}
|
||||
|
||||
const values: Values = { bool: '0', simple: 'foo', multi: ['bar'] }
|
||||
const rawValues = [
|
||||
{ name: 'bool' },
|
||||
{ name: 'simple', value: 'foo' },
|
||||
{ name: 'multi', value: 'bar' },
|
||||
]
|
||||
|
||||
it('module only', () => {
|
||||
assertAllUndefined(new Record(m))
|
||||
})
|
||||
|
||||
it('module with empty object', () => {
|
||||
assertAllUndefined(new Record(m, {}))
|
||||
})
|
||||
|
||||
it('empty object with module', () => {
|
||||
assertAllUndefined(new Record({}, m))
|
||||
})
|
||||
|
||||
it('module with non-empty values object', () => {
|
||||
assertSimpleSet(new Record(m, values))
|
||||
})
|
||||
|
||||
it('non-empty values object with module', () => {
|
||||
assertSimpleSet(new Record(values, m))
|
||||
})
|
||||
|
||||
it('module with non-empty raw values', () => {
|
||||
assertSimpleSet(new Record(m, rawValues))
|
||||
})
|
||||
|
||||
it('raw values with module', () => {
|
||||
assertSimpleSet(new Record(rawValues, m))
|
||||
})
|
||||
|
||||
it('should not corrupt recordID when there are no values', () => {
|
||||
expect(new Record({ recordID: '42' }, m)).to.have.property('recordID').equal('42')
|
||||
})
|
||||
|
||||
it('should handle garbage input', () => {
|
||||
// @ts-ignore
|
||||
expect(() => new Record(false, m)).to.throw
|
||||
// @ts-ignore
|
||||
expect(() => new Record(null, m)).to.throw
|
||||
// @ts-ignore
|
||||
expect(() => new Record('some string', m)).to.throw
|
||||
// @ts-ignore
|
||||
expect(() => new Record(42, m)).to.throw
|
||||
})
|
||||
|
||||
it('should handle Record instance', () => {
|
||||
const rr = new Record({ recordID: '42' }, m)
|
||||
expect(new Record(rr)).to.have.property('recordID').equal('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi value reading', () => {
|
||||
const bars = ['bar1', 'bar2', 'bar3', 'bar4']
|
||||
|
||||
it('should have all values from explicit value setting', () => {
|
||||
const r = new Record(m)
|
||||
r.values.multi = bars
|
||||
expect(r.values.multi).to.be.lengthOf(4)
|
||||
expect(r.values.multi).to.deep.eq(bars)
|
||||
})
|
||||
|
||||
it('should have all values from setValues (with Values)', () => {
|
||||
const r = new Record(m)
|
||||
r.setValues({ multi: bars })
|
||||
expect(r.values.multi).to.be.lengthOf(4)
|
||||
expect(r.values.multi).to.deep.eq(bars)
|
||||
})
|
||||
|
||||
it('should have all values from setValues (with RawValue[])', () => {
|
||||
const r = new Record(m)
|
||||
r.setValues([
|
||||
{ name: 'multi', value: 'bar1' },
|
||||
{ name: 'multi', value: 'bar2' },
|
||||
{ name: 'multi', value: 'bar3' },
|
||||
{ name: 'multi', value: 'bar4' },
|
||||
])
|
||||
expect(r.values.multi).to.be.lengthOf(4)
|
||||
expect(r.values.multi).to.deep.eq(bars)
|
||||
})
|
||||
|
||||
it('should have all values from record initialization', () => {
|
||||
const r = new Record(m, [
|
||||
{ name: 'multi', value: 'bar1' },
|
||||
{ name: 'multi', value: 'bar2' },
|
||||
{ name: 'multi', value: 'bar3' },
|
||||
{ name: 'multi', value: 'bar4' },
|
||||
])
|
||||
|
||||
expect(r.values.multi).to.be.lengthOf(4)
|
||||
expect(r.values.multi).to.deep.eq(bars)
|
||||
})
|
||||
})
|
||||
|
||||
describe('value setting', () => {
|
||||
let r: Record
|
||||
beforeEach(() => {
|
||||
r = new Record(m)
|
||||
})
|
||||
|
||||
it('should properly set values via object', () => {
|
||||
r.values.simple = 'foo'
|
||||
expect(r.values.simple).to.eq('foo')
|
||||
})
|
||||
|
||||
it('should properly set from values object', () => {
|
||||
r.setValues({ simple: 'foo' })
|
||||
expect(r.values.simple).to.eq('foo')
|
||||
})
|
||||
|
||||
it('should properly set from array of raw-values objects', () => {
|
||||
r.setValues([{ name: 'simple', value: 'foo' }])
|
||||
expect(r.values.simple).to.eq('foo')
|
||||
})
|
||||
|
||||
it('should properly set multiple values via values to a non-multi-value field', () => {
|
||||
r.setValues({ simple: ['bar', 'baz'] })
|
||||
expect(r.values.simple).to.eq('bar')
|
||||
})
|
||||
|
||||
it.skip('should properly set value directly to a multi-value field', () => {
|
||||
r.values.multi = 'bar'
|
||||
expect(r.values.multi).to.deep.eq(['bar'])
|
||||
})
|
||||
|
||||
it('should properly set value directly to a multi-value field', () => {
|
||||
r.values.multi = ['bar']
|
||||
expect(r.values.multi).to.deep.eq(['bar'])
|
||||
})
|
||||
|
||||
it('should properly set value via setValues to a multi-value field', () => {
|
||||
r.setValues({ multi: 'bar' })
|
||||
expect(r.values.multi).to.deep.eq(['bar'])
|
||||
})
|
||||
|
||||
it('should properly set multiple values via raw-values to a non-multi-value field', () => {
|
||||
r.setValues([{ name: 'simple', value: 'foo' }, { name: 'simple', value: 'foo' }])
|
||||
expect(r.values.simple).to.eq('foo')
|
||||
})
|
||||
|
||||
it('should properly set value via raw-values to a multi-value field', () => {
|
||||
r.setValues([{ name: 'multi', value: 'bar' }])
|
||||
expect(r.values.multi).to.deep.eq(['bar'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('JSON serialization', () => {
|
||||
let r: Record
|
||||
beforeEach(() => {
|
||||
r = new Record(m)
|
||||
})
|
||||
|
||||
it('should properly serialize whole record', () => {
|
||||
r.setValues([{ simple: 'foo', multi: ['bar', 'baz'] }])
|
||||
expect(JSON.stringify(r)).to.equal('{"recordID":"0","moduleID":"0","namespaceID":"0","revision":0,"values":[{"name":"simple","value":"foo"},{"name":"multi","value":"bar"},{"name":"multi","value":"baz"}],"valueErrors":{},"meta":{},"canUpdateRecord":false,"canReadRecord":false,"canDeleteRecord":false,"canUndeleteRecord":false,"canManageOwnerOnRecord":false,"canSearchRevision":false,"canGrant":false}')
|
||||
})
|
||||
|
||||
it('serialization magic should sustain object manipulation', () => {
|
||||
r.setValues([{ simple: 'foo', multi: ['bar', 'baz'] }])
|
||||
const { values } = r
|
||||
|
||||
expect(JSON.stringify(values)).to.equal(
|
||||
'[{"name":"simple","value":"foo"},{"name":"multi","value":"bar"},{"name":"multi","value":"baz"}]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prevent value corruption when when value prop is missing on rawValue and one of the fields is named "name"', () => {
|
||||
const m = Object.freeze(new Module({
|
||||
fields: [
|
||||
{ name: 'name' },
|
||||
{ name: 'alt' },
|
||||
],
|
||||
}))
|
||||
|
||||
it('it should not set value for name when constructing an object', () => {
|
||||
const r = new Record(m, [{ name: 'alt' }])
|
||||
expect(r.values.name).to.eq(undefined)
|
||||
expect(r.values.alt).to.eq(undefined)
|
||||
})
|
||||
|
||||
it('it should set some values for name when constructing an object', () => {
|
||||
const r = new Record(m, [{ name: 'alt' }, { name: 'name', value: 'bar' }])
|
||||
expect(r.values.name).to.eq('bar')
|
||||
expect(r.values.alt).to.eq(undefined)
|
||||
})
|
||||
|
||||
it('it should not set value for name when applying new values as array', () => {
|
||||
const r = new Record(m)
|
||||
r.apply([{ name: 'alt' }])
|
||||
expect(r.values.name).to.eq(undefined)
|
||||
expect(r.values.alt).to.eq(undefined)
|
||||
})
|
||||
|
||||
it('it should set value for name when applying new values as object', () => {
|
||||
const r = new Record(m)
|
||||
r.apply({ name: 'alt' })
|
||||
expect(r.values.name).to.eq('alt')
|
||||
expect(r.values.alt).to.eq(undefined)
|
||||
})
|
||||
|
||||
it('it should not set value for name when applying new values', () => {
|
||||
const r = new Record(m)
|
||||
r.apply([{ name: 'alt' }, { alt: 'foo' }])
|
||||
expect(r.values.name).to.eq('alt')
|
||||
expect(r.values.alt).to.eq('foo')
|
||||
})
|
||||
})
|
||||
})
|
||||
474
lib/js/src/compose/types/record.ts
Normal file
474
lib/js/src/compose/types/record.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { AreObjectsOf, IsOf } from '../../guards'
|
||||
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
|
||||
import { Module } from './module'
|
||||
import { Namespace } from './namespace'
|
||||
|
||||
const fieldIndex = Symbol('fieldIndex')
|
||||
const propModule = Symbol('module')
|
||||
const cleanValues = Symbol('cleanValues')
|
||||
|
||||
const reservedFieldNames = [
|
||||
'toJSON',
|
||||
]
|
||||
|
||||
interface FieldIndex {
|
||||
isMulti: boolean;
|
||||
kind: string;
|
||||
defaultValue: Array<{ value: string }>;
|
||||
}
|
||||
|
||||
interface RawValue {
|
||||
name: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface PartialRecord extends Partial<Omit<Record, 'values' | 'createdAt' | 'updatedAt' | 'deletedAt'>> {
|
||||
values?: RawValue[];
|
||||
|
||||
createdAt?: string|number|Date;
|
||||
updatedAt?: string|number|Date;
|
||||
deletedAt?: string|number|Date;
|
||||
}
|
||||
|
||||
export interface Values {
|
||||
[name: string]: string|string[]|undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combination of valid value types/structures
|
||||
*/
|
||||
type ValueCombo = RawValue[] | Values | Values[]
|
||||
|
||||
/**
|
||||
* Combination of valid types that can be passed as Record ctor's 1st (and 2nd) parameter
|
||||
*/
|
||||
type RecordCtorCombo = Record | Module | PartialRecord | ValueCombo
|
||||
|
||||
/**
|
||||
* For something to be useful module (for a Record), it needs to contain fields
|
||||
*/
|
||||
function isModule (m?: unknown): m is Module {
|
||||
return !!m && IsOf<Module>(m, 'fields') && Array.isArray(m.fields) && m.fields.length > 0
|
||||
}
|
||||
|
||||
function isRawValue (v: unknown): v is RawValue {
|
||||
return IsOf<RawValue>(v, 'name')
|
||||
}
|
||||
|
||||
/**
|
||||
* Record class will be used all over the place, user scripts, etc..
|
||||
*
|
||||
* Constructor (and apply fn) is as versatile as possible to handle
|
||||
* different use-cases.
|
||||
*/
|
||||
export class Record {
|
||||
public recordID = NoID;
|
||||
public moduleID = NoID;
|
||||
public namespaceID = NoID;
|
||||
public revision = 0;
|
||||
|
||||
public values: Values = {}
|
||||
public valueErrors: object = {}
|
||||
public meta: object = {};
|
||||
|
||||
public createdAt?: Date = undefined;
|
||||
public updatedAt?: Date = undefined;
|
||||
public deletedAt?: Date = undefined;
|
||||
|
||||
public ownedBy = undefined;
|
||||
public createdBy = undefined;
|
||||
public updatedBy = undefined;
|
||||
public deletedBy = undefined;
|
||||
|
||||
public canUpdateRecord = false;
|
||||
public canReadRecord = false;
|
||||
public canDeleteRecord = false;
|
||||
public canUndeleteRecord = false;
|
||||
public canManageOwnerOnRecord = false;
|
||||
public canSearchRevision = false;
|
||||
public canGrant = false;
|
||||
|
||||
// @ts-ignore
|
||||
private [fieldIndex]: Map<string, FieldIndex>
|
||||
private [propModule]?: Module
|
||||
private [cleanValues]: Values = {}
|
||||
|
||||
constructor (recModVal1: RecordCtorCombo, recModVal2?: RecordCtorCombo) {
|
||||
if (recModVal1 instanceof Record) {
|
||||
this.module = recModVal1.module
|
||||
this.apply(recModVal1)
|
||||
return
|
||||
}
|
||||
|
||||
if (isModule(recModVal1)) {
|
||||
this.module = recModVal1
|
||||
this.apply(recModVal2)
|
||||
return
|
||||
}
|
||||
|
||||
if (isModule(recModVal2)) {
|
||||
this.module = recModVal2
|
||||
this.apply(recModVal1)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('invalid module used to initialize a record')
|
||||
}
|
||||
|
||||
clone (): Record {
|
||||
return new Record(this.module, JSON.parse(JSON.stringify(this)))
|
||||
}
|
||||
|
||||
/**
|
||||
* apply (partially) updates record and it's values
|
||||
*
|
||||
* @param p
|
||||
*/
|
||||
apply (p?: unknown): void {
|
||||
if (p === undefined) {
|
||||
// This is a brand new record; set default values
|
||||
this.defaultValues()
|
||||
return
|
||||
}
|
||||
|
||||
let r
|
||||
|
||||
// Determine what kind of value we got
|
||||
switch (true) {
|
||||
case IsOf<Record>(p, 'recordID') || IsOf<Record>(p, 'values'):
|
||||
// p1 is something that looks like a record object
|
||||
r = p as Record
|
||||
break
|
||||
|
||||
case AreObjectsOf<RawValue>(p, 'name'):
|
||||
// assuming p1 is array of raw values
|
||||
r = ({ values: p as RawValue[] }) as PartialRecord
|
||||
break
|
||||
|
||||
default:
|
||||
r = ({ values: p }) as Record
|
||||
}
|
||||
|
||||
r = r as PartialRecord
|
||||
|
||||
if (this.module && r.moduleID && r.moduleID !== this.module.moduleID) {
|
||||
throw new Error('can not change module on a record')
|
||||
}
|
||||
|
||||
if (this.namespace && r.namespaceID && r.namespaceID !== this.namespace.namespaceID) {
|
||||
throw new Error('can not change namespace on a record')
|
||||
}
|
||||
|
||||
if (r.namespaceID && r.namespaceID !== this.module.namespaceID) {
|
||||
throw new Error('record and module namespace do not match')
|
||||
}
|
||||
|
||||
Apply(this, r, CortezaID, 'recordID', 'moduleID', 'namespaceID')
|
||||
Apply(this, r, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
|
||||
Apply(this, r, CortezaID, 'ownedBy', 'createdBy', 'updatedBy', 'deletedBy')
|
||||
|
||||
Apply(this, r, Number,
|
||||
'revision',
|
||||
)
|
||||
|
||||
Apply(this, r, Boolean,
|
||||
'canUpdateRecord',
|
||||
'canReadRecord',
|
||||
'canDeleteRecord',
|
||||
'canUndeleteRecord',
|
||||
'canManageOwnerOnRecord',
|
||||
'canGrant',
|
||||
)
|
||||
|
||||
// This is a brand-new record; set default values
|
||||
if (!r.recordID || r.recordID === NoID) {
|
||||
this.defaultValues()
|
||||
}
|
||||
|
||||
if (r.values !== undefined) {
|
||||
this.updateValues(r.values)
|
||||
}
|
||||
|
||||
if (!this[cleanValues]) {
|
||||
// When there are no clean values,
|
||||
// make copy of values so that we know if change occurred
|
||||
this[cleanValues] = Object.freeze({ ...this.values })
|
||||
}
|
||||
|
||||
if (r.valueErrors) {
|
||||
this.valueErrors = r.valueErrors
|
||||
}
|
||||
|
||||
if (IsOf(r, 'meta')) {
|
||||
this.meta = { ...r.meta }
|
||||
}
|
||||
}
|
||||
|
||||
public get cleanValues (): Values {
|
||||
return this[cleanValues]
|
||||
}
|
||||
|
||||
public get module (): Module {
|
||||
if (this[propModule] === undefined) {
|
||||
throw new Error('module not set')
|
||||
}
|
||||
|
||||
return this[propModule] as Module
|
||||
}
|
||||
|
||||
public set module (m: Module) {
|
||||
if (this[propModule]) {
|
||||
if ((this[propModule] as Module).moduleID !== m.moduleID) {
|
||||
throw new Error('module for this record already set')
|
||||
}
|
||||
}
|
||||
|
||||
if (!m.fields || !Array.isArray(m.fields) || m.fields.length === 0) {
|
||||
throw new Error('module used to initialize a record does not contain any fields')
|
||||
}
|
||||
|
||||
this.moduleID = m.moduleID
|
||||
this.namespaceID = m.namespaceID
|
||||
|
||||
this[fieldIndex] = new Map()
|
||||
|
||||
if (Object.isFrozen(m)) {
|
||||
this[propModule] = m
|
||||
} else {
|
||||
// Making a copy and freezing it
|
||||
this[propModule] = Object.freeze(new Module(m))
|
||||
}
|
||||
|
||||
(this[propModule] as Module).fields.forEach(f => {
|
||||
const {
|
||||
name,
|
||||
isMulti,
|
||||
kind,
|
||||
defaultValue,
|
||||
} = f
|
||||
|
||||
if (reservedFieldNames.includes(name)) {
|
||||
throw new Error('can not use reserved field name ' + name)
|
||||
}
|
||||
|
||||
this[fieldIndex].set(name, { isMulti, kind, defaultValue })
|
||||
})
|
||||
|
||||
Object.freeze(this[fieldIndex])
|
||||
|
||||
this.initValues()
|
||||
}
|
||||
|
||||
public get namespace (): Namespace {
|
||||
return this.module.namespace
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts internal representation of values into array of RawValue objects
|
||||
*/
|
||||
serializeValues (): RawValue[] {
|
||||
const vv: RawValue[] = []
|
||||
|
||||
this[fieldIndex].forEach(({ isMulti }, name) => {
|
||||
if (this.values[name] === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const val = this.values[name] as string|string[]
|
||||
|
||||
if (isMulti) {
|
||||
if (Array.isArray(this.values[name])) {
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
if (val[i] !== undefined) {
|
||||
vv.push({ name, value: val[i].toString() })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vv.push({ name, value: val.toString() })
|
||||
}
|
||||
})
|
||||
|
||||
return vv
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing, resets default values and updates it with new ones
|
||||
*/
|
||||
public setValues (...i: ValueCombo[]): void {
|
||||
this.initValues()
|
||||
this.defaultValues()
|
||||
this.updateValues(...i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing and resets default values
|
||||
*/
|
||||
protected initValues (): void {
|
||||
const dst: Values = {}
|
||||
|
||||
this[fieldIndex].forEach(({ isMulti }, name) => {
|
||||
if (isMulti) {
|
||||
dst[name] = []
|
||||
} else {
|
||||
dst[name] = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// TypeScript complains about incompatibility between
|
||||
// indexed object and toJSON function
|
||||
// @ts-ignore
|
||||
dst.toJSON = (): RawValue[] => this.serializeValues()
|
||||
|
||||
this.values = dst
|
||||
}
|
||||
|
||||
protected defaultValues (): void {
|
||||
this[fieldIndex].forEach(({ isMulti, defaultValue }, name) => {
|
||||
if (defaultValue && Array.isArray(defaultValue) && defaultValue.length > 0) {
|
||||
if (isMulti) {
|
||||
this.values[name] = defaultValue.map(({ value }) => value)
|
||||
} else {
|
||||
this.values[name] = defaultValue[0].value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates record's values object with provided input
|
||||
*
|
||||
* Accepted values:
|
||||
* 1. Array of RawValue objects:
|
||||
* updateValues([{ name: ..., value: ...}, ...])
|
||||
*
|
||||
* 2. One or more Value object:
|
||||
* updateValues({ foo: ..., bar: ... }, ...)
|
||||
*/
|
||||
protected updateValues (...combo: ValueCombo[]): void {
|
||||
// If all values are formatted as raw value
|
||||
if (combo.length === 1 && AreObjectsOf<RawValue>(combo[0], 'name')) {
|
||||
(combo[0] as Array<RawValue>).forEach(({ name, value }) => this.setValue(name, value))
|
||||
return
|
||||
}
|
||||
|
||||
(combo as Array<Values>).forEach(v => {
|
||||
if (Array.isArray(v)) {
|
||||
this.updateValues(...v)
|
||||
return
|
||||
}
|
||||
|
||||
if (!v || typeof v !== 'object') {
|
||||
throw Error('expecting array of values or values object')
|
||||
}
|
||||
|
||||
// Handle Values
|
||||
for (const name of Object.getOwnPropertyNames(v)) {
|
||||
this.setValue(name, v[name])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets single value
|
||||
*
|
||||
* @param name
|
||||
* @param value
|
||||
*/
|
||||
public setValue (name: string, value: undefined|string|string[], index = -1): void {
|
||||
// Skip reserved names
|
||||
if (reservedFieldNames.includes(name)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip unknown fields
|
||||
if (!this[fieldIndex].has(name)) {
|
||||
return
|
||||
}
|
||||
const { kind, isMulti } = this[fieldIndex].get(name) as FieldIndex
|
||||
|
||||
if (value === undefined || value.length === 0) {
|
||||
// nothing given, nothing set
|
||||
this.values[name] = isMulti ? [] : (kind === 'Bool' ? '0' : undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (isMulti) {
|
||||
if (Array.isArray(value)) {
|
||||
if (index < -1) {
|
||||
// assigning [] to [i]
|
||||
throw Error('can not set array of values to a single value')
|
||||
}
|
||||
|
||||
this.values[name] = Array.isArray(value) ? value : [value]
|
||||
return
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
(this.values[name] as string[]).push(value)
|
||||
return
|
||||
}
|
||||
|
||||
(this.values[name] as string[])[index] = value
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0]
|
||||
}
|
||||
|
||||
// Update with first item or set to undefined
|
||||
this.values[name] = value
|
||||
}
|
||||
|
||||
public serialize (): Partial<Record> {
|
||||
const { toJSON, ...values } = this.values
|
||||
return { ...this, values }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns resource ID
|
||||
*/
|
||||
get resourceID (): string {
|
||||
return `${this.resourceType}:${this.recordID}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource type
|
||||
*/
|
||||
get resourceType (): string {
|
||||
return 'compose:record'
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy to Record's meta to maintain BC
|
||||
*/
|
||||
get labels (): object {
|
||||
return this.meta
|
||||
}
|
||||
|
||||
get properties (): string[] {
|
||||
return [
|
||||
'recordID',
|
||||
'moduleID',
|
||||
'namespaceID',
|
||||
'revision',
|
||||
'meta',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletedAt',
|
||||
'ownedBy',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
'deletedBy',
|
||||
'canUpdateRecord',
|
||||
'canReadRecord',
|
||||
'canDeleteRecord',
|
||||
'canUndeleteRecord',
|
||||
'canManageOwnerOnRecord',
|
||||
'canSearchRevision',
|
||||
'canGrant',
|
||||
]
|
||||
}
|
||||
}
|
||||
78
lib/js/src/compose/types/revision.ts
Normal file
78
lib/js/src/compose/types/revision.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { User } from '../../system'
|
||||
|
||||
export interface RevisionChange {
|
||||
key: string;
|
||||
old: Array<unknown>;
|
||||
new: Array<unknown>;
|
||||
}
|
||||
|
||||
export interface Revision {
|
||||
changeID: string;
|
||||
timestamp: Date;
|
||||
resource: string;
|
||||
revision: number;
|
||||
operation: string;
|
||||
userID: string;
|
||||
user: User | null;
|
||||
changes: Array<RevisionChange>;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface RawRevisionPayload {
|
||||
set: Array<{
|
||||
changeID: string;
|
||||
timestamp: string;
|
||||
resource: string;
|
||||
revision: number;
|
||||
operation: string;
|
||||
userID: string;
|
||||
|
||||
// revisions without field changes (soft-deleted, undeleted) are sent with a null delta
|
||||
changes: Array<RevisionChange> | null;
|
||||
comment: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function isRawRevisionPayload (raw: unknown): raw is RawRevisionPayload {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
console.warn('not an object', raw)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!Object.getOwnPropertyNames(raw).includes('set')) {
|
||||
console.warn('no set prop', raw)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!Array.isArray((raw as { set: unknown }).set)) {
|
||||
console.warn('set prop not array', raw)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function convertRevisionPayloadToRevision (payload: unknown, validChangeKeys: string[]): Array<Revision> {
|
||||
if (!isRawRevisionPayload(payload)) {
|
||||
throw new Error('Invalid revision payload')
|
||||
}
|
||||
|
||||
let filterChanges = (cc: Array<RevisionChange>): Array<RevisionChange> => cc
|
||||
|
||||
if (validChangeKeys.length > 0) {
|
||||
// filter out changes that don't have valid keys
|
||||
filterChanges = (cc: Array<RevisionChange>): Array<RevisionChange> => cc.filter(c => validChangeKeys.includes(c.key))
|
||||
}
|
||||
|
||||
return payload.set.map(raw => ({
|
||||
changeID: raw.changeID,
|
||||
timestamp: new Date(raw.timestamp),
|
||||
resource: raw.resource,
|
||||
revision: raw.revision,
|
||||
operation: raw.operation,
|
||||
userID: raw.userID,
|
||||
user: null,
|
||||
comment: raw.comment,
|
||||
changes: filterChanges(Array.isArray(raw.changes) ? raw.changes : []),
|
||||
}))
|
||||
}
|
||||
44
lib/js/src/compose/validators/record.test.ts
Normal file
44
lib/js/src/compose/validators/record.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect } from 'chai'
|
||||
import { RecordValidator } from './record'
|
||||
import { Module } from '../types/module'
|
||||
import { Record } from '../types/record'
|
||||
import { Validated, ValidatorError } from '../../validator/validator'
|
||||
|
||||
const m = Object.freeze(new Module({
|
||||
fields: [
|
||||
{ kind: 'String', name: 'simple' },
|
||||
{ kind: 'String', name: 'required', isRequired: true },
|
||||
{ kind: 'String', name: 'multi', isMulti: true },
|
||||
{ kind: 'String', name: 'multiRequired', isRequired: true, isMulti: true },
|
||||
],
|
||||
}))
|
||||
|
||||
describe('record', () => {
|
||||
let r: Record, v: RecordValidator
|
||||
|
||||
beforeEach(() => {
|
||||
r = new Record(m)
|
||||
v = new RecordValidator(m)
|
||||
})
|
||||
|
||||
it('should return errors when required values are not set', () => {
|
||||
expect(v.run(r).get()).deep.members([
|
||||
new ValidatorError({ kind: 'empty', message: 'field:required-field', meta: { field: 'required', id: 'parent:0' } }),
|
||||
new ValidatorError({ kind: 'empty', message: 'field:required-field', meta: { field: 'multiRequired', id: 'parent:0' } }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validator', () => {
|
||||
it('should properly filter by meta key', () => {
|
||||
const v = new Validated(
|
||||
new ValidatorError('foo'),
|
||||
new ValidatorError({ kind: 'test', message: 'bar', meta: { bar: true } }),
|
||||
new ValidatorError({ kind: 'test', message: 'baz', meta: { bar: false } }),
|
||||
)
|
||||
|
||||
expect(v.filterByMeta('bar')).to.have.lengthOf(2)
|
||||
expect(v.filterByMeta('bar', true)).to.have.lengthOf(1)
|
||||
expect(v.filterByMeta('bar', false)).to.have.lengthOf(1)
|
||||
})
|
||||
})
|
||||
120
lib/js/src/compose/validators/record.ts
Normal file
120
lib/js/src/compose/validators/record.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Validator, ValidatorFn, ValidatorResult, ValidatorError, Validated, IsEmpty } from '../../validator/validator'
|
||||
import { Record } from '../types/record'
|
||||
import { Module } from '../types/module'
|
||||
import { ModuleField } from '../types/module-field'
|
||||
import { IsOf } from '../../guards'
|
||||
import { NoID } from '../../cast'
|
||||
|
||||
const emptyErr = new ValidatorError({ kind: 'empty', message: 'field:required-field' })
|
||||
|
||||
// Validator value types
|
||||
interface FieldValidatorPayload {
|
||||
field: ModuleField;
|
||||
value: unknown | string | string[];
|
||||
oldValue: unknown | string | string[];
|
||||
}
|
||||
|
||||
function genericFieldValidator (field: ModuleField): ValidatorFn<Record> {
|
||||
// newValue is of type unknown to satisfy ValidatorFn interface
|
||||
return function (this: Record, arg0: unknown): ValidatorResult {
|
||||
if (!IsOf<FieldValidatorPayload>(arg0, 'field', 'value', 'oldValue')) {
|
||||
throw Error('invalid field validator argument type')
|
||||
}
|
||||
const { value } = arg0
|
||||
|
||||
if (field.isRequired) {
|
||||
const isNewRecord = this.recordID === NoID
|
||||
const canManageFieldValue = isNewRecord ? true : field.canReadRecordValue && field.canUpdateRecordValue
|
||||
|
||||
if ((value === undefined || IsEmpty(value)) && canManageFieldValue) {
|
||||
return emptyErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordValidator extends Validator<Record> {
|
||||
// registered field validators:
|
||||
protected rfv: { [field: string]: Validator<Record> }
|
||||
|
||||
/**
|
||||
* Construct record validator from module (or record)
|
||||
*
|
||||
* @param m
|
||||
*/
|
||||
constructor (m: Module|Record) {
|
||||
super()
|
||||
|
||||
this.rfv = {}
|
||||
|
||||
if (m instanceof Record) {
|
||||
m = m.module
|
||||
}
|
||||
|
||||
m.fields.forEach(field => {
|
||||
this.rfv[field.name] = new Validator<Record>(genericFieldValidator(field))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Append more record validators
|
||||
*
|
||||
* @param name
|
||||
* @param vfn
|
||||
*/
|
||||
public push (...vfn: ValidatorFn<Record>[]): void {
|
||||
this.registered.push(...vfn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append more field validators
|
||||
*
|
||||
* @param name
|
||||
* @param vfn
|
||||
*/
|
||||
public pushToField (name: string, ...vfn: ValidatorFn<Record>[]): void {
|
||||
if (!this.rfv[name]) {
|
||||
throw new Error('can not push validators to unknown field')
|
||||
}
|
||||
|
||||
this.rfv[name].push(...vfn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs validators on record and all (or whitelisted) fields
|
||||
*/
|
||||
public run (r: Record, ...fields: string[]): Validated {
|
||||
const out = new Validated()
|
||||
|
||||
if (fields.length === 0) {
|
||||
// Fields are not explicitly provided,
|
||||
// we can run record-wide validators:
|
||||
const result = super.run(r)
|
||||
out.push(result.get())
|
||||
|
||||
// get list of fields from registered field validators
|
||||
fields = Object.getOwnPropertyNames(this.rfv)
|
||||
}
|
||||
|
||||
for (const f of fields) {
|
||||
const field = r.module.findField(f)
|
||||
if (!field) {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload: FieldValidatorPayload = {
|
||||
value: r.values[f],
|
||||
oldValue: r.cleanValues[f],
|
||||
field,
|
||||
}
|
||||
|
||||
const { recordID = NoID } = r || {}
|
||||
const results = this.rfv[f].run(r, payload)
|
||||
|
||||
results.applyMeta({ field: f, id: recordID === NoID ? 'parent:0' : recordID })
|
||||
out.push(results.get())
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
64
lib/js/src/corredor/args-corteza.ts
Normal file
64
lib/js/src/corredor/args-corteza.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { User, Role, Application, SinkResponse, SinkRequest } from '../system'
|
||||
import { Module, Page, Namespace, Record } from '../compose'
|
||||
import { Caster, GenericCaster, GenericCasterFreezer } from './shared'
|
||||
|
||||
interface RecordCasterCaller {
|
||||
$module: Module;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record type caster
|
||||
*
|
||||
* Record arg is a bit special, it takes 2 params (record itself + record's module)
|
||||
*/
|
||||
function recordCaster (this: RecordCasterCaller, val: unknown): Record|undefined {
|
||||
if (val) {
|
||||
try {
|
||||
return new Record(this.$module, val as object)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function recordCasterFreezer (this: RecordCasterCaller, val: unknown): Readonly<Record>|undefined {
|
||||
if (val) {
|
||||
try {
|
||||
return Object.freeze(new Record(this.$module, val as object))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* CortezaTypes map helps ExecArgs class with translation of (special) arguments
|
||||
* to their respected types
|
||||
*
|
||||
* There's noe need to set/define casters for old* arguments,
|
||||
* It's auto-magically done by Args class
|
||||
*/
|
||||
export const CortezaTypes: Caster = new Map()
|
||||
|
||||
CortezaTypes.set('authUser', GenericCasterFreezer(User))
|
||||
CortezaTypes.set('invoker', GenericCasterFreezer(User))
|
||||
CortezaTypes.set('module', GenericCaster(Module))
|
||||
CortezaTypes.set('oldModule', GenericCasterFreezer(Module))
|
||||
CortezaTypes.set('page', GenericCaster(Page))
|
||||
CortezaTypes.set('oldPage', GenericCasterFreezer(Page))
|
||||
CortezaTypes.set('namespace', GenericCaster(Namespace))
|
||||
CortezaTypes.set('oldNamespace', GenericCasterFreezer(Namespace))
|
||||
CortezaTypes.set('application', GenericCaster(Application))
|
||||
CortezaTypes.set('oldApplication', GenericCasterFreezer(Application))
|
||||
CortezaTypes.set('user', GenericCaster(User))
|
||||
CortezaTypes.set('oldUser', GenericCasterFreezer(User))
|
||||
CortezaTypes.set('role', GenericCaster(Role))
|
||||
CortezaTypes.set('oldRole', GenericCasterFreezer(Role))
|
||||
CortezaTypes.set('record', recordCaster)
|
||||
CortezaTypes.set('oldRecord', recordCasterFreezer)
|
||||
CortezaTypes.set('request', GenericCasterFreezer(SinkRequest))
|
||||
CortezaTypes.set('response', GenericCaster(SinkResponse))
|
||||
36
lib/js/src/corredor/args.test.ts
Normal file
36
lib/js/src/corredor/args.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, it } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { Args } from './args'
|
||||
import { CortezaTypes } from './args-corteza'
|
||||
import { Module } from '../compose/types/module'
|
||||
|
||||
describe('args', () => {
|
||||
it('should provide getter for a given arg', () => {
|
||||
const args = new Args({ foo: 'bar' })
|
||||
expect(args).to.haveOwnProperty('foo')
|
||||
expect(args).property('foo').eq('bar')
|
||||
})
|
||||
|
||||
it('should not have undefined property', () => {
|
||||
const args = new Args({})
|
||||
expect(args).not.to.haveOwnProperty('foo')
|
||||
})
|
||||
|
||||
it('should use caster', () => {
|
||||
const module = { moduleID: '42' }
|
||||
const args = new Args({ module }, CortezaTypes)
|
||||
expect(args).to.haveOwnProperty('$module')
|
||||
expect(args).to.haveOwnProperty('rawModule')
|
||||
expect(args).property('$module').instanceOf(Module)
|
||||
expect(args).property('rawModule').to.deep.eq(module)
|
||||
})
|
||||
|
||||
it('should properly handle pre-casted variables', () => {
|
||||
const module = new Module({ moduleID: '42' })
|
||||
const args = new Args({ module }, CortezaTypes)
|
||||
expect(args).to.haveOwnProperty('$module')
|
||||
expect(args).to.haveOwnProperty('rawModule')
|
||||
expect(args).property('$module').instanceOf(Module)
|
||||
expect(args).property('rawModule').to.deep.eq(module)
|
||||
})
|
||||
})
|
||||
90
lib/js/src/corredor/args.ts
Normal file
90
lib/js/src/corredor/args.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { CortezaTypes } from './args-corteza'
|
||||
import { Caster } from './shared'
|
||||
|
||||
/**
|
||||
* Handles arguments, passed to the script
|
||||
*
|
||||
* By convention variables holding "current" resources are prefixed with dollar ($) sign.
|
||||
* For example, before/after triggers for record will call registered scripts with $record, $module
|
||||
* and $namespace, holding current record, it's module and namespace.
|
||||
*
|
||||
* All these variables are casted (if passed as an argument) to proper types ($record => Record, $module => Module, ...)
|
||||
*/
|
||||
export class Args {
|
||||
constructor (args: {[_: string]: unknown}, caster: Caster = CortezaTypes) {
|
||||
const cachedArgs: { [_: string]: any } = {}
|
||||
|
||||
for (const arg in args) {
|
||||
if (caster && caster.has(arg)) {
|
||||
const cast = caster.get(arg)
|
||||
|
||||
if (cast) {
|
||||
Object.defineProperty(this, `$${arg}`, {
|
||||
get: () => {
|
||||
if (!cachedArgs[arg]) {
|
||||
cachedArgs[arg] = cast.call(this, args[arg])
|
||||
}
|
||||
|
||||
return cachedArgs[arg]
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(this, `raw${arg.substring(0, 1).toUpperCase()}${arg.substring(1)}`, {
|
||||
value: args[arg],
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
})
|
||||
} else {
|
||||
Object.defineProperty(this, arg, {
|
||||
value: args[arg],
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles arguments, passed to the script but preserves references to the original objects
|
||||
*
|
||||
* By convention variables holding "current" resources are prefixed with dollar ($) sign.
|
||||
* For example, before/after triggers for record will call registered scripts with $record, $module
|
||||
* and $namespace, holding current record, it's module and namespace.
|
||||
*
|
||||
* These variables are not additionally casted, since in order to preserve references they should
|
||||
* already be in the correct type.
|
||||
*/
|
||||
export class ArgsProxy {
|
||||
constructor (args: {[_: string]: unknown}, caster: Caster = CortezaTypes) {
|
||||
for (const arg in args) {
|
||||
// For consistency only prefix args with & and raw that have a defined caster
|
||||
if (caster && caster.has(arg)) {
|
||||
Object.defineProperty(this, `$${arg}`, {
|
||||
get: () => args[arg],
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(this, `raw${arg.substring(0, 1).toUpperCase()}${arg.substring(1)}`, {
|
||||
value: args[arg],
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
})
|
||||
} else {
|
||||
Object.defineProperty(this, arg, {
|
||||
value: args[arg],
|
||||
writable: false,
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
lib/js/src/corredor/ctx.test.ts
Normal file
27
lib/js/src/corredor/ctx.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { Ctx } from './ctx'
|
||||
import { User } from '../system'
|
||||
import pino from 'pino'
|
||||
|
||||
describe('ctx', () => {
|
||||
describe('context sanity check', () => {
|
||||
it('should have a valid getter', () => {
|
||||
const cscfg = { apiBaseURL: '' }
|
||||
const ctx = new Ctx(
|
||||
{
|
||||
$invoker: new User(),
|
||||
authToken: '',
|
||||
},
|
||||
pino(),
|
||||
{ config: { cServers: { system: cscfg, compose: cscfg } } },
|
||||
)
|
||||
|
||||
expect(ctx.console).to.not.be.undefined
|
||||
expect(ctx.SystemAPI).to.not.be.undefined
|
||||
expect(ctx.ComposeAPI).to.not.be.undefined
|
||||
expect(ctx.System).to.not.be.undefined
|
||||
expect(ctx.Compose).to.not.be.undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user