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:
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
|
||||
})
|
||||
})
|
||||
})
|
||||
154
lib/js/src/corredor/ctx.ts
Normal file
154
lib/js/src/corredor/ctx.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as apiClients from '../api-clients'
|
||||
import { SystemHelper, ComposeHelper } from './helpers'
|
||||
import { Logger } from 'pino'
|
||||
import { BaseArgs } from './shared'
|
||||
import { User } from '../system'
|
||||
|
||||
export interface ConfigCServers {
|
||||
system?: ConfigServer;
|
||||
compose?: ConfigServer;
|
||||
}
|
||||
|
||||
export interface ConfigServer {
|
||||
apiBaseURL?: string;
|
||||
}
|
||||
|
||||
export interface ConfigFrontend {
|
||||
baseURL?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
cServers?: ConfigCServers;
|
||||
frontend?: ConfigFrontend;
|
||||
}
|
||||
|
||||
interface CtxInitArgs {
|
||||
config?:
|
||||
Config;
|
||||
|
||||
systemAPI?:
|
||||
apiClients.System;
|
||||
|
||||
composeAPI?:
|
||||
apiClients.Compose;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles script execution context
|
||||
*
|
||||
* Context accepts pre-assembled *API props or it construct them fly from passed config
|
||||
*
|
||||
* Naming convention for properties:
|
||||
* - Corteza classes, high-level helpers, API clients are upper cased
|
||||
* - low-level helpers are lower cased
|
||||
* - simple scalar are lower cased
|
||||
* - $authUser is the only one prefixed with the dollar sign for historical reasons
|
||||
*/
|
||||
export class Ctx {
|
||||
protected args: BaseArgs;
|
||||
protected config?: Config;
|
||||
|
||||
protected logger: Logger;
|
||||
|
||||
protected systemAPI?:
|
||||
apiClients.System;
|
||||
|
||||
protected composeAPI?:
|
||||
apiClients.Compose;
|
||||
|
||||
constructor (args: BaseArgs, logger: Logger, a?: CtxInitArgs) {
|
||||
this.args = args
|
||||
this.logger = logger
|
||||
|
||||
if (a) {
|
||||
Object.assign(this, a)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for log, to make developer's life easier <3
|
||||
*/
|
||||
get console (): Logger {
|
||||
return this.logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for log, to make developer's life easier <3
|
||||
*/
|
||||
get log (): Logger {
|
||||
return this.logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns promise with the current user (if authToken argument was given)
|
||||
*
|
||||
* This is a temporary solution that decodes the userID from the access token (JWT)
|
||||
* and fetches the user info
|
||||
*
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
get $authUser (): Promise<User> {
|
||||
const [, payload] = this.args.authToken.split('.')
|
||||
const buf = new Buffer(payload, 'base64')
|
||||
const { sub: userID } = JSON.parse(buf.toString('ascii'))
|
||||
|
||||
return this.SystemAPI.userRead({ userID }).then(r => new User(r))
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures and returns system API client
|
||||
*/
|
||||
get SystemAPI (): apiClients.System {
|
||||
if (!this.systemAPI) {
|
||||
if (!this.config?.cServers?.system) {
|
||||
throw new Error('configuration for corteza system server missing')
|
||||
}
|
||||
|
||||
this.systemAPI = new apiClients.System({
|
||||
baseURL: this.config.cServers.system.apiBaseURL,
|
||||
accessTokenFn: (): string => this.args.authToken,
|
||||
})
|
||||
}
|
||||
|
||||
return this.systemAPI
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures and returns compose API client
|
||||
*/
|
||||
get ComposeAPI (): apiClients.Compose {
|
||||
if (!this.composeAPI) {
|
||||
if (!this.config?.cServers?.compose) {
|
||||
throw new Error('configuration for corteza compose server missing')
|
||||
}
|
||||
|
||||
this.composeAPI = new apiClients.Compose({
|
||||
baseURL: this.config.cServers.compose.apiBaseURL,
|
||||
accessTokenFn: (): string => this.args.authToken,
|
||||
})
|
||||
}
|
||||
|
||||
return this.composeAPI
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures and returns system helper
|
||||
*/
|
||||
get System (): SystemHelper {
|
||||
return new SystemHelper({ SystemAPI: this.SystemAPI, ...this.args })
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures and returns compose helper
|
||||
*/
|
||||
get Compose (): ComposeHelper {
|
||||
return new ComposeHelper({ ComposeAPI: this.ComposeAPI, ...this.args })
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
get frontendBaseURL (): string|undefined {
|
||||
return this.config?.frontend?.baseURL
|
||||
}
|
||||
}
|
||||
165
lib/js/src/corredor/exec.test.ts
Normal file
165
lib/js/src/corredor/exec.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/* eslint-disable no-unused-expressions,@typescript-eslint/no-empty-function,@typescript-eslint/ban-ts-ignore */
|
||||
import { describe, it } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { Exec, ScriptExecFn } from './exec'
|
||||
|
||||
// @ts-ignore
|
||||
import pino from 'pino'
|
||||
import { Config, Ctx } from './ctx'
|
||||
import { User } from '../system'
|
||||
import { BaseArgs } from './shared'
|
||||
|
||||
interface CheckerFnArgs {
|
||||
result?: {[_: string]: unknown}|unknown;
|
||||
logs?: string[];
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface CheckerFn {
|
||||
(_: CheckerFnArgs): void;
|
||||
}
|
||||
|
||||
class Dummy {}
|
||||
|
||||
describe('execution', () => {
|
||||
const execIt = (name: string, check: CheckerFn, exec: ScriptExecFn): void => {
|
||||
it(name, async () => {
|
||||
const scriptLogger = pino()
|
||||
const config: Config = { cServers: { compose: {}, system: {} } }
|
||||
const args: BaseArgs = {
|
||||
authToken: '',
|
||||
$invoker: new User(),
|
||||
}
|
||||
|
||||
Exec({ exec }, args, new Ctx(args, scriptLogger, { config }))
|
||||
.then((result: object) => check({ result }))
|
||||
.catch((error: Error|undefined) => check({ error }))
|
||||
})
|
||||
}
|
||||
|
||||
execIt(
|
||||
'empty',
|
||||
() => {},
|
||||
() => {},
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get true when returning true',
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ result: true })
|
||||
},
|
||||
|
||||
() => true,
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get error with returning false',
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(result).to.be.undefined
|
||||
expect(error).to.be.instanceOf(Error)
|
||||
},
|
||||
() => false,
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get empty string when returning empty string',
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ result: '' })
|
||||
},
|
||||
() => '',
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get string when returning string',
|
||||
// @ts-ignore
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ result: 'rval-string' })
|
||||
},
|
||||
() => 'rval-string',
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get empty array when returning empty array',
|
||||
// @ts-ignore
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ result: [] })
|
||||
},
|
||||
() => ([]),
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get array when returning array',
|
||||
// @ts-ignore
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ result: ['rval-string'] })
|
||||
},
|
||||
() => (['rval-string']),
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get empty object when returning empty object',
|
||||
// @ts-ignore
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({})
|
||||
},
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get object when returning object',
|
||||
// @ts-ignore
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ an: 'object' })
|
||||
},
|
||||
() => ({ an: 'object' }),
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should get non-plain-object under result',
|
||||
// @ts-ignore
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(error).to.be.undefined
|
||||
expect(result).to.deep.eq({ result: new Dummy() })
|
||||
},
|
||||
() => new Dummy(),
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should handle thrown exception',
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(result).to.be.undefined
|
||||
expect(error).to.be.instanceOf(Error)
|
||||
if (error !== undefined) {
|
||||
expect(error.message).to.be.eq('err')
|
||||
}
|
||||
},
|
||||
() => { throw new Error('err') },
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should handle rejection',
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
expect(result).to.be.undefined
|
||||
expect(error).to.be.eq('err')
|
||||
},
|
||||
// eslint-disable-next-line prefer-promise-reject-errors
|
||||
async () => { return Promise.reject('err') },
|
||||
)
|
||||
|
||||
execIt(
|
||||
'should handle promise',
|
||||
({ result, error }: CheckerFnArgs) => {
|
||||
// @ts-ignore
|
||||
expect(result.result).to.be.eq('ok')
|
||||
expect(error).to.be.undefined
|
||||
},
|
||||
async () => { return Promise.resolve('ok') },
|
||||
)
|
||||
})
|
||||
48
lib/js/src/corredor/exec.ts
Normal file
48
lib/js/src/corredor/exec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BaseArgs } from './shared'
|
||||
import { Ctx } from './ctx'
|
||||
|
||||
export interface ScriptExecFn {
|
||||
(args: BaseArgs, ctx?: Ctx): unknown;
|
||||
}
|
||||
|
||||
export interface ExecutableScript {
|
||||
exec: ScriptExecFn;
|
||||
}
|
||||
|
||||
interface Results {
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Script executor
|
||||
*
|
||||
* @param script - Script to be executed
|
||||
* @param args - Arguments for the script
|
||||
* @param ctx - Exec context (exec function's 2nd param)
|
||||
*/
|
||||
export async function Exec (script: ExecutableScript, args: BaseArgs, ctx: Ctx): Promise<Results> {
|
||||
try {
|
||||
// Wrap exec() with Promise.resolve - we do not know if function is async or not.
|
||||
return Promise.resolve(script.exec(args, ctx)).then((rval: unknown): object => {
|
||||
let result = {}
|
||||
|
||||
if (rval === false) {
|
||||
// Abort when returning false!
|
||||
throw new Error('Aborted')
|
||||
}
|
||||
|
||||
if (typeof rval === 'object' && rval && rval.constructor.name === 'Object') {
|
||||
// Expand returned values into result if function returned a plain javascript object
|
||||
result = { ...rval }
|
||||
} else if (rval !== undefined) {
|
||||
// If anything usable was returned, stack it under 'result' property
|
||||
result = { result: rval }
|
||||
}
|
||||
|
||||
// Wrap returning value
|
||||
return result
|
||||
})
|
||||
} catch (e) {
|
||||
return Promise.reject(e)
|
||||
}
|
||||
}
|
||||
508
lib/js/src/corredor/helpers/compose.test.ts
Normal file
508
lib/js/src/corredor/helpers/compose.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, it, beforeEach } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { StubbedInstance, stubObject } from 'ts-sinon'
|
||||
import ComposeHelper from './compose'
|
||||
import { Module, ModuleFieldNumber, ModuleFieldString, Namespace, Page, Record } from '../../compose'
|
||||
import { Compose as ComposeAPI } from '../../api-clients'
|
||||
import { kv } from './shared'
|
||||
import { NoID } from '../../cast'
|
||||
|
||||
const pagePayload = Object.freeze({
|
||||
title: 'My Amazing Page',
|
||||
})
|
||||
|
||||
const ns = (h: ComposeHelper): { namespaceID: string } => {
|
||||
let namespaceID = NoID
|
||||
if (h && h.$namespace) {
|
||||
namespaceID = h.$namespace.namespaceID
|
||||
}
|
||||
|
||||
return { namespaceID }
|
||||
}
|
||||
|
||||
describe('compose', () => {
|
||||
describe('supporting functions', () => {
|
||||
describe('resolving module', () => {
|
||||
let h: StubbedInstance<ComposeHelper>
|
||||
|
||||
beforeEach(() => {
|
||||
h = stubObject<ComposeHelper>(
|
||||
new ComposeHelper({ ComposeAPI: new ComposeAPI({}) }),
|
||||
[
|
||||
'findModuleByHandle',
|
||||
'findModuleByName',
|
||||
'findModuleByID',
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('should return first valid module', async () => {
|
||||
const m = new Module({ moduleID: '1', namespaceID: '2' })
|
||||
|
||||
expect(await h.resolveModule(undefined, null, false, 0, '', m)).to.deep.equal(m)
|
||||
|
||||
expect(h.findModuleByHandle.notCalled, 'findModuleByHandle call not expected').true
|
||||
expect(h.findModuleByName.notCalled, 'findModuleByName call not expected').true
|
||||
expect(h.findModuleByID.notCalled, 'findModuleByID call not expected').true
|
||||
})
|
||||
|
||||
it('should resolve ID', async () => {
|
||||
const m = new Module({ moduleID: '444', namespaceID: '555' })
|
||||
|
||||
h.findModuleByID.resolves(m)
|
||||
|
||||
expect(await h.resolveModule(m.moduleID)).to.deep.equal(m)
|
||||
|
||||
expect(h.findModuleByID.calledOnceWith(m.moduleID), 'findModuleByID call expected').true
|
||||
expect(h.findModuleByHandle.notCalled, 'findModuleByHandle call not expected').true
|
||||
expect(h.findModuleByName.notCalled, 'findModuleByName call not expected').true
|
||||
})
|
||||
|
||||
it('should resolve handle', async () => {
|
||||
const m = new Module({ handle: 'm-handle' })
|
||||
|
||||
h.findModuleByHandle.resolves(m)
|
||||
|
||||
expect(await h.resolveModule(m.handle)).to.deep.equal(m)
|
||||
|
||||
expect(h.findModuleByID.notCalled, 'findModuleByID call not expected').true
|
||||
expect(h.findModuleByHandle.calledOnceWith(m.handle), 'findModuleByHandle call expected').true
|
||||
expect(h.findModuleByName.notCalled, 'findModuleByName call not expected').true
|
||||
})
|
||||
|
||||
it('should resolve name', async () => {
|
||||
const m = new Module({ name: 'm-name' })
|
||||
|
||||
h.findModuleByName.resolves(m)
|
||||
h.findModuleByHandle.resolves(undefined)
|
||||
|
||||
expect(await h.resolveModule(m.name)).to.deep.equal(m)
|
||||
|
||||
expect(h.findModuleByID.notCalled, 'findModuleByID call not expected').true
|
||||
expect(h.findModuleByHandle.calledOnceWith(m.name), 'findModuleByHandle call expected').true
|
||||
expect(h.findModuleByName.calledOnceWith(m.name), 'findModuleByName call expected').true
|
||||
})
|
||||
|
||||
it('should resolve numeric name', async () => {
|
||||
const m = new Module({ name: '555' })
|
||||
|
||||
h.findModuleByID.rejects(Error('compose.repository.ModuleNotFound'))
|
||||
h.findModuleByName.resolves(m)
|
||||
h.findModuleByHandle.resolves(undefined)
|
||||
|
||||
expect(await h.resolveModule(m.name)).to.deep.equal(m)
|
||||
|
||||
expect(h.findModuleByID.calledOnceWith(m.name), 'findModuleByID call expected').true
|
||||
expect(h.findModuleByHandle.calledOnceWith(m.name), 'findModuleByHandle call expected').true
|
||||
expect(h.findModuleByName.calledOnceWith(m.name), 'findModuleByName call expected').true
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNamespace', () => {
|
||||
let h: StubbedInstance<ComposeHelper>
|
||||
|
||||
beforeEach(() => {
|
||||
h = stubObject<ComposeHelper>(
|
||||
new ComposeHelper({ ComposeAPI: new ComposeAPI({}) }),
|
||||
[
|
||||
'findNamespaceBySlug',
|
||||
'findNamespaceByID',
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it('should return first valid namespace', async () => {
|
||||
const ns = new Namespace({ namespaceID: '2' })
|
||||
expect(await h.resolveNamespace(undefined, null, false, 0, '', ns)).to.deep.equal(ns)
|
||||
})
|
||||
|
||||
it('should resolve ID', async () => {
|
||||
const ns = new Namespace({ namespaceID: '555' })
|
||||
|
||||
h.findNamespaceByID.resolves(ns)
|
||||
|
||||
expect(await h.resolveNamespace(ns.namespaceID)).to.deep.equal(ns)
|
||||
|
||||
expect(h.findNamespaceByID.calledOnceWith(ns.namespaceID), 'findNamespaceByID call expected').true
|
||||
})
|
||||
|
||||
it('should resolve slug', async () => {
|
||||
const ns = new Namespace({ slug: 'ns-slug' })
|
||||
|
||||
h.findNamespaceBySlug.resolves(ns)
|
||||
|
||||
expect(await h.resolveNamespace(ns.slug)).to.deep.equal(ns)
|
||||
|
||||
expect(h.findNamespaceBySlug.calledOnceWith(ns.slug), 'findNamespaceBySlug call expected').true
|
||||
})
|
||||
|
||||
it('should resolve numeric slug', async () => {
|
||||
const ns = new Namespace({ slug: '555' })
|
||||
|
||||
h.findNamespaceByID.rejects(Error('compose.repository.NamespaceNotFound'))
|
||||
h.findNamespaceBySlug.resolves(ns)
|
||||
|
||||
expect(await h.resolveNamespace(ns.slug)).to.deep.equal(ns)
|
||||
|
||||
expect(h.findNamespaceByID.calledOnceWith(ns.slug), 'findNamespaceByID call expected').true
|
||||
expect(h.findNamespaceBySlug.calledOnceWith(ns.slug), 'findNamespaceBySlug call expected').true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
let h: ComposeHelper
|
||||
let composeApiStub: StubbedInstance<ComposeAPI>
|
||||
|
||||
const $namespace = new Namespace({
|
||||
namespaceID: '2',
|
||||
slug: 'slg',
|
||||
name: 'name of the name-space',
|
||||
})
|
||||
|
||||
const $module = new Module({
|
||||
moduleID: '1',
|
||||
fields: [
|
||||
new ModuleFieldString({ name: 'str' }),
|
||||
new ModuleFieldNumber({ name: 'num' }),
|
||||
new ModuleFieldString({ name: 'multi', isMulti: true }),
|
||||
],
|
||||
}, $namespace)
|
||||
|
||||
beforeEach(() => {
|
||||
composeApiStub = stubObject<ComposeAPI>(new ComposeAPI({}))
|
||||
|
||||
h = new ComposeHelper({
|
||||
ComposeAPI: composeApiStub,
|
||||
$module,
|
||||
$namespace,
|
||||
})
|
||||
})
|
||||
|
||||
describe('record making', () => {
|
||||
it('should make a record using $module', async () => {
|
||||
const record = await h.makeRecord({ str: 'foo' })
|
||||
|
||||
expect(record).to.instanceof(Record)
|
||||
expect(record.moduleID).equal($module.moduleID)
|
||||
expect(record.values.str).to.equal('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('record saving', () => {
|
||||
it('should reject invalid record', async () => {
|
||||
const tests = [
|
||||
{ val: null, label: 'Null value' },
|
||||
{ val: {}, label: 'Empty object' },
|
||||
]
|
||||
for (const t of tests) {
|
||||
expect(async () => { await h.saveRecord(t.val as unknown as Record) }, t.label).to.throw
|
||||
}
|
||||
})
|
||||
|
||||
it('should create new', async () => {
|
||||
const record = await h.makeRecord({})
|
||||
|
||||
composeApiStub.recordCreate.resolves(kv(record))
|
||||
|
||||
await h.saveRecord(record)
|
||||
|
||||
expect(composeApiStub.recordCreate.calledOnceWith(kv(record))).true
|
||||
})
|
||||
|
||||
it('should update existing', async () => {
|
||||
const record = new Record($module, { recordID: '222' })
|
||||
|
||||
composeApiStub.recordUpdate.resolves(kv(record))
|
||||
|
||||
await h.saveRecord(record)
|
||||
|
||||
expect(composeApiStub.recordUpdate.calledOnceWith(kv(record))).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('record deleting', () => {
|
||||
it('should validate input', async () => {
|
||||
expect(async () => { await h.deleteRecord(null as unknown as Record) }).to.throw
|
||||
expect(async () => { await h.deleteRecord(false as unknown as Record) }).to.throw
|
||||
expect(async () => { await h.deleteRecord(true as unknown as Record) }).to.throw
|
||||
expect(async () => { await h.deleteRecord({} as unknown as Record) }).to.throw
|
||||
expect(async () => { await h.deleteRecord($module as unknown as Record) }).to.throw
|
||||
expect(async () => { await h.deleteRecord(Promise.resolve($module) as unknown as Record) }).to.throw
|
||||
})
|
||||
|
||||
it('should delete existing record', async () => {
|
||||
const record = new Record($module, { recordID: '222' })
|
||||
composeApiStub.recordDelete.resolves(kv(record))
|
||||
await h.deleteRecord(record)
|
||||
expect(composeApiStub.recordDelete.calledOnceWith(kv(record))).true
|
||||
})
|
||||
|
||||
it('should not delete fresh record', async () => {
|
||||
const record = new Record($module)
|
||||
composeApiStub.recordDelete.resolves()
|
||||
await h.deleteRecord(record)
|
||||
expect(composeApiStub.recordDelete.notCalled).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('record finding', () => {
|
||||
it('should find records on $module')
|
||||
it('should find records on a different module')
|
||||
it('should properly translate filter to ID when numeric')
|
||||
it('should properly translate filter to query when string')
|
||||
it('should cast retrieved objects to Record')
|
||||
})
|
||||
|
||||
describe('record finding by id', () => {
|
||||
it('should find record on $module')
|
||||
it('should find record on a different module')
|
||||
it('should find by ID when given a Record object')
|
||||
it('should cast retrieved objects to Record')
|
||||
})
|
||||
|
||||
describe('page making', () => {
|
||||
it('should make a page', async () => {
|
||||
// expect(await h.makeRecord({}, $module)).to.instanceof(Record)
|
||||
expect((await h.makePage({ title: 'foo' })).title).to.equal('foo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('page saving', () => {
|
||||
it('should create new', async () => {
|
||||
const page = await h.makePage(pagePayload)
|
||||
composeApiStub.pageCreate.resolves(pagePayload)
|
||||
|
||||
expect(await h.savePage(page)).instanceOf(Page)
|
||||
expect(composeApiStub.pageCreate.calledWith(kv(page))).true
|
||||
})
|
||||
|
||||
it('should update existing', async () => {
|
||||
const page = await h.makePage({ ...pagePayload, pageID: '123' })
|
||||
composeApiStub.pageUpdate.resolves(pagePayload)
|
||||
|
||||
expect(await h.savePage(page)).instanceOf(Page)
|
||||
expect(composeApiStub.pageUpdate.calledWith(kv(page))).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('page deleting', () => {
|
||||
it('should delete existing page', async () => {
|
||||
const page = await h.makePage({ ...pagePayload, pageID: '123' })
|
||||
composeApiStub.pageDelete.resolves(kv(page))
|
||||
|
||||
await h.deletePage(page)
|
||||
expect(composeApiStub.pageDelete.calledWith(kv(page))).true
|
||||
})
|
||||
|
||||
it('should not delete fresh page', async () => {
|
||||
const page = await h.makePage({ ...pagePayload })
|
||||
composeApiStub.pageDelete.resolves(kv(page))
|
||||
|
||||
await h.deletePage(page)
|
||||
expect(composeApiStub.pageDelete.notCalled).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('page finding', () => {
|
||||
it('should find pages on current namespace', async () => {
|
||||
composeApiStub.pageList.resolves({ filter: {}, set: [pagePayload] })
|
||||
|
||||
await h.findPages()
|
||||
|
||||
expect(composeApiStub.pageList.calledWith({ ...ns(h) })).true
|
||||
})
|
||||
|
||||
it('should find pages on given namespace', async () => {
|
||||
composeApiStub.pageList.resolves({ filter: {}, set: [pagePayload] })
|
||||
|
||||
await h.findPages(undefined, new Namespace({ namespaceID: '22' }))
|
||||
expect(composeApiStub.pageList.calledWith({ namespaceID: '22' })).true
|
||||
})
|
||||
|
||||
it('should filter (as string) pages on given namespace', async () => {
|
||||
composeApiStub.pageList.resolves({ filter: {}, set: [pagePayload] })
|
||||
|
||||
await h.findPages('filter')
|
||||
expect(composeApiStub.pageList.calledWith({ ...ns(h), query: 'filter' })).true
|
||||
})
|
||||
|
||||
it('should filter (as object) pages on given namespace', async () => {
|
||||
composeApiStub.pageList.resolves({ filter: {}, set: [pagePayload] })
|
||||
|
||||
await h.findPages({ query: 'filter', limit: 10 })
|
||||
expect(composeApiStub.pageList.calledWith({ ...ns(h), query: 'filter', limit: 10 })).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('findPageByID', () => {
|
||||
it('should find page on current namespace', async () => {
|
||||
composeApiStub.pageRead.resolves(pagePayload)
|
||||
|
||||
await h.findPageByID('1000')
|
||||
expect(composeApiStub.pageRead.calledWith({ ...ns(h), pageID: '1000' })).true
|
||||
})
|
||||
|
||||
it('should find page on given namespace', async () => {
|
||||
composeApiStub.pageRead.resolves(pagePayload)
|
||||
|
||||
await h.findPageByID('1000', new Namespace({ namespaceID: '22' }))
|
||||
expect(composeApiStub.pageRead.calledWith({ namespaceID: '22', pageID: '1000' })).true
|
||||
})
|
||||
|
||||
it('should find page from Object', async () => {
|
||||
composeApiStub.pageRead.resolves(pagePayload)
|
||||
const page = await h.makePage({ ...pagePayload, pageID: '1001' })
|
||||
|
||||
await h.findPageByID(page, new Namespace({ namespaceID: '22' }))
|
||||
expect(composeApiStub.pageRead.calledWith({ namespaceID: '22', pageID: '1001' })).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('making field name from label ', () => {
|
||||
it('should clean it up and present in camelcase', () => {
|
||||
const test = 'so.me;me:sa,ge+he_ok ok'
|
||||
const exp = 'SoMeMeSaGeHe_okOk'
|
||||
|
||||
expect(h.moduleFieldNameFromLabel(test)).to.eq(exp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('module making', () => {
|
||||
it('should make new', async () => {
|
||||
const module = await h.makeModule({ name: 'MyModule' })
|
||||
expect(module).to.be.instanceOf(Module)
|
||||
expect(module.name).to.equal('MyModule')
|
||||
})
|
||||
})
|
||||
|
||||
describe('module saving', async () => {
|
||||
it('should create new', async () => {
|
||||
const module = new Module()
|
||||
|
||||
composeApiStub.moduleCreate.resolves(kv(module))
|
||||
|
||||
await h.saveModule(module)
|
||||
|
||||
expect(composeApiStub.moduleCreate.calledWith(kv(module))).true
|
||||
})
|
||||
|
||||
it('should update existing', async () => {
|
||||
const module = new Module({ moduleID: '555' })
|
||||
|
||||
composeApiStub.moduleUpdate.resolves(kv(module))
|
||||
|
||||
await h.saveModule(module)
|
||||
|
||||
expect(composeApiStub.moduleUpdate.calledWith(kv(module))).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('module finding', () => {
|
||||
it('should find modules on $namespace')
|
||||
it('should find modules on a different namespace')
|
||||
it('should properly translate filter to query when string')
|
||||
it('should cast retrieved objects to Module')
|
||||
})
|
||||
|
||||
describe('module finding by id', () => {
|
||||
it('should find module by id on $namespace', async () => {
|
||||
const module = new Module({ moduleID: '555' })
|
||||
|
||||
composeApiStub.moduleRead.resolves({ ...module })
|
||||
|
||||
expect(await h.findModuleByID('555')).to.be.instanceOf(Module)
|
||||
|
||||
expect(composeApiStub.moduleRead.calledWith({ moduleID: '555', ...ns(h) })).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('module finding by handle', () => {
|
||||
it('should find module by handle on $namespace', async () => {
|
||||
const module = new Module({ moduleID: '555' })
|
||||
|
||||
composeApiStub.moduleList.resolves({ filter: { limit: 1 }, set: [module] })
|
||||
|
||||
expect(await h.findModuleByHandle('some-module')).to.be.instanceOf(Module)
|
||||
|
||||
expect(composeApiStub.moduleList.calledWith({ handle: 'some-module', ...ns(h) })).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('module finding by name', () => {
|
||||
it('should find module by name on $namespace', async () => {
|
||||
const module = new Module({ moduleID: '555' })
|
||||
|
||||
composeApiStub.moduleList.resolves({ filter: { limit: 1 }, set: [module] })
|
||||
|
||||
expect(await h.findModuleByName('some-module')).to.be.instanceOf(Module)
|
||||
|
||||
expect(composeApiStub.moduleList.calledWith({ name: 'some-module', ...ns(h) })).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('namespace making', () => {
|
||||
it('should make active namespace', async () => {
|
||||
expect((await h.makeNamespace()).enabled).to.be.true
|
||||
})
|
||||
|
||||
it('should use slug as name', async () => {
|
||||
const ns = await h.makeNamespace({ slug: 'sluggy-slug' })
|
||||
expect(ns.slug).to.equal('sluggy-slug')
|
||||
expect(ns.name).to.equal('sluggy-slug')
|
||||
})
|
||||
})
|
||||
|
||||
describe('namespace saving', async () => {
|
||||
it('should create new', async () => {
|
||||
const ns = new Namespace()
|
||||
composeApiStub.namespaceCreate.resolves(kv(ns))
|
||||
await h.saveNamespace(ns)
|
||||
expect(composeApiStub.namespaceCreate.calledWith(kv(ns))).true
|
||||
})
|
||||
|
||||
it('should update existing', async () => {
|
||||
const ns = new Namespace({ namespaceID: '555' })
|
||||
composeApiStub.namespaceUpdate.resolves(kv(ns))
|
||||
await h.saveNamespace(ns)
|
||||
expect(composeApiStub.namespaceUpdate.calledWith(kv(ns))).true
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendEmail', () => {
|
||||
it('should write some tests')
|
||||
})
|
||||
|
||||
describe('recordToPlainText', () => {
|
||||
const m = new Module({
|
||||
fields: [
|
||||
{ name: 'dummy' },
|
||||
{ name: 'multi', isMulti: true },
|
||||
{ name: 'empty' },
|
||||
],
|
||||
})
|
||||
|
||||
it('should convert a given record to plain text', () => {
|
||||
const record = new Record(m, {
|
||||
values: [
|
||||
{ name: 'dummy', value: 'value' },
|
||||
{ name: 'multi', value: 'v1' },
|
||||
{ name: 'multi', value: 'v2' },
|
||||
],
|
||||
})
|
||||
expect(h.recordToPlainText(null, record)).to.eq('dummy:\nvalue\n\nmulti:\nv1, v2\n\nempty:\n/')
|
||||
})
|
||||
|
||||
it('should convert white-listed fields of a given record to plain text', () => {
|
||||
const record = new Record(m, {
|
||||
values: [
|
||||
{ name: 'dummy', value: 'value' },
|
||||
{ name: 'multi', value: 'v1' },
|
||||
{ name: 'multi', value: 'v2' },
|
||||
],
|
||||
})
|
||||
expect(h.recordToPlainText(['dummy', 'multi'], record)).to.eq('dummy:\nvalue\n\nmulti:\nv1, v2')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
1148
lib/js/src/corredor/helpers/compose.ts
Normal file
1148
lib/js/src/corredor/helpers/compose.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user