Start reworking dashboard using Web Awesome
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
6ca9900d9c
commit
b1b83e78a6
@ -0,0 +1,109 @@
|
|||||||
|
const sleep = x => new Promise(res => setTimeout(() => res(), x * 1000))
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const ora = (await import('ora')).default
|
||||||
|
|
||||||
|
class Builder {
|
||||||
|
constructor() {
|
||||||
|
this.spinner = ora('Starting build').start()
|
||||||
|
this.contextMaxLines = 3
|
||||||
|
this.contextLines = Array(this.contextMaxLines).fill('')
|
||||||
|
this.allLines = []
|
||||||
|
this.steps = []
|
||||||
|
this.renderLines()
|
||||||
|
}
|
||||||
|
|
||||||
|
pushLine(line) {
|
||||||
|
if ( this.contextLines.length >= this.contextMaxLines ) this.contextLines = this.contextLines.slice(1)
|
||||||
|
this.contextLines.push(String(line).trimEnd())
|
||||||
|
this.allLines.push(String(line).trimEnd())
|
||||||
|
this.renderLines()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLines() {
|
||||||
|
this.spinner.suffixText = `\n--\n${this.contextLines.join('\n')}\n--`
|
||||||
|
}
|
||||||
|
|
||||||
|
addStep(name, callback) {
|
||||||
|
this.steps.push({ name, callback })
|
||||||
|
}
|
||||||
|
|
||||||
|
async build() {
|
||||||
|
for ( let i = 0; i < this.steps.length; i += 1 ) {
|
||||||
|
const step = this.steps[i]
|
||||||
|
this.spinner.text = `(${i+1}/${this.steps.length}) ${step.name}`
|
||||||
|
await step.callback(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.spinner.suffixText = ''
|
||||||
|
this.spinner.succeed('Built successfully')
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeOrExit(prog, ...args) {
|
||||||
|
try {
|
||||||
|
return await this.execute(prog, ...args)
|
||||||
|
} catch (e) {
|
||||||
|
this.spinner.suffixText = ''
|
||||||
|
this.spinner.fail(`${this.spinner.text} - ${e.message}`)
|
||||||
|
console.log(this.allLines.join('\n'))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(prog, ...args) {
|
||||||
|
this.pushLine(`> ${prog} ${args.join(' ')}`)
|
||||||
|
|
||||||
|
const cmd = spawn(prog, args)
|
||||||
|
let output = ''
|
||||||
|
|
||||||
|
cmd.stdout.on('data', data => {
|
||||||
|
output += data
|
||||||
|
String(data).split('\n').map(x => this.pushLine(x))
|
||||||
|
})
|
||||||
|
cmd.stderr.on('data', data => {
|
||||||
|
output += data
|
||||||
|
String(data).split('\n').map(x => this.pushLine(x))
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
cmd.on('close', code => {
|
||||||
|
if ( code ) {
|
||||||
|
return rej(new Error('Process exited with code: ' + code))
|
||||||
|
} else {
|
||||||
|
res(output.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const b= new Builder
|
||||||
|
|
||||||
|
b.addStep(
|
||||||
|
'Remove old build files',
|
||||||
|
b => b.executeOrExit('./node_modules/.bin/rimraf', 'lib'),
|
||||||
|
)
|
||||||
|
|
||||||
|
b.addStep(
|
||||||
|
'Build back-end TypeScript code',
|
||||||
|
b => b.executeOrExit('./node_modules/.bin/tsc', '-p', 'tsconfig.node.json'),
|
||||||
|
)
|
||||||
|
|
||||||
|
b.addStep(
|
||||||
|
'Copy resources to output bundle',
|
||||||
|
b => b.executeOrExit('./node_modules/.bin/fse', 'copy', '--all', '--dereference', '--preserveTimestamps', '--keepExisting=false', '--quiet', '--errorOnExist=false', 'src/app/resources', 'lib/app/resources'),
|
||||||
|
)
|
||||||
|
|
||||||
|
b.addStep(
|
||||||
|
'Build front-end TypeScript code',
|
||||||
|
b => b.executeOrExit('./node_modules/.bin/tsc', '-p', 'tsconfig.client.json'),
|
||||||
|
)
|
||||||
|
|
||||||
|
b.addStep(
|
||||||
|
'Create front-end output bundle',
|
||||||
|
b => b.executeOrExit('./node_modules/.bin/webpack', '--config', 'webpack.config.js'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await b.build()
|
||||||
|
})();
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
Config,
|
||||||
|
Controller,
|
||||||
|
HTTPError,
|
||||||
|
HTTPStatus,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logging,
|
||||||
|
Maybe,
|
||||||
|
ResponseObject,
|
||||||
|
Routing,
|
||||||
|
SecurityContext,
|
||||||
|
view,
|
||||||
|
} from '@extollo/lib'
|
||||||
|
import {User} from '../../models/User.model'
|
||||||
|
import {one} from '@extollo/lib/lib/http/response/api'
|
||||||
|
import {ResourceAction, ResourceConfiguration} from '../../cobalt'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class Dash2 extends Controller {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
public async index(): Promise<ResponseObject> {
|
||||||
|
return view('dash2:home', {
|
||||||
|
title: 'Home',
|
||||||
|
...(await this.appData()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cobalt(): Promise<ResponseObject> {
|
||||||
|
return view('dash2:cobalt', {
|
||||||
|
...(await this.appData()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cobaltSettings(): Promise<ResponseObject> {
|
||||||
|
const layout = this.request.safe('layoutname').string()
|
||||||
|
const params: Record<string, unknown> = this.request.input('params') as any || {}
|
||||||
|
|
||||||
|
if ( layout === 'resource:form' && typeof params.resource === 'string' ) {
|
||||||
|
return this.getResourceFormSettings(params.resource, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( layout === 'resource:list' && typeof params.resource === 'string' ) {
|
||||||
|
return this.getResourceListSettings(params.resource, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
return one({
|
||||||
|
layout,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cobaltResourceList(): Promise<ResponseObject> {
|
||||||
|
return view('dash2:cobalt', {
|
||||||
|
...(await this.appData()),
|
||||||
|
layout: 'resource:list',
|
||||||
|
params: { resource: this.request.safe('key').string() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getResourceListSettings(resourceName: string, params: Record<string, unknown>): Promise<ResponseObject> {
|
||||||
|
const config = this.getResourceConfigOrFail(resourceName)
|
||||||
|
|
||||||
|
return one({
|
||||||
|
loadActions: [
|
||||||
|
{
|
||||||
|
target: {
|
||||||
|
sourceName: `resource:list:${resourceName}`,
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'route',
|
||||||
|
method: 'GET',
|
||||||
|
route: `/dash/cobalt/resource/${resourceName}`,
|
||||||
|
successKey: 'success',
|
||||||
|
resultKey: 'data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layoutChildren: [
|
||||||
|
{
|
||||||
|
component: 'table',
|
||||||
|
config: {
|
||||||
|
sourceName: `resource:list:${resourceName}`,
|
||||||
|
rowKey: 'records',
|
||||||
|
fields: config.fields,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getResourceFormSettings(resourceName: string, params: Record<string, unknown>): Promise<ResponseObject> {
|
||||||
|
const config = this.getResourceConfigOrFail(resourceName)
|
||||||
|
|
||||||
|
return one({
|
||||||
|
layoutChildren: [
|
||||||
|
{
|
||||||
|
component: 'card',
|
||||||
|
config: {
|
||||||
|
display: config.display.singular,
|
||||||
|
},
|
||||||
|
layoutChildren: [
|
||||||
|
{
|
||||||
|
component: 'form',
|
||||||
|
sourceName: `resource:form:${resourceName}`,
|
||||||
|
layoutChildren: config.fields
|
||||||
|
.filter(f => !f.hideOn?.form)
|
||||||
|
.map(f => ({
|
||||||
|
component: 'field',
|
||||||
|
config: f,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async appData() {
|
||||||
|
const resourceConfigs = this.config.get('cobalt.resources', []) as ResourceConfiguration[]
|
||||||
|
const user = this.security.user() as User
|
||||||
|
return {
|
||||||
|
appData: {
|
||||||
|
appUrl: this.routing.getAppUrl().toRemote,
|
||||||
|
user: {
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
username: user.username,
|
||||||
|
photoUrl: user.photoUrl,
|
||||||
|
initials: user.initials,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resources: resourceConfigs
|
||||||
|
.filter(c => c.supportedActions.includes(ResourceAction.read))
|
||||||
|
.map(c => ({
|
||||||
|
display: c.display.plural,
|
||||||
|
href: `/dash2/cobalt/resource/${c.key}/list`,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getResourceConfigOrFail(key: string): ResourceConfiguration {
|
||||||
|
const config = this.getResourceConfig(key)
|
||||||
|
if ( !config ) {
|
||||||
|
throw new HTTPError(HTTPStatus.NOT_FOUND)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getResourceConfig(key: string): Maybe<ResourceConfiguration> {
|
||||||
|
const configs = this.config.get('cobalt.resources') as ResourceConfiguration[]
|
||||||
|
for ( const config of configs ) {
|
||||||
|
if ( config.key === key ) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +0,0 @@
|
|||||||
import {app, BaseApp} from './App'
|
|
||||||
|
|
||||||
export abstract class Component extends HTMLElement {
|
|
||||||
protected app(): BaseApp|undefined {
|
|
||||||
return app()
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
this.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected initialize(): void {}
|
|
||||||
}
|
|
@ -0,0 +1,44 @@
|
|||||||
|
import {BehaviorSubject, IBehaviorSubject, MappedBehaviorSubject} from '../BehaviorSubject'
|
||||||
|
|
||||||
|
export type DataSource = {
|
||||||
|
getField(fieldPath: string): IBehaviorSubject<unknown>
|
||||||
|
getAll(): IBehaviorSubject<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RecordDataSource implements DataSource {
|
||||||
|
private base: BehaviorSubject<Record<string, unknown>> = new BehaviorSubject({})
|
||||||
|
|
||||||
|
getField(field: string): IBehaviorSubject<unknown> {
|
||||||
|
return new MappedBehaviorSubject<Record<string, unknown>, typeof field>(this.base, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): IBehaviorSubject<Record<string, unknown>> {
|
||||||
|
return this.base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataPlane {
|
||||||
|
private sources: Record<string, DataSource> = {}
|
||||||
|
|
||||||
|
public ensureSource(name: string, source?: DataSource) {
|
||||||
|
if ( !this.hasSource(name) ) {
|
||||||
|
this.addSource(name, source || new RecordDataSource())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSource(name: string, source: DataSource) {
|
||||||
|
this.sources[name] = source
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasSource(name: string): boolean {
|
||||||
|
return Boolean(this.sources[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
public getField(source: string, field: string): IBehaviorSubject<unknown> | undefined {
|
||||||
|
return this.sources[source]?.getField?.(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAll(source: string): IBehaviorSubject<Record<string, unknown>> | undefined {
|
||||||
|
return this.sources[source]?.getAll?.()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,125 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {Ex} from '../../util'
|
||||||
|
import uuid = Ex.uuid
|
||||||
|
import {EditorView, basicSetup} from 'codemirror'
|
||||||
|
import {keymap, placeholder} from '@codemirror/view'
|
||||||
|
import {EditorState} from '@codemirror/state'
|
||||||
|
import {indentWithTab} from '@codemirror/commands'
|
||||||
|
import * as langHtml from '@codemirror/lang-html'
|
||||||
|
|
||||||
|
export class CodeChangeEvent extends Event {
|
||||||
|
constructor(public readonly content: string, baseEventContent?: EventInit) {
|
||||||
|
super('code-change', baseEventContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-code')
|
||||||
|
export class Code extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.editor-container {
|
||||||
|
width: calc(100% - 10px);
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property()
|
||||||
|
initialValue?: string
|
||||||
|
|
||||||
|
@property()
|
||||||
|
syntax?: string
|
||||||
|
|
||||||
|
@property()
|
||||||
|
placeholder?: string
|
||||||
|
|
||||||
|
@property()
|
||||||
|
required?: boolean
|
||||||
|
|
||||||
|
@property()
|
||||||
|
disabled?: boolean
|
||||||
|
|
||||||
|
@property()
|
||||||
|
label?: string
|
||||||
|
|
||||||
|
@property()
|
||||||
|
helpText?: string
|
||||||
|
|
||||||
|
private editorId: string = 'cobalt-code-' + uuid()
|
||||||
|
private value: string = ''
|
||||||
|
private editor?: EditorView
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback()
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = this.getEditorContainer()
|
||||||
|
if ( !el ) {
|
||||||
|
console.warn('Could not initialize cobalt-code: could not get editor container element')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializeEditor(el)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const helpText = this.helpText ? html`<small>${this.helpText}</small>` : html``
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<slot name="label">${this.label}${this.required ? '*' : ''}</slot>
|
||||||
|
<div
|
||||||
|
class="editor-container"
|
||||||
|
id=${this.editorId}
|
||||||
|
></div>
|
||||||
|
${helpText}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeEditor(parent: HTMLElement) {
|
||||||
|
this.value = this.initialValue || ''
|
||||||
|
|
||||||
|
const extensions = [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
EditorView.updateListener.of((e) => {
|
||||||
|
if ( e.docChanged ) {
|
||||||
|
this.dispatchEvent(new CodeChangeEvent(this.editor!.state.doc.toString()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const lang = this.getLangPlugin()
|
||||||
|
if ( lang ) {
|
||||||
|
extensions.push(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.placeholder ) {
|
||||||
|
extensions.push(placeholder(this.placeholder))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.disabled ) {
|
||||||
|
extensions.push(EditorState.readOnly.of(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor = new EditorView({
|
||||||
|
extensions,
|
||||||
|
parent,
|
||||||
|
doc: this.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLangPlugin() {
|
||||||
|
if ( this.syntax === 'html' ) {
|
||||||
|
return langHtml.html()
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEditorContainer(): HTMLElement | null {
|
||||||
|
return (this.shadowRoot || this).querySelector(`#${this.editorId}`)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,285 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {FieldType, SelectOptions} from '../types'
|
||||||
|
import type {FieldDefinition} from '../types'
|
||||||
|
import {DataPlane} from '../DataPlane'
|
||||||
|
import {IBehaviorSubject, Unsubscribe} from '../../BehaviorSubject'
|
||||||
|
import {Ex} from '../../util'
|
||||||
|
import uuid = Ex.uuid
|
||||||
|
import {CodeChangeEvent} from './Code'
|
||||||
|
import {FormState} from './State'
|
||||||
|
|
||||||
|
export const inferFieldValue = (value: string, type: FieldType): unknown => {
|
||||||
|
if ( type === FieldType.number ) {
|
||||||
|
if ( !value ) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFloat(String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( type === FieldType.integer ) {
|
||||||
|
if ( !value ) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(String(value), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( type === FieldType.date ) {
|
||||||
|
const date = new Date(`${value} 00:00:00`)
|
||||||
|
if ( isNaN(date.getTime()) ) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-form-field')
|
||||||
|
export class Field extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.field-wrapper {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
config?: FieldDefinition
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
private value?: IBehaviorSubject<unknown>
|
||||||
|
private selectOptionMap?: Record<string, unknown>
|
||||||
|
private dirty: boolean = false
|
||||||
|
private formStateSub?: Unsubscribe
|
||||||
|
private formState?: FormState
|
||||||
|
|
||||||
|
private getSourceName(): string|undefined {
|
||||||
|
return this.parentContext.sourceName
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStateSourceName(): string|undefined {
|
||||||
|
const sourceName = this.getSourceName()
|
||||||
|
return sourceName ? `${sourceName}-state` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="field-wrapper">
|
||||||
|
${this.renderField()}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
renderField() {
|
||||||
|
const sourceName = this.getSourceName()
|
||||||
|
const stateSourceName = this.getStateSourceName()
|
||||||
|
|
||||||
|
if ( !this.config || !sourceName || !this.plane ) {
|
||||||
|
return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !this.value ) {
|
||||||
|
this.value = this.plane.getField(sourceName, this.config.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( stateSourceName && !this.formStateSub ) {
|
||||||
|
this.formStateSub = this.plane
|
||||||
|
.getAll(stateSourceName)
|
||||||
|
?.subscribe(state => {
|
||||||
|
this.formState = state as FormState
|
||||||
|
this.requestUpdate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.isInputType(this.config.type) ) {
|
||||||
|
return html`
|
||||||
|
<wa-input
|
||||||
|
id="field-${sourceName}-${this.config.key}"
|
||||||
|
label=${this.config.display}
|
||||||
|
name=${this.config.key}
|
||||||
|
type="${this.mapInputType(this.config.type)}"
|
||||||
|
?required=${this.config.required}
|
||||||
|
?clearable=${!this.config.required}
|
||||||
|
?disabled=${this.config.readonly}
|
||||||
|
placeholder=${this.config.placeholder}
|
||||||
|
help-text=${this.config.helpText}
|
||||||
|
@wa-change=${this.setValue}
|
||||||
|
></wa-input>
|
||||||
|
${this.renderErrors()}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.config.type === FieldType.textarea ) {
|
||||||
|
return html`
|
||||||
|
<wa-textarea
|
||||||
|
id="field-${sourceName}-${this.config.key}"
|
||||||
|
label=${this.config.display}
|
||||||
|
name=${this.config.key}
|
||||||
|
resize="auto"
|
||||||
|
rows="2"
|
||||||
|
?required=${this.config.required}
|
||||||
|
?clearable=${!this.config.required}
|
||||||
|
?disabled=${this.config.readonly}
|
||||||
|
placeholder=${this.config.placeholder}
|
||||||
|
help-text=${this.config.helpText}
|
||||||
|
@wa-change=${this.setValue}
|
||||||
|
></wa-textarea>
|
||||||
|
${this.renderErrors()}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.config.type === FieldType.bool ) {
|
||||||
|
return html`
|
||||||
|
<wa-switch
|
||||||
|
id="field-${sourceName}-${this.config.key}"
|
||||||
|
name=${this.config.key}
|
||||||
|
help-text=${this.config.helpText}
|
||||||
|
?required=${this.config.required}
|
||||||
|
?disabled=${this.config.readonly}
|
||||||
|
@wa-change=${this.setValue}
|
||||||
|
>
|
||||||
|
${this.config.display}
|
||||||
|
</wa-switch>
|
||||||
|
${this.renderErrors()}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.config.type === FieldType.select && 'options' in this.config ) {
|
||||||
|
if ( !this.selectOptionMap ) {
|
||||||
|
this.buildSelectOptionMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<wa-select
|
||||||
|
id="field-${sourceName}-${this.config.key}"
|
||||||
|
name=${this.config.key}
|
||||||
|
label=${this.config.display}
|
||||||
|
placeholder=${this.config.placeholder}
|
||||||
|
help-text=${this.config.helpText}
|
||||||
|
?multiple=${this.config.multiple}
|
||||||
|
?clearable=${!this.config.required}
|
||||||
|
?required=${this.config.required}
|
||||||
|
?disabled=${this.config.readonly}
|
||||||
|
@wa-change=${this.setValue}
|
||||||
|
>
|
||||||
|
${this.config.options.map(o => this.renderSelectOption(o))}
|
||||||
|
</wa-select>
|
||||||
|
${this.renderErrors()}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.config.type === FieldType.html ) {
|
||||||
|
return html`
|
||||||
|
<cobalt-code
|
||||||
|
id="field-${sourceName}-${this.config.key}"
|
||||||
|
syntax="html"
|
||||||
|
placeholder=${this.config.placeholder}
|
||||||
|
label=${this.config.display}
|
||||||
|
help-text=${this.config.helpText}
|
||||||
|
?required=${this.config.required}
|
||||||
|
?disabled=${this.config.readonly}
|
||||||
|
@code-change=${this.setValue}
|
||||||
|
></cobalt-code>
|
||||||
|
${this.renderErrors()}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
Invalid field configuration: ${this.config.type}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSelectOptionMap() {
|
||||||
|
if ( !this.config || this.config.type !== FieldType.select || !('options' in this.config) ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectOptionMap = {}
|
||||||
|
for ( const opt of this.config.options ) {
|
||||||
|
if ( !opt.uuid ) {
|
||||||
|
opt.uuid = uuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectOptionMap[opt.uuid] = opt.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSelectOption(option: SelectOptions[0]) {
|
||||||
|
return html`
|
||||||
|
<wa-option
|
||||||
|
.value=${option.uuid!}
|
||||||
|
>
|
||||||
|
${option.display}
|
||||||
|
</wa-option>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderErrors() {
|
||||||
|
if ( !this.formState?.errors?.[this.config!.key]?.length || !this.dirty ) {
|
||||||
|
return html``
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorDisplays = this.formState!.errors[this.config!.key]!
|
||||||
|
.map(str => html`<small style="color: darkred;">${str}</small>`)
|
||||||
|
|
||||||
|
return html`<div class="field-errors">${errorDisplays}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapInputType(type: FieldType): string {
|
||||||
|
if ( type === FieldType.email ) {
|
||||||
|
return 'email'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( type === FieldType.number || type === FieldType.integer ) {
|
||||||
|
return 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( type === FieldType.date ) {
|
||||||
|
return 'date'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInputType(type: FieldType): boolean {
|
||||||
|
return [
|
||||||
|
FieldType.text,
|
||||||
|
FieldType.email,
|
||||||
|
FieldType.number,
|
||||||
|
FieldType.integer,
|
||||||
|
FieldType.date,
|
||||||
|
].includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setValue(event: Event) {
|
||||||
|
const newValue = this.getFieldValue(event)
|
||||||
|
this.dirty = newValue !== this.value?.value()
|
||||||
|
this.value?.next(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFieldValue(event: Event) {
|
||||||
|
const input = (this.shadowRoot || this).querySelector(`#field-${this.getSourceName()!}-${this.config!.key}`)
|
||||||
|
|
||||||
|
if ( this.config!.type === FieldType.bool ) {
|
||||||
|
return !!(input as any)?.checked
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.config!.type === FieldType.select ) {
|
||||||
|
const uuid = (input as any)?.value
|
||||||
|
return this.selectOptionMap?.[uuid]
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.config!.type === FieldType.html && event instanceof CodeChangeEvent ) {
|
||||||
|
return event.content
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferFieldValue((input as any)?.value, this.config!.type)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,86 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import type {FieldDefinition, FormConfig} from '../types'
|
||||||
|
import {DataPlane} from '../DataPlane'
|
||||||
|
import {Ex} from '../../util'
|
||||||
|
import uuid = Ex.uuid
|
||||||
|
import {LayoutSpec} from '../layout/Layout'
|
||||||
|
import {FormStateManager} from './State'
|
||||||
|
|
||||||
|
@customElement('cobalt-form')
|
||||||
|
export class Form extends LitElement {
|
||||||
|
static override styles = css``
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
config?: FormConfig
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
private stateManager?: FormStateManager
|
||||||
|
|
||||||
|
getContextIdentifier(): string {
|
||||||
|
return this.config?.sourceName || uuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateContextIdentifier(): string {
|
||||||
|
return `${this.getContextIdentifier()}-state`
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if ( !this.config || !this.plane ) {
|
||||||
|
return html`Loading...`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.plane.ensureSource(this.getContextIdentifier())
|
||||||
|
this.plane.ensureSource(this.getStateContextIdentifier())
|
||||||
|
|
||||||
|
if ( !this.stateManager ) {
|
||||||
|
this.stateManager = new FormStateManager(
|
||||||
|
this.getFields(this.layoutChildren || []),
|
||||||
|
this.getContextIdentifier(),
|
||||||
|
this.getStateContextIdentifier(),
|
||||||
|
this.plane,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
...this.parentContext,
|
||||||
|
sourceName: this.getContextIdentifier(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<cobalt-layout
|
||||||
|
.plane=${this.plane}
|
||||||
|
.parentContext=${context}
|
||||||
|
.layoutChildren=${this.layoutChildren}
|
||||||
|
></cobalt-layout>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFields(layout: LayoutSpec[], fields: FieldDefinition[] = []): FieldDefinition[] {
|
||||||
|
for ( const item of layout ) {
|
||||||
|
if ( item.component === 'field' ) {
|
||||||
|
if ( item.config ) {
|
||||||
|
fields.push(item.config as FieldDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( item.component === 'form' || !item.layoutChildren ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getFields(item.layoutChildren, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,108 @@
|
|||||||
|
import {DataPlane, DataSource, RecordDataSource} from '../DataPlane'
|
||||||
|
import {FieldDefinition, FieldType} from '../types'
|
||||||
|
import {Unsubscribe} from '../../BehaviorSubject'
|
||||||
|
|
||||||
|
export type FormErrors = Record<string, string[]>
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
dirty: boolean
|
||||||
|
valid: boolean
|
||||||
|
errors: FormErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FormStateManager {
|
||||||
|
private sub?: Unsubscribe
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly fields: FieldDefinition[],
|
||||||
|
private readonly formSource: string,
|
||||||
|
private readonly stateSource: string,
|
||||||
|
private readonly plane: DataPlane,
|
||||||
|
) {
|
||||||
|
this.sub = this.plane.getAll(this.formSource)
|
||||||
|
?.subscribe(formData =>
|
||||||
|
this.rebuildState(formData))
|
||||||
|
|
||||||
|
this.rebuildState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildState(formData?: Record<string, unknown>) {
|
||||||
|
if ( !formData ) {
|
||||||
|
this.plane
|
||||||
|
.getAll(this.stateSource)
|
||||||
|
?.next({ dirty: false, errors: {} })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = this.updateValidation(formData)
|
||||||
|
this.plane
|
||||||
|
.getAll(this.stateSource)
|
||||||
|
?.next({
|
||||||
|
dirty: true,
|
||||||
|
valid: !Object.keys(errors).length,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateValidation(formData: Record<string, unknown>): FormErrors {
|
||||||
|
const errors: FormErrors = {}
|
||||||
|
|
||||||
|
for ( const field of this.fields ) {
|
||||||
|
const value = formData[field.key]
|
||||||
|
|
||||||
|
if (
|
||||||
|
field.required
|
||||||
|
&& !value
|
||||||
|
&& (
|
||||||
|
(
|
||||||
|
field.type !== FieldType.integer
|
||||||
|
&& field.type !== FieldType.number
|
||||||
|
)
|
||||||
|
|| value !== 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if ( !errors[field.key] ) errors[field.key] = []
|
||||||
|
errors[field.key]!.push(`${field.display} is required.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value
|
||||||
|
&& field.type === FieldType.email
|
||||||
|
&& !(/^\S+@\S+\.\S+$/.test(String(value)))
|
||||||
|
) {
|
||||||
|
if ( !errors[field.key] ) errors[field.key] = []
|
||||||
|
errors[field.key]!.push(`${field.display} must be a valid email address.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value || value === 0)
|
||||||
|
&& (
|
||||||
|
field.type === FieldType.integer
|
||||||
|
|| field.type === FieldType.number
|
||||||
|
)
|
||||||
|
&& (
|
||||||
|
typeof value !== 'number'
|
||||||
|
|| isNaN(value)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if ( !errors[field.key] ) errors[field.key] = []
|
||||||
|
errors[field.key]!.push(`${field.display} must be a valid ${field.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value
|
||||||
|
&& field.type === FieldType.date
|
||||||
|
&& (
|
||||||
|
!(value instanceof Date)
|
||||||
|
|| isNaN(value.getTime())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if ( !errors[field.key] ) errors[field.key] = []
|
||||||
|
errors[field.key]!.push(`${field.display} must be a valid date`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,226 @@
|
|||||||
|
import {html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../DataPlane'
|
||||||
|
import {Task} from '@lit/task'
|
||||||
|
import {LayoutSpec} from '../layout/Layout'
|
||||||
|
import {CobaltAction, CobaltActionResult, LoadAction} from '../types'
|
||||||
|
|
||||||
|
export type HostSettings = {
|
||||||
|
parentContext?: Record<string, any>,
|
||||||
|
layoutChildren: LayoutSpec[],
|
||||||
|
loadActions?: LoadAction[],
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-host')
|
||||||
|
export class Host extends LitElement {
|
||||||
|
@property()
|
||||||
|
settingsendpoint?: string
|
||||||
|
|
||||||
|
@property()
|
||||||
|
layoutname?: string
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property()
|
||||||
|
paramsSourceName: string = 'params'
|
||||||
|
|
||||||
|
@property()
|
||||||
|
initialParamsSourceName?: string
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
initialParams?: Record<string, any>
|
||||||
|
|
||||||
|
@property()
|
||||||
|
initialparamsjson?: string
|
||||||
|
|
||||||
|
private settings?: HostSettings
|
||||||
|
|
||||||
|
private renderTask = new Task(this, {
|
||||||
|
args: () => [this.settingsendpoint, this.layoutname, this.plane, this.initialParams, this.initialparamsjson],
|
||||||
|
task: async ([settingsEndpoint, layoutName,,], options) => {
|
||||||
|
if ( this.settings ) {
|
||||||
|
return this.settings
|
||||||
|
}
|
||||||
|
|
||||||
|
const plane = this.getDataPlane()
|
||||||
|
plane.ensureSource(this.paramsSourceName)
|
||||||
|
|
||||||
|
const params = this.getParams()
|
||||||
|
await plane.getAll(this.paramsSourceName)!.next(params)
|
||||||
|
|
||||||
|
const searchQuery = new URLSearchParams({
|
||||||
|
layoutname: `${layoutName || ''}`,
|
||||||
|
params: JSON.stringify(params),
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsUrl = `${settingsEndpoint}?${searchQuery}`
|
||||||
|
const response = await fetch(settingsUrl)
|
||||||
|
const json: any = await response.json()
|
||||||
|
|
||||||
|
if ( !json.success || !json.data?.layoutChildren ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings = json.data
|
||||||
|
await this.runLoadActions()
|
||||||
|
return this.settings
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return this.renderTask.render({
|
||||||
|
pending: () => html`...`,
|
||||||
|
complete: (settings?: HostSettings) => {
|
||||||
|
if ( !settings ) {
|
||||||
|
return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<cobalt-layout
|
||||||
|
.plane=${this.getDataPlane()}
|
||||||
|
.parentContext=${settings.parentContext || {}}
|
||||||
|
.layoutChildren=${settings.layoutChildren}
|
||||||
|
></cobalt-layout>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
error: e => html`Error: ${e}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getParams(): Record<string, any> {
|
||||||
|
let params: Record<string, any> = {}
|
||||||
|
|
||||||
|
// Merge params from the data plane, if we have any:
|
||||||
|
if ( this.initialParamsSourceName ) {
|
||||||
|
const planeParams = this.getDataPlane()
|
||||||
|
.getAll(this.initialParamsSourceName)
|
||||||
|
?.value()
|
||||||
|
|
||||||
|
if ( planeParams ) {
|
||||||
|
params = {...params, ...planeParams}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge params passed in via JSON, if we have any:
|
||||||
|
if ( this.initialparamsjson ) {
|
||||||
|
const jsonParams = JSON.parse(this.initialparamsjson)
|
||||||
|
params = {...params, ...jsonParams}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge params passed in directly, if we have any:
|
||||||
|
if ( this.initialParams ) {
|
||||||
|
params = {...params, ...this.initialParams}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
private dp?: DataPlane
|
||||||
|
private getDataPlane(): DataPlane {
|
||||||
|
return this.plane || this.dp || (this.dp = new DataPlane)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runLoadActions(): Promise<void> {
|
||||||
|
if ( !this.settings?.loadActions ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
this.settings
|
||||||
|
.loadActions
|
||||||
|
.map(load => this.runLoadAction(load))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runLoadAction(load: LoadAction): Promise<void> {
|
||||||
|
const result = await this.runAction(load.action)
|
||||||
|
if ( !result.success ) {
|
||||||
|
console.error('Load action failed!', result)
|
||||||
|
return // fixme: handle this better
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`setting load data for ${load.target.sourceName}->${load.target.fieldName || '@'}`, result)
|
||||||
|
|
||||||
|
this.getDataPlane().ensureSource(load.target.sourceName)
|
||||||
|
|
||||||
|
const target = load.target.fieldName
|
||||||
|
? this.getDataPlane().getField(load.target.sourceName, load.target.fieldName)
|
||||||
|
: this.getDataPlane().getAll(load.target.sourceName)
|
||||||
|
|
||||||
|
await target?.next(result.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runAction(action: CobaltAction): Promise<CobaltActionResult> {
|
||||||
|
try {
|
||||||
|
const params = await this.gatherActionParams(action)
|
||||||
|
|
||||||
|
let result: any
|
||||||
|
if ( action.type === 'route' ) {
|
||||||
|
result = await this.runRouteAction(action, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( action.successKey && !result[action.successKey] ) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message || undefined,
|
||||||
|
error: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: result.message || undefined,
|
||||||
|
result: action.resultKey ? result[action.resultKey] : result,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runRouteAction(action: CobaltAction & { type: 'route' }, params: Record<string, unknown>): Promise<any> {
|
||||||
|
let body: undefined|string = undefined
|
||||||
|
let route = action.route
|
||||||
|
if ( action.method === 'GET' || action.method === 'HEAD' ) {
|
||||||
|
route = `${route}?${new URLSearchParams({ params: JSON.stringify(params) })}`
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({ params })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(route, {
|
||||||
|
method: action.method,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async gatherActionParams(action: CobaltAction): Promise<Record<string, unknown>> {
|
||||||
|
let params: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if ( action.data ) {
|
||||||
|
params = {...params, ...action.data}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( action.gather ) {
|
||||||
|
const plane = this.getDataPlane()
|
||||||
|
for ( const key in action.gather ) {
|
||||||
|
const ref = action.gather[key]
|
||||||
|
if ( !ref ) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ref.fieldName ) {
|
||||||
|
params[key] = plane.getField(ref.sourceName, ref.fieldName)?.value()
|
||||||
|
} else {
|
||||||
|
params[key] = plane.getAll(ref.sourceName)?.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
export * from './form/Form'
|
||||||
|
export * from './form/Field'
|
||||||
|
export * from './form/Code'
|
||||||
|
|
||||||
|
export * from './list/Table'
|
||||||
|
|
||||||
|
export * from './layout/components'
|
||||||
|
export * from './layout/Layout'
|
||||||
|
|
||||||
|
export * from './host/Host'
|
@ -0,0 +1,180 @@
|
|||||||
|
import {html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../DataPlane'
|
||||||
|
import {FieldType} from '../types'
|
||||||
|
|
||||||
|
export type LayoutSpec = {
|
||||||
|
component: string
|
||||||
|
config?: unknown
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutContext = {
|
||||||
|
plane: DataPlane
|
||||||
|
config: any
|
||||||
|
parentContext: Record<string, any>
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutComponent = {
|
||||||
|
name: string
|
||||||
|
factory: (context: LayoutContext) => unknown, // fixme
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-layout')
|
||||||
|
export class Layout extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
private registry: Record<string, LayoutComponent> = {
|
||||||
|
row: {
|
||||||
|
name: 'row',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-row
|
||||||
|
.plane=${context.plane}
|
||||||
|
.layoutChildren=${context.layoutChildren}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-row>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
col: {
|
||||||
|
name: 'col',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-col
|
||||||
|
.plane=${context.plane}
|
||||||
|
.layoutChildren=${context.layoutChildren}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-col>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
name: 'group',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-group
|
||||||
|
.plane=${context.plane}
|
||||||
|
.config=${context.config}
|
||||||
|
.layoutChildren=${context.layoutChildren}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-group>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
name: 'card',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-card
|
||||||
|
.plane=${context.plane}
|
||||||
|
.config=${context.config}
|
||||||
|
.layoutChildren=${context.layoutChildren}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-card>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
name: 'form',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-form
|
||||||
|
.plane=${context.plane}
|
||||||
|
.config=${context.config}
|
||||||
|
.layoutChildren=${context.layoutChildren}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-form>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
name: 'field',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-form-field
|
||||||
|
.plane=${context.plane}
|
||||||
|
.config=${context.config}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-form-field>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
name: 'table',
|
||||||
|
factory: context => html`
|
||||||
|
<cobalt-table
|
||||||
|
.plane=${context.plane}
|
||||||
|
.config=${context.config}
|
||||||
|
.parentContext=${context.parentContext}
|
||||||
|
></cobalt-table>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
private spec: LayoutSpec = {
|
||||||
|
component: 'card',
|
||||||
|
config: {
|
||||||
|
display: 'People',
|
||||||
|
},
|
||||||
|
layoutChildren: [
|
||||||
|
{
|
||||||
|
component: 'table',
|
||||||
|
config: {
|
||||||
|
sourceName: 'test-list-1',
|
||||||
|
rowKey: 'rows',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'first_name',
|
||||||
|
display: 'First Name',
|
||||||
|
type: FieldType.text,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'last_name',
|
||||||
|
display: 'Last Name',
|
||||||
|
type: FieldType.text,
|
||||||
|
sort: 'asc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if ( !this.layoutChildren ) {
|
||||||
|
return this.makeComponent(this.spec)
|
||||||
|
// return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.layoutChildren.map(child => this.makeComponent(child))}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeComponent(spec: LayoutSpec) {
|
||||||
|
const factory = this.registry[spec.component]
|
||||||
|
if ( !factory ) {
|
||||||
|
console.error('Cannot find component factory for spec:', spec)
|
||||||
|
return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: LayoutContext = {
|
||||||
|
plane: this.getDataPlane(),
|
||||||
|
config: spec.config || {},
|
||||||
|
parentContext: this.parentContext,
|
||||||
|
layoutChildren: spec.layoutChildren,
|
||||||
|
}
|
||||||
|
|
||||||
|
return factory.factory(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private dp?: DataPlane
|
||||||
|
private getDataPlane(): DataPlane {
|
||||||
|
if ( this.plane ) {
|
||||||
|
return this.plane
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.dp ) {
|
||||||
|
return this.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dp = new DataPlane()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../../DataPlane'
|
||||||
|
import {LayoutSpec} from '../Layout'
|
||||||
|
|
||||||
|
export type CardConfig = {
|
||||||
|
display?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-card')
|
||||||
|
export class Card extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.card-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 50%;
|
||||||
|
box-shadow: 3px 3px 8px 0 rgba(156, 156, 156, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
padding: 7px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
config: CardConfig = {}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="card-wrapper">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">${this.config.display || ''}</div>
|
||||||
|
<div class="content">
|
||||||
|
<cobalt-layout
|
||||||
|
.plane=${this.plane}
|
||||||
|
.parentContext=${this.parentContext}
|
||||||
|
.layoutChildren=${this.layoutChildren}
|
||||||
|
></cobalt-layout>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../../DataPlane'
|
||||||
|
import {LayoutSpec} from '../Layout'
|
||||||
|
|
||||||
|
@customElement('cobalt-col')
|
||||||
|
export class Column extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.cobalt-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cobalt-col .col-item {
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if ( !this.layoutChildren ) {
|
||||||
|
return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="cobalt-col">
|
||||||
|
${this.layoutChildren.map(x => this.renderItem(x))}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderItem(spec: LayoutSpec) {
|
||||||
|
return html`
|
||||||
|
<div class="col-item">
|
||||||
|
<cobalt-layout
|
||||||
|
.plane=${this.plane}
|
||||||
|
.layoutChildren=${[spec]}
|
||||||
|
.parentContext=${this.parentContext}
|
||||||
|
></cobalt-layout>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../../DataPlane'
|
||||||
|
import {LayoutSpec} from '../Layout'
|
||||||
|
|
||||||
|
export type GroupConfig = {
|
||||||
|
display?: string
|
||||||
|
backgroundColor?: string
|
||||||
|
borderColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-group')
|
||||||
|
export class Group extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.group-wrapper {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-wrapper .content {
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #cfcfcf;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
config: GroupConfig = {}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="group-wrapper">
|
||||||
|
<div class="label">${this.config.display || ''}</div>
|
||||||
|
<div class="content">
|
||||||
|
<cobalt-layout
|
||||||
|
.plane=${this.plane}
|
||||||
|
.parentContext=${this.parentContext}
|
||||||
|
.layoutChildren=${this.layoutChildren}
|
||||||
|
></cobalt-layout>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../../DataPlane'
|
||||||
|
import {LayoutSpec} from '../Layout'
|
||||||
|
|
||||||
|
@customElement('cobalt-row')
|
||||||
|
export class Row extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
.cobalt-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cobalt-row .row-item {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if ( !this.layoutChildren ) {
|
||||||
|
return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="cobalt-row">
|
||||||
|
${this.layoutChildren.map(x => this.renderItem(x))}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderItem(spec: LayoutSpec) {
|
||||||
|
return html`
|
||||||
|
<div class="row-item">
|
||||||
|
<cobalt-layout
|
||||||
|
.plane=${this.plane}
|
||||||
|
.layoutChildren=${[spec]}
|
||||||
|
.parentContext=${this.parentContext}
|
||||||
|
></cobalt-layout>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
import {css, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {DataPlane} from '../../DataPlane'
|
||||||
|
import {LayoutSpec} from '../Layout'
|
||||||
|
|
||||||
|
export type TitleConfig = {
|
||||||
|
display?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('cobalt-title')
|
||||||
|
export class Title extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
config: TitleConfig = {}
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
layoutChildren?: LayoutSpec[]
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export * from './Row'
|
||||||
|
export * from './Column'
|
||||||
|
export * from './Group'
|
||||||
|
export * from './Card'
|
@ -0,0 +1,123 @@
|
|||||||
|
import {css, html, LitElement} from 'lit'
|
||||||
|
import {customElement, property} from 'lit/decorators.js'
|
||||||
|
import {unsafeHTML} from 'lit/directives/unsafe-html.js'
|
||||||
|
import {DataPlane} from '../DataPlane'
|
||||||
|
import type {FieldDefinition, ListConfig} from '../types'
|
||||||
|
import {FieldType} from '../types'
|
||||||
|
import {IBehaviorSubject, Unsubscribe} from '../../BehaviorSubject'
|
||||||
|
import {Ex} from '../../util'
|
||||||
|
import dateToDisplay = Ex.dateToDisplay
|
||||||
|
|
||||||
|
@customElement('cobalt-table')
|
||||||
|
export class Table extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
plane?: DataPlane
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
config?: ListConfig
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
parentContext: Record<string, any> = {}
|
||||||
|
|
||||||
|
private data: Record<string, any>[] = []
|
||||||
|
|
||||||
|
private dataSub?: Unsubscribe
|
||||||
|
private rowField?: IBehaviorSubject<unknown>
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if ( !this.plane || !this.config ) {
|
||||||
|
return html`...`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !this.dataSub ) {
|
||||||
|
this.plane.ensureSource(this.config.sourceName)
|
||||||
|
|
||||||
|
this.rowField = this.plane
|
||||||
|
.getField(this.config.sourceName, this.config.rowKey)
|
||||||
|
|
||||||
|
if ( this.rowField ) {
|
||||||
|
this.dataSub = this.rowField
|
||||||
|
?.subscribe(rows => {
|
||||||
|
this.updateData(rows)
|
||||||
|
this.requestUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.updateData(this.rowField.value())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th class="row-num">#</th>
|
||||||
|
${this.config.fields.map(f => this.renderHeader(f))}
|
||||||
|
</tr>
|
||||||
|
${this.data.map((r, i) => this.renderRow(i, r))}
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateData(rows: unknown) {
|
||||||
|
if ( !Array.isArray(rows) ) {
|
||||||
|
console.error('Could not update table data: rows not an array', rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHeader(field: FieldDefinition) {
|
||||||
|
return html`
|
||||||
|
<th>${field.display}</th>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRow(idx: number, row: Record<string, any>) {
|
||||||
|
const cols = this.config!.fields
|
||||||
|
.map(field => {
|
||||||
|
if ( field.type === FieldType.html ) {
|
||||||
|
return html`
|
||||||
|
<td>${unsafeHTML(row[field.key] || '')}</td>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( field.type === FieldType.date ) {
|
||||||
|
return html`
|
||||||
|
<td>${row[field.key] ? dateToDisplay(new Date(row[field.key])) : ''}</td>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixme: handle select
|
||||||
|
|
||||||
|
if ( field.type === FieldType.bool ) {
|
||||||
|
return html`
|
||||||
|
<td style="color: ${row[field.key] ? 'darkgreen' : 'darkred'}; font-weight: 500">
|
||||||
|
${row[field.key] ? 'Yes' : 'No'}
|
||||||
|
</td>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<td>${row[field.key] || ''}</td>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
|
return html`<tr>
|
||||||
|
<td>${idx+1}</td>
|
||||||
|
${cols}
|
||||||
|
</tr>`
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
export enum FieldType {
|
||||||
|
text = 'text',
|
||||||
|
textarea = 'textarea',
|
||||||
|
html = 'html',
|
||||||
|
email = 'email',
|
||||||
|
number = 'number',
|
||||||
|
integer = 'integer',
|
||||||
|
date = 'date',
|
||||||
|
select = 'select',
|
||||||
|
bool = 'bool',
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ResourceAction {
|
||||||
|
create = 'create',
|
||||||
|
read = 'read',
|
||||||
|
readOne = 'readOne',
|
||||||
|
update = 'update',
|
||||||
|
delete = 'delete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Renderer {
|
||||||
|
text = 'text',
|
||||||
|
html = 'html',
|
||||||
|
bool = 'bool',
|
||||||
|
date = 'date',
|
||||||
|
time = 'time',
|
||||||
|
datetime = 'datetime',
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResourceActions = [
|
||||||
|
ResourceAction.create, ResourceAction.read, ResourceAction.readOne,
|
||||||
|
ResourceAction.update, ResourceAction.delete,
|
||||||
|
]
|
||||||
|
|
||||||
|
export type SourceRef = {
|
||||||
|
sourceName: string,
|
||||||
|
fieldName?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CobaltActionBase = {
|
||||||
|
successKey?: string,
|
||||||
|
resultKey?: string,
|
||||||
|
data?: Record<string, any>,
|
||||||
|
gather?: Record<string, SourceRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CobaltAction = CobaltActionBase & (
|
||||||
|
{
|
||||||
|
type: 'route',
|
||||||
|
route: string,
|
||||||
|
method: string,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export type LoadAction = {
|
||||||
|
target: SourceRef,
|
||||||
|
action: CobaltAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CobaltActionResult =
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message?: string,
|
||||||
|
result?: unknown,
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false,
|
||||||
|
message?: string,
|
||||||
|
error?: unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OldCobaltAction = {
|
||||||
|
slug: string,
|
||||||
|
title: string,
|
||||||
|
color: string,
|
||||||
|
icon: string,
|
||||||
|
type: 'route',
|
||||||
|
route: string,
|
||||||
|
overall?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldBase = {
|
||||||
|
key: string,
|
||||||
|
display: string,
|
||||||
|
type: FieldType,
|
||||||
|
required: boolean,
|
||||||
|
sort?: 'asc' | 'desc',
|
||||||
|
renderer?: Renderer,
|
||||||
|
readonly?: boolean,
|
||||||
|
helpText?: string,
|
||||||
|
placeholder?: string,
|
||||||
|
queryable?: boolean,
|
||||||
|
hideOn?: {
|
||||||
|
form?: boolean,
|
||||||
|
listing?: boolean,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectOptions = {display: string, value: any, uuid?: string}[]
|
||||||
|
|
||||||
|
export type FieldDefinition = FieldBase
|
||||||
|
| FieldBase & { type: FieldType.select, multiple?: boolean, options: SelectOptions }
|
||||||
|
|
||||||
|
export type FormConfig = {
|
||||||
|
sourceName?: string,
|
||||||
|
fields: FieldDefinition[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListConfig = {
|
||||||
|
sourceName: string,
|
||||||
|
rowKey: string,
|
||||||
|
fields: FieldDefinition[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/*export interface ResourceConfiguration {
|
||||||
|
key: string,
|
||||||
|
source: DataSource,
|
||||||
|
primaryKey: string,
|
||||||
|
orderField?: string,
|
||||||
|
orderDirection?: OrderDirection,
|
||||||
|
generateKeyOnInsert?: () => string|number,
|
||||||
|
processBeforeInsert?: (row: QueryRow) => Awaitable<QueryRow>,
|
||||||
|
processAfterRead?: (row: QueryRow) => Awaitable<QueryRow>,
|
||||||
|
display: {
|
||||||
|
field?: string,
|
||||||
|
singular: string,
|
||||||
|
plural: string,
|
||||||
|
},
|
||||||
|
supportedActions: ResourceAction[],
|
||||||
|
otherActions: CobaltAction[],
|
||||||
|
fields: FieldDefinition[],
|
||||||
|
}*/
|
||||||
|
|
||||||
|
export { ResourceAction, allResourceActions }
|
@ -1,31 +0,0 @@
|
|||||||
import {Component} from '../Component'
|
|
||||||
import {MessageAlertEvent} from '../types'
|
|
||||||
|
|
||||||
export class MessageContainer extends Component {
|
|
||||||
public activeAlerts: MessageAlertEvent[] = []
|
|
||||||
|
|
||||||
protected initialize() {
|
|
||||||
this.attachShadow({ mode: 'open' })
|
|
||||||
|
|
||||||
this.app()
|
|
||||||
?.event('message.alert')
|
|
||||||
.subscribe(alert => {
|
|
||||||
this.activeAlerts.push(alert)
|
|
||||||
this.render()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if ( !this.shadowRoot ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
|
||||||
<div class="messages">
|
|
||||||
${this.activeAlerts.map(x => '<div>' + x + '</div>')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import {MessageContainer} from './MessageContainer'
|
|
||||||
|
|
||||||
export const registerComponents = () => {
|
|
||||||
customElements.define('ex-messages', MessageContainer)
|
|
||||||
}
|
|
@ -1,4 +1,8 @@
|
|||||||
|
import {app} from './instance'
|
||||||
|
|
||||||
export * from './App'
|
export * from './App'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
export * from './util'
|
export * from './util'
|
||||||
|
export * from './cobalt'
|
||||||
|
|
||||||
|
app()
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
import {App} from './App'
|
||||||
|
import {BaseAppDataMap, BaseEventMap} from './types'
|
||||||
|
|
||||||
|
type MyEventMap = BaseEventMap & {
|
||||||
|
'wa.ready': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type MyAppDataMap = BaseAppDataMap & {
|
||||||
|
waReady: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
let appInstance: any = undefined
|
||||||
|
export const app = (): App<MyEventMap, MyAppDataMap> => {
|
||||||
|
if ( !appInstance ) {
|
||||||
|
appInstance = new App<MyEventMap, MyAppDataMap>()
|
||||||
|
appInstance.event('wa.ready')
|
||||||
|
.subscribe(() => appInstance.set('waReady', true))
|
||||||
|
}
|
||||||
|
|
||||||
|
return appInstance
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Red Hat Display';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/assets/font/redhat/RedHatDisplay-Italic-VariableFont_wght.ttf) format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Red Hat Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/assets/font/redhat/RedHatDisplay-VariableFont_wght.ttf) format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "Red Hat Display", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
min-width: 300px;
|
||||||
|
margin: 30px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav li {
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
box-shadow: 4px 4px 9px #bebebe, -4px -4px 9px #ffffff;
|
||||||
|
padding: 7px 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
transition: all 0.2s linear;
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 475;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav li:hover {
|
||||||
|
box-shadow: 4px 4px 9px #cecece, -4px -4px 9px #efefef;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper wa-divider {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: scroll;
|
||||||
|
padding-bottom: 10vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
wa-divider {
|
||||||
|
--color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
wa-dropdown *[slot="trigger"]:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
(() => {
|
||||||
|
|
||||||
|
const userMenu = document.querySelector('wa-menu#user-menu')
|
||||||
|
userMenu.addEventListener('wa-select', event => {
|
||||||
|
console.log('user menu click', event)
|
||||||
|
|
||||||
|
const item = event.detail.item.value
|
||||||
|
if ( item === 'old' ) {
|
||||||
|
window.location.href = '/dash'
|
||||||
|
} else if ( item === 'home' ) {
|
||||||
|
window.location.href = '/'
|
||||||
|
} else if ( item === 'logout' ) {
|
||||||
|
window.location.href = '/auth/coreid/logout'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})();
|
@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2021 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,14 @@
|
|||||||
|
extends index
|
||||||
|
|
||||||
|
block content
|
||||||
|
div#preloader(style="display: none")
|
||||||
|
wa-input
|
||||||
|
wa-textarea
|
||||||
|
wa-switch
|
||||||
|
wa-select
|
||||||
|
wa-option
|
||||||
|
cobalt-host(
|
||||||
|
settingsendpoint='/dash2/cobalt/settings'
|
||||||
|
layoutname=layout,
|
||||||
|
initialparamsjson=JSON.stringify(params)
|
||||||
|
)
|
@ -0,0 +1,4 @@
|
|||||||
|
extends index
|
||||||
|
|
||||||
|
block content
|
||||||
|
p Welcome!
|
@ -0,0 +1,59 @@
|
|||||||
|
doctype html
|
||||||
|
html
|
||||||
|
head
|
||||||
|
title #{title ? title + ' | ' : ''}Dashboard
|
||||||
|
|
||||||
|
meta(charset='utf-8')
|
||||||
|
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||||
|
|
||||||
|
block styles
|
||||||
|
link(rel='stylesheet' href='https://early.webawesome.com/webawesome@3.0.0-alpha.2/dist/themes/default.css')
|
||||||
|
link(rel='stylesheet' href=asset('dash2/main.css'))
|
||||||
|
|
||||||
|
body
|
||||||
|
.wrapper
|
||||||
|
nav
|
||||||
|
ul
|
||||||
|
li Home
|
||||||
|
li Another Page
|
||||||
|
li Yet Another Page
|
||||||
|
wa-divider
|
||||||
|
each resource in resources
|
||||||
|
li(onclick=`window.location.href="${resource.href}"`) #{resource.display}
|
||||||
|
wa-divider(vertical)
|
||||||
|
div.layout
|
||||||
|
header
|
||||||
|
div.left
|
||||||
|
if title
|
||||||
|
h1 #{title}
|
||||||
|
div.right
|
||||||
|
wa-dropdown
|
||||||
|
wa-avatar(slot='trigger' initials=appData.user.initials image=appData.user.photoUrl)
|
||||||
|
wa-menu#user-menu
|
||||||
|
wa-menu-label Hi, #{appData.user.firstName}.
|
||||||
|
wa-menu-item(value='home')
|
||||||
|
wa-icon(slot='prefix' name='house' variant='solid')
|
||||||
|
| Go to Homepage
|
||||||
|
wa-menu-item(value='old')
|
||||||
|
wa-icon(slot='prefix' name='repeat' variant='solid')
|
||||||
|
| Switch to Old Dashboard
|
||||||
|
wa-divider
|
||||||
|
wa-menu-item(value='logout')
|
||||||
|
wa-icon(slot='prefix' name='right-from-bracket' variant='solid')
|
||||||
|
| Logout
|
||||||
|
|
||||||
|
wa-divider
|
||||||
|
div.main-content
|
||||||
|
block content
|
||||||
|
|
||||||
|
block scripts
|
||||||
|
script.
|
||||||
|
window.appData = !{JSON.stringify(appData)}
|
||||||
|
|
||||||
|
script(src=asset('app.js'))
|
||||||
|
script(type='module' src='https://early.webawesome.com/webawesome@3.0.0-alpha.2/dist/webawesome.loader.js')
|
||||||
|
script(type='module').
|
||||||
|
setTimeout(() => window.exAppInstance.fire('wa.ready', {}), 2000)
|
||||||
|
script(src=asset('dash2/main.js'))
|
||||||
|
|
||||||
|
|
@ -1,9 +1,29 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"declaration": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"preserveSymlinks": true,
|
||||||
"outDir": "lib/app/resources/assets/app",
|
"outDir": "lib/app/resources/assets/app",
|
||||||
"lib": ["dom", "es2015"]
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strict": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": [
|
||||||
|
"es2022",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/app/resources/assets/app"]
|
"include": ["src/app/resources/assets/app"]
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: 'production',
|
||||||
|
entry: './lib/app/resources/assets/app/index.js',
|
||||||
|
output: {
|
||||||
|
filename: 'app.js',
|
||||||
|
path: path.resolve(__dirname, 'lib', 'app', 'resources', 'assets'),
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: ['style-loader', 'css-loader'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.ttf$/,
|
||||||
|
type: 'asset/resource'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
Loading…
Reference in new issue