Signed-off-by: Avior <git@avior.me>
This commit is contained in:
Florian Bouillon 2024-06-13 22:34:40 +02:00
parent f50ec828fb
commit 89d2debb1e
24 changed files with 1615 additions and 1186 deletions

View File

@ -71,5 +71,8 @@ export default defineConfig({
experimental: { experimental: {
rewriting: true, rewriting: true,
}, },
devToolbar: {
enabled: false
}
}) })

1018
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@
"install:test": "playwright install --with-deps" "install:test": "playwright install --with-deps"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^7", "@astrojs/node": "^8",
"@astrojs/tailwind": "^5", "@astrojs/tailwind": "^5",
"@dzeio/logger": "^3", "@dzeio/logger": "^3",
"@dzeio/object-util": "^1", "@dzeio/object-util": "^1",

View File

@ -26,7 +26,7 @@ if (baseProps.type === 'textarea') {
<p class="prefix">{Astro.props.prefix}</p> <p class="prefix">{Astro.props.prefix}</p>
)} )}
{Astro.props.type === 'textarea' && ( {Astro.props.type === 'textarea' && (
<textarea class="textarea transition-[min-height]" {...baseProps} /> <textarea data-component="textarea" class="textarea transition-[min-height]" {...baseProps} />
) || ( ) || (
<input {...baseProps as any} /> <input {...baseProps as any} />
)} )}
@ -49,7 +49,7 @@ if (baseProps.type === 'textarea') {
@apply select-none font-light text-gray-400 @apply select-none font-light text-gray-400
} }
.input, .textarea { .input, .textarea {
@apply px-4 w-full bg-gray-100 rounded-lg border-gray-200 min-h-0 border flex items-center gap-2 py-2 @apply px-4 w-full bg-gray-100 rounded-lg border-gray-200 min-h-0 border flex items-center gap-2 py-2 text-black
} }
.input textarea, .input input { .input textarea, .input input {
@apply bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none @apply bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none
@ -76,7 +76,7 @@ if (baseProps.type === 'textarea') {
}) })
} }
} }
Component.handle<HTMLTextAreaElement>('textarea', (it) => { Component.addComponent<HTMLTextAreaElement>('textarea', (it) => {
updateHeight(it) updateHeight(it)
it.addEventListener('input', () => updateHeight(it)) it.addEventListener('input', () => updateHeight(it))
}) })

View File

@ -7,14 +7,14 @@ export interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
block?: boolean block?: boolean
suffix?: string suffix?: string
prefix?: string prefix?: string
options: Array<string | number | {title: string | number, description?: string | number | null}> options: Array<string | number | {value?: string, title: string | number, description?: string | number | null}>
} }
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix', 'options') const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix', 'options')
--- ---
<!-- input wrapper --> <!-- input wrapper -->
<label class:list={['parent', 'select', {'w-full': Astro.props.block}]}> <label data-component="select" data-options={Astro.props.options} class:list={['parent', {'w-full': Astro.props.block}]}>
{Astro.props.label && ( {Astro.props.label && (
<div class="label">{Astro.props.label}</div> <div class="label">{Astro.props.label}</div>
)} )}
@ -23,14 +23,15 @@ const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix',
{Astro.props.prefix && ( {Astro.props.prefix && (
<p class="prefix">{Astro.props.prefix}</p> <p class="prefix">{Astro.props.prefix}</p>
)} )}
<input readonly {...baseProps as any} /> <input type="hidden" {...baseProps} />
<input readonly {...objectOmit(baseProps, 'name') as any} />
<ul class="list hidden"> <ul class="list hidden">
{Astro.props.options.map((it) => { {Astro.props.options.map((it) => {
if (typeof it !== 'object') { if (typeof it !== 'object') {
it = {title: it} it = {title: it}
} }
return ( return (
<li data-value={it.title}> <li data-value={it.value ?? it.title}>
<p>{it.title}</p> <p>{it.title}</p>
{it.description && ( {it.description && (
<p class="desc">{it.description}</p> <p class="desc">{it.description}</p>
@ -60,26 +61,28 @@ const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix',
.input, .textarea { .input, .textarea {
@apply px-4 w-full bg-gray-100 rounded-lg border-gray-200 min-h-0 border flex items-center gap-2 py-2 @apply px-4 w-full bg-gray-100 rounded-lg border-gray-200 min-h-0 border flex items-center gap-2 py-2
} }
.input textarea, .input input { .input textarea, .input input {
@apply bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none @apply bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none text-black
} }
.textarea { .textarea {
@apply overflow-y-hidden @apply overflow-y-hidden
} }
.list { .list {
@apply absolute top-full mt-2 z-10 bg-gray-50 rounded-lg border-1 border-gray-300 overflow-hidden @apply absolute top-full mt-2 z-10 bg-gray-50 rounded-lg border border-gray-300 overflow-hidden
} }
input:focus + ul { input:focus + ul, ul:hover {
@apply block @apply block
} }
li { ul :global(li) {
@apply px-4 py-2 flex flex-col gap-1 hover:bg-gray-100 cursor-pointer @apply px-4 py-2 flex flex-col gap-1 hover:bg-gray-100 cursor-pointer
} }
li p { ul :global(li p) {
@apply text-gray-600 @apply text-gray-600
} }
li p.desc { ul :global(li p.desc) {
@apply text-sm font-light @apply text-sm font-light
} }
</style> </style>
@ -87,16 +90,31 @@ const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix',
<script> <script>
import Component from 'libs/Component' import Component from 'libs/Component'
Component.handle<HTMLElement>('.select', (it) => {
const input = it.querySelector('input')
Component.addComponent<HTMLElement>('select', (it) => {
const input = it.querySelector<HTMLInputElement>('input[type="hidden"]')
const displayInput = it.querySelector<HTMLInputElement>('input[readonly]')
const list = it.querySelector('ul') const list = it.querySelector('ul')
if (!input || !list) { if (!input || !list || !displayInput) {
return return
} }
function setSelectValue(value: string, displayValue: string) {
input!.value = value
input!.setAttribute('value', value)
displayInput!.value = displayValue
input!.dispatchEvent(new Event('change'))
}
const value = input.getAttribute('value') ?? displayInput.getAttribute('value')
if (value) {
const displayValue = list.querySelector(`li[data-value="${value}"]`)
if (displayValue) {
setSelectValue(value!, displayValue.querySelector('p')?.innerText ?? '')
}
}
list.querySelectorAll('li').forEach((listItem) => { list.querySelectorAll('li').forEach((listItem) => {
listItem.addEventListener('pointerdown', () => { listItem.addEventListener('click', () => {
input.value = listItem.dataset.value as string setSelectValue(listItem.dataset.value!, listItem.querySelector('p')?.innerText ?? '')
input.dispatchEvent(new Event('change'))
}) })
}) })
}) })

View File

@ -8,19 +8,21 @@ delete props.header
delete props.data delete props.data
--- ---
<table {...props}> <table {...props}>
<thead> <slot>
<tr data-row="0"> <thead>
{Astro.props.header?.map((it, idx) => <th data-cell={idx}>{it}</th>)} <tr data-row="0">
</tr> {Astro.props.header?.map((it, idx) => <th data-cell={idx}>{it}</th>)}
</thead> </tr>
<tbody> </thead>
{Astro.props.data?.map((row, rowIdx) => <tr data-row={rowIdx}>{row.map((it, cellIdx) => <td data-cell={cellIdx}>{it}</td>)}</tr>)} <tbody>
</tbody> {Astro.props.data?.map((row, rowIdx) => <tr data-row={rowIdx}>{row.map((it, cellIdx) => <td data-cell={cellIdx}>{it}</td>)}</tr>)}
</tbody>
</slot>
</table> </table>
<style> <style>
table { table {
@apply flex w-full flex-col border-1 rounded-lg overflow-clip @apply flex w-full flex-col border rounded-lg overflow-clip
} }
table :global(th) { table :global(th) {
@apply font-medium @apply font-medium
@ -42,6 +44,6 @@ delete props.data
@apply border-gray-200 @apply border-gray-200
} }
table :global(thead) { table :global(thead) {
@apply bg-gray-100 border-b-1 border-gray-200 @apply bg-gray-100 border-b border-gray-200 text-black
} }
</style> </style>

View File

@ -18,7 +18,14 @@ export interface Props extends HeadProps {
</html> </html>
<script> <script>
import Hyperion from 'libs/Hyperion' import Component from 'libs/Component'
import Hyperion from 'libs/Hyperion'
Hyperion.setup() Hyperion.setup()
.on('error', ({ error }) => {
console.log('Hyperion had an error :(', error)
})
.on('htmlChange', ({ newElement }) => {
Component.load(newElement)
})
</script> </script>

View File

@ -1,9 +1,12 @@
type Fn<T extends HTMLElement> = (el: Component<T>) => void | Promise<void> type Fn<T extends HTMLElement> = (el: Component<T>) => void | Promise<void>
/** /**
* Component client side initialisation class * Client-side Component management class
*
* just add a `data-component="name"` to your component to make it work flowlessly
*/ */
export default class Component<T extends HTMLElement> { export default class Component<T extends HTMLElement> {
private static components: Record<string, (el: HTMLElement) => void | Promise<void>> = {}
private constructor( private constructor(
public element: T public element: T
) {} ) {}
@ -50,4 +53,31 @@ export default class Component<T extends HTMLElement> {
}) })
}) })
} }
public static addComponent<T extends HTMLElement>(name: string, fn: (el: T) => void | Promise<void>) {
this.components[name] = fn as any
this.load()
}
public static load(base?: HTMLElement) {
if (base) {
this.loadElement(base)
}
(base ?? document.body).querySelectorAll<HTMLElement>('[data-component]').forEach((it) => {
this.loadElement(it)
})
}
private static loadElement(it: HTMLElement) {
const component = it.dataset.component
if (!component) {
return
}
const fn = this.components[component]
if (!fn) {
return
}
fn(it)
it.removeAttribute('data-component')
}
} }

View File

@ -1,484 +0,0 @@
import { mustBeObject, objectLoop, objectSize } from '@dzeio/object-util'
/**
* Hyperion is a library that allow you to make changes to the HTML content by using a mix of `<template />` and `JSON` Reponse from API endpoints
*
* ex:
* ```html
* <button
* data-action="/api/v1/checkout" // the endpoint to use
* data-template="#pouet" // the template to use
* >Checkout</button>
* <template id="pouet"><p
* data-attribute="test" // the JSON path
* ></p></template>
* ```
* will place inside the <button> tag a <p> tag with the text of `test` from the JSON of the API endpoint
*
* this library is a mix between systems like React/Svelte (which have a lot of frontend handling) and HTMX (which return HTML entirely from the server)
*
* _Hyperion is a mix of HyperText(HTML) and Action_
*
* @author Florian Bouillon <contact@avior.me>
* @version 1.0.0
* @license MIT
*/
export default class Hyperion {
private static instance: Hyperion
private constructor() {
this.init()
}
/**
* setup the library for usage
*/
public static setup() {
if (!this.instance) {
this.instance = new Hyperion()
}
return this.instance
}
/**
* Allows you to manually trigger an element
* @param el the element to trigger
*/
public static trigger(el: HTMLElement) {
el.dispatchEvent(new Event('hyperion:trigger'))
}
/**
* initialise hyperion
* @param base the base to query from
*/
private init(base?: HTMLElement) {
(base ?? document).querySelectorAll<HTMLElement>('[data-action]').forEach((it) => {
this.initItem(it)
})
}
/**
* initialize a single element
* @param it the element to initialize
*/
private initItem(it: HTMLElement) {
// get the trigger action
let trigger = it.dataset.trigger ?? (it.tagName === 'FORM' ? 'submit' : 'click')
// the triggering options
const options: {
once?: boolean
load?: boolean
after?: number
} = {}
// handle options
const splitted = trigger.split(' ')
for (const item of splitted) {
console.log('ah', splitted, item)
// item runs only once
if (item === 'once') {
options.once = true
continue
}
if (item === 'load') {
options.load = true
continue
}
if (item.includes(':')) {
const [key, value] = item.split(':', 2)
// trigger is done after {x}ms (if another trigger is not done)
if (key === 'after') {
options.after = parseInt(value!)
}
continue
}
// trigger is not special
trigger = item
}
let timeout: NodeJS.Timeout | undefined
// the triggering function
let fn = (ev?: Event) => {
if (ev) {
ev.preventDefault()
}
if (options.after) { // handle options:after
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
timeout = setTimeout(() => {
this.processElement(it)
timeout = undefined
}, options.after)
} else {
this.processElement(it)
}
// handle event only happening once
if (options.once) {
it.removeEventListener('hyperion:trigger', fn)
it.removeEventListener(trigger, fn)
}
}
// event can also manually be triggered using `hyperion:trigger`
it.addEventListener('hyperion:trigger', fn)
it.addEventListener(trigger, fn)
// on load run instantly
if (options.load) {
this.processElement(it)
}
}
/**
* process an element that has it's trigger triggered
* @param it the element to process
*/
private async processElement(it: HTMLElement) {
console.log(it)
const action = it.dataset.action // get the URL to try
const method = it.dataset.method ?? 'GET' // get the method to use
const subPath = it.dataset.path // get a subPath if desired (dot-separated values)
let target = it.dataset.target ?? 'innerHTML' // indicate how the data is set
const templateQuery = it.dataset.template // get the template query
const runQuery = it.dataset.run // get the template query
const multiple = it.hasAttribute('data-multiple') // get if the result should be an array
const params: Record<string, any> = {} // the request parameters
/**
* PRE REQUEST HANDLING
*/
// handle mendatory elements that are not necessary set
if ((!templateQuery && !runQuery) || !action) {
it.dispatchEvent(new CustomEvent('hyperion:attributeError', {
detail: {
reason: 'data-template/data-run or data-action not found'
}
}))
throw new Error(`the data-template (${templateQuery}) and data-run (${runQuery}) or data-action (${action}) attribute is not set :(`)
}
const url = new URL(action, window.location.href)
// pre-process the target attribute
let targetEl: HTMLElement | null = it
// handle if the target has a querySelector needed
if (target.includes(' ')) {
// split the target and the query
let [newTarget, query] = target.split(' ', 2)
// set the new target
target = newTarget!
// handle if the query start with `this`
let base: HTMLElement | Document = document
if (query?.startsWith('this')) {
query.replace('this', '')
base = it
}
// query the targetElement
targetEl = base.querySelector<HTMLElement>(query!)
}
// handle if the target was not found
if (!targetEl) {
it.dispatchEvent(new CustomEvent('hyperion:targetError', {
detail: {
type: 'target not found',
data: target
}
}))
throw new Error(`target not found ${target}`)
}
if (it.tagName === 'INPUT') {
const value = (it as HTMLInputElement).value
if (value) {
params[(it as HTMLInputElement).name] = (it as HTMLInputElement).value
}
} else if (it.tagName === 'FORM') {
const form = (it as HTMLFormElement)
for (const item of form.elements) {
const name = (item as HTMLInputElement).name
const value = (item as HTMLInputElement).value
if (value) {
params[name] = value
}
}
}
/**
* REQUEST HANDLING
*/
if (method === 'GET' && objectSize(params) > 0) {
objectLoop(params, (value, key) => {
url.searchParams.set(key, value)
})
}
// do the request
const res = await fetch(url, {
method: method,
body: method === 'POST' && objectSize(params) > 0 ? JSON.stringify(params) : null
})
/**
* POST REQUEST HANDLING
*/
// handle if the request does not succeed
if (res.status >= 400) {
it.dispatchEvent(new CustomEvent('hyperion:statusError', {
detail: {
statusCode: res.status
}
}))
throw new Error(`request returned a ${res.status} error code :(`)
}
// transform the response into JSON
let json = await res.json()
if (runQuery) {
const runEl = document.querySelector<HTMLElement>(runQuery)
if (!runEl) {
it.dispatchEvent(new CustomEvent('hyperion:runError', {
detail: {
reason: 'run element not found'
}
}))
throw new Error(`the run Element for the query ${runQuery} was not found ! :(`)
}
Hyperion.trigger(runEl)
return
}
// query the remote template
const template = document.querySelector<HTMLTemplateElement>(templateQuery!)
if (!template) {
it.dispatchEvent(new CustomEvent('hyperion:templateError', {
detail: {
reason: 'template not found'
}
}))
throw new Error(`the Template for the query ${templateQuery} was not found ! :(`)
}
// if subPath was set get to the path described
if (subPath) {
for (const child of subPath.split('.')) {
json = json[child]
}
}
// create the template clone(s)
const clones: Array<HTMLElement> = []
// if the query should have multiple clones (data-multiple)
if (multiple) {
// the data should be an array :D
if (!Array.isArray(json)) {
it.dispatchEvent(new CustomEvent('hyperion:dataError', {
detail: {
type: 'data_not_array',
data: json
}
}))
throw new Error(`the data inputted should be an array, instead it is an ${typeof json}`)
}
// loop through the array
for (const item of json) {
// fill the template element with every values
clones.push(this.fillTemplate(template, item))
}
} else {
// fill the template element with the data
clones.push(this.fillTemplate(template, json))
}
// handle how the clones are placed
switch (target) {
case 'outerHTML': // the clones replace the targetted element
targetEl.replaceWith(...clones)
break;
case 'innerHTML': // the clones replace the childs of the targetted element
// remove the current childs
while (targetEl.firstChild) {
targetEl.removeChild(targetEl.firstChild)
}
// add each clones
clones.forEach((it) => targetEl.appendChild(it))
break
default:
break;
}
}
/**
* fill the template from the data given
*
* @param template the template to fill
* @param data the data to filel with
* @returns the filled HTMLElement
*/
private fillTemplate(template: HTMLTemplateElement, data: any): HTMLElement {
// clone the template
const clone = template.content.cloneNode(true) as HTMLElement
clone.querySelectorAll<HTMLElement>('[data-loop]').forEach((it) => {
const attr = it.dataset.loop!
const tpl = it.children.item(0)!
const childContent = attr === 'this' ? data : data[attr]
console.log(it, childContent)
if (!Array.isArray(childContent)) {
throw new Error('child MUST be an array')
}
for (let idx = 0; idx < childContent.length; idx++) {
const child = tpl!.cloneNode(true) as HTMLElement
console.log(child)
let childAttr = child.dataset.attribute
if (childAttr === 'this') childAttr = idx.toString()
child.dataset.attribute = attr + '.' + childAttr
it.appendChild(child)
}
it.removeChild(tpl)
})
// go through every elements that has a attribute to fill
clone.querySelectorAll<HTMLElement>('[data-attribute]').forEach((it) => {
// get the raw attribute
const attrRaw = it.dataset.attribute!
// parse into an array
let attrs: Array<string> = []
let quoteCount = 0
let current = ''
const splitted = attrRaw.split('')
for (let idx = 0; idx < splitted.length; idx++) {
const char = splitted[idx];
if (char === '\'' && splitted[idx - 1] != '\\') {
quoteCount += 1
continue
} else if (char === ' ' && quoteCount % 2 === 0) {
attrs.push(current)
current = ''
continue
}
if (char === '\'' && splitted[idx - 1] === '\\') {
current = current.slice(0, current.length - 1)
}
current += char
}
if (current) {
attrs.push(current)
}
console.log(attrs)
// loop through each attributes
for (let attr of attrs) {
// get the display type of the attribute
let target = it.dataset.type ?? 'text'
// detect if it should be set as an attribute instead
const setToAttribute = attr.includes(':')
if (setToAttribute) {
const splitted = attr.split(':', 2)
attr = splitted.pop()!
target = splitted[0]!
}
// handle processing of string
let content: string
// handle a string with attribute processing
if (attr.includes('{')) {
let res = /\{(.*?)\}/g.exec(attr)
while (res) {
attr = attr.replace(`${res[0]}`, data[res[1]!])
res = /\{(.*?)\}/g.exec(attr)
}
content = attr
} else { // handle basic string
content = attr === 'this' ? data : objectGet(data, attr)
}
if (setToAttribute) { // set it as attribute
it.setAttribute(target, content)
} else {
// get the real target function
if (target === 'html') {
target = 'innerHTML'
} else if ( target === 'text') {
target = 'innerText'
}
if (!(target in it)) {
template.dispatchEvent(new CustomEvent('hyperion:dataError', {
detail: {
type: 'data-type invalid',
data: target
}
}))
throw new Error(`the data-type is not valid (${target}), it should be one of ('innerHTML' | 'innerText' | 'outerHTML' | 'outerText')`)
}
// set it has inner/outer HTML/Text
it[target as 'innerHTML' | 'innerText' | 'outerHTML' | 'outerText'] = content
}
}
// idk if necessary but remove the attributes from the final HTML
// it.removeAttribute('data-attribute')
// it.removeAttribute('data-type')
})
// setup the clone to work if it contains Hyperion markup
this.init(clone)
return clone
}
}
/**
* go through an object to get a specific value
*
* note: it will be slower than getting it directly but it allows some dynamism and other libs to query things
*
* @param obj the object to go through
* @param path the path to follow (if path is a string it will be splitted with `.` and ints will be parsed)
*
* @returns the value or undefined
*/
function objectGet(obj: object, path: Array<string | number | symbol> | string): any | undefined {
mustBeObject(obj)
// transform path into an Array
if (typeof path === 'string') {
path = path.split('.').map((it) => /^\d+$/g.test(it) ? parseInt(it) : it)
}
let pointer: object = obj
for (let index = 0; index < path.length; index++) {
const key = path[index]
const nextIndex = index + 1;
if (typeof key === 'undefined' || !Object.prototype.hasOwnProperty.call(pointer, key) && nextIndex < path.length) {
return undefined
}
// if last index
if (nextIndex === path.length) {
return (pointer as any)[key]
}
// move pointer to new key
pointer = (pointer as any)[key]
}
}

133
src/libs/Hyperion/README.md Normal file
View File

@ -0,0 +1,133 @@
# Hyperion
Hyperion is a library that allow you to make changes to the HTML content by using a mix of `<template />` and `JSON` Reponse from API endpoints
## Installation
```bash
npm i hyperion
```
## Usage
```js
import Hyperion from 'hyperion'
Hyperion.setup()
```
```html
<button
data-trigger="" <-- How the input will be triggered
data-input="/api/v1/" <-- get from a remote source (result MUST be in JSON)
data-input="delete:/api/v1/" <-- the method is chosen by using a method
data-input="run:action" <-- will run with param0 being the HTMLElement & param1 being the input data in JSON (form only)
-- IO --
data-path="path" <-- run the output on a child instead of the base object
data-multiple <-- (ONLY if response object is an array) will display multiple elements instead of one
data-output="template location|body|this{location} inner|outer|append" <-- Will fill the template, and display it in the location (inner,outer define if it replace or is a child)
data-output="run:action" <-- will run with param0 being the HTMLElement & param1 being the data in JSON
data-output="hyp:query" <-- Will run another Hyperion element by the query
></button>
```
- Template
it MUST only have one child
```html
<template id="pokemon">
<p
data-attribute="key" <-- set the inner text of the element
data-attribute="html:key" <-- set the inner HTML of the element
data-attribute="outertext:key" <-- set the outer text of the element
data-attribute="outerhtml:key" <-- set the outer HTML of the element
data-attribute="any-other-attribute:key" <-- set the attribute of the element
data-loop="key" <-- child elements will loop through each items of the array defined by `key`
></p>
</template>
```
## References
### Attributes
one of the `data-input` or `data-output` MUST be set so that everything work.
#### data-trigger: (optionnal)
the different trigger available (default: 'submit' for <form> else 'click' for the rest) (multiple can be used)
- load: action is run on start
- once: only run Hyperion once (manual trigger can still happens if `force` is `true`)
- `after:xx`: trigger will defer until xx time of ms passed (allow to dedup requests)
- HTMLListenerEvent: any HTMLElement event available
#### data-input: (optionnal)
if data-input is not set it will directly got for data-output with an empty object
- url: will query the URL using a GET method for JSON data
- `method:url`: will query the URL using the method for JSON data
- `run:action`: Get informations by running an action
#### data-path: (optionnal)
a Subpath of the input data to lessen strain on the output
#### data-multiple: (optionnal)
if the input is an array and data-multiple is set, the output will be using array
#### data-output: (optionnal)
- template: The template used to display
- location: the location to display the element (default: this)
note: by using `this` as a prefix it will query relative to the current element (ex: `this#pouet`)
- replace|child: replace or append as child (default: child)
### Actions
Actions are elements defined in Hyperion that run code to fetch informations or run custom code
there is two types of actions:
- input Action
An input Action MUST return a JSON object and will have access to the HTMLElement that was triggered
`(element?: HTMLElement, input?: object) => Promise<object> | object`
the element can be omitted when trigger is done remotely
the input can be available depending on the source
- output Action
`(element: HTMLElement, output: object) => Promise<void> | void`
the output is the data fetched by the input
example output Action
`popup`
will search a popup template by using an additionnal attribute `data-template` and fill the elements to display a popup
builtin output actions
- `reload`: Reload the current page
### Hyperion Class
- Static
- setup()
- Methods
- on('trigger', ({ target: HTMLElement, trigger: string, force: boolean }) => void)
- on('htmlChanged', ({ rootElement: HTMLElement }) => void)
- on('error', ({ error: Error }) => void)
- trigger('trigger', HTMLElement, options?: { force: boolean })
- addInputAction(action)
- addOutputAction(action)
- fillTemplate(template: HTMLTemplateElement, data: object) => HTMLElement
## Examples
### pushing changes of a textarea to the remote server
```html
<textarea data-trigger="keyup after:200" data-input="post:/api/v1/text" name="text">
base text
</textarea>
```
It will send this as a POST request to the url `/api/v1/text` with the body below
```json
{"text": "base text"}
```

662
src/libs/Hyperion/index.ts Normal file
View File

@ -0,0 +1,662 @@
import { isObject, objectGet, objectLoop } from '@dzeio/object-util'
type MaybePromise<T = void> = T | Promise<T>
type InputAction = (element?: HTMLElement, input?: object) => MaybePromise<object>
type OutputAction = (element: HTMLElement, output: object) => MaybePromise
interface Options {
force?: boolean
}
interface Events {
trigger: (ev: {
/**
* the source element
*/
target: HTMLElement,
/**
* the trigger that triggered
*/
trigger: string,
/**
* is the trigger forced or not
*/
force: boolean
}) => MaybePromise
htmlChange: (ev: { newElement: HTMLElement }) => MaybePromise
error: (ev: { error: Error, params?: object | undefined }) => MaybePromise
}
/**
* Hyperion is a library that allow you to make changes to the HTML content by using a mix of `<template />` and `JSON` Reponse from API endpoints
*
* @author Florian Bouillon <contact@avior.me>
* @version 2.0.0
* @license MIT
*/
export default class Hyperion {
private static instance: Hyperion
private constructor() {
this.init()
}
private events: Partial<Record<keyof Events, Array<Events[keyof Events]>>> = {}
private inputActions: Record<string, InputAction> = {}
private outputActions: Record<string, OutputAction> = {
reload() {
window.location.reload()
},
debug(el, out) {
console.log('Output launched !', el, out)
}
}
/**
* setup the library for usage
*/
public static setup() {
if (!Hyperion.instance) {
Hyperion.instance = new Hyperion()
}
return Hyperion.instance
}
public addOutputAction(action: string, ev: OutputAction | null) {
if (ev === null) {
delete this.outputActions[action]
return this
}
this.outputActions[action] = ev
return this
}
/**
* add an event to Hyperion
*
* @param event the event to register on
* @param ev the event that will run
*/
public on<T extends keyof Events>(event: T, ev: Events[T]): this {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event]?.push(ev)
return this
}
/**
* manually trigger an element
*
* @param element the element to trigger
*/
public trigger(element: HTMLElement): this {
this.processInput(element)
return this
}
private emit<T extends keyof Events>(ev: T, params: Parameters<Events[T]>[0]) {
const listeners = (this.events[ev] ?? []) as Array<Events[T]>
for (const listener of listeners) {
listener(params as any)
}
}
/**
* initialise hyperion
* @param base the base to query from
*/
private init(base?: HTMLElement) {
// setup on itself when possible
if (base && (base.dataset.output || base.dataset.input)) {
this.setupTrigger(base)
}
// biome-ignore lint/complexity/noForEach: <explanation>
(base ?? document).querySelectorAll<HTMLElement>('[data-output],[data-input]').forEach((it) => {
this.setupTrigger(it)
})
}
private async setupTrigger(it: HTMLElement) {
// get the trigger action
const isForm = it.tagName === 'FORM'
const triggers: Array<string> = []
// the triggering options
const options: {
once?: boolean
load?: boolean
after?: number
} = {}
// handle options
const splitted = this.betterSplit(it.dataset.trigger ?? '')
for (const item of splitted) {
const { prefix, value } = this.decodeParam(item)
switch (prefix ?? value) {
case 'once':
options.once = true
break
case 'load':
options.load = true
break
case 'after':
options.after = Number.parseInt(value, 10)
break
default:
triggers.push(item)
break;
}
}
if (triggers.length === 0) {
triggers.push(isForm ? 'submit' : 'click')
}
let timeout: NodeJS.Timeout | undefined
// the triggering function
const fn = (ev?: Event) => {
if (isForm && ev) {
ev.preventDefault()
}
// handle event only happening once
if (options.once) {
for (const trigger of triggers) {
it.removeEventListener(trigger, fn)
}
}
// special case for option `after:xxx`
if (options.after) {
if (timeout) { // remove existing timer
clearTimeout(timeout)
timeout = undefined
}
// set a new timer
timeout = setTimeout(() => {
// process
this.processInput(it)
timeout = undefined
}, options.after)
return
}
// run it
this.processInput(it)
}
// event can also manually be triggered using `hyperion:trigger`
for (const trigger of triggers) {
it.addEventListener(trigger, fn)
}
// on load run instantly
if (options.load) {
this.processInput(it)
}
}
private async processInput(it: HTMLElement, data?: object, options?: Options): Promise<void> {
// get the input
let input = it.dataset.input
/**
* Setup Params
*/
const params: object = data ?? {}
// parse form values into input params
if (it.tagName === 'FORM') {
const formData = new FormData(it as HTMLFormElement)
formData.forEach((value, key) => {
params[key] = value
})
// parse input value into input param
} else if (it.tagName === 'INPUT' && (it as HTMLInputElement).name) {
params[(it as HTMLInputElement).name] = (it as HTMLInputElement).value
}
if (it.dataset.params) {
const exchange = this.betterSplit(it.dataset.params)
for (const item of exchange) {
const { prefix, value } = this.decodeParam(item)
if (!prefix) {
continue
}
params[prefix] = value
}
}
// if data-input is not set goto output
if (!input) {
return this.processOutput(it, params, options)
}
const { prefix, value } = this.decodeParam(input)
// handle running an input Action
if (prefix === 'run') {
const inputAction = this.inputActions[value]
if (!inputAction) {
throw this.makeError(
it,
`Input Action not found (${value}), you might need to add it using Hyperion.addInputAction()`
)
}
const res = await inputAction(it, params)
return this.processOutput(it, res, options)
}
let method = 'GET'
if (prefix) {
method = prefix.toUpperCase()
input = value
}
console.log(method, input)
const url = new URL(input, window.location.href)
let body: string | FormData | null = null
if (method === 'GET') {
console.log('going into GET mode')
objectLoop(params, (value, key) => {
url.searchParams.set(key, value)
})
} else if (it.getAttribute('enctype') === 'multipart/form-data') {
const formData = new FormData()
objectLoop(params, (value, key) => {
formData.set(key, value)
})
body = formData
} else {
body = JSON.stringify(params)
}
// do the request
const res = await fetch(url, {
method: method,
body: body
})
// handle if the request does not succeed
if (res.status >= 400) {
throw this.makeError(it, `request returned a ${res.status} error code :(`, { statusCode: res.status })
}
// transform the response into JSON
return this.processOutput(it, await res.json(), options)
}
private async processOutput(it: HTMLElement, dataTmp: object, options?: Options): Promise<void> {
let data = dataTmp
const output = it.dataset.output
if (!output) {
return
}
const subpath = it.dataset.path
if (subpath) {
data = objectGet(data, subpath) as object
}
const { prefix, value } = this.decodeParam(output)
if (prefix === 'run') {
const outputActions = this.outputActions[value]
if (!outputActions) {
throw this.makeError(it, `Output Action Action not found (${value}), you might need to add it using Hyperion.addOutputAction()`)
}
return outputActions(it, data)
}
if (prefix === 'hyp') {
const hypItem = document.querySelector<HTMLElement>(value)
if (!hypItem) {
throw this.makeError(it, `Could not find Hyperion element using the query (${value})`)
}
return this.processInput(hypItem, data, options)
}
let [
templateQuery,
locationQuery = null,
placement = 'inner'
] = this.betterSplit(output)
const template = document.querySelector<HTMLTemplateElement>(templateQuery as string)
if (!template || template.tagName !== 'TEMPLATE') {
const msg = !template ? `Template not found using query(${templateQuery})` : `Template is not a template ! (tag is ${template.tagName} while it should be TEMPLATE)`
throw this.makeError(it, msg)
}
const placements = ['inner', 'outer', 'append']
if (locationQuery && placements.includes(locationQuery)) {
placement = locationQuery
locationQuery = null
}
const isArray = 'multiple' in it.dataset
const clones = isArray ? (data as Array<object>).map((it) => this.fillTemplate(template, it)) : [this.fillTemplate(template, data)]
let location = it
if (locationQuery) {
if (locationQuery === 'body') {
location = document.body
} else {
let origin = document.body
if (locationQuery.startsWith('this')) {
origin = it
locationQuery = locationQuery.slice(4)
}
const tmp = origin.querySelector<HTMLElement>(locationQuery)
if (!tmp) {
throw this.makeError(it, `New location not found (origin: ${origin.tagName}, query: ${locationQuery})`)
}
location = tmp
}
}
if (!location) {
throw this.makeError(it, 'location not found :(')
}
switch (placement) {
case 'outer': // the clones replace the targetted element
location.replaceWith(...clones)
// TODO: might be buggy due to shit
for (const clone of clones) {
this.emit('htmlChange', { newElement: clone })
}
break;
case 'inner': // the clones replace the childs of the targetted element
// remove the current childs
while (location.firstChild) {
location.removeChild(location.firstChild)
}
// add each clones
// biome-ignore lint/complexity/noForEach: <explanation>
clones.forEach((it) => {
const appendedChild = location.appendChild(it)
this.emit('htmlChange', { newElement: appendedChild })
})
break
case 'append': {
// biome-ignore lint/complexity/noForEach: <explanation>
clones.forEach((it) => {
document.body.appendChild(it)
this.emit('htmlChange', { newElement: it })
})
break
}
default:
break;
}
}
/**
* fill the template from the data given
*
* @param template the template to fill
* @param data the data to filel with
* @returns the filled HTMLElement
*/
public fillTemplate(template: HTMLTemplateElement, data: object): HTMLElement {
// clone the template
let node = template.content.cloneNode(true) as DocumentFragment | HTMLElement
if (node.childNodes.length > 1) {
const parent = document.createElement('div')
parent.appendChild(node)
node = parent
}
const clone = node.firstElementChild as HTMLElement
return this.fill(clone, data)
}
/**
*
* @param el the element to fill
* @param data the data to fill it with
* @returns the filled element (original changed)
*/
public fill(el: HTMLElement, data: object) {
if (el.dataset.loop) {
this.fillLoop(el, data)
}
let childLoop = el.querySelector<HTMLElement>('[data-loop]')
while (childLoop) {
this.fillLoop(childLoop, data)
childLoop = el.querySelector<HTMLElement>('[data-loop]')
}
// go through every elements that has a attribute to fill
this.fillAttrs(el, data)
// biome-ignore lint/complexity/noForEach: <explanation>
el.querySelectorAll<HTMLElement>('[data-attribute],[data-input],[data-output],[data-params]').forEach((it) => this.fillAttrs(it, data))
// setup the clone to work if it contains Hyperion markup
this.init(el)
return el
}
private fillLoop(it: HTMLElement, data: object, context: Array<string | number> = []) {
const path = it.dataset.loop
if (!path) {
return
}
const subElement = objectGet(data, path) as Array<any>
for (let idx = 0; idx < subElement.length; idx++) {
const currentContext = [...context, path, idx]
const child = it.cloneNode(true) as HTMLElement
child.removeAttribute('data-loop')
let childLoop = child.querySelector<HTMLElement>('[data-loop]')
while (childLoop) {
this.fillLoop(childLoop, data, currentContext)
childLoop = child.querySelector<HTMLElement>('[data-loop]')
}
this.fillLoop(child, data, currentContext)
// go through every elements that has a attribute to fill
this.fillAttrs(child, data, currentContext)
// biome-ignore lint/complexity/noForEach: <explanation>
const items = Array.from(child.querySelectorAll<HTMLElement>('[data-attribute],[data-input],[data-output]'))
for (const item of items) {
this.fillAttrs(item, data, currentContext)
}
// child.querySelectorAll<HTMLElement>('[data-attribute],[data-input],[data-output]').forEach((it) => this.fillAttrs(it, sub))
it!.after(child)
}
it.remove()
}
/**
* fill the attributes to the element
* @param it the element to fill
* @param data the data link to this element
* @returns the filled element
*/
private fillAttrs(it: HTMLElement, data: object, context: Array<string | number> = []) {
// get the raw attribute
const attrRaw = it.dataset.attribute
for (const attr of ['output', 'input', 'trigger', 'params']) {
let value = it.dataset[attr]
if (!value || !value.includes('{')) {
continue
}
value = this.parseValue(value, data, context)
it.setAttribute(`data-${attr}`, value)
// it.dataset[attr] = value
}
if (typeof attrRaw !== 'string') {
return
}
// parse into an array
const attrs: Array<string> = this.betterSplit(attrRaw)
// loop through each attributes
for (const attrRaw of attrs) {
const { prefix = 'innerhtml', value } = this.decodeParam(attrRaw)
// handle processing of string
// handle a string with attribute processing
const attr = this.parseValue(value, data, context)
switch (prefix) {
case 'html':
case 'innerhtml': {
it.innerHTML = attr
break
}
case 'text':
case 'innertext': {
it.innerText = attr
break
}
case 'outerhtml': {
it.outerHTML = attr
break
}
case 'outertext': {
it.outerText = attr
break
}
default: {
it.setAttribute(prefix, attr)
}
}
}
// idk if necessary but remove the attributes from the final HTML
it.removeAttribute('data-attribute')
}
/**
* A space centered split but it does not split if inside singlequotes
*/
private betterSplit(input: string): Array<string> {
const attrs: Array<string> = []
let quoteCount = 0
let current = ''
const splitted = input.split('')
for (let idx = 0; idx < splitted.length; idx++) {
const char = splitted[idx];
if (char === '\'' && splitted[idx - 1] !== '\\') {
quoteCount += 1
continue
}
if (char === ' ' && quoteCount % 2 === 0) {
attrs.push(current)
current = ''
continue
}
if (char === '\'' && splitted[idx - 1] === '\\') {
current = current.slice(0, current.length - 1)
}
current += char
}
if (current) {
attrs.push(current)
}
return attrs
}
private decodeParam(str: string): { prefix?: string, value: string } {
const index = str.indexOf(':')
if (index === -1) {
return { value: str }
}
return { prefix: str.slice(0, index), value: str.slice(index + 1) }
}
private parseValue(str: string, data: object, context: Array<string | number>): string {
let value = str
if (!value) {
return value
}
function findValue(key: string) {
if (key.startsWith('this')) {
const fromContext = objectGet(data, context)
let sliced = key.slice(4)
if (sliced.startsWith('.')) {
sliced = sliced.slice(1)
const tmp = objectGet(fromContext, sliced)
if (!tmp) {
console.log(3)
return key
}
return tmp
} else {
console.log(fromContext, data, context)
return fromContext
}
}
if (!isObject(data) && (Array.isArray(key) && key.length > 0 || key)) {
console.log(str, data, context, key)
return key
}
const res = objectGet(data, key)
if (!res) {
console.log(2)
return key
}
return res
}
if (!value.includes('{')) {
return findValue(value)
} else {
let res = /\{(.*?)\}/g.exec(value)
while (res && res.length >= 2) {
const key = res[1]!
value = value.replace(`${res[0]}`, findValue(key))
res = /\{(.*?)\}/g.exec(value)
}
}
return value
}
private makeError(el: HTMLElement, message: string, params?: object) {
el.dispatchEvent(new CustomEvent('hyperion:error', {
detail: {
error: message,
params: params
}
}))
const error = new Error(message)
this.emit('error', { error, params })
return error
}
}

View File

@ -13,7 +13,7 @@ import { filter } from './AdapterUtils'
export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> { export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
private id!: Array<string> private id: Array<string> = []
public constructor( public constructor(
/** /**
@ -24,15 +24,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
* the table name * the table name
*/ */
public readonly table: string, public readonly table: string,
/**
* the id(s)
*/
id?: keyof T | Array<keyof T>,
/**
* other secondary keys necessary to update data
*/
private readonly partitionKeys?: Array<keyof T>,
/** /**
* additionnal options to make the adapter work * additionnal options to make the adapter work
*/ */
@ -43,20 +35,14 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
debug?: boolean debug?: boolean
} }
) { ) {
if (!id) { objectLoop(this.schema.model, (schema, key) => {
objectLoop(schema.model, (value, key) => { if (!isSchemaItem(schema)) {
if (!isSchemaItem(value)) { return
return true }
} if (schema.database?.auto) {
if (!value.database?.unique) { this.id.push(key)
return true }
} })
id = key
return false
})
} else {
this.id = typeof id === 'string' ? [id] : id as Array<string>
}
} }
// TODO: make it clearer what it does // TODO: make it clearer what it does
@ -107,65 +93,6 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> { public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
let req: Array<string> = ['SELECT', '*', 'FROM', this.table] let req: Array<string> = ['SELECT', '*', 'FROM', this.table]
// list of the differents items in the WHERE statement
const whereItems: Array<string> = []
// if ((query?.where?.length ?? 0) > 0 && (query?.where?.length !== 1 || query?.where?.[0]?.[1] !== 'includes')) {
// for (const it of query?.where ?? []) {
// // eslint-disable-next-line max-depth
// switch (it[1]) {
// case 'in':
// // eslint-disable-next-line no-case-declarations
// const arr = it[2] as Array<any>
// whereItems.push(`${String(it[0])} IN (${arr.map(() => '?').join(',')})`)
// params.push(...arr)
// break
// case 'equal':
// whereItems.push(`${String(it[0])} = ?`)
// params.push(it[2])
// break
// case 'after':
// whereItems.push(`${String(it[0])} >= ?`)
// params.push(it[2])
// break
// case 'before':
// whereItems.push(`${String(it[0])} <= ?`)
// params.push(it[2])
// break
// }
// }
// }
if (whereItems.length > 0) {
req.push('WHERE')
for (let idx = 0; idx < whereItems.length; idx++) {
const item = whereItems[idx] as string
if (idx > 0) {
req.push('AND')
}
req.push(item)
}
}
// ORDER BY (not working as we want :()
// const sort = query?.$sort
// if (sort && sort.length >= 1) {
// const suffix = sort[0]?.[1] === 'asc' ? 'ASC' : 'DESC'
// req = req.concat(['ORDER', 'BY', sort[0]?.[0] as string, suffix])
// }
// LIMIT (not working because of ORDER BY)
// const page: number = query?.page ?? 0
// const pageLimit: number | null = query?.limit ?? null
// let limit: number | null = null
// if (pageLimit && pageLimit > 0) {
// limit = pageLimit * (page + 1)
// req = req.concat(['LIMIT', limit.toString()])
// }
const client = (await Client.get())! const client = (await Client.get())!
if (this.options?.debug) { if (this.options?.debug) {
@ -190,7 +117,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
} }
} }
let dataset = res.rows const raw = res.rows
.map((obj) => objectRemap(this.schema.model, (_, key) => ({ .map((obj) => objectRemap(this.schema.model, (_, key) => ({
key, key,
value: this.dbToValue(key, (obj as any)[key]) value: this.dbToValue(key, (obj as any)[key])
@ -207,45 +134,6 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
.map((it) => this.schema.parse(it)) .map((it) => this.schema.parse(it))
.filter((it): it is Implementation<T> => !!it) .filter((it): it is Implementation<T> => !!it)
/**
* POST QUERY TREATMENT
*/
// if ((query?.where?.length ?? 0) > 0) {
// for (const it of query?.where ?? []) {
// // eslint-disable-next-line max-depth
// switch (it[1]) {
// case 'includes':
// dataset = dataset.filter((entry) => entry[it[0]]?.toString()?.includes(it[2]))
// break
// }
// }
// }
// sort
// const sort = query?.$sort
// if (sort) {
// const sortKey = sort ? sort[0]![0] : objectFind(this.schema.model, (value) => {
// if (!isSchemaItem(value)) {
// return false
// }
// return !!value.database?.created
// })
// const sortValue = sort ? sort[0]![1] : 'asc'
// if (sortKey && sortValue) {
// if (sortValue === 'asc') {
// dataset = dataset.sort((a, b) => b[sortKey as string]! > a[sortKey as string]! ? 1 : -1)
// } else {
// dataset = dataset.sort((a, b) => b[sortKey as string]! < a[sortKey as string]! ? 1 : -1)
// }
// }
// }
// console.log(res.rows, req)
// post request processing
// if (limit) {
// dataset = dataset.slice(page * (query?.limit ?? 0), limit)
// }
// temp modification of comportement to use the new and better query system // temp modification of comportement to use the new and better query system
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) { if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
// temp fix for the sorting algorithm // temp fix for the sorting algorithm
@ -256,13 +144,13 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
query.$sort = { created: Sort.DESC } query.$sort = { created: Sort.DESC }
} }
} }
let dataset = raw
if (query) { if (query) {
dataset = filter(query, dataset, this.options) dataset = filter(query, dataset, this.options)
} }
// console.log(res) // console.log(res)
return { return {
rows: res.rowCount ?? 0, rows: dataset.length ?? 0,
rowsTotal: res.rowCount ?? 0, rowsTotal: res.rowCount ?? 0,
page: 1, page: 1,
pageTotal: 1, pageTotal: 1,
@ -307,7 +195,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
} }
// map the items to update // map the items to update
const keys = objectMap(obj as {}, (_, key) => `${key}=?`) const keys = objectMap(obj as {}, (_, key, idx) => `${key}=$${idx+1}`)
parts.push(keys.join(', ')) parts.push(keys.join(', '))
params.push(...objectValues(obj as {})) params.push(...objectValues(obj as {}))
@ -320,7 +208,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
if (idx > 0) { if (idx > 0) {
parts.push('AND') parts.push('AND')
} }
parts.push(`${key}=?`) parts.push(`${key}=$${params.length+1}`)
const value = obj[key] ?? (typeof id === 'string' ? id : id[key]) const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
read[key] = this.valueToDB(key, value) read[key] = this.valueToDB(key, value)
if (!value) { if (!value) {
@ -329,14 +217,6 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
params.push(value) params.push(value)
} }
if (this.partitionKeys && this.partitionKeys?.length > 0) {
const { data } = await this.read(read)
const item = data[0]
for (const key of this.partitionKeys) {
parts.push('AND', `${key as string}=${this.valueToDB(key, item![key])}`)
}
}
const req = parts.join(' ') const req = parts.join(' ')
const client = await Client.get() const client = await Client.get()
@ -345,7 +225,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
} }
try { try {
const res = await client!.query(`${req}`) const res = await client!.query(req, params)
// console.log(res, req) // console.log(res, req)
if (this.options?.debug) { if (this.options?.debug) {
console.log('post patch result', res, req) console.log('post patch result', res, req)

View File

@ -2,11 +2,28 @@ import { type Query } from './Query'
import type { Implementation, Model } from './Schema' import type { Implementation, Model } from './Schema'
export interface DBPull<T extends Model> { export interface DBPull<T extends Model> {
/**
* total number of rows that are valid with the specified query
*/
rows: number rows: number
/**
* total number of rows in the table
*/
rowsTotal: number rowsTotal: number
/**
* current page number
*/
page: number page: number
/**
* total amount of pages
*/
pageTotal: number pageTotal: number
/**
* the data fetched
*/
data: Array<Implementation<T>> data: Array<Implementation<T>>
} }

View File

@ -7,15 +7,16 @@ export default {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT, name TEXT,
description TEXT, description TEXT,
displayID TEXT, displayid TEXT,
visibility BOOL, visibility BOOL,
archived BOOL archived BOOL
);`) );`)
await client.query(`CREATE TABLE IF NOT EXISTS state ( await client.query(`CREATE TABLE IF NOT EXISTS state (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
project TEXT,
name TEXT, name TEXT,
color CHAR(6), color CHAR(7),
preset BOOL preset BOOL
)`) )`)

View File

@ -12,7 +12,7 @@ const schema = new Schema({
description: { type: String, nullable: true }, description: { type: String, nullable: true },
displayID: { type: String, nullable: true }, displayid: { type: String, nullable: true },
visibility: { type: String, nullable: true }, visibility: { type: String, nullable: true },
archived: { type: Boolean, defaultValue: false } archived: { type: Boolean, defaultValue: false }
}) })

View File

@ -210,7 +210,7 @@ export default class Schema<M extends Model> {
* @returns an object containin ONLY the elements defined by the schema * @returns an object containin ONLY the elements defined by the schema
*/ */
public parse(input: any): Implementation<M> | null { public parse(input: any): Implementation<M> | null {
console.log(input) // console.log(input)
if (!isObject(input)) { if (!isObject(input)) {
return null return null
} }

View File

@ -19,4 +19,4 @@ const schema = new Schema({
export default schema export default schema
export type ProjectObj = Impl<typeof schema> export type StateObj = Impl<typeof schema>

View File

@ -0,0 +1,15 @@
import { objectOmit } from '@dzeio/object-util'
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory'
export const POST: APIRoute = async (ctx) => {
const id = ctx.params.id!
const dao = DaoFactory.get('project')
const body = objectOmit(await ctx.request.json(), 'label')
const res = await dao.patch(id, body)
return new ResponseBuilder()
.body(res)
.build()
}

View File

@ -1,3 +1,4 @@
import { objectOmit } from '@dzeio/object-util'
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder' import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory' import DaoFactory from 'models/DaoFactory'
@ -11,3 +12,13 @@ export const GET: APIRoute = async (ctx) => {
.body((await dao.findOne({project: projectId, id: taskId}))) .body((await dao.findOne({project: projectId, id: taskId})))
.build() .build()
} }
export const POST: APIRoute = async (ctx) => {
const taskId = ctx.params.issueId!
const dao = DaoFactory.get('issue')
const body = objectOmit(await ctx.request.json(), 'label')
const res = await dao.patch(taskId, {id: taskId, ...body})
return new ResponseBuilder()
.body(res)
.build()
}

View File

@ -0,0 +1,31 @@
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory'
export const POST: APIRoute = async (ctx) => {
const taskId = ctx.params.issueId!
const body = await ctx.request.json()
const newLabel = body.label
const dao = DaoFactory.get('issue')
const req = await dao.get(taskId)
req?.labels.push(newLabel)
const res = await dao.update(req!)
return new ResponseBuilder()
.body(res)
.build()
}
export const DELETE: APIRoute = async (ctx) => {
const taskId = ctx.params.issueId!
const body = await ctx.request.json()
const labelToRemove = body.label
const dao = DaoFactory.get('issue')
const req = await dao.get(taskId)!
console.log(req, taskId)
req!.labels.splice(req!.labels.indexOf(labelToRemove), 1)
const res = await dao.update(req!)
return new ResponseBuilder()
.body(res)
.build()
}

View File

@ -1,6 +1,7 @@
import type { APIRoute } from 'astro' import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder' import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory' import DaoFactory from 'models/DaoFactory'
import { Sort } from 'models/Query'
export const POST: APIRoute = async (ctx) => { export const POST: APIRoute = async (ctx) => {
const projectId = ctx.params.id! const projectId = ctx.params.id!
@ -13,11 +14,10 @@ export const POST: APIRoute = async (ctx) => {
project: projectId, project: projectId,
preset: true preset: true
}) })
console.log(issueCount)
const res = await dao.create({ const res = await dao.create({
...(await ctx.request.json()), ...(await ctx.request.json()),
project: projectId, project: projectId,
localid: issueCount.rowsTotal + 1, localid: issueCount.rows + 1,
state: defaultState?.id ?? 'empty', state: defaultState?.id ?? 'empty',
labels: [] labels: []
}) })
@ -29,6 +29,6 @@ export const GET: APIRoute = async (ctx) => {
const dao = DaoFactory.get('issue') const dao = DaoFactory.get('issue')
return new ResponseBuilder() return new ResponseBuilder()
.body((await dao.findAll({project: projectId})).data) .body((await dao.findAll({project: projectId, $sort: { localid: Sort.ASC}})).data)
.build() .build()
} }

View File

@ -7,7 +7,7 @@ export const GET: APIRoute = async ({ url }) => {
const dao = DaoFactory.get('project') const dao = DaoFactory.get('project')
const filters: Query<any> = {} const filters: Query<any> = {}
url.searchParams.forEach((it, key) => { url.searchParams.forEach((it, key) => {
filters[key] = it filters[key] = { $inc: it }
}) })
const res = await dao.findAll(filters) const res = await dao.findAll(filters)
return new ResponseBuilder().body(res.data).build() return new ResponseBuilder().body(res.data).build()

View File

@ -4,9 +4,24 @@ import Input from 'components/global/Input.astro'
import MainLayout from 'layouts/MainLayout.astro' import MainLayout from 'layouts/MainLayout.astro'
import DaoFactory from 'models/DaoFactory' import DaoFactory from 'models/DaoFactory'
import Schema from 'models/Schema' import Schema from 'models/Schema'
import type { StateObj } from 'models/State'
import route from 'route' import route from 'route'
const dao = DaoFactory.get('project') const dao = DaoFactory.get('project')
const stateDao = DaoFactory.get('state')
const defaultStates: Array<Partial<StateObj>> = [{
name: 'Todo',
color: '#123456',
preset: true
}, {
name: 'WIP',
color: '#123456',
preset: false
}, {
name: 'Done',
color: '#123456',
preset: false
}]
if (Astro.request.method === 'POST') { if (Astro.request.method === 'POST') {
const input = new Schema({ const input = new Schema({
@ -16,9 +31,17 @@ if (Astro.request.method === 'POST') {
const project = await dao.create({ const project = await dao.create({
name: input.name name: input.name
}) })
if (!project) {
return Astro.redirect('/?message=project+not+created')
}
for (const state of defaultStates) {
await stateDao.create({ ...state, project: project.id })
}
if (project) { if (project) {
return Astro.redirect(route('/projects/[id]', {id: project.id, message: 'project created succefully'})) return Astro.redirect(route('/projects/[id]', {id: project.id, message: 'project created succefully'}))
} }
} }
@ -36,10 +59,9 @@ if (Astro.request.method === 'POST') {
</form> </form>
<Input <Input
name="name" name="name"
data-action="/api/v1/projects" data-input="/api/v1/projects"
data-template="#projectItem" data-output="#projectItem ul inner"
data-trigger="keydown load after:100" data-trigger="keydown load after:100"
data-target="innerHTML ul"
data-multiple data-multiple
/> />

View File

@ -4,6 +4,31 @@ import Button from 'components/global/Button.astro'
import DaoFactory from 'models/DaoFactory' import DaoFactory from 'models/DaoFactory'
import route from 'route' import route from 'route'
import Input from 'components/global/Input.astro' import Input from 'components/global/Input.astro'
import Table from 'components/global/Table/Table.astro'
import Select from 'components/global/Select.astro'
import { X } from 'lucide-astro'
import { Sort } from 'models/Query'
import type { StateObj } from 'models/State'
const defaultStates: Array<StateObj> = [{
id: '',
name: 'Todo',
project: '',
color: '#123456',
preset: true
}, {
id: '',
name: 'WIP',
project: '',
color: '#123456',
preset: false
}, {
id: '',
name: 'Done',
project: '',
color: '#123456',
preset: false
}]
const id = Astro.params.id! const id = Astro.params.id!
const project = await DaoFactory.get('project').findById(id) const project = await DaoFactory.get('project').findById(id)
@ -12,22 +37,51 @@ if (!project) {
return Astro.rewrite(route('/404')) return Astro.rewrite(route('/404'))
} }
const tasks = await DaoFactory.get('issue').findAll({ const dbStates = await DaoFactory.get('state').findAll({ project: project.id, $sort: { preset: Sort.DESC } })
project: project.id console.log(dbStates)
}) const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
console.log(project, states)
--- ---
<MainLayout> <MainLayout>
<main class="container flex flex-col gap-24 justify-center items-center md:mt-6"> <main class="container flex gap-24 justify-center items-center md:mt-6">
<div>
<Input
name="displayID"
value={project.displayid}
data-trigger="input after:500"
data-input={`post:${route('/api/v1/projects/[id]', {id: project.id})}`}
data-output="run:reload"
/>
</div>
<div> <div>
<h1 class="text-6xl text-center font-bold">{project.name}</h1> <h1 class="text-6xl text-center font-bold">{project.name}</h1>
<ul <Table>
data-action={route('/api/v1/projects/[id]/issues', {id: project.id})} <thead>
data-multiple <tr>
data-template="template#issue" <th>id</th>
>{tasks.data.map((it) => (<li>{project.displayID}-{it.localid} {it.name}</li>))}</ul> <th>name</th>
<template id="issue"><li data-template="#issueTemplate" data-target="innerHTML #issue-details" data-attribute={`'${project.displayID}-{localid} {name}' data-action:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}`}></li></template> </tr>
<form data-action={route('/api/v1/projects/[id]/issues', {id: project.id})} data-method="POST" data-run="ul[data-action]"> </thead>
<tbody
data-input={route('/api/v1/projects/[id]/issues', {id: project.id})}
data-multiple
data-trigger="load"
data-output="template#issue"
></tbody>
</Table>
<template id="issue">
<tr
class="cursor-pointer"
data-output="#issueTemplate #issue-details inner"
data-input={route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}
data-attribute="data-id:{id}"
>
<td data-attribute={`${project.displayid}-{localid}`}></td>
<td data-attribute="name"></td>
</tr>
</template>
<form data-input={`post:${route('/api/v1/projects/[id]/issues', {id: project.id})}`} data-output="hyp:tbody[data-input]">
<Input name="name" label="Ajouter une Issue" placeholder="nom de la tache" /> <Input name="name" label="Ajouter une Issue" placeholder="nom de la tache" />
<Button>Ajouter</Button> <Button>Ajouter</Button>
</form> </form>
@ -35,11 +89,32 @@ const tasks = await DaoFactory.get('issue').findAll({
<div> <div>
<div id="issue-details"></div> <div id="issue-details"></div>
<template id="issueTemplate"> <template id="issueTemplate">
<h2 data-attribute="name"></h2> <form
<ul data-loop="labels"> data-trigger="keyup pointerup after:250"
<li data-attribute="this"></li> data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}`}
</ul> >
<p data-attribute="description"></p> <h2 data-attribute="name"></h2>
<Select label="progress" name="state" data-attribute="value:state" options={states.map((state) => ({ value: state.id, title: state.name}))} />
<ul>
<li data-loop="labels">
<span data-attribute="this"></span>
<X
data-params="label:{this}"
data-input={`delete:${route('/api/v1/projects/[id]/issues/[issueId]/labels', {id: project.id, issueId: '{id}'}, true)}`}
data-output="hyp:[data-id='{id}']"
/>
</li>
<li><Select
data-trigger="change"
name="label"
options={['a', 'b', 'c']}
data-output="hyp:[data-id='{id}']"
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]/labels', {id: project.id, issueId: '{id}'}, true)}`}
/></li>
</ul>
<Input type="textarea" name="description" data-attribute="description"></Input>
<button type="submit">Submit</button>
</form>
</template> </template>
</div> </div>
</main> </main>