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

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

43
lib/Makefile Normal file
View File

@@ -0,0 +1,43 @@
.PHONY: build dev test test-js test-vue lint fresh audit
build:
@echo "---Building js---"
@(cd $(CURDIR)/js && yarn && yarn build) || (echo "Failed to build js"; exit 1)
@echo "---Building vue---"
@(cd $(CURDIR)/vue && yarn && yarn build) || (echo "Failed to build vue"; exit 1)
dev: build
@echo "---Creating sym link for js---"
@(cd $(CURDIR)/js && yarn && yarn link && yarn build) || (echo "Failed to link js"; exit 1)
@echo "---Building vue and creating sym link---"
@(cd $(CURDIR)/vue && yarn && yarn cdeps && yarn build && yarn link) || (echo "Failed to process vue lib"; exit 1)
test: test-js test-vue
test-js:
@echo "---Testing js---"
@(cd $(CURDIR)/js && yarn test) || (echo "Failed to test js"; exit 1)
test-vue:
@echo "---Testing vue---"
@(cd $(CURDIR)/vue && yarn test) || (echo "Failed to test vue"; exit 1)
lint:
@echo "---Linting js---"
@(cd $(CURDIR)/js && yarn lint) || (echo "Failed to lint js"; exit 1)
@echo "---Linting vue---"
@(cd $(CURDIR)/vue && yarn lint) || (echo "Failed to lint vue"; exit 1)
fresh:
@echo "---Fresh js---"
@(cd $(CURDIR)/js && rm -rf node_modules yarn.lock && yarn && yarn link && yarn build) || (echo "Failed to fresh js"; exit 1)
@echo "---Fresh vue---"
@(cd $(CURDIR)/vue && rm -rf node_modules yarn.lock && yarn && yarn cdeps && yarn build && yarn link) || (echo "Failed to fresh vue"; exit 1)
audit:
@echo "---Audit js---"
@(cd $(CURDIR)/js && yarn audit) || true
@echo "---Audit vue---"
@(cd $(CURDIR)/vue && yarn audit) || true
.DEFAULT_GOAL := dev

18
lib/eslint-client/.gitignore vendored Normal file
View 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*

View File

@@ -0,0 +1,53 @@
module.exports = {
root: false,
env: {
es6: true,
node: true,
mocha: true,
},
extends: [
'plugin:vue/recommended',
'@vue/standard',
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'import/no-named-default': 'off',
'new-cap': 'off',
'comma-dangle': ['error', 'always-multiline'],
'no-misleading-character-class': 'off',
'no-useless-catch': 'off',
'no-async-promise-executor': 'off',
'no-case-declarations': 'off',
'vue/no-v-model-argument': 'off',
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
'vue/order-in-components': ['error'],
'vue/no-v-html': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-lone-template': 'off',
'vue/no-mutating-props': 'off',
'vue/component-definition-name-casing': 'off',
'vue/no-v-text-v-html-on-component': 'off',
'vue/attributes-order': ['error', {
order: [
'DEFINITION',
'LIST_RENDERING',
'CONDITIONALS',
'RENDER_MODIFIERS',
'GLOBAL',
'UNIQUE',
'TWO_WAY_BINDING',
'SLOT',
'OTHER_DIRECTIVES',
'OTHER_ATTR',
'EVENTS',
'CONTENT',
],
alphabetical: false,
}],
},
parserOptions: {
parser: 'babel-eslint',
},
}

View File

@@ -0,0 +1,14 @@
{
"name": "eslint-config-corteza-client",
"description": "Eslint config for corteza client",
"version": "2024.9.10",
"license": "Apache-2.0",
"main": "./index.js",
"peerDependencies": {
"@vue/cli-plugin-eslint": "5.0.8",
"@vue/eslint-config-standard": "^8.0.1",
"@babel/eslint-parser": "^7.26.5",
"eslint": "^8.7.2",
"eslint-plugin-vue": "^9.32.0"
}
}

View File

@@ -0,0 +1,4 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

7
lib/js/.eslintignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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(),
],
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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()
},
}
}
}

File diff suppressed because it is too large Load Diff

View 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'

View 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'

View 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 }
}
}
}

View 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 }
}
}
}

View 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
}
}
}

View 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
}

View 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
View 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
View 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 || '')
}

View 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}'`)
}
}

View 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}`
}

View 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
}
}

View 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'

View 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'

View 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
}
}

View File

@@ -0,0 +1 @@
export const defaultBGColor = 'rgba(165, 165, 165, 1)'

View 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: [] },
})
}
}

View 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')
}
}
}

View 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'

View 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: {},
})
}
}

View 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,
}

View 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')
})
})

View 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>()

View 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)

View 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)

View 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)

View 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)

View 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)

View 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,
}

View 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)

View 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)

View 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: '' })
})
})

View 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)

View 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)

View 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)

View 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)

View 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)
})
})
})

View 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
}
}

View 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')
})
})
})

View 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
}
}

View 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)

View 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>()

View 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
})
}

View File

@@ -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
})
})
}

View 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
}
}

View File

@@ -0,0 +1 @@
export { PageBlockCalendar } from './page-block'

View File

@@ -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')
})
})
})

View 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)

View File

@@ -0,0 +1,4 @@
export const feedResources = {
record: 'compose:record',
reminder: 'system:reminder',
}

View 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;
}

View 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)

View 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)

View 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)

View 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)

View 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)))
})
}

View 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
}
}

View File

@@ -0,0 +1 @@
export { PageBlockGeometry } from './page-block'

View 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)

View File

@@ -0,0 +1,3 @@
export const feedResources = {
record: 'compose:record',
}

View 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)

View 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,
}

View 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)

View 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)

View File

@@ -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 }
}
}
}

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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'

View 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,
}
}
}

View 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)
})
})
})

View 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,
}
}
}

View 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')
})
})
})

View 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',
]
}
}

View 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 : []),
}))
}

View 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)
})
})

View 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
}
}

View 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))

View 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)
})
})

View 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,
})
}
}
}
}

View File

@@ -0,0 +1,27 @@
import { describe, it } from 'mocha'
import { expect } from 'chai'
import { Ctx } from './ctx'
import { User } from '../system'
import pino from 'pino'
describe('ctx', () => {
describe('context sanity check', () => {
it('should have a valid getter', () => {
const cscfg = { apiBaseURL: '' }
const ctx = new Ctx(
{
$invoker: new User(),
authToken: '',
},
pino(),
{ config: { cServers: { system: cscfg, compose: cscfg } } },
)
expect(ctx.console).to.not.be.undefined
expect(ctx.SystemAPI).to.not.be.undefined
expect(ctx.ComposeAPI).to.not.be.undefined
expect(ctx.System).to.not.be.undefined
expect(ctx.Compose).to.not.be.undefined
})
})
})

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