Start reworking dashboard using Web Awesome
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
6ca9900d9c
commit
b1b83e78a6
109
build.js
Normal file
109
build.js
Normal file
@ -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()
|
||||||
|
})();
|
@ -30,6 +30,17 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: garrettmills-www
|
- name: garrettmills-www
|
||||||
image: registry.millslan.net/garrettmills/www
|
image: registry.millslan.net/garrettmills/www
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
env:
|
env:
|
||||||
- name: EXTOLLO_LOGGING_LEVEL
|
- name: EXTOLLO_LOGGING_LEVEL
|
||||||
value: '4'
|
value: '4'
|
||||||
|
13
package.json
13
package.json
@ -9,16 +9,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atao60/fse-cli": "^0.1.7",
|
"@atao60/fse-cli": "^0.1.7",
|
||||||
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@extollo/lib": "^0.14.14",
|
"@extollo/lib": "^0.14.14",
|
||||||
|
"@lit/task": "^1.0.1",
|
||||||
"@types/marked": "^4.0.8",
|
"@types/marked": "^4.0.8",
|
||||||
"@types/node": "^18.19.39",
|
"@types/node": "^18.19.39",
|
||||||
"@types/xml2js": "^0.4.11",
|
"@types/xml2js": "^0.4.11",
|
||||||
"any-date-parser": "^1.5.3",
|
"any-date-parser": "^1.5.3",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"feed": "^4.2.2",
|
"feed": "^4.2.2",
|
||||||
"gotify": "^1.1.0",
|
"gotify": "^1.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lib": "link:@extollo/lib:../extollo/lib",
|
"lib": "link:@extollo/lib:../extollo/lib",
|
||||||
|
"lit": "^3.1.4",
|
||||||
"marked": "^4.2.12",
|
"marked": "^4.2.12",
|
||||||
"marked-footnote": "^1.2.2",
|
"marked-footnote": "^1.2.2",
|
||||||
"ts-expose-internals": "^4.5.4",
|
"ts-expose-internals": "^4.5.4",
|
||||||
@ -31,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"build": "pnpm run clean && tsc -p tsconfig.node.json && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/app/resources lib/app/resources && tsc -p tsconfig.client.json",
|
"build": "node build.js",
|
||||||
"clean": "rimraf lib",
|
"clean": "rimraf lib",
|
||||||
"watch": "nodemon --ext js,pug,ts --watch src --exec 'ts-node src/index.ts'",
|
"watch": "nodemon --ext js,pug,ts --watch src --exec 'ts-node src/index.ts'",
|
||||||
"app": "ts-node src/index.ts",
|
"app": "ts-node src/index.ts",
|
||||||
@ -63,6 +67,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@extollo/cc": "^0.6.0",
|
"@extollo/cc": "^0.6.0",
|
||||||
"rimraf": "^3.0.2"
|
"css-loader": "^7.1.2",
|
||||||
|
"ora": "^8.0.1",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"style-loader": "^4.0.0",
|
||||||
|
"webpack": "^5.93.0",
|
||||||
|
"webpack-cli": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1254
pnpm-lock.yaml
1254
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
169
src/app/http/controllers/Dash2.controller.ts
Normal file
169
src/app/http/controllers/Dash2.controller.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ import {Dash} from '../controllers/Dash.controller'
|
|||||||
import CobaltMiddleware from '../middlewares/Cobalt.middleware'
|
import CobaltMiddleware from '../middlewares/Cobalt.middleware'
|
||||||
import {ResourceAPI} from '../controllers/cobalt/ResourceAPI.controller'
|
import {ResourceAPI} from '../controllers/cobalt/ResourceAPI.controller'
|
||||||
import {Interface} from '../controllers/cobalt/Interface.controller'
|
import {Interface} from '../controllers/cobalt/Interface.controller'
|
||||||
|
import {Dash2} from '../controllers/Dash2.controller'
|
||||||
|
|
||||||
const parseKey = (request: Request) => right(request.safe('key').string())
|
const parseKey = (request: Request) => right(request.safe('key').string())
|
||||||
const parseId = (request: Request) => {
|
const parseId = (request: Request) => {
|
||||||
@ -10,6 +11,24 @@ const parseId = (request: Request) => {
|
|||||||
return right(request.input('id') as number|string)
|
return right(request.input('id') as number|string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Route
|
||||||
|
.group('/dash2', () => {
|
||||||
|
Route.get('/')
|
||||||
|
.calls<Dash2>(Dash2, d => d.index)
|
||||||
|
|
||||||
|
Route.get('/cobalt')
|
||||||
|
.calls<Dash2>(Dash2, d => d.cobalt)
|
||||||
|
|
||||||
|
Route.get('/cobalt/settings')
|
||||||
|
.calls<Dash2>(Dash2, d => d.cobaltSettings)
|
||||||
|
|
||||||
|
Route.get('/cobalt/resource/:key/list')
|
||||||
|
.calls<Dash2>(Dash2, d => d.cobaltResourceList)
|
||||||
|
})
|
||||||
|
.pre(SessionAuthMiddleware)
|
||||||
|
.pre(AuthRequiredMiddleware)
|
||||||
|
.pre(CobaltMiddleware)
|
||||||
|
|
||||||
Route
|
Route
|
||||||
.group('/dash', () => {
|
.group('/dash', () => {
|
||||||
Route.get('/')
|
Route.get('/')
|
||||||
|
@ -18,6 +18,15 @@ export class User extends ORMUser {
|
|||||||
return `${this.routing.getAppUrl().toRemote}/pub/${this.username}`
|
return `${this.routing.getAppUrl().toRemote}/pub/${this.username}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get initials(): string {
|
||||||
|
return `${this.firstName || ''} ${this.lastName || ''}`
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map(s => s[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
async toWebfinger(): Promise<Pub.Webfinger> {
|
async toWebfinger(): Promise<Pub.Webfinger> {
|
||||||
const host = new URL(this.routing.getAppUrl().toRemote).host
|
const host = new URL(this.routing.getAppUrl().toRemote).host
|
||||||
return {
|
return {
|
||||||
|
@ -56,6 +56,6 @@ export class App<TEventMap extends BaseEventMap, TDataMap extends BaseAppDataMap
|
|||||||
let url = this.url()
|
let url = this.url()
|
||||||
if ( url.startsWith('https://') ) url = url.substring(8)
|
if ( url.startsWith('https://') ) url = url.substring(8)
|
||||||
else if ( url.startsWith('http://') ) url = url.substring(7)
|
else if ( url.startsWith('http://') ) url = url.substring(7)
|
||||||
return url.split('/')[0].split(':')[0]
|
return (url.split('/')[0] || '').split(':')[0] || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,10 +50,59 @@ export type Subscription<T> = SubscriberFunction<T> | ComplexSubscriber<T>
|
|||||||
*/
|
*/
|
||||||
export type Unsubscribe = { unsubscribe: () => void }
|
export type Unsubscribe = { unsubscribe: () => void }
|
||||||
|
|
||||||
|
export interface IBehaviorSubject<T> {
|
||||||
|
/**
|
||||||
|
* Register a new subscription to this subject.
|
||||||
|
* @param {Subscription} subscriber
|
||||||
|
* @return Unsubscribe
|
||||||
|
*/
|
||||||
|
subscribe(subscriber: Subscription<T>): Unsubscribe
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast this subject to a promise, which resolves on the output of the next value.
|
||||||
|
* @return Promise
|
||||||
|
*/
|
||||||
|
toPromise(): Promise<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a new value to this subject. The promise resolves when all subscribers have been pushed to.
|
||||||
|
* @param val
|
||||||
|
* @return Promise<void>
|
||||||
|
*/
|
||||||
|
next(val: T): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the given array of values to this subject in order.
|
||||||
|
* The promise resolves when all subscribers have been pushed to for all values.
|
||||||
|
* @param {Array} vals
|
||||||
|
* @return Promise<void>
|
||||||
|
*/
|
||||||
|
push(vals: T[]): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark this subject as complete.
|
||||||
|
* The promise resolves when all subscribers have been pushed to.
|
||||||
|
* @param [finalValue] - optionally, a final value to set
|
||||||
|
* @return Promise<void>
|
||||||
|
*/
|
||||||
|
complete(finalValue?: T): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current value of this subject.
|
||||||
|
*/
|
||||||
|
value(): T | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if this subject is marked as complete.
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
isComplete(): boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stream-based state class.
|
* A stream-based state class.
|
||||||
*/
|
*/
|
||||||
export class BehaviorSubject<T> {
|
export class BehaviorSubject<T> implements IBehaviorSubject<T> {
|
||||||
/**
|
/**
|
||||||
* Subscribers to this subject.
|
* Subscribers to this subject.
|
||||||
* @type Array<ComplexSubscriber>
|
* @type Array<ComplexSubscriber>
|
||||||
@ -77,6 +126,12 @@ export class BehaviorSubject<T> {
|
|||||||
*/
|
*/
|
||||||
protected hasPush = false
|
protected hasPush = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
initialValue?: T
|
||||||
|
) {
|
||||||
|
this.currentValue = initialValue
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new subscription to this subject.
|
* Register a new subscription to this subject.
|
||||||
* @param {Subscription} subscriber
|
* @param {Subscription} subscriber
|
||||||
@ -210,3 +265,76 @@ export class BehaviorSubject<T> {
|
|||||||
return this.subjectIsComplete
|
return this.subjectIsComplete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A behavior subject that targets a sub-value of a parent subject. */
|
||||||
|
export class MappedBehaviorSubject<TBase extends {}, TKey extends keyof TBase> implements IBehaviorSubject<TBase[TKey]> {
|
||||||
|
constructor(
|
||||||
|
private parent: IBehaviorSubject<TBase>,
|
||||||
|
private key: TKey,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if this subject has been marked complete.
|
||||||
|
* @type boolean
|
||||||
|
*/
|
||||||
|
protected subjectIsComplete = false
|
||||||
|
|
||||||
|
async complete(finalValue?: TBase[TKey]): Promise<void> {
|
||||||
|
this.subjectIsComplete = true
|
||||||
|
|
||||||
|
if ( finalValue ) {
|
||||||
|
const current = this.parent.value()
|
||||||
|
if ( !current ) {
|
||||||
|
throw new Error('Cannot set mapped behavior subject final value: parent value is undefined')
|
||||||
|
}
|
||||||
|
|
||||||
|
current[this.key] = finalValue
|
||||||
|
return this.parent.complete(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parent.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
isComplete(): boolean {
|
||||||
|
return this.subjectIsComplete || this.parent.isComplete()
|
||||||
|
}
|
||||||
|
|
||||||
|
async next(val: TBase[TKey]): Promise<void> {
|
||||||
|
const current = this.parent.value()
|
||||||
|
if ( !current ) {
|
||||||
|
throw new Error('Cannot set mapped behavior subject: parent value is undefined')
|
||||||
|
}
|
||||||
|
|
||||||
|
current[this.key] = val
|
||||||
|
return this.parent.next(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
async push(vals: TBase[TKey][]): Promise<void> {
|
||||||
|
for ( const val of vals ) {
|
||||||
|
await this.next(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(subscriber: Subscription<TBase[TKey]>): Unsubscribe {
|
||||||
|
if ( typeof subscriber === 'function' ) {
|
||||||
|
return this.parent
|
||||||
|
.subscribe(val => subscriber(val[this.key]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parent
|
||||||
|
.subscribe({
|
||||||
|
next: (val: TBase) => subscriber.next?.(val[this.key]),
|
||||||
|
error: error => subscriber.error?.(error),
|
||||||
|
complete: (val: TBase|undefined) => subscriber.complete?.(val?.[this.key]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async toPromise(): Promise<TBase[TKey]> {
|
||||||
|
const val = await this.parent.toPromise()
|
||||||
|
return val[this.key]
|
||||||
|
}
|
||||||
|
|
||||||
|
value(): TBase[TKey] | undefined {
|
||||||
|
return this.parent.value()?.[this.key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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 {}
|
|
||||||
}
|
|
44
src/app/resources/assets/app/cobalt/DataPlane.ts
Normal file
44
src/app/resources/assets/app/cobalt/DataPlane.ts
Normal file
@ -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?.()
|
||||||
|
}
|
||||||
|
}
|
125
src/app/resources/assets/app/cobalt/form/Code.ts
Normal file
125
src/app/resources/assets/app/cobalt/form/Code.ts
Normal file
@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
285
src/app/resources/assets/app/cobalt/form/Field.ts
Normal file
285
src/app/resources/assets/app/cobalt/form/Field.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
86
src/app/resources/assets/app/cobalt/form/Form.ts
Normal file
86
src/app/resources/assets/app/cobalt/form/Form.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
108
src/app/resources/assets/app/cobalt/form/State.ts
Normal file
108
src/app/resources/assets/app/cobalt/form/State.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
226
src/app/resources/assets/app/cobalt/host/Host.ts
Normal file
226
src/app/resources/assets/app/cobalt/host/Host.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
10
src/app/resources/assets/app/cobalt/index.ts
Normal file
10
src/app/resources/assets/app/cobalt/index.ts
Normal file
@ -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'
|
180
src/app/resources/assets/app/cobalt/layout/Layout.ts
Normal file
180
src/app/resources/assets/app/cobalt/layout/Layout.ts
Normal file
@ -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>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
51
src/app/resources/assets/app/cobalt/layout/components/Row.ts
Normal file
51
src/app/resources/assets/app/cobalt/layout/components/Row.ts
Normal file
@ -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'
|
123
src/app/resources/assets/app/cobalt/list/Table.ts
Normal file
123
src/app/resources/assets/app/cobalt/list/Table.ts
Normal file
@ -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>`
|
||||||
|
}
|
||||||
|
}
|
134
src/app/resources/assets/app/cobalt/types.ts
Normal file
134
src/app/resources/assets/app/cobalt/types.ts
Normal file
@ -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()
|
||||||
|
21
src/app/resources/assets/app/instance.ts
Normal file
21
src/app/resources/assets/app/instance.ts
Normal file
@ -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
|
||||||
|
}
|
@ -24,7 +24,7 @@ export namespace Ex {
|
|||||||
export const uuid = (): string => {
|
export const uuid = (): string => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
(c ^ crypto.getRandomValues(new Uint8Array(1))[0]! & 15 >> c / 4).toString(16)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
104
src/app/resources/assets/dash2/main.css
Normal file
104
src/app/resources/assets/dash2/main.css
Normal file
@ -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;
|
||||||
|
}
|
17
src/app/resources/assets/dash2/main.js
Normal file
17
src/app/resources/assets/dash2/main.js
Normal file
@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
})();
|
93
src/app/resources/assets/font/redhat/OFL.txt
Normal file
93
src/app/resources/assets/font/redhat/OFL.txt
Normal file
@ -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.
|
77
src/app/resources/assets/font/redhat/README.txt
Normal file
77
src/app/resources/assets/font/redhat/README.txt
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
Red Hat Display Variable Font
|
||||||
|
=============================
|
||||||
|
|
||||||
|
This download contains Red Hat Display as both variable fonts and static fonts.
|
||||||
|
|
||||||
|
Red Hat Display is a variable font with this axis:
|
||||||
|
wght
|
||||||
|
|
||||||
|
This means all the styles are contained in these files:
|
||||||
|
RedHatDisplay-VariableFont_wght.ttf
|
||||||
|
RedHatDisplay-Italic-VariableFont_wght.ttf
|
||||||
|
|
||||||
|
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||||
|
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||||
|
in those cases you can use the static font files for Red Hat Display:
|
||||||
|
static/RedHatDisplay-Light.ttf
|
||||||
|
static/RedHatDisplay-Regular.ttf
|
||||||
|
static/RedHatDisplay-Medium.ttf
|
||||||
|
static/RedHatDisplay-SemiBold.ttf
|
||||||
|
static/RedHatDisplay-Bold.ttf
|
||||||
|
static/RedHatDisplay-ExtraBold.ttf
|
||||||
|
static/RedHatDisplay-Black.ttf
|
||||||
|
static/RedHatDisplay-LightItalic.ttf
|
||||||
|
static/RedHatDisplay-Italic.ttf
|
||||||
|
static/RedHatDisplay-MediumItalic.ttf
|
||||||
|
static/RedHatDisplay-SemiBoldItalic.ttf
|
||||||
|
static/RedHatDisplay-BoldItalic.ttf
|
||||||
|
static/RedHatDisplay-ExtraBoldItalic.ttf
|
||||||
|
static/RedHatDisplay-BlackItalic.ttf
|
||||||
|
|
||||||
|
Get started
|
||||||
|
-----------
|
||||||
|
|
||||||
|
1. Install the font files you want to use
|
||||||
|
|
||||||
|
2. Use your app's font picker to view the font family and all the
|
||||||
|
available styles
|
||||||
|
|
||||||
|
Learn more about variable fonts
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||||
|
https://variablefonts.typenetwork.com
|
||||||
|
https://medium.com/variable-fonts
|
||||||
|
|
||||||
|
In desktop apps
|
||||||
|
|
||||||
|
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||||
|
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||||
|
|
||||||
|
Online
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/getting_started
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||||
|
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||||
|
|
||||||
|
Installing fonts
|
||||||
|
|
||||||
|
MacOS: https://support.apple.com/en-us/HT201749
|
||||||
|
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||||
|
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||||
|
|
||||||
|
Android Apps
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/android
|
||||||
|
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
Please read the full license text (OFL.txt) to understand the permissions,
|
||||||
|
restrictions and requirements for usage, redistribution, and modification.
|
||||||
|
|
||||||
|
You can use them in your products & projects – print or digital,
|
||||||
|
commercial or otherwise.
|
||||||
|
|
||||||
|
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||||
|
license for all details.
|
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.
14
src/app/resources/views/dash2/cobalt.pug
Normal file
14
src/app/resources/views/dash2/cobalt.pug
Normal file
@ -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)
|
||||||
|
)
|
4
src/app/resources/views/dash2/home.pug
Normal file
4
src/app/resources/views/dash2/home.pug
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
extends index
|
||||||
|
|
||||||
|
block content
|
||||||
|
p Welcome!
|
59
src/app/resources/views/dash2/index.pug
Normal file
59
src/app/resources/views/dash2/index.pug
Normal file
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "es2022",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "./lib",
|
"outDir": "./lib",
|
||||||
|
22
webpack.config.js
Normal file
22
webpack.config.js
Normal file
@ -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
Block a user