Start reworking dashboard using Web Awesome
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Garrett Mills 2024-07-21 19:47:19 -04:00
parent 6ca9900d9c
commit b1b83e78a6
58 changed files with 3678 additions and 109 deletions

1
.npmrc Normal file
View File

@ -0,0 +1 @@
shamefully-hoist=true

109
build.js Normal file
View 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()
})();

View File

@ -30,6 +30,17 @@ spec:
containers:
- name: 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:
- name: EXTOLLO_LOGGING_LEVEL
value: '4'

View File

@ -9,16 +9,20 @@
},
"dependencies": {
"@atao60/fse-cli": "^0.1.7",
"@codemirror/lang-html": "^6.4.9",
"@extollo/lib": "^0.14.14",
"@lit/task": "^1.0.1",
"@types/marked": "^4.0.8",
"@types/node": "^18.19.39",
"@types/xml2js": "^0.4.11",
"any-date-parser": "^1.5.3",
"codemirror": "^6.0.1",
"copyfiles": "^2.4.1",
"feed": "^4.2.2",
"gotify": "^1.1.0",
"gray-matter": "^4.0.3",
"lib": "link:@extollo/lib:../extollo/lib",
"lit": "^3.1.4",
"marked": "^4.2.12",
"marked-footnote": "^1.2.2",
"ts-expose-internals": "^4.5.4",
@ -31,7 +35,7 @@
},
"scripts": {
"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",
"watch": "nodemon --ext js,pug,ts --watch src --exec 'ts-node src/index.ts'",
"app": "ts-node src/index.ts",
@ -63,6 +67,11 @@
},
"devDependencies": {
"@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"
}
}

File diff suppressed because it is too large Load Diff

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

View File

@ -3,6 +3,7 @@ import {Dash} from '../controllers/Dash.controller'
import CobaltMiddleware from '../middlewares/Cobalt.middleware'
import {ResourceAPI} from '../controllers/cobalt/ResourceAPI.controller'
import {Interface} from '../controllers/cobalt/Interface.controller'
import {Dash2} from '../controllers/Dash2.controller'
const parseKey = (request: Request) => right(request.safe('key').string())
const parseId = (request: Request) => {
@ -10,6 +11,24 @@ const parseId = (request: Request) => {
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
.group('/dash', () => {
Route.get('/')

View File

@ -18,6 +18,15 @@ export class User extends ORMUser {
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> {
const host = new URL(this.routing.getAppUrl().toRemote).host
return {

View File

@ -56,6 +56,6 @@ export class App<TEventMap extends BaseEventMap, TDataMap extends BaseAppDataMap
let url = this.url()
if ( url.startsWith('https://') ) url = url.substring(8)
else if ( url.startsWith('http://') ) url = url.substring(7)
return url.split('/')[0].split(':')[0]
return (url.split('/')[0] || '').split(':')[0] || ''
}
}

View File

@ -50,10 +50,59 @@ export type Subscription<T> = SubscriberFunction<T> | ComplexSubscriber<T>
*/
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.
*/
export class BehaviorSubject<T> {
export class BehaviorSubject<T> implements IBehaviorSubject<T> {
/**
* Subscribers to this subject.
* @type Array<ComplexSubscriber>
@ -77,6 +126,12 @@ export class BehaviorSubject<T> {
*/
protected hasPush = false
constructor(
initialValue?: T
) {
this.currentValue = initialValue
}
/**
* Register a new subscription to this subject.
* @param {Subscription} subscriber
@ -210,3 +265,76 @@ export class BehaviorSubject<T> {
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]
}
}

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,5 @@
export * from './Row'
export * from './Column'
export * from './Group'
export * from './Card'

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

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

View File

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

View File

@ -1,5 +0,0 @@
import {MessageContainer} from './MessageContainer'
export const registerComponents = () => {
customElements.define('ex-messages', MessageContainer)
}

View File

@ -1,4 +1,8 @@
import {app} from './instance'
export * from './App'
export * from './types'
export * from './util'
export * from './cobalt'
app()

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

View File

@ -24,7 +24,7 @@ export namespace Ex {
export const uuid = (): string => {
// @ts-ignore
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)
)
}

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

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

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

View 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 arent 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.

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

View File

@ -0,0 +1,4 @@
extends index
block content
p Welcome!

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

View File

@ -1,9 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"preserveSymlinks": true,
"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"]
}

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "esnext",
"target": "es2022",
"module": "commonjs",
"declaration": true,
"outDir": "./lib",

22
webpack.config.js Normal file
View 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'
},
],
},
}