fix: update

Signed-off-by: Avior <git@avior.me>
This commit is contained in:
2024-10-09 17:47:31 +02:00
parent 7fa18d682d
commit e38bc9b0b2
69 changed files with 3482 additions and 2071 deletions

9
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@dzeio/object-util": "^1",
"@dzeio/url-manager": "^1",
"astro": "^4",
"hyperions": "^1.0.0-beta.8",
"lucide-astro": "^0",
"pg": "^8.11.5",
"sharp": "^0",
@ -3739,6 +3740,14 @@
"node": ">=16.17.0"
}
},
"node_modules/hyperions": {
"version": "1.0.0-beta.10",
"resolved": "https://registry.npmjs.org/hyperions/-/hyperions-1.0.0-beta.10.tgz",
"integrity": "sha512-Jyx9WPQOsUfMhhzQEibsR1sCLlf2QajZjDDkhwcsIcs2eQ2P3OOoMJtk0xZR6547e1sTn+fjN666ABGN8SLMYQ==",
"dependencies": {
"@dzeio/object-util": "^1.8.3"
}
},
"node_modules/import-meta-resolve": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",

View File

@ -21,6 +21,7 @@
"@dzeio/object-util": "^1",
"@dzeio/url-manager": "^1",
"astro": "^4",
"hyperions": "^1.0.0-beta.8",
"lucide-astro": "^0",
"pg": "^8.11.5",
"sharp": "^0",

View File

@ -0,0 +1,23 @@
import SchemaItem, { type JSONSchemaItem } from '../SchemaItem'
export default class DzeioLiteral<T> extends SchemaItem<T> {
public constructor(private readonly value: T) {
super()
this.validations.push({
fn(input) {
return input === value
}
})
}
public override isOfType(input: unknown): input is T {
return typeof input === typeof this.value
}
public override toJSON(): JSONSchemaItem {
return {
type: 'literal',
params: [this.value as string]
}
}
}

View File

@ -0,0 +1,93 @@
import type { ValidationError, ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
export default class SchemaArray<A> extends SchemaItem<Array<A>> {
public constructor(
private readonly values: SchemaItem<A>
) {
super()
}
public override parse(input: unknown): A[] | unknown {
// let master handle the first pass is desired
input = super.parse(input)
if (!Array.isArray(input)) {
return input
}
const clone = []
for (const item of input) {
clone.push(this.values.parse(item))
}
return clone
}
public override validate(input: A[], fast = false): ValidationResult<A[]> {
const tmp = super.validate(input, fast)
if (tmp.error) {
return tmp
}
const clone: Array<A> = []
const errs: Array<ValidationError> = []
for (let idx = 0; idx < tmp.object.length; idx++) {
const item = tmp.object[idx];
const res = this.values.validate(item as A)
if (res.error) {
const errors = res.error.map((it) => ({
message: it.message,
field: it.field ? `${idx}.${it.field}` : idx.toString()
}))
if (fast) {
return {
error: errors
}
}
errs.push(...errors)
} else {
clone.push(res.object as A)
}
}
if (errs.length > 0) {
return {
error: errs
}
}
return {
object: clone
}
}
public override transform(input: A[]): A[] {
const clone = []
for (const item of super.transform(input)) {
clone.push(this.values.transform(item))
}
return clone
}
/**
* transform the array so it only contains one of each elements
*/
public unique(): this {
this.transforms.push((input) => input.filter((it, idx) => input.indexOf(it) === idx))
return this
}
public override isOfType(input: unknown): input is Array<A> {
return Array.isArray(input)
}
// public override toJSON(): JSONSchemaItem {
// return {
// type: 'array',
// childs: this.values
// }
// }
}

View File

@ -0,0 +1,8 @@
import SchemaItem from '../SchemaItem'
export default class SchemaBoolean extends SchemaItem<boolean> {
public override isOfType(input: unknown): input is boolean {
return typeof input === 'boolean'
}
}

View File

@ -0,0 +1,62 @@
import SchemaItem from '../SchemaItem'
export default class SchemaDate extends SchemaItem<Date> {
public parseString(): this {
this.parseActions.push((input) => typeof input === 'string' ? new Date(input) : input)
return this
}
public parseFrenchDate(): this {
this.parseActions.push((input) => {
if (typeof input !== 'string') {
return input
}
const splitted = input.split('/').map((it) => Number.parseInt(it, 10))
if (splitted.length !== 3) {
return input
}
return new Date(Date.UTC(splitted[2]!, splitted[1], splitted[0]))
})
return this
}
public min(value: Date, message?: string): this {
this.validations.push({
fn(input) {
return input >= value
},
message: message
})
return this
}
public parseFromExcelString(): this {
this.parseActions.push((input) => {
if (typeof input !== 'string') {
return input
}
const days = parseFloat(input)
const millis = days * 24 * 60 * 60 * 1000
const date = new Date('1900-01-01')
date.setTime(date.getTime() + millis)
return date
})
return this
}
public max(value: Date, message?: string): this {
this.validations.push({
fn(input) {
return input <= value
},
message: message
})
return this
}
public override isOfType(input: unknown): input is Date {
return input instanceof Date && !isNaN(input.getTime())
}
}

View File

@ -0,0 +1,21 @@
import SchemaItem from '../SchemaItem'
export default class SchemaFile extends SchemaItem<File> {
constructor () {
super()
this.parseActions.push((input) => this.isOfType(input) && input.size > 0 ? input : undefined)
}
public extension(ext: string, message?: string): this {
this.validations.push({
fn: (input) => input.name.endsWith(ext),
message
})
return this
}
public override isOfType(input: unknown): input is File {
return input instanceof File
}
}

View File

@ -0,0 +1,65 @@
import type { ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
import { isNull } from '../utils'
export default class SchemaNullable<A> extends SchemaItem<A | undefined | null> {
public constructor(private readonly item: SchemaItem<A>) {
super()
}
public emptyAsNull(): this {
this.parseActions.push((input) => {
if (typeof input === 'string' && input === '') {
return null
}
return input
})
return this
}
public falthyAsNull(): this {
this.parseActions.push((input) => {
if (!input) {
return null
}
return input
})
return this
}
public override transform(input: A | null | undefined): A | null | undefined {
const transformed = super.transform(input)
if (isNull(transformed) || isNull(input)) {
return transformed
}
return this.item.transform(input)
}
public override validate(input: A | null | undefined): ValidationResult<A | null | undefined> {
if (isNull(input)) {
return {
object: input
}
}
return this.item.validate(input)
}
public override parse(input: unknown): (A | null | undefined) | unknown {
const parsed = super.parse(input)
if (isNull(parsed) || isNull(input)) {
return parsed
}
return this.item.parse(input)
}
public override isOfType(input: unknown): input is A | undefined | null {
return isNull(input) || this.item.isOfType(input)
}
}

View File

@ -0,0 +1,89 @@
import SchemaItem from '../SchemaItem'
export default class SchemaNumber extends SchemaItem<number> {
public min(...params: Parameters<SchemaNumber['gte']>): this {
return this.gte(...params)
}
public max(...params: Parameters<SchemaNumber['lte']>): this {
return this.lte(...params)
}
/**
* validate that the number is less or equal than {@link value}
* @param value the maxumum value (inclusive)
* @param message the message sent if not valid
*/
public lte(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input <= value
},
message: message
})
return this
}
/**
* validate that the number is more or equal than {@link value}
* @param value the minimum value (inclusive)
* @param message the message sent if not valid
*/
public gte(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input >= value
},
message: message
})
return this
}
/**
* validate that the number is less than {@link value}
* @param value the maxumum value (exclusive)
* @param message the message sent if not valid
*/
public lt(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input < value
},
message: message
})
return this
}
/**
* validate that the number is more than {@link value}
* @param value the minimum value (exclusive)
* @param message the message sent if not valid
*/
public gt(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input > value
},
message: message
})
return this
}
/**
* Try to parse strings before validating
*/
public parseString(): this {
this.parseActions.push((input) =>
typeof input === 'string' ? Number.parseFloat(input) : input
)
return this
}
public override isOfType(input: unknown): input is number {
return typeof input === 'number' && !Number.isNaN(input)
}
}

View File

@ -0,0 +1,88 @@
import { isObject, objectLoop, objectRemap } from '@dzeio/object-util'
import type { ValidationError, ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
export default class SchemaRecord<A extends string | number | symbol, B> extends SchemaItem<Record<A, B>> {
public constructor(
private readonly key: SchemaItem<A>,
private readonly values: SchemaItem<B>
) {
super()
}
public override parse(input: unknown): unknown {
input = super.parse(input)
if (!this.isOfType(input)) {
return input
}
const finalObj: Record<A, B> = {} as Record<A, B>
const error = objectLoop(input, (value, key) => {
const res1 = this.key.parse(key)
const res2 = this.values.parse(value)
if (typeof res1 !== 'string' && typeof res1 !== 'number') {
return false
}
// @ts-expect-error normal behavior
finalObj[res1] = res2
return true
})
if (error) {
return input
}
return finalObj
}
public override transform(input: Record<A, B>): Record<A, B> {
return objectRemap(super.transform(input), (value, key) => {
return {
key: this.key.transform(key),
value: this.values.transform(value)
}
})
}
public override validate(input: Record<A, B>, fast = false): ValidationResult<Record<A, B>> {
const tmp = super.validate(input)
if (tmp.error) {
return tmp
}
const errs: Array<ValidationError> = []
const finalObj: Record<A, B> = {} as Record<A, B>
objectLoop(tmp.object, (value, key) => {
const res1 = this.key.validate(key)
const res2 = this.values.validate(value)
const localErrs = (res1.error ?? []).concat(...(res2.error ?? []))
if (localErrs.length > 0) {
errs.push(...localErrs.map((it) => ({
message: it.message,
field: it.field ? `${key as string}.${it.field}` : key.toString()
})))
return !fast
} else {
// @ts-expect-error the check in the if assure the typing below
finalObj[res1.object] = res2.object
}
return true
})
if (errs.length > 0) {
return {
error: errs
}
}
return {
object: finalObj
}
}
public override isOfType(input: unknown): input is Record<A, B> {
return isObject(input) && Object.prototype.toString.call(input) === '[object Object]'
}
}

View File

@ -0,0 +1,76 @@
import SchemaItem from '../SchemaItem'
import SchemaNullable from './SchemaNullable'
export default class SchemaString extends SchemaItem<string> {
/**
* force the input text to be a minimum of `value` size
* @param value the minimum length of the text
* @param message the message to display on an error
*/
public min(value: number, message?: string): SchemaString {
this.validations.push({
fn(input) {
return input.length >= value
},
message: message
})
return this
}
/**
* force the input text to be a maximum of `value` size
* @param value the maximum length of the text
* @param message the message to display on an error
*/
public max(value: number, message?: string): SchemaString {
this.validations.push({
fn(input) {
return input.length <= value
},
message: message
})
return this
}
/**
* the value must not be empty (`''`)
* @param message
* @returns
*/
public notEmpty(message?: string): this {
this.validations.push({
fn(input) {
return input !== ''
},
message: message
})
return this
}
/**
* note: this nullable MUST be used last as it change the type of the returned function
*/
public nullable() {
return new SchemaNullable(this)
}
/**
* force the input text to respect a Regexp
* @param regex the regex to validate against
* @param message the message to display on an error
*/
public regex(regex: RegExp, message?: string): SchemaString {
this.validations.push({
fn(input) {
return regex.test(input)
},
message
})
return this
}
public override isOfType(input: unknown): input is string {
return typeof input === 'string'
}
}

25
src/Schema/README.md Normal file
View File

@ -0,0 +1,25 @@
a Full featured and lightweight Schema validation/parsing library
it is meant to be used for input validation
example :
```ts
import Schema, { s, type SchemaInfer } from 'libs/Schema'
const schema = new Schema({
test: s.record(s.string(), s.object({
a: s.number().parseString().min(3, 'a is too small')
}))
})
const t = {
test: {
b: {a: '34'}
}
}
// validate that `t` is coherant with the schema above
const { object, error } = schema.validate(t)
console.log(object, error)
```

169
src/Schema/SchemaItem.ts Normal file
View File

@ -0,0 +1,169 @@
import type { ValidationResult } from '.'
import Schema from '.'
import { isNull } from './utils'
export interface Messages {
globalInvalid: string
}
/**
* An element of a schema
*/
export default abstract class SchemaItem<T> {
/**
* get additionnal attributes used to make the Schema work with outside libs
*/
public attributes: Array<string> = []
/**
* the list of validations
*/
protected validations: Array<{
fn: (input: T) => boolean
message?: string | undefined
}> = []
protected parseActions: Array<(input: unknown) => T | unknown> = []
protected transforms: Array<(input: T) => T> = []
/**
* set the list of attributes for the item of the schema
* @param attributes the attributes
*/
public attr(...attributes: Array<string>) {
this.attributes = attributes
return this
}
/**
* set the default value of the schema element
* @param value the default value
* @param strict if strict, it will use it for null/undefined, else it will check for falthy values
*/
public defaultValue(value: T, strict = true) {
this.parseActions.push((input) => {
if (strict && isNull(input)) {
return value
}
if (!value) {
return input
}
return input
})
return this
}
/**
*
* @param values the possible values the field can be
* @param message the message returned if it does not respect the value
*/
public in(values: Array<T>, message?: string) {
this.validations.push({
fn: (input) => values.includes(input),
message
})
return this
}
/**
* Try to parse the input from another format
*
* @param input the input to transform, it is done before validation, so the value can be anything
* @returns the transformed value
*/
public parse(input: unknown): T | unknown {
for (const transform of this.parseActions) {
const tmp = transform(input)
if (this.isOfType(tmp)) {
return tmp
}
}
return input
}
/**
* transform a valid value
*
* @param input the input to transform, it MUST be validated beforehand
* @returns the transformed value
*/
public transform(input: T): T {
let res = input
for (const action of this.transforms) {
res = action(res)
}
return res
}
/**
* validate that the input is valid or not
* @param input the input to validate
* @param fast if true the validation stops at the first error
* @returns a string if it's not valid, else null
*/
public validate(input: T, fast = false): ValidationResult<T> {
for (const validation of this.validations) {
if (!validation.fn(input as T)) {
return {
error: [{
message: validation.message ?? Schema.messages.globalInvalid
}]
}
}
}
return {
object: input as T
}
}
/**
* validate that the input value is of the type of the schema item
*
* it makes others functions easier to works with
* @param input the input to validate
*/
public abstract isOfType(input: unknown): input is T
// public abstract toJSON(): JSONSchemaItem
}
type Parseable = string | number | boolean
export interface ValidatorJSON {
/**
* the function name (ex: `min`, `max`)
*/
name: string
/**
* the function parameters
*/
params?: Array<Parseable>
}
export interface JSONSchemaItem {
/**
* Schema item
*
* ex: `string`, `number`, `boolean`, ...
*/
type: string
/**
* constructor params
*/
params?: Array<Parseable>
/**
* list of attributes
*/
attributes?: Array<string>
actions?: Array<ValidatorJSON>
}
export type JSONSchema = {
[a: string]: JSONSchemaItem
}

216
src/Schema/index.ts Normal file
View File

@ -0,0 +1,216 @@
import { isObject, objectLoop } from '@dzeio/object-util'
import DzeioLiteral from './Items/DzeioLiteral'
import SchemaArray from './Items/SchemaArray'
import SchemaBoolean from './Items/SchemaBoolean'
import SchemaDate from './Items/SchemaDate'
import SchemaFile from './Items/SchemaFile'
import SchemaNullable from './Items/SchemaNullable'
import SchemaNumber from './Items/SchemaNumber'
import SchemaRecord from './Items/SchemaRecord'
import SchemaString from './Items/SchemaString'
import SchemaItem from './SchemaItem'
export interface ValidationError {
message: string
field?: string
value?: unknown
}
export type ValidationResult<T> = {
object: T
error?: undefined
} | {
object?: undefined
error: Array<ValidationError>
}
export type Model = Record<string, SchemaItem<any>>
export type SchemaInfer<S extends Schema> = ModelInfer<S['model']>
export type ModelInfer<M extends Model> = {
[key in keyof M]: ReturnType<M[key]['transform']>
}
/**
* A schema to validate input or external datas
*/
export default class Schema<M extends Model = Model> extends SchemaItem<ModelInfer<Model>> {
public static messages = {
typeInvalid: 'Type of field is not valid',
notAnObject: 'the data submitted is not valid',
globalInvalid: 'the field is invalid'
}
public constructor(public readonly model: M) {
super()
}
/**
* See {@link SchemaString}
*/
public static string(
...inputs: ConstructorParameters<typeof SchemaString>
): SchemaString {
return new SchemaString(...inputs)
}
public static file(
...inputs: ConstructorParameters<typeof SchemaFile>
): SchemaFile {
return new SchemaFile(...inputs)
}
public static number(
...inputs: ConstructorParameters<typeof SchemaNumber>
): SchemaNumber {
return new SchemaNumber(...inputs)
}
public static date(
...inputs: ConstructorParameters<typeof SchemaDate>
): SchemaDate {
return new SchemaDate(...inputs)
}
public static literal<T>(
...inputs: ConstructorParameters<typeof DzeioLiteral<T>>
): DzeioLiteral<T> {
return new DzeioLiteral<T>(...inputs)
}
public static object<T extends Model>(
...inputs: ConstructorParameters<typeof Schema<T>>
): Schema<T> {
return new Schema(...inputs)
}
public static record<A extends string | number, B>(
...inputs: ConstructorParameters<typeof SchemaRecord<A, B>>
): SchemaRecord<A, B> {
return new SchemaRecord<A, B>(...inputs)
}
public static array<A>(
...inputs: ConstructorParameters<typeof SchemaArray<A>>
): SchemaArray<A> {
return new SchemaArray<A>(...inputs)
}
public static nullable<A>(
...inputs: ConstructorParameters<typeof SchemaNullable<A>>
): SchemaNullable<A> {
return new SchemaNullable<A>(...inputs)
}
public static boolean(
...inputs: ConstructorParameters<typeof SchemaBoolean>
): SchemaBoolean {
return new SchemaBoolean(...inputs)
}
/**
* @param query the URL params to validate
* @returns
*/
public validateQuery(query: URLSearchParams, fast = false): ReturnType<Schema<M>['validate']> {
const record: Record<string, unknown> = {}
for (const [key, value] of query) {
record[key] = value
}
return this.validate(record, fast)
}
/**
* @param form the form to validate
*/
public validateForm(form: HTMLFormElement, fast = false): ReturnType<Schema<M>['validate']> {
const data = new FormData(form)
return this.validateFormData(data, fast)
}
/**
* @param data the FormData to validate
* @returns
*/
public validateFormData(data: FormData, fast = false): ReturnType<Schema<M>['validate']> {
const record: Record<string, unknown> = {}
for (const [key, value] of data) {
const isArray = this.model[key]?.isOfType([]) ?? false
record[key] = isArray ? data.getAll(key) : value
}
return this.validate(record, fast)
}
/**
*
* @param input the data to validate
* @param options additionnal validation options
* @returns blablabla
*/
public override validate(input: unknown, fast = false): ValidationResult<SchemaInfer<this>> {
if (!isObject(input)) {
return {
error: [{
message: Schema.messages.notAnObject
}]
}
}
const errors: Array<ValidationError> = []
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const res: ModelInfer<M> = {} as any
objectLoop(this.model, (v, k) => {
// parse value from other formats
const value = v.parse(input[k])
// validate that the value is of type
if (!v.isOfType(value)) {
errors.push({
message: Schema.messages.typeInvalid,
field: k,
value: value
})
return !fast
}
// run validations
const invalid = v.validate(value)
if (invalid.error) {
errors.push(...invalid.error.map((it) => ({
message: it.message,
field: it.field ? `${k}.${it.field}` : k
})))
return !fast
}
// transform and assign final value
// @ts-expect-error normal behavior
res[k] = v.transform(value)
return true
})
if (errors.length > 0) {
return {
error: errors
}
}
return {
object: res
}
}
public override isOfType(input: unknown): input is ModelInfer<Model> {
return isObject(input)
}
}
/**
* alias of {@link Schema}
*/
export const s = Schema

3
src/Schema/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export function isNull(value: unknown): value is undefined | null {
return typeof value === 'undefined' || value === null
}

View File

@ -6,6 +6,7 @@ interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
block?: boolean
suffix?: string
prefix?: string
parentClass?: string
}
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix')
@ -16,7 +17,7 @@ if (baseProps.type === 'textarea') {
---
<!-- input wrapper -->
<label class:list={['parent', {'w-full': Astro.props.block}]}>
<label class:list={['parent', {'w-full': Astro.props.block}, Astro.props.parentClass]}>
{Astro.props.label && (
<div class="label">{Astro.props.label}</div>
)}
@ -26,7 +27,7 @@ if (baseProps.type === 'textarea') {
<p class="prefix">{Astro.props.prefix}</p>
)}
{Astro.props.type === 'textarea' && (
<textarea data-component="textarea" class="textarea transition-[min-height]" {...baseProps} />
<textarea data-component="textarea" class:list={["textarea transition-[min-height]", baseProps.class]} {...baseProps} />
) || (
<input {...baseProps as any} />
)}

View File

@ -0,0 +1,58 @@
import Component from 'libs/Component'
import DOMElement from 'libs/DOMElement'
export function updateSelectValues(el: HTMLInputElement, values: Array<string | number | { title: string | number, description?: string | number | null, value?: string | number, image?: string }>, options?: { clearText?: boolean } = { clearText: false }) {
const list = new DOMElement(el.parentElement?.querySelector('ul')!)
const label = el.parentElement?.parentElement!
label.dataset.component = 'select'
const name = el.dataset.name!
const displayValue = el.value
if (options.clearText && !values.includes(displayValue)) {
el.value = ''
}
list.removeChilds()
for (let item of values) {
if (typeof item !== 'object') {
item = { title: item }
}
list.appendChild(
new DOMElement('li')
.addClass('flex')
.appendChild(
new DOMElement('label')
.addClass('flex', 'gap-2')
.appendChild(
new DOMElement('input')
.attr('width', 14)
.attr('height', 14)
.attr('hidden', !el.dataset.multiple)
.attr('type', el.dataset.multiple ? 'checkbox' : 'radio')
.attr('name', name)
.attr('value', item.value ?? item.title)
.attr('checked', ''),
new DOMElement('div')
.classList('flex', 'gap-2', 'items-center')
.appendChild(
item.image ? new DOMElement('img')
.attr('width', 24)
.attr('height', 24)
.classList('rounded-full', 'w-6', 'h-6')
.attr('src', item.image) : undefined,
new DOMElement('div')
.appendChild(
new DOMElement('p')
.text(item.title.toString())
)
)
)
)
)
}
Component.load(label)
}

View File

@ -1,22 +1,38 @@
---
import { objectOmit } from '@dzeio/object-util'
import { ChevronDown, X } from 'lucide-astro'
export interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
placeholder?: string
label?: string
iconLeft?: any
/**
* clear the selected value by clicking the xross on the left of the icon
*/
clearable?: boolean
iconRight?: any
block?: boolean
suffix?: string
prefix?: string
filterable?: boolean
/**
* mutually exclusive with `autocomplete`
*/
multiple?: boolean
options: Array<string | number | {value?: string, title: string | number, description?: string | number | null}>
/**
* mutually exclusive with `multiple`
*/
autocomplete?: boolean
debug?: boolean
options?: Array<string | number | { title: string | number, description?: string | number | null, value?: string | number, image?: string }>
}
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix', 'options')
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix', 'options', 'multiple', 'debug', 'clearable')
const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.props.value?.toString()?.split(',') ?? [])
---
<!-- input wrapper -->
<label data-component="select" data-options={JSON.stringify(Astro.props.options)} class:list={['parent', {'w-full': Astro.props.block}]}>
<label data-component="select" class:list={['parent', 'select', {'w-full': Astro.props.block}]}>
{Astro.props.label && (
<div class="label">{Astro.props.label}</div>
)}
@ -25,27 +41,48 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
{Astro.props.prefix && (
<p class="prefix">{Astro.props.prefix}</p>
)}
<input readonly {...objectOmit(baseProps, 'name') as any} />
<ul class="list hidden">
{Astro.props.options.map((it) => {
{Astro.props.iconLeft && (
<Astro.props.iconLeft class="prefix" />
)}
<input data-multiple={Astro.props.multiple} data-name={Astro.props.name} readonly={!Astro.props.autocomplete} {...objectOmit(baseProps, 'name') as any} />
<ul class:list={['list hidden', {'hover:block': Astro.props.multiple}]}>
{Astro.props.options?.map((it) => {
if (typeof it !== 'object') {
it = {title: it}
}
const itemValue = it.value ?? it.title
const checked = values.includes(itemValue)
return (
<li>
<li class="flex">
<label class="flex gap-2">
<input width={14} height={14} hidden={!Astro.props.multiple} type={Astro.props.multiple ? 'checkbox' : 'radio'} name={baseProps.name} value={itemValue} checked={checked} />
<p>{it.title}</p>
{it.description && (
<p class="desc">{it.description}</p>
)}
<input width={14} height={14} hidden={!Astro.props.multiple && !Astro.props.debug} type={Astro.props.multiple ? 'checkbox' : 'radio'} name={baseProps.name} value={itemValue} checked={checked} />
<div class="flex gap-2 items-center">
{it.image && (
<img loading="lazy" src={it.image} width={24} height={24} class="rounded-full h-6 w-6" />
)}
<div>
<p>{it.title}</p>
{it.description && (
<p class="desc">{it.description}</p>
)}
</div>
</div>
</label>
</li>
)
})}
</ul>
{Astro.props.clearable && (
<label class="clearable">
<X class="prefix" />
<input hidden type="radio" name={Astro.props.name} value="" />
</label>
)}
{Astro.props.iconRight && (
<Astro.props.iconRight class="suffix" />
) || (
<ChevronDown class="suffix peer-focus:rotate-180" />
)}
{Astro.props.suffix && (
<p class="suffix">{Astro.props.suffix}</p>
)}
@ -54,18 +91,22 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
<style>
.parent {
@apply flex flex-col cursor-text gap-2
@apply flex flex-col cursor-text gap-2 min-w-9
}
.suffix, .prefix {
@apply select-none font-light text-gray-400
}
.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 dark:bg-gray-700 rounded-lg border-gray-200 dark:border-slate-900 min-h-0 border flex items-center gap-2 py-2 outline outline-0 transition-all duration-100
}
.input:focus-within {
@apply outline outline-4 outline-primary-300/50 border-primary-300
}
.input > input {
@apply w-full bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none text-black
@apply w-full bg-transparent outline-none invalid:border-red-300 placeholder:text-gray-400 placeholder:font-light focus-visible:outline-none text-black dark:text-white
}
.textarea {
@ -73,52 +114,119 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
}
.list {
@apply absolute top-full mt-2 z-10 bg-gray-50 rounded-lg border border-gray-300 overflow-hidden
@apply max-h-96 overflow-y-auto absolute top-full left-0 mt-2 z-10 w-full bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-300 overflow-hidden
}
.input > input:focus + ul, ul:hover {
.input > input:focus + ul, ul:active {
@apply block
}
ul :global(label) {
@apply px-4 py-2 cursor-pointer
}
ul :global(li) {
@apply px-4 py-2 flex flex-col gap-1 hover:bg-gray-100 cursor-pointer
@apply flex-col gap-1 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer
}
ul :global(li p) {
@apply text-gray-600
@apply text-gray-600 dark:text-gray-50
}
ul :global(li p.desc) {
@apply text-sm font-light
}
input:placeholder-shown ~ .clearable {
display: none;
}
</style>
<script>
import Component from 'libs/Component'
Component.addComponent<HTMLElement>('select', (it) => {
const displayInput = it.querySelector<HTMLInputElement>('input[readonly]')
const displayInput = it.querySelector<HTMLInputElement>('.input > input')!
const inputs = Array.from(it.querySelectorAll<HTMLInputElement>('li input'))
const entries = Array.from(it.querySelectorAll<HTMLInputElement>('li')).map((it) => ({
li: it,
input: it.querySelector('input')!,
title: it.querySelector('p')!
}))
const list = it.querySelector('ul')
if (!list || !displayInput) {
return
}
function updateSelectValue() {
const checkedValues = inputs.filter((it) => it.checked).map((it) => it.value)
displayInput.value = checkedValues.toString()
function updateSelectValue(updateText = true, blur = true) {
const checkedValues = entries.filter((it) => it.input.checked)
if (updateText) {
displayInput.value = checkedValues.map((it) => it.title.innerText).join(', ')
displayInput.dispatchEvent(new Event('change'))
if (blur) {
displayInput?.blur()
}
for (const entry of entries) {
entry.li.classList.remove('hidden')
entry.li.classList.add('flex')
}
}
}
const values = displayInput.getAttribute('value')?.split(',') ?? []
console.log('values', values)
if (values.length > 0) {
const checkbox = list.querySelector(`input[value="${value}"]`)
if (checkbox) {
checkbox.checked = true
const defaultValue = (displayInput.value ?? displayInput.getAttribute('value'))
const values = defaultValue?.split(',') ?? []
if (values.length > 0 && values[0] !== '') {
let valueText = ''
for (const value of values) {
const checkbox = entries.find((entry) => entry.input.value === value)
if (checkbox) {
checkbox.input.checked = true
valueText += checkbox.title.innerText + ','
}
}
console.log(valueText)
if (valueText) {
displayInput.value = valueText.slice(0, -1)
}
}
list.querySelectorAll('li input').forEach((listItem: HTMLInputElement) => {
console.log('')
listItem.addEventListener('change', () => {
console.log(listItem, 'changed to', listItem.checked)
updateSelectValue()
if (!displayInput.readOnly) {
displayInput.addEventListener('input', () => {
const value = displayInput.value
for (const entry of entries) {
if (entry.title.innerText.toLowerCase().includes(value.toLowerCase())) {
entry.li.classList.remove('hidden')
entry.li.classList.add('flex')
} else {
entry.li.classList.remove('flex')
entry.li.classList.add('hidden')
}
}
})
})
}
const isMultipleAndAutocomplete = !displayInput.readOnly && typeof displayInput.dataset.multiple === 'string'
if (isMultipleAndAutocomplete) {
let isSelecting = false
displayInput.addEventListener('focus', () => {
if (!isSelecting) {
displayInput.value = ''
}
})
displayInput.addEventListener('blur', () => {
updateSelectValue(!isSelecting)
})
it.addEventListener('pointerenter', () => {
isSelecting = true
})
it.addEventListener('pointerleave', () =>{
isSelecting = false
setTimeout(() => {
if (!isSelecting) {
updateSelectValue(true, false)
}
}, 200);
})
} else {
it.querySelectorAll<HTMLInputElement>('input[type="checkbox"],input[type="radio"]').forEach((listItem: HTMLInputElement) => {
listItem.addEventListener('click', () => {
updateSelectValue()
})
})
}
})
</script>

34
src/env.d.ts vendored
View File

@ -1,6 +1,7 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
/**
* Environment variables declaration
*/
@ -21,5 +22,36 @@ declare namespace App {
/**
* Middlewares variables
*/
interface Locals {}
interface Locals { }
}
declare namespace astroHTML.JSX {
interface HTMLAttributes {
'hyp:trigger'?: string
'hyp:multiple'?: boolean
'hyp:path'?: string
'hyp:action'?: string
[key?: `hyp:action${number}`]: string
// deprecated ones
'data-input'?: string
'data-output'?: string
'data-trigger'?: string
'data-path'?: string
'data-multiple'?: true
//
// TEMPLATE
//
'hyp:set'?: string
'hyp:loop'?: string
'hyp:if'?: string
'hyp:ifnot'?: string
// deprecated
'data-attribute'?: string
'data-loop'?: string
}
}

View File

@ -19,7 +19,7 @@ export interface Props extends HeadProps {
<script>
import Component from 'libs/Component'
import Hyperion from 'libs/Hyperion'
import Hyperion, { utils } from 'hyperions'
Hyperion.setup()
.on('error', ({ error }) => {
@ -28,4 +28,27 @@ import Hyperion from 'libs/Hyperion'
.on('htmlChange', ({ newElement }) => {
Component.load(newElement)
})
.addAction('clear', ({ origin, value }) => {
const item = utils.locate<HTMLFormElement>(origin, value!)
if (item) {
item.remove()
}
})
.addAction('load', ({ origin, value }) => {
const item = utils.locate<HTMLFormElement>(origin, value!)!
const formData = new FormData(item);
const res: Record<string, any> = {}
formData.forEach((value, key) => {
const multi = item.querySelector<HTMLInputElement>(`input[type][name="${key}"]`)?.type === "checkbox";
if (multi) {
res[key] = formData.getAll(key);
} else {
res[key] = value;
}
})
return {
data: res
}
})
.load()
</script>

241
src/libs/DOMElement.ts Normal file
View File

@ -0,0 +1,241 @@
type Tags = keyof HTMLElementTagNameMap
export default class DOMElement<T extends HTMLElement = HTMLElement> {
public item: T
public constructor(tagName: Tags | T, options?: ElementCreationOptions) {
if (tagName instanceof HTMLElement) {
this.item = tagName
return
}
this.item = document.createElement(tagName, options) as any
}
public static create<K extends Tags>(
tagName: K,
options?: ElementCreationOptions
): DOMElement<HTMLElementTagNameMap[K]>
public static create(
tagName: string,
options?: ElementCreationOptions
): DOMElement<HTMLElement> {
return new DOMElement(tagName as Tags, options)
}
public static get<GT extends HTMLElement = HTMLElement>(
query: string | GT,
source?: HTMLElement | DOMElement
): DOMElement<GT> | undefined {
if (!(query instanceof HTMLElement)) {
const tmp = (source instanceof DOMElement ? source.item : source || document).querySelector<GT>(query)
if (!tmp) {
return undefined
}
return new DOMElement(tmp)
}
return new DOMElement(query)
}
public static getAll<GT extends HTMLElement = HTMLElement>(
query: string,
source?: HTMLElement | DOMElement
): Array<DOMElement<GT>> | undefined {
const tmp = (source instanceof DOMElement ? source.item : source || document).querySelectorAll<GT>(query)
let items: Array<DOMElement<GT>> = []
tmp.forEach((it) => items.push(DOMElement.get(it)))
return items
}
public on<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: T, ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): this
public on(
type: string,
listener: (this: T, ev: Event) => void,
options?: boolean | AddEventListenerOptions
): this
public on(
type: string,
listener: (this: T, ev: Event) => void,
options?: boolean | AddEventListenerOptions
) {
this.item.addEventListener(type, listener, options)
return this
}
public off<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void
) {
this.item.removeEventListener(type, listener)
return this
}
public text(): string
public text(val: string): this
public text(val?: string) {
if (typeof val !== 'undefined') {
this.item.innerText = val
return this
}
return this.item.innerText
}
public html(): string
public html(val: string): this
public html(val?: string) {
if (typeof val !== 'undefined') {
this.item.innerHTML = val
return this
}
return this.item.innerHTML
}
public addClass(...classes: Array<string>) {
this.item.classList.add(...classes)
return this
}
public setClass(...classes: Array<string>) {
this.item.classList.forEach((cls) => {
if (!classes.includes(cls)) {
this.item.classList.remove(cls)
}
})
this.addClass(...classes)
return this
}
public classList(...classes: Array<string>): this
public classList(): Array<string>
public classList(...classes: Array<string>) {
if (!classes) {
const res: Array<string> = []
this.item.classList.forEach((el) => res.push(el))
return res
}
return this.setClass(...classes)
}
public toggleClass(...classes: Array<string>) {
for (const classe of classes) {
this.item.classList.toggle(classe)
}
return this
}
public removeClass(...classes: Array<string>) {
this.item.classList.remove(...classes)
return this
}
public emit<E extends keyof HTMLElementEventMap>(event: E): this
public emit(event: string): this
public emit(event: string): this {
if (event in this.item) {
(this.item as any)[event]()
return this
}
this.item.dispatchEvent(new Event(event))
return this
}
public attr(key: string): string | null
public attr(key: string, value: string | number | null): this
public attr(key: keyof T, value: boolean): this
public attr(key: string | keyof T, value?: string | number | boolean | null): this | string | null {
if (typeof value === 'undefined') {
return this.item.getAttribute(key as string)
}
if (value === null) {
this.item.removeAttribute(key as string)
return this
}
if (typeof value === 'boolean') {
this.item[key as 'draggable'] = value
return this
}
this.item.setAttribute(key as string, value.toString())
return this
}
public data(key: string): string | null
public data(key: string, value: string | null): this
public data(key: string, value?: string | null): this | string | null {
if (key in this.item.dataset) {
return this.item.dataset[key]
}
return this.attr(`data-${key}`, value)
}
public style(key: keyof CSSStyleDeclaration): CSSStyleDeclaration[typeof key]
public style(key: keyof CSSStyleDeclaration, value: CSSStyleDeclaration[typeof key]): this
public style(key: keyof CSSStyleDeclaration, value?: CSSStyleDeclaration[typeof key]) {
if (typeof value === 'undefined') {
return this.item.style[key]
}
this.item.style[key as any] = value as string
return this
}
public exist() {
return !!this.item
}
public placeBefore(item: DOMElement | HTMLElement) {
if (item instanceof DOMElement) {
item = item.item
}
const parent = item.parentElement
if (!parent) {
throw new Error('can\'t place DOMElement before item because it has no parent')
}
parent.insertBefore(this.item, item)
return this
}
public placeAsChildOf(item: DOMElement | HTMLElement) {
if (item instanceof DOMElement) {
item = item.item
}
item.appendChild(this.item)
return this
}
public place(verb: 'before' | 'asChildOf', item: DOMElement | HTMLElement) {
if (verb === 'before') {
return this.placeBefore(item)
} else {
return this.placeAsChildOf(item)
}
}
public appendChild(...items: Array<DOMElement | HTMLElement | undefined>) {
for (let item of items) {
if (typeof item === 'undefined') {
continue
}
if (item instanceof DOMElement) {
item = item.item
}
this.item.appendChild(item)
}
return this
}
public removeChilds() {
while (this.item.firstChild) {
this.item.firstChild.remove()
}
}
public remove() {
this.item.remove()
}
}

View File

@ -1,133 +0,0 @@
# 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"}
```

View File

@ -1,662 +0,0 @@
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

@ -0,0 +1,23 @@
import SchemaItem, { type JSONSchemaItem } from '../SchemaItem'
export default class DzeioLiteral<T> extends SchemaItem<T> {
public constructor(private readonly value: T) {
super()
this.validations.push({
fn(input) {
return input === value
}
})
}
public override isOfType(input: unknown): input is T {
return typeof input === typeof this.value
}
public override toJSON(): JSONSchemaItem {
return {
type: 'literal',
params: [this.value as string]
}
}
}

View File

@ -0,0 +1,93 @@
import type { ValidationError, ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
export default class SchemaArray<A> extends SchemaItem<Array<A>> {
public constructor(
private readonly values: SchemaItem<A>
) {
super()
}
public override parse(input: unknown): A[] | unknown {
// let master handle the first pass is desired
input = super.parse(input)
if (!Array.isArray(input)) {
return input
}
const clone = []
for (const item of input) {
clone.push(this.values.parse(item))
}
return clone
}
public override validate(input: A[], fast = false): ValidationResult<A[]> {
const tmp = super.validate(input, fast)
if (tmp.error) {
return tmp
}
const clone: Array<A> = []
const errs: Array<ValidationError> = []
for (let idx = 0; idx < tmp.object.length; idx++) {
const item = tmp.object[idx];
const res = this.values.validate(item as A)
if (res.error) {
const errors = res.error.map((it) => ({
message: it.message,
field: it.field ? `${idx}.${it.field}` : idx.toString()
}))
if (fast) {
return {
error: errors
}
}
errs.push(...errors)
} else {
clone.push(res.object as A)
}
}
if (errs.length > 0) {
return {
error: errs
}
}
return {
object: clone
}
}
public override transform(input: A[]): A[] {
const clone = []
for (const item of super.transform(input)) {
clone.push(this.values.transform(item))
}
return clone
}
/**
* transform the array so it only contains one of each elements
*/
public unique(): this {
this.transforms.push((input) => input.filter((it, idx) => input.indexOf(it) === idx))
return this
}
public override isOfType(input: unknown): input is Array<A> {
return Array.isArray(input)
}
// public override toJSON(): JSONSchemaItem {
// return {
// type: 'array',
// childs: this.values
// }
// }
}

View File

@ -0,0 +1,8 @@
import SchemaItem from '../SchemaItem'
export default class SchemaBoolean extends SchemaItem<boolean> {
public override isOfType(input: unknown): input is boolean {
return typeof input === 'boolean'
}
}

View File

@ -0,0 +1,62 @@
import SchemaItem from '../SchemaItem'
export default class SchemaDate extends SchemaItem<Date> {
public parseString(): this {
this.parseActions.push((input) => typeof input === 'string' ? new Date(input) : input)
return this
}
public parseFrenchDate(): this {
this.parseActions.push((input) => {
if (typeof input !== 'string') {
return input
}
const splitted = input.split('/').map((it) => Number.parseInt(it, 10))
if (splitted.length !== 3) {
return input
}
return new Date(Date.UTC(splitted[2]!, splitted[1], splitted[0]))
})
return this
}
public min(value: Date, message?: string): this {
this.validations.push({
fn(input) {
return input >= value
},
message: message
})
return this
}
public parseFromExcelString(): this {
this.parseActions.push((input) => {
if (typeof input !== 'string') {
return input
}
const days = parseFloat(input)
const millis = days * 24 * 60 * 60 * 1000
const date = new Date('1900-01-01')
date.setTime(date.getTime() + millis)
return date
})
return this
}
public max(value: Date, message?: string): this {
this.validations.push({
fn(input) {
return input <= value
},
message: message
})
return this
}
public override isOfType(input: unknown): input is Date {
return input instanceof Date && !isNaN(input.getTime())
}
}

View File

@ -0,0 +1,21 @@
import SchemaItem from '../SchemaItem'
export default class SchemaFile extends SchemaItem<File> {
constructor () {
super()
this.parseActions.push((input) => this.isOfType(input) && input.size > 0 ? input : undefined)
}
public extension(ext: string, message?: string): this {
this.validations.push({
fn: (input) => input.name.endsWith(ext),
message
})
return this
}
public override isOfType(input: unknown): input is File {
return input instanceof File
}
}

View File

@ -0,0 +1,65 @@
import type { ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
import { isNull } from '../utils'
export default class SchemaNullable<A> extends SchemaItem<A | undefined | null> {
public constructor(private readonly item: SchemaItem<A>) {
super()
}
public emptyAsNull(): this {
this.parseActions.push((input) => {
if (typeof input === 'string' && input === '') {
return null
}
return input
})
return this
}
public falthyAsNull(): this {
this.parseActions.push((input) => {
if (!input) {
return null
}
return input
})
return this
}
public override transform(input: A | null | undefined): A | null | undefined {
const transformed = super.transform(input)
if (isNull(transformed) || isNull(input)) {
return transformed
}
return this.item.transform(input)
}
public override validate(input: A | null | undefined): ValidationResult<A | null | undefined> {
if (isNull(input)) {
return {
object: input
}
}
return this.item.validate(input)
}
public override parse(input: unknown): (A | null | undefined) | unknown {
const parsed = super.parse(input)
if (isNull(parsed) || isNull(input)) {
return parsed
}
return this.item.parse(input)
}
public override isOfType(input: unknown): input is A | undefined | null {
return isNull(input) || this.item.isOfType(input)
}
}

View File

@ -0,0 +1,89 @@
import SchemaItem from '../SchemaItem'
export default class SchemaNumber extends SchemaItem<number> {
public min(...params: Parameters<SchemaNumber['gte']>): this {
return this.gte(...params)
}
public max(...params: Parameters<SchemaNumber['lte']>): this {
return this.lte(...params)
}
/**
* validate that the number is less or equal than {@link value}
* @param value the maxumum value (inclusive)
* @param message the message sent if not valid
*/
public lte(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input <= value
},
message: message
})
return this
}
/**
* validate that the number is more or equal than {@link value}
* @param value the minimum value (inclusive)
* @param message the message sent if not valid
*/
public gte(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input >= value
},
message: message
})
return this
}
/**
* validate that the number is less than {@link value}
* @param value the maxumum value (exclusive)
* @param message the message sent if not valid
*/
public lt(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input < value
},
message: message
})
return this
}
/**
* validate that the number is more than {@link value}
* @param value the minimum value (exclusive)
* @param message the message sent if not valid
*/
public gt(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input > value
},
message: message
})
return this
}
/**
* Try to parse strings before validating
*/
public parseString(): this {
this.parseActions.push((input) =>
typeof input === 'string' ? Number.parseFloat(input) : input
)
return this
}
public override isOfType(input: unknown): input is number {
return typeof input === 'number' && !Number.isNaN(input)
}
}

View File

@ -0,0 +1,88 @@
import { isObject, objectLoop, objectRemap } from '@dzeio/object-util'
import type { ValidationError, ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
export default class SchemaRecord<A extends string | number | symbol, B> extends SchemaItem<Record<A, B>> {
public constructor(
private readonly key: SchemaItem<A>,
private readonly values: SchemaItem<B>
) {
super()
}
public override parse(input: unknown): unknown {
input = super.parse(input)
if (!this.isOfType(input)) {
return input
}
const finalObj: Record<A, B> = {} as Record<A, B>
const error = objectLoop(input, (value, key) => {
const res1 = this.key.parse(key)
const res2 = this.values.parse(value)
if (typeof res1 !== 'string' && typeof res1 !== 'number') {
return false
}
// @ts-expect-error normal behavior
finalObj[res1] = res2
return true
})
if (error) {
return input
}
return finalObj
}
public override transform(input: Record<A, B>): Record<A, B> {
return objectRemap(super.transform(input), (value, key) => {
return {
key: this.key.transform(key),
value: this.values.transform(value)
}
})
}
public override validate(input: Record<A, B>, fast = false): ValidationResult<Record<A, B>> {
const tmp = super.validate(input)
if (tmp.error) {
return tmp
}
const errs: Array<ValidationError> = []
const finalObj: Record<A, B> = {} as Record<A, B>
objectLoop(tmp.object, (value, key) => {
const res1 = this.key.validate(key)
const res2 = this.values.validate(value)
const localErrs = (res1.error ?? []).concat(...(res2.error ?? []))
if (localErrs.length > 0) {
errs.push(...localErrs.map((it) => ({
message: it.message,
field: it.field ? `${key as string}.${it.field}` : key.toString()
})))
return !fast
} else {
// @ts-expect-error the check in the if assure the typing below
finalObj[res1.object] = res2.object
}
return true
})
if (errs.length > 0) {
return {
error: errs
}
}
return {
object: finalObj
}
}
public override isOfType(input: unknown): input is Record<A, B> {
return isObject(input) && Object.prototype.toString.call(input) === '[object Object]'
}
}

View File

@ -0,0 +1,76 @@
import SchemaItem from '../SchemaItem'
import SchemaNullable from './SchemaNullable'
export default class SchemaString extends SchemaItem<string> {
/**
* force the input text to be a minimum of `value` size
* @param value the minimum length of the text
* @param message the message to display on an error
*/
public min(value: number, message?: string): SchemaString {
this.validations.push({
fn(input) {
return input.length >= value
},
message: message
})
return this
}
/**
* force the input text to be a maximum of `value` size
* @param value the maximum length of the text
* @param message the message to display on an error
*/
public max(value: number, message?: string): SchemaString {
this.validations.push({
fn(input) {
return input.length <= value
},
message: message
})
return this
}
/**
* the value must not be empty (`''`)
* @param message
* @returns
*/
public notEmpty(message?: string): this {
this.validations.push({
fn(input) {
return input !== ''
},
message: message
})
return this
}
/**
* note: this nullable MUST be used last as it change the type of the returned function
*/
public nullable() {
return new SchemaNullable(this)
}
/**
* force the input text to respect a Regexp
* @param regex the regex to validate against
* @param message the message to display on an error
*/
public regex(regex: RegExp, message?: string): SchemaString {
this.validations.push({
fn(input) {
return regex.test(input)
},
message
})
return this
}
public override isOfType(input: unknown): input is string {
return typeof input === 'string'
}
}

25
src/libs/Schema/README.md Normal file
View File

@ -0,0 +1,25 @@
a Full featured and lightweight Schema validation/parsing library
it is meant to be used for input validation
example :
```ts
import Schema, { s, type SchemaInfer } from 'libs/Schema'
const schema = new Schema({
test: s.record(s.string(), s.object({
a: s.number().parseString().min(3, 'a is too small')
}))
})
const t = {
test: {
b: {a: '34'}
}
}
// validate that `t` is coherant with the schema above
const { object, error } = schema.validate(t)
console.log(object, error)
```

View File

@ -0,0 +1,169 @@
import type { ValidationResult } from '.'
import Schema from '.'
import { isNull } from './utils'
export interface Messages {
globalInvalid: string
}
/**
* An element of a schema
*/
export default abstract class SchemaItem<T> {
/**
* get additionnal attributes used to make the Schema work with outside libs
*/
public attributes: Array<string> = []
/**
* the list of validations
*/
protected validations: Array<{
fn: (input: T) => boolean
message?: string | undefined
}> = []
protected parseActions: Array<(input: unknown) => T | unknown> = []
protected transforms: Array<(input: T) => T> = []
/**
* set the list of attributes for the item of the schema
* @param attributes the attributes
*/
public attr(...attributes: Array<string>) {
this.attributes = attributes
return this
}
/**
* set the default value of the schema element
* @param value the default value
* @param strict if strict, it will use it for null/undefined, else it will check for falthy values
*/
public defaultValue(value: T, strict = true) {
this.parseActions.push((input) => {
if (strict && isNull(input)) {
return value
}
if (!value) {
return input
}
return input
})
return this
}
/**
*
* @param values the possible values the field can be
* @param message the message returned if it does not respect the value
*/
public in(values: Array<T>, message?: string) {
this.validations.push({
fn: (input) => values.includes(input),
message
})
return this
}
/**
* Try to parse the input from another format
*
* @param input the input to transform, it is done before validation, so the value can be anything
* @returns the transformed value
*/
public parse(input: unknown): T | unknown {
for (const transform of this.parseActions) {
const tmp = transform(input)
if (this.isOfType(tmp)) {
return tmp
}
}
return input
}
/**
* transform a valid value
*
* @param input the input to transform, it MUST be validated beforehand
* @returns the transformed value
*/
public transform(input: T): T {
let res = input
for (const action of this.transforms) {
res = action(res)
}
return res
}
/**
* validate that the input is valid or not
* @param input the input to validate
* @param fast if true the validation stops at the first error
* @returns a string if it's not valid, else null
*/
public validate(input: T, fast = false): ValidationResult<T> {
for (const validation of this.validations) {
if (!validation.fn(input as T)) {
return {
error: [{
message: validation.message ?? Schema.messages.globalInvalid
}]
}
}
}
return {
object: input as T
}
}
/**
* validate that the input value is of the type of the schema item
*
* it makes others functions easier to works with
* @param input the input to validate
*/
public abstract isOfType(input: unknown): input is T
// public abstract toJSON(): JSONSchemaItem
}
type Parseable = string | number | boolean
export interface ValidatorJSON {
/**
* the function name (ex: `min`, `max`)
*/
name: string
/**
* the function parameters
*/
params?: Array<Parseable>
}
export interface JSONSchemaItem {
/**
* Schema item
*
* ex: `string`, `number`, `boolean`, ...
*/
type: string
/**
* constructor params
*/
params?: Array<Parseable>
/**
* list of attributes
*/
attributes?: Array<string>
actions?: Array<ValidatorJSON>
}
export type JSONSchema = {
[a: string]: JSONSchemaItem
}

216
src/libs/Schema/index.ts Normal file
View File

@ -0,0 +1,216 @@
import { isObject, objectLoop } from '@dzeio/object-util'
import DzeioLiteral from './Items/DzeioLiteral'
import SchemaArray from './Items/SchemaArray'
import SchemaBoolean from './Items/SchemaBoolean'
import SchemaDate from './Items/SchemaDate'
import SchemaFile from './Items/SchemaFile'
import SchemaNullable from './Items/SchemaNullable'
import SchemaNumber from './Items/SchemaNumber'
import SchemaRecord from './Items/SchemaRecord'
import SchemaString from './Items/SchemaString'
import SchemaItem from './SchemaItem'
export interface ValidationError {
message: string
field?: string
value?: unknown
}
export type ValidationResult<T> = {
object: T
error?: undefined
} | {
object?: undefined
error: Array<ValidationError>
}
export type Model = Record<string, SchemaItem<any>>
export type SchemaInfer<S extends Schema> = ModelInfer<S['model']>
export type ModelInfer<M extends Model> = {
[key in keyof M]: ReturnType<M[key]['transform']>
}
/**
* A schema to validate input or external datas
*/
export default class Schema<M extends Model = Model> extends SchemaItem<ModelInfer<Model>> {
public static messages = {
typeInvalid: 'Type of field is not valid',
notAnObject: 'the data submitted is not valid',
globalInvalid: 'the field is invalid'
}
public constructor(public readonly model: M) {
super()
}
/**
* See {@link SchemaString}
*/
public static string(
...inputs: ConstructorParameters<typeof SchemaString>
): SchemaString {
return new SchemaString(...inputs)
}
public static file(
...inputs: ConstructorParameters<typeof SchemaFile>
): SchemaFile {
return new SchemaFile(...inputs)
}
public static number(
...inputs: ConstructorParameters<typeof SchemaNumber>
): SchemaNumber {
return new SchemaNumber(...inputs)
}
public static date(
...inputs: ConstructorParameters<typeof SchemaDate>
): SchemaDate {
return new SchemaDate(...inputs)
}
public static literal<T>(
...inputs: ConstructorParameters<typeof DzeioLiteral<T>>
): DzeioLiteral<T> {
return new DzeioLiteral<T>(...inputs)
}
public static object<T extends Model>(
...inputs: ConstructorParameters<typeof Schema<T>>
): Schema<T> {
return new Schema(...inputs)
}
public static record<A extends string | number, B>(
...inputs: ConstructorParameters<typeof SchemaRecord<A, B>>
): SchemaRecord<A, B> {
return new SchemaRecord<A, B>(...inputs)
}
public static array<A>(
...inputs: ConstructorParameters<typeof SchemaArray<A>>
): SchemaArray<A> {
return new SchemaArray<A>(...inputs)
}
public static nullable<A>(
...inputs: ConstructorParameters<typeof SchemaNullable<A>>
): SchemaNullable<A> {
return new SchemaNullable<A>(...inputs)
}
public static boolean(
...inputs: ConstructorParameters<typeof SchemaBoolean>
): SchemaBoolean {
return new SchemaBoolean(...inputs)
}
/**
* @param query the URL params to validate
* @returns
*/
public validateQuery(query: URLSearchParams, fast = false): ReturnType<Schema<M>['validate']> {
const record: Record<string, unknown> = {}
for (const [key, value] of query) {
record[key] = value
}
return this.validate(record, fast)
}
/**
* @param form the form to validate
*/
public validateForm(form: HTMLFormElement, fast = false): ReturnType<Schema<M>['validate']> {
const data = new FormData(form)
return this.validateFormData(data, fast)
}
/**
* @param data the FormData to validate
* @returns
*/
public validateFormData(data: FormData, fast = false): ReturnType<Schema<M>['validate']> {
const record: Record<string, unknown> = {}
for (const [key, value] of data) {
const isArray = this.model[key]?.isOfType([]) ?? false
record[key] = isArray ? data.getAll(key) : value
}
return this.validate(record, fast)
}
/**
*
* @param input the data to validate
* @param options additionnal validation options
* @returns blablabla
*/
public override validate(input: unknown, fast = false): ValidationResult<SchemaInfer<this>> {
if (!isObject(input)) {
return {
error: [{
message: Schema.messages.notAnObject
}]
}
}
const errors: Array<ValidationError> = []
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const res: ModelInfer<M> = {} as any
objectLoop(this.model, (v, k) => {
// parse value from other formats
const value = v.parse(input[k])
// validate that the value is of type
if (!v.isOfType(value)) {
errors.push({
message: Schema.messages.typeInvalid,
field: k,
value: value
})
return !fast
}
// run validations
const invalid = v.validate(value)
if (invalid.error) {
errors.push(...invalid.error.map((it) => ({
message: it.message,
field: it.field ? `${k}.${it.field}` : k
})))
return !fast
}
// transform and assign final value
// @ts-expect-error normal behavior
res[k] = v.transform(value)
return true
})
if (errors.length > 0) {
return {
error: errors
}
}
return {
object: res
}
}
public override isOfType(input: unknown): input is ModelInfer<Model> {
return isObject(input)
}
}
/**
* alias of {@link Schema}
*/
export const s = Schema

3
src/libs/Schema/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export function isNull(value: unknown): value is undefined | null {
return typeof value === 'undefined' || value === null
}

View File

@ -0,0 +1,35 @@
import { defineMiddleware } from 'astro/middleware'
import { buildRFC7807 } from 'libs/RFCs/RFC7807'
import config from 'models/config'
import route from 'route'
// `context` and `next` are automatically typed
export default defineMiddleware(async (ctx, next) => {
const url = ctx.url
const client = config.mainClient
const c = await client.get()
await c.connect()
const isMigrated = await c.isMigrated()
if (!isMigrated) {
c.migrateToLatest()
}
// check if the client is ready
if (!isMigrated && url.pathname !== '/setup') {
if (url.pathname.startsWith('/api')) {
// don't redirect but indicate it usign a RFC7807
return buildRFC7807({
title: 'Server is starting up',
status: 503,
details: 'Server is starting, please wait a bit'
})
}
// redirect user to a specific page
return ctx.redirect(route('/setup', { goto: url.pathname + url.search }))
}
if (isMigrated && url.pathname === '/setup') {
return ctx.redirect(url.searchParams.get('goto') ?? route('/'))
}
return next()
})

View File

@ -1,5 +1,6 @@
import { sequence } from "astro/middleware"
import database from "./database"
import logger from './logger'
export const onRequest = sequence(logger)
export const onRequest = sequence(logger, database)

View File

@ -3,7 +3,8 @@ import { Sort, type Query, type QueryList, type QueryValues } from 'models/Query
export declare type AllowedValues = string | number | bigint | boolean | null | undefined
export function filter<T extends object>(query: Query<T>, results: Array<T>, options?: { debug?: boolean }): Array<T> {
// eslint-disable-next-line complexity
export function filter<T extends object>(query: Query<T>, results: Array<T>, options?: { debug?: boolean }): {filtered: Array<T>, unpaginatedLength: number} {
if (options?.debug) {
console.log('Query', query)
}
@ -36,27 +37,54 @@ export function filter<T extends object>(query: Query<T>, results: Array<T>, opt
if (query.$sort) {
// temp until better solution is found
const first = objectFind(query.$sort, () => true)
filtered = filtered.sort((a, b) => {
if (first?.value === Sort.ASC) {
return (a[first!.key] ?? 0) > (b[first!.key] ?? 0) ? 1 : -1
filtered = filtered.sort((objA, objB) => {
const a = objA[first!.key]
const b = objB[first!.key]
const ascend = first?.value !== Sort.DESC
if (typeof a === 'number' && typeof b === 'number') {
if (ascend) {
return b - a
} else {
return a - b
}
}
return (a[first!.key] ?? 0) > (b[first!.key] ?? 0) ? -1 : 1
if (a instanceof Date && b instanceof Date) {
if (ascend) {
return a.getTime() - b.getTime()
} else {
return b.getTime() - a.getTime()
}
}
if (typeof a === 'string' && typeof b === 'string') {
if (ascend) {
return a.localeCompare(b)
} else {
return b.localeCompare(a)
}
}
if (ascend) {
return a > b ? 1 : -1
}
return a > b ? -1 : 1
})
}
if (options?.debug) {
console.log('postSort', filtered)
}
// length of the query assuming a single page
const unpaginatedLength = filtered.length
// limit
if (query.$offset || query.$limit) {
const offset = query.$offset ?? 0
filtered = filtered.slice(offset, offset + (query.$limit ?? 0))
filtered = filtered.slice(offset, offset + (query.$limit ?? Infinity))
}
if (options?.debug) {
console.log('postLimit', filtered)
}
return filtered
return { filtered, unpaginatedLength }
}
/**
@ -142,7 +170,7 @@ function filterItem(value: any, query: QueryValues<AllowedValues>): boolean {
}
if ('$inc' in query) {
return value.toString().includes(query.$inc)
return (value.toString() as string).toLowerCase().includes(query.$inc!.toString()!.toLowerCase())
}
if ('$eq' in query) {
@ -176,6 +204,9 @@ function filterItem(value: any, query: QueryValues<AllowedValues>): boolean {
return typeof value === 'number' && typeof comparedValue === 'number' && value <= comparedValue
}
if ('$len' in query && Array.isArray(value)) {
return value.length === query.$len
}
/**
* Logical Operators

View File

@ -0,0 +1,55 @@
import type { CSVOptions } from 'libs/FilesFormats/CSV'
import CSV from 'libs/FilesFormats/CSV'
import type Schema from 'libs/Schema'
import type { SchemaInfer } from 'libs/Schema'
import fs from 'node:fs'
import type { Query } from '../Query'
import { filter } from './AdapterUtils'
import type DaoAdapter from './DaoAdapter'
import type { DBPull } from './DaoAdapter'
export default class CSVAdapter<T extends Schema> implements DaoAdapter<T> {
private data: Array<SchemaInfer<T>>
public constructor(
public readonly schema: T,
public readonly serverPath: string,
private readonly csvOptions?: CSVOptions
) {
const data = fs.readFileSync(serverPath, 'utf-8')
this.data = CSV.parse(data, csvOptions) as Array<SchemaInfer<T>>
}
public async create(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async read(query?: Query<SchemaInfer<T>>): Promise<DBPull<T>> {
const res = filter(query ?? {}, this.data)
return {
rows: res.filtered.length,
rowsTotal: res.unpaginatedLength,
page: 1,
pageTotal: 1,
data: res.filtered
}
}
public async update(_obj: SchemaInfer<T>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async patch(_id: string, _obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async delete(obj: SchemaInfer<T>): Promise<boolean> {
throw new Error('not implemented')
}
}

View File

@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
import { types, type ArrayOrObject } from 'cassandra-driver'
import type { ArrayOrObject } from 'cassandra-driver'
import crypto from 'node:crypto'
import Client from '../Client'
import type DaoAdapter from '../DaoAdapter'
import type { DBPull } from '../DaoAdapter'
import Client from '../Clients/CassandraClient'
import { Sort, type Query } from '../Query'
import type Schema from '../Schema'
import { isSchemaItem, type Implementation, type Item, type Model } from '../Schema'
import { filter } from './AdapterUtils'
import type DaoAdapter from './DaoAdapter'
import type { DBPull } from './DaoAdapter'
export default class CassandraAdapter<T extends Model> implements DaoAdapter<T> {
@ -35,12 +35,12 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
/**
* additionnal options to make the adapter work
*/
private readonly options?: {
public readonly options: {
/**
* log the requests made to cassandra
*/
debug?: boolean
}
} = {}
) {
if (!id) {
objectLoop(schema.model, (value, key) => {
@ -174,7 +174,7 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
console.log(req, params)
}
let res: types.ResultSet | undefined
let res: Array<Record<string, any>>
try {
res = await client.execute(req.join(' '), params)
} catch (error) {
@ -192,10 +192,10 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
}
}
let dataset = res.rows
let dataset = res
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
key,
value: this.dbToValue(key, obj.get(key))
value: this.dbToValue(key, obj[key])
})))
.map((obj) => {
objectLoop(this.schema.model, (item, key) => {
@ -248,6 +248,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
// dataset = dataset.slice(page * (query?.limit ?? 0), limit)
// }
// length of the query assuming a single page
let unpaginatedLength = dataset.length
// temp modification of comportement to use the new and better query system
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
// temp fix for the sorting algorithm
@ -259,17 +261,19 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
}
}
if (query) {
dataset = filter(query, dataset, this.options)
const { filtered, unpaginatedLength: ul } = filter(query, dataset, this.options)
dataset = filtered
unpaginatedLength = ul
}
// console.log(res)
const pageLimit = query?.$limit ?? 10
const pageOffset = query?.$offset ?? 0
return {
rows: res.rows.length,
rowsTotal: res.rowLength,
page: 1,
pageTotal: 1,
// page: page,
// pageTotal: pageLimit ? res.rowLength / pageLimit : 1,
rows: dataset.length,
rowsTotal: unpaginatedLength,
page: Math.floor(pageOffset / pageLimit),
pageTotal: Math.max(1, Math.ceil(unpaginatedLength / pageLimit)),
data: dataset
}
}
@ -303,7 +307,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
const params: Array<any> = []
// remove ids
for (const tmp of this.id) {
const ids = Array.isArray(this.id) ? this.id : [this.id]
for (const tmp of ids) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete obj[tmp]
}
@ -316,8 +321,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
// filter by the ids
parts.push('WHERE')
const read: Partial<any> = {}
for (let idx = 0; idx < this.id.length; idx++) {
const key = this.id[idx] as string
for (let idx = 0; idx < ids.length; idx++) {
const key = ids[idx] as string
if (idx > 0) {
parts.push('AND')
@ -364,8 +369,15 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
const parts = ['DELETE', 'FROM', this.table, 'WHERE']
const params: ArrayOrObject = []
objectLoop(obj as {}, (value, key, idx) => {
if (idx > 0) {
objectLoop(obj as {}, (value, key) => {
let allowedWheres = ([] as Array<any>).concat(Array.isArray(this.id) ? this.id : [this.id])
if (this.partitionKeys) {
allowedWheres.push(...this.partitionKeys )
}
if (!allowedWheres.includes(key)) {
return
}
if (parts.length > 4) {
parts.push('AND')
}
parts.push(`${key}=?`)

View File

@ -1,7 +1,8 @@
import { type Query } from './Query'
import type { Implementation, Model } from './Schema'
import type Schema from 'libs/Schema'
import type { SchemaInfer } from 'libs/Schema'
import type { Query } from '../Query'
export interface DBPull<T extends Model> {
export interface DBPull<T extends Schema> {
/**
* total number of rows that are valid with the specified query
*/
@ -24,7 +25,7 @@ export interface DBPull<T extends Model> {
/**
* the data fetched
*/
data: Array<Implementation<T>>
data: Array<SchemaInfer<T>>
}
/**
@ -32,27 +33,27 @@ export interface DBPull<T extends Model> {
*
* you MUST call it through the `DaoFactory` file
*/
export default interface DaoAdapter<T extends Model> {
export default interface DaoAdapter<T extends Schema> {
/**
* create a new object in the remote source
*
* @param obj the object to create
*/
create?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
create?(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
/**
* read from the remote source
*
* @param query the query to filter/sort results
*/
read?(query?: Query<Implementation<T>>, ...args: any): Promise<DBPull<T>>
read?(query?: Query<SchemaInfer<T>>): Promise<DBPull<T>>
/**
* update an object to the remote source
*
* @param obj the object to update
*/
update?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
update?(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
/**
* depending if the object already exists or not
@ -60,7 +61,7 @@ export default interface DaoAdapter<T extends Model> {
*
* @param obj the object to insert/update
*/
upsert?(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
upsert?(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
/**
* update an object to the remote source
@ -68,11 +69,11 @@ export default interface DaoAdapter<T extends Model> {
* @param id (DEPRECATED) the ID of the object
* @param obj the object to patch (MUST include ids, and changes)
*/
patch?(id: string, obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
patch?(id: string, obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
/**
* delete an object from the source
* @param obj the object ot delete (it must at least include the id(s))
*/
delete?(obj: Partial<Implementation<T>>): Promise<boolean>
delete?(obj: Partial<SchemaInfer<T>>): Promise<boolean>
}

View File

@ -1,26 +1,26 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { objectLoop } from '@dzeio/object-util'
import archiver from 'archiver'
import fs from 'fs/promises'
import file_system from 'fs'
import type DaoAdapter from '../DaoAdapter'
import type { DBPull } from '../DaoAdapter'
import { type Query } from '../Query'
import type Schema from '../Schema'
import { isSchemaItem, type Implementation, type Model } from '../Schema'
import type Schema from 'libs/Schema'
import type { Model, ModelInfer } from 'libs/Schema'
import type SchemaBuffer from 'libs/Schema/Items/SchemaBuffer'
import type SchemaNumber from 'libs/Schema/Items/SchemaNumber'
import type SchemaString from 'libs/Schema/Items/SchemaString'
import fileSystem from 'node:fs'
import fs from 'node:fs/promises'
import type { Query } from '../Query'
import type DaoAdapter from './DaoAdapter'
import type { DBPull } from './DaoAdapter'
interface FS extends Model {
filename: StringConstructor
path: StringConstructor
filename: SchemaString
path: SchemaString
// eslint-disable-next-line no-undef
data: BufferConstructor
type: StringConstructor
size: NumberConstructor
data: SchemaBuffer
type: SchemaString
size: SchemaNumber
}
export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
private id!: string
export default class FSAdapter<T extends FS> implements DaoAdapter<Schema<T>> {
public constructor(
public readonly schema: Schema<T>,
@ -33,7 +33,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
}
// TODO: make it clearer what it does
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async create(obj: Partial<ModelInfer<T>>): Promise<ModelInfer<T> | null> {
const realPath = this.getFullPath(obj.path!)
const finalFolder = realPath.slice(0, realPath.lastIndexOf('/'))
@ -51,49 +51,16 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
} else {
await fs.writeFile(realPath, data as string)
}
return obj as Implementation<T>
return obj as ModelInfer<T>
} else {
console.log('making the final directory', realPath)
await fs.mkdir(realPath)
return obj as Implementation<T>
return obj as ModelInfer<T>
}
}
public async createZippedBufferFromDirectory(directoryPath: string) {
const archive = archiver('zip', {zlib: {level: 9}})
archive.on('error', function(err) {
throw err
})
archive.on('warning', function(err) {
if (err.code === 'ENOENT') {
console.log('warning: ', err)
} else {
throw err
}
})
const fileName = `${this.basePath}/zip/${directoryPath.split(this.basePath)[1]}.zip`
fs.mkdir(fileName.slice(0, fileName.lastIndexOf('/')), {recursive: true})
const output = file_system.createWriteStream(fileName)
archive.pipe(output)
archive.directory(directoryPath, false)
const timeout = (cb: (value: (value: unknown) => void) => void, interval: number) => () =>
new Promise((resolve) => {
setTimeout(() => cb(resolve), interval)
})
const onTimeout = (seconds: number) => timeout((resolve) =>
resolve(`Timed out while zipping ${directoryPath}`), seconds * 1000)()
const error = await Promise.race([archive.finalize(), onTimeout(60)])
if (typeof error === 'string') {
console.log('Error:', error)
return null
}
return await fs.readFile(fileName)
}
// eslint-disable-next-line complexity
public async read(query?: Query<Implementation<T>> | undefined, toZip?: boolean): Promise<DBPull<T>> {
public async read(query?: Query<ModelInfer<T>> | undefined): Promise<DBPull<Schema<T>>> {
const localPath = query?.path as string ?? ''
@ -104,38 +71,24 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
try {
const stats = await fs.stat(realPath)
const files: Array<Implementation<T>> = []
const files: Array<ModelInfer<T>> = []
if (stats.isDirectory()) {
const dirFiles = await fs.readdir(realPath)
// eslint-disable-next-line max-depth
if (toZip === true) { // put queried file/folder in a zip file
const buffer = await this.createZippedBufferFromDirectory(realPath)
// eslint-disable-next-line max-depth
if (buffer !== null) {
files.push({
path: localPath,
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
data: buffer,
type: 'file',
size: buffer.length,
} as any)
}
} else { // return every sub files
// eslint-disable-next-line max-depth
for await (const file of dirFiles) {
files.push(await this.readFile(localPath + '/' + file))
}
for await (const file of dirFiles) {
files.push(await this.readFile(`${localPath}/${file}`))
}
} else {
files.push(await this.readFile(localPath))
}
const pageLimit = query?.$limit ?? Infinity
const pageOffset = query?.$offset ?? 0
return {
rows: files.length,
rowsTotal: files.length,
page: 0,
pageTotal: 1,
data: files
page: Math.floor(pageOffset / pageLimit),
pageTotal: Math.max(1, Math.ceil(files.length / pageLimit)),
data: files.slice(pageOffset, pageOffset + pageLimit)
}
} catch {
return {
@ -148,23 +101,32 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
}
}
public async update(_obj: Implementation<T>): Promise<Implementation<T> | null> {
public async update(_obj: ModelInfer<T>): Promise<ModelInfer<T> | null> {
throw new Error('not implemented')
}
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async patch(_id: string, _obj: Partial<ModelInfer<T>>): Promise<ModelInfer<T> | null> {
throw new Error('not implemented')
}
public async delete(_obj: Implementation<T>): Promise<boolean> {
throw new Error('not implemented')
public async delete(obj: ModelInfer<T>): Promise<boolean> {
const localPath = obj?.path as string ?? ''
const realPath = this.getFullPath(localPath)
try {
await fs.stat(realPath)
await fs.rm(realPath, { recursive: true, force: true })
return true
} catch {
console.error('Could not remove file', localPath)
return false
}
}
private getFullPath(localPath?: string): string {
if (localPath && !localPath?.startsWith('/')) {
console.warn('Your path should start with a "/", adding it')
localPath = ('/' + localPath) as any
localPath = (`/${localPath}`)
}
let realPath = this.basePath + (localPath ? localPath : '')
@ -176,7 +138,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
return realPath
}
private async readFile(localPath: string): Promise<Implementation<T>> {
private async readFile(localPath: string): Promise<ModelInfer<T>> {
const path = this.getFullPath(localPath)
console.log('reading file at', path)
@ -184,7 +146,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
const type = stats.isFile() ? 'file' : 'directory'
console.log('file is a', type)
const obj: Implementation<T> = {
const obj: ModelInfer<T> = {
path: localPath,
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
data: type === 'file' ? await fs.readFile(path) : '',
@ -193,10 +155,10 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
} as any
objectLoop(this.schema.model, (item, key) => {
if (isSchemaItem(item) && item.database?.created) {
if (item.attributes.includes('db:created')) {
// @ts-expect-error things get validated anyway
obj[key] = stats.ctime
} else if (isSchemaItem(item) && item.database?.updated) {
} else if (item.attributes.includes('db:updated')) {
// @ts-expect-error things get validated anyway
obj[key] = stats.mtime
}

View File

@ -1,25 +1,30 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { objectClone, objectLoop, objectMap, objectRemap } from '@dzeio/object-util'
import { objectClone, objectLoop, objectMap, objectOmit, objectRemap } from '@dzeio/object-util'
import ldap from 'ldapjs'
import type DaoAdapter from 'models/DaoAdapter'
import type { DBPull } from 'models/DaoAdapter'
import { type Query } from 'models/Query'
import Schema, { type Implementation, type Model } from 'models/Schema'
type LDAPFields = 'uid' | 'mail' | 'givenname' | 'sn' | 'jpegPhoto' | 'password'
import type Schema from 'libs/Schema'
import type { SchemaInfer } from 'libs/Schema'
import type DaoAdapter from 'models/Adapters/DaoAdapter'
import type { DBPull } from 'models/Adapters/DaoAdapter'
import type { Query } from 'models/Query'
import { filter } from './AdapterUtils'
type LDAPFields = 'uid' | 'mail' | 'givenname' | 'sn' | 'jpegphoto' | 'password'
export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
export default class LDAPAdapter<T extends Schema> implements DaoAdapter<T> {
private reverseReference: Partial<Record<LDAPFields | string, keyof T>> = {}
private attributes: Array<LDAPFields | string> = []
public constructor(
public readonly schema: Schema<T>,
public readonly schema: T,
public readonly options: {
url: string
dnSuffix: string
adminUsername: string
adminPassword: string
fieldsCorrespondance?: Partial<Record<keyof T, LDAPFields | string>>
admin: {
dn?: string | undefined
username?: string | undefined
password: string
}
fieldsCorrespondance?: Partial<Record<keyof SchemaInfer<T>, LDAPFields | string>>
}
) {
objectLoop(options.fieldsCorrespondance ?? {}, (value, key) => {
@ -29,12 +34,12 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
}
// TODO: make it clearer what it does
public async create(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async create(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
// eslint-disable-next-line complexity
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
public async read(query?: Query<SchemaInfer<T>> | undefined): Promise<DBPull<T>> {
const passwordField = this.options.fieldsCorrespondance?.password ?? 'password'
const doLogin = !!query?.[passwordField]
@ -56,51 +61,67 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
?.filter((it) => it.slice(0, it.indexOf('=')) !== passwordField)
?.join(',')
if (!doLogin) {
const client = await this.bind(`cn=${this.options.adminUsername},${this.options.dnSuffix}`, this.options.adminPassword)
// @ts-expect-error nique ta mere
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const results = (await this.ldapFind(client, objectMap(query, (value, key) => [key as string, '', value as string])
.map((it) => ({key: it[0] as LDAPFields, value: it[2]}))!
)).map((it) => this.schema.parse(
objectRemap(it, (value, key) => ({key: this.reverseReference[key as string] as string, value: value}))
)).filter((it): it is Implementation<T> => !!it)
const bind = this.options.admin.dn ?? `cn=${this.options.admin.username},${this.options.dnSuffix}`
try {
const client = await this.bind(bind, this.options.admin.password)
// @ts-expect-error nique ta mere
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const results = (await this.ldapFind(client, objectMap(query, (value, key) => ({key: this.options.fieldsCorrespondance?.[key], value: value}))
)).map((it) => this.schema.parse(
objectRemap(it, (value, key) => ({key: this.reverseReference[key.toLowerCase() as string] as string, value: value}))
)).filter((it): it is SchemaInfer<T> => !!it)
return {
rows: results.length,
rowsTotal: results.length,
page: 1,
pageTotal: 1,
data: results
const res = filter(query, results)
return {
rows: res.filtered.length,
rowsTotal: results.length,
page: 1,
pageTotal: 1,
data: res.filtered
}
} catch {
return emptyResult
}
}
// password authentication
try {
const clone = objectClone(query)
delete clone.password
// find using admin privileges
const res = await this.read(clone)
const user = res.data[0]
if (!user) {
return emptyResult
}
const password = query.password as string ?? ''
const client = await this.bind(`cn=${user[this.reverseReference.uid as keyof typeof user]!},${this.options.dnSuffix}`, password)
const client = await this.bind(`uid=${user[this.reverseReference.uid as keyof typeof user]!},${this.options.dnSuffix}`, password)
// @ts-expect-error nique x2
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const results = (await this.ldapFind(client, objectMap(clone, (value, key) => ({key: key as keyof LDAPFields, value: value}))
)).map((it) => this.schema.parse(
objectRemap(it, (value, key) => ({key: this.reverseReference[key as string] as string, value: value}))
)).filter((it): it is Implementation<T> => !!it)
const results = (await this.ldapFind(client, objectMap(clone, (value, key) => {
const finalKey = this.options.fieldsCorrespondance?.[key]
if (results.length !== 1) {
return {key: finalKey, value: value}
})
)).map((it) => this.schema.parse(
objectRemap(it, (value, key) => ({ key: this.reverseReference[key as string] as string, value: value }))
)).filter((it): it is SchemaInfer<T> => !!it)
const final = filter(objectOmit(query, 'password'), results)
// console.log(final, query, results)
if (final.filtered.length !== 1) {
return emptyResult
}
return {
rows: results.length,
rows: final.filtered.length,
rowsTotal: results.length,
page: 1,
pageTotal: 1,
data: results
data: final.filtered
}
} catch (e) {
@ -109,15 +130,15 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
}
}
public async update(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async update(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async patch(_id: string, _obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async delete(_obj: Partial<Implementation<T>>): Promise<boolean> {
public async delete(_obj: Partial<SchemaInfer<T>>): Promise<boolean> {
throw new Error('not implemented')
}
@ -126,11 +147,13 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
url: this.options.url
})
return new Promise<ldap.Client>((res, rej) => {
console.log('binding as', dn)
client.on('connect', () => {
client.bind(dn, password, (err) => {
if (err) {
console.error('error binding as', dn, err)
client.unbind()
rej(err)
return
}
console.log('binded as', dn)
@ -152,12 +175,15 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
const firstFilter = filters.shift()!
return new Promise<Array<Record<LDAPFields, string | Array<string> | undefined>>>((res, rej) => {
const users: Array<Record<LDAPFields, string | Array<string> | undefined>> = []
const filter = {
attribute: firstFilter.key as any,
value: firstFilter.value,
}
console.log('Searching on LDAP')
client.search(
this.options.dnSuffix, {
filter: new ldap.EqualityFilter({
attribute: firstFilter.key as any,
value: firstFilter.value,
}),
filter: new ldap.EqualityFilter(filter),
// filter: `${filter.attribute}:caseExactMatch:=${filter.value}`,
scope: 'sub',
attributes: this.attributes
}, (err, search) => {
@ -184,10 +210,12 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
private parseUser(usr: ldap.SearchEntry): Record<LDAPFields, string | Array<string> | undefined> {
const user: Record<string, string | Array<string> | undefined> = { dn: usr.objectName ?? undefined }
usr.attributes.forEach((attribute) => {
user[attribute.type] =
attribute.values.length === 1 ? attribute.values[0] : attribute.values
})
for (const attribute of usr.attributes) {
user[attribute.type] = attribute.values.length === 1 ? attribute.values[0] : attribute.values
}
return user
}
}

View File

@ -1,12 +1,13 @@
import type DaoAdapter from 'models/DaoAdapter'
import Schema, { type Implementation, type Model } from 'models/Schema'
import type Schema from 'libs/Schema'
import type { SchemaInfer } from 'libs/Schema'
import type DaoAdapter from 'models/Adapters/DaoAdapter'
export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
export default class MultiAdapter<T extends Schema> implements DaoAdapter<T> {
public constructor(
public readonly schema: Schema<T>,
public readonly schema: T,
public readonly adapters: Array<{
adapter: DaoAdapter<Partial<T>>
adapter: DaoAdapter<T>
fields: Array<keyof T>
/**
* a field from the main adapter that will backreference the child adapter
@ -16,11 +17,11 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
) {}
// TODO: make it clearer what it does
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
let final: Implementation<T> = {} as any
public async create(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
let final: SchemaInfer<T> = {} as any
// start by processing the childs
for (const adapter of this.adapters.sort((a) => a.childReference ? -1 : 1)) {
const partialObject: Partial<Implementation<T>> = {}
const partialObject: Partial<SchemaInfer<T>> = {}
for (const key of adapter.fields) {
partialObject[key] = obj[key]
}
@ -34,11 +35,11 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
}
// eslint-disable-next-line complexity
// public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
// let final: Implementation<T> = {} as any
// public async read(query?: Query<SchemaInfer<T>> | undefined): Promise<DBPull<T>> {
// let final: SchemaInfer<T> = {} as any
// // start by processing the childs
// for (const adapter of this.adapters.sort((a) => a.childReference ? -1 : 1)) {
// const partialObject: Partial<Implementation<T>> = {}
// const partialObject: Partial<SchemaInfer<T>> = {}
// for (const key of adapter.fields) {
// partialObject[key] = obj[key]
// }
@ -52,16 +53,16 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
// return final
// }
public async update(_obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async update(_obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async patch(_id: string, _obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async patch(_id: string, _obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
throw new Error('not implemented')
}
public async delete(_obj: Partial<Implementation<T>>): Promise<boolean> {
public async delete(_obj: Partial<SchemaInfer<T>>): Promise<boolean> {
throw new Error('not implemented')
}

View File

@ -1,17 +1,19 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
import type Schema from 'libs/Schema'
import type { SchemaInfer } from 'libs/Schema'
import type SchemaItem from 'libs/Schema/SchemaItem'
import crypto from 'node:crypto'
import type { QueryResult } from 'pg'
import Client from '../Clients/PostgresClient'
import type DaoAdapter from '../DaoAdapter'
import type { DBPull } from '../DaoAdapter'
import PostgresClient from '../Clients/PostgresClient'
import { Sort, type Query } from '../Query'
import type Schema from '../Schema'
import { isSchemaItem, type Implementation, type Item, type Model } from '../Schema'
import { filter } from './AdapterUtils'
import type DaoAdapter from './DaoAdapter'
import type { DBPull } from './DaoAdapter'
export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
const specialKeywords = ['user', 'end'] as const
export default class PostgresAdapter<T extends Schema> implements DaoAdapter<T> {
private id: Array<string> = []
@ -19,7 +21,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
/**
* the schema used by Cassandra
*/
public readonly schema: Schema<T>,
public readonly schema: T,
/**
* the table name
*/
@ -36,43 +38,51 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
) {
objectLoop(this.schema.model, (schema, key) => {
if (!isSchemaItem(schema)) {
return
}
if (schema.database?.auto) {
if (schema.attributes.includes('db:auto')) {
this.id.push(key)
}
})
}
// TODO: make it clearer what it does
public async create(obj: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
console.log(obj)
public async create(obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
// handle automated values
objectLoop(this.schema.model, (item, key) => {
if (isSchemaItem(item) && (item.database?.created || item.database?.updated)) {
if (item.attributes.includes('db:created') || item.attributes.includes('db:updated')) {
// @ts-expect-error things get validated anyway
obj[key] = new Date()
} else if (isSchemaItem(item) && item.database?.auto && !obj[key]) {
if (item.type === String) {
} else if (item.attributes.includes('db:auto') && !obj[key]) {
if (item.isOfType('')) {
// @ts-expect-error things get validated anyway
obj[key] = crypto.randomBytes(16).toString('hex')
} else {
} else if (item.isOfType(123)) {
// @ts-expect-error things get validated anyway
obj[key] = crypto.randomBytes(16).readUint32BE()
} else {
throw new Error('cannot generate ID because it is not compatible with it')
}
}
})
const clone = this.schema.parse(obj)
if (!clone) {
// parse the data with the Schema
const { object: clone, error} = this.schema.validate(obj)
if (error) {
console.error(error)
throw new Error('Invalid data given to create the final object')
}
// prepare the database query
const keys = objectKeys(clone)
.map((it) => {
if (specialKeywords.includes(it)) { // handle the special keyword
return `"${it}"`
}
return it
})
const keysStr = keys.join(', ')
const values = keys.map((_, idx) => `$${idx+1}`).join(', ')
const req = `INSERT INTO ${this.table} (${keysStr}) VALUES (${values});`
const client = (await Client.get())!
const client = await PostgresClient.get()
const params = objectMap(clone as any, (value, key) => this.valueToDB(key as any, value))
@ -80,28 +90,31 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
console.log(req, params)
}
// send to the database
try {
await client.query(req, params)
await client.execute(req, params)
} catch (e) {
console.log(e, req, params)
return null
}
return this.schema.parse(clone)
return this.schema.validate(clone).object ?? null
}
// eslint-disable-next-line complexity
public async read(query?: Query<Implementation<T>> | undefined): Promise<DBPull<T>> {
public async read(query?: Query<SchemaInfer<T>> | undefined): Promise<DBPull<T>> {
// prepare the request to the database based on the query parameters
let req: Array<string> = ['SELECT', '*', 'FROM', this.table]
const client = (await Client.get())!
const client = await PostgresClient.get()
if (this.options?.debug) {
console.log(req)
}
let res: QueryResult<any> | undefined
// read from the database
let res: Array<Record<string, any>>
try {
res = await client.query(`${req.join(' ')}`)
res = await client.execute(`${req.join(' ')}`)
} catch (error) {
console.error('error running request')
console.error(req)
@ -117,22 +130,28 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
}
const raw = res.rows
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
key,
value: this.dbToValue(key, (obj as any)[key])
})))
.map((obj) => {
objectLoop(this.schema.model, (item, key) => {
if (Array.isArray(item) && !obj[key]) {
obj[key] = []
}
})
if (this.options?.debug) {
console.log('preEdits', res)
}
return obj
// post-process the data from the database
const raw = res
.map((obj) => {
// remap to use system value instead of db values
obj = objectRemap(this.schema.model, (_, key) => ({
key,
value: this.dbToValue(key as any, (obj as any)[key])
}))
// validate the schema
const res = this.schema.validate(obj)
if (res.object) {
return res.object
}
console.log(res.error)
return null
})
.map((it) => this.schema.parse(it))
.filter((it): it is Implementation<T> => !!it)
.filter((it): it is SchemaInfer<T> => !!it)
// temp modification of comportement to use the new and better query system
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
@ -145,13 +164,18 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
}
let dataset = raw
if (query) {
dataset = filter(query, dataset, this.options)
if (this.options?.debug) {
console.log('preFilters', dataset)
}
if (query) {
dataset = filter(query, dataset, this.options).filtered
}
// console.log(res)
return {
rows: dataset.length ?? 0,
rowsTotal: res.rowCount ?? 0,
rowsTotal: res.length ?? 0,
page: 1,
pageTotal: 1,
// page: page,
@ -160,25 +184,32 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
}
public async update(obj: Implementation<T>): Promise<Implementation<T> | null> {
public async update(obj: SchemaInfer<T>): Promise<SchemaInfer<T> | null> {
return this.patch(obj)
}
public async patch(id: Partial<Implementation<T>>): Promise<Implementation<T> | null>
public async patch(id: string, obj: Partial<Implementation<T>>): Promise<Implementation<T> | null>
public async patch(id: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
public async patch(id: string, obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
// eslint-disable-next-line complexity
public async patch(id: string | Partial<Implementation<T>>, obj?: Partial<Implementation<T>>): Promise<Implementation<T> | null> {
public async patch(id: string | Partial<SchemaInfer<T>>, obj?: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null> {
if (!obj) {
if (typeof id === 'string') {
return null
}
obj = {...id} as Partial<Implementation<T>>
obj = {...id} as Partial<SchemaInfer<T>>
}
// const tmp = this.schema.validate(obj)
// // if (tmp.error) {
// // throw new Error(`obj invalid can\'t patch ${JSON.stringify(tmp.error)}`)
// // }
// obj = tmp.object
// update the updated time
objectLoop(this.schema.model, (item, key) => {
if (isSchemaItem(item) && item.database?.updated) {
if (item.attributes.includes('db:updated')) {
// @ts-expect-error things get validated anyway
obj[key] = new Date()
}
@ -195,7 +226,13 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
// map the items to update
const keys = objectMap(obj as {}, (_, key, idx) => `${key}=$${idx+1}`)
const keys = objectMap(obj as {}, (_, key, idx) => {
if (specialKeywords.includes(key)) {
return `"${key}"=$${idx+1}`
}
return `${key}=$${idx+1}`
})
parts.push(keys.join(', '))
params.push(...objectValues(obj as {}))
@ -210,7 +247,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
parts.push(`${key}=$${params.length+1}`)
const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
read[key] = this.valueToDB(key, value)
read[key] = this.valueToDB(key as any, value)
if (!value) {
throw new Error(`Missing id (${key})`)
}
@ -218,14 +255,14 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
const req = parts.join(' ')
const client = await Client.get()
const client = await PostgresClient.get()
if (this.options?.debug) {
console.log(req, params)
}
try {
const res = await client!.query(req, params)
const res = await client!.execute(req, params)
// console.log(res, req)
if (this.options?.debug) {
console.log('post patch result', res, req)
@ -237,22 +274,34 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
return null
}
public async delete(obj: Implementation<T>): Promise<boolean> {
public async delete(obj: SchemaInfer<T>): Promise<boolean> {
const parts = ['DELETE', 'FROM', this.table, 'WHERE']
objectLoop(obj as {}, (value, key, idx) => {
if (idx > 0) {
parts.push('AND')
}
parts.push(`${key}=${value}`)
if (specialKeywords.includes(key)) {
// @ts-expect-error gnagnagna
key = `"${key}"`
}
switch (typeof value) {
case 'string':
parts.push(`${key} = '${value}'`)
break
default:
parts.push(`${key} = ${value}`)
break
}
})
const client = await Client.get()
const client = await PostgresClient.get()
if (this.options?.debug) {}
try {
await client!.query(`${parts.join(' ')}`)
await client!.execute(`${parts.join(' ')}`)
} catch (e) {
console.error(e, parts)
throw e
@ -261,30 +310,24 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
}
private valueToDB(key: keyof T, value: any): string | number | boolean | Date {
const item = this.schema.model[key] as Item
const type = isSchemaItem(item) ? item.type : item
const item: SchemaItem<unknown> = (this.schema.model as any)[key]
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
if (item.isOfType({})) {
return JSON.stringify(value)
}
if (typeof value === 'undefined' || value === null) {
return value
}
return value
}
private dbToValue(key: keyof T, value: string | number | boolean | Date): any {
const item = this.schema.model[key] as Item
const type = isSchemaItem(item) ? item.type : item
const item: SchemaItem<unknown> = (this.schema.model as any)[key]
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
return JSON.parse(value as string)
if (item.isOfType(543) && typeof value === 'string') {
return parseFloat(value)
}
if (typeof value === 'undefined' || value === null) {
return value
if (item.isOfType({}) && typeof value === 'string') {
return JSON.parse(value)
}
return value

View File

@ -0,0 +1,130 @@
import { objectRemap } from '@dzeio/object-util'
import Cassandra from 'cassandra-driver'
import { getEnv, requireEnv } from 'libs/Env'
import Client from '.'
export default class CassandraClient extends Client {
private static instance: CassandraClient | null = null
private client?: Cassandra.Client | null = null
public async getVersion(): Promise<number> {
try {
await this.execute(`USE ${requireEnv('CASSANDRA_DATABASE')}`)
} catch (e) {
// database not found
console.log('database not found', e)
return -1
}
try {
const res = await this.execute('SELECT value FROM settings WHERE id = \'db_version\'')
const value = res[0]?.value
if (value.includes('T')) {
return new Date(value).getTime()
}
return Number.parseInt(value)
} catch (e) {
// table does not exists
console.log('Settings table does not exists', e)
return -1
}
}
public override async setVersion(version: number): Promise<void> {
await this.execute(`
UPDATE settings SET value = ? WHERE id = 'db_version';
`.trim(), [version.toString()])
}
public async execute(query: string, params?: Array<unknown> | object, options?: Cassandra.QueryOptions): Promise<Array<Record<string, any>>> {
if (!this.client || this.client.getState().getConnectedHosts().length === 0) {
throw new Error('not connected to the database !')
}
const res = await this.client.execute(query, params, options)
// if (query.includes('users'))
// console.log(res)
return res.rows?.map((it) => objectRemap(it.keys(), (key: string) => ({ key: key, value: it.get(key) }))) ?? []
}
/**
* get the connexion to cassandra, it will try until it succedeed
*/
public static async get() {
const client = CassandraClient.instance ?? new CassandraClient()
CassandraClient.instance = client
return client
}
/**
* connect to Cassandra
*/
public async connect() {
if (await this.isReady()) {
return
}
console.log('connecting to cassandra')
let authProvider: Cassandra.auth.AuthProvider | undefined
const method = getEnv('CASSANDRA_AUTH_METHOD')
if (method) {
switch (method.toLowerCase()) {
case 'passwordauthenticator':
case 'plaintext':
authProvider = new Cassandra.auth.PlainTextAuthProvider(
requireEnv('CASSANDRA_USERNAME'),
requireEnv('CASSANDRA_PASSWORD')
)
break
case 'dseplaintext':
authProvider = new Cassandra.auth.DsePlainTextAuthProvider(
requireEnv('CASSANDRA_USERNAME'),
requireEnv('CASSANDRA_PASSWORD'),
getEnv('CASSANDRA_AUTHORIZATION_ID')
)
break
case 'none':
break
default:
console.error('Please use a valid CASSANDRA_AUTH_METHOD value (none|plaintext|dseplaintext)')
throw new Error('Please use a valid CASSANDRA_AUTH_METHOD value (none|plaintext|dseplaintext)')
}
}
this.client = new Cassandra.Client({
contactPoints: [requireEnv('CASSANDRA_CONTACT_POINT')],
authProvider: authProvider as Cassandra.auth.AuthProvider,
localDataCenter: getEnv('CASSANDRA_LOCAL_DATA_CENTER', 'datacenter1')
})
// this.client.on('log', (level, loggerName, message, furtherInfo) => {
// console.log(`${level} - ${loggerName}: ${message}`);
// })
try {
await this.client.connect()
} catch (e) {
this.client = null
console.error(e)
throw new Error('Error connecting to Cassandra')
}
// try {
// await Migration.migrateToLatest()
// } catch (e) {
// this.migrated = -1
// console.error(e)
// throw new Error('An error occured while migrating')
// }
// this.migrated = 1
}
public async isReady(): Promise<boolean> {
return !!this.client && this.client.getState().getConnectedHosts().length >= 1
}
}

View File

@ -1,91 +1,70 @@
import { wait } from 'libs/AsyncUtils'
import { getEnv, requireEnv } from 'libs/Env'
import pg from 'pg'
import Migration from '../Migrations'
import Client from '.'
const Postgres = pg.Client
export default class Client {
private static client?: pg.Client | null
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export default class PostgresClient extends Client {
private static instance: PostgresClient = new PostgresClient()
private client?: pg.Client | null
public override async getVersion(): Promise<number> {
try {
const res = await this.execute(`SELECT value FROM settings WHERE id = 'db_version'`)
const value = res[0]?.value
if (!value) {
return -1
}
return Number.parseInt(value)
} catch (e) {
// table does not exists
console.log('Settings table does not exists', e)
return -1
}
}
public override async setVersion(version: number): Promise<void> {
await this.execute(`UPDATE settings SET value = $1 WHERE id = 'db_version';`, [version.toString()])
}
public override async execute(query: string, params?: Array<unknown> | object, ...options: Array<any>): Promise<Array<Record<string, unknown>>> {
if (!this.client || !await this.isReady()) {
throw new Error('not connected')
}
const res = await this.client.query<Record<string, unknown>>(query, params)
return res.rows
}
public override async connect(): Promise<void> {
if (this.client) {
return
}
this.client = new Postgres({
host: requireEnv('POSTGRES_HOST'),
user: requireEnv('POSTGRES_USERNAME'),
password: requireEnv('POSTGRES_PASSWORD'),
port: parseInt(getEnv('POSTGRES_PORT', '5432')),
database: requireEnv('POSTGRES_DATABASE', 'projectmanager'),
// debug(connection, query, parameters, paramTypes) {
// console.log(`${query}, ${parameters}`);
// },
})
.on('end', () => {
this.client = null
})
try {
await this.client.connect()
} catch (e) {
this.client = null
console.error(e)
throw new Error('Error connecting to Postgres')
}
}
public override async isReady(): Promise<boolean> {
return !!this.client
}
/**
* tri state value with
* -1 not started
* 0 migrating
* 1 migrated
*/
private static migrated = -1
/**
* get the connexion to cassandra, it will try until it succedeed
*/
public static async get(skipMigrations = false) {
while (this.migrated === 0 && !skipMigrations) {
await wait(100)
}
if (this.migrated === -1) {
await this.setup()
}
return this.client
}
public static async require(skipMigrations = false) {
const client = await this.get(skipMigrations)
if (!client) {
throw new Error('Client not set but required')
}
return client
}
/**
* connect to Cassandra
*/
// eslint-disable-next-line complexity
public static async setup() {
if (this.migrated === 0) {
return this.migrated
}
if (!this.client || this.migrated === -1) {
this.migrated = 0
console.log('connecting to postgres')
this.client = new Postgres({
host: requireEnv('POSTGRES_HOST'),
user: requireEnv('POSTGRES_USERNAME'),
password: requireEnv('POSTGRES_PASSWORD'),
port: parseInt(getEnv('POSTGRES_PORT', '5432')),
database: requireEnv('POSTGRES_DATABASE', 'projectmanager'),
// debug(connection, query, parameters, paramTypes) {
// console.log(`${query}, ${parameters}`);
// },
})
try {
await this.client.connect()
} catch (e) {
this.client = null
this.migrated = -1
console.error(e)
throw new Error('Error connecting to Postgres')
}
try {
await Migration.migrateToLatest()
} catch (e) {
this.migrated = -1
console.error(e)
throw new Error('An error occured while migrating')
}
this.migrated = 1
}
return this.migrated
}
public static isReady(): boolean {
if (this.migrated === -1) {
this.setup().catch(() => {/** empty result to not crash the app */})
return false
}
if (this.migrated === 1) {
return true
}
return false
public static async get() {
return PostgresClient.instance
}
}

141
src/models/Clients/index.ts Normal file
View File

@ -0,0 +1,141 @@
import config from 'models/config'
import type MigrationObj from 'models/Migrations'
export enum ConnectionStatus {
DISCONNECTED,
MIGRATING,
READY
}
export interface ClientStatic<C extends Client = Client> {
get(): Promise<C>
}
export default abstract class Client {
public status: ConnectionStatus = ConnectionStatus.DISCONNECTED
/**
* -1: unknown
* 0: migrating
* 1: migrated
*/
public migrationStatus = -1
/**
* get the current migration version
*
* -1 nothing/error
* 0+ current migration
*/
public abstract getVersion(): Promise<number>
public abstract setVersion(version: number): Promise<void>
public abstract execute(query: string, params?: Array<unknown> | object, ...options: Array<any>): Promise<Array<Record<string, unknown>>>
public abstract connect(): Promise<void>
/**
* Migrate the database to the latest version
*/
public async migrateToLatest() {
const migrations = this.getMigrations()
const latest = migrations[migrations.length - 1]
if (!latest) {
return
}
return await this.migrateTo(latest.date)
}
public getMigrations(): ReadonlyArray<MigrationObj> {
return config.migrations as ReadonlyArray<MigrationObj>
}
/**
* migrate to a specific date in time
* @param newVersion the date to try to migrate to
*/
public async migrateTo(newVersion: number) {
if (this.migrationStatus === 0) {
return
}
this.migrationStatus = 0
let currentVersion = await this.getVersion() ?? -1
const migrations = this.getMigrations()
// same version, don't to anything
if (newVersion === currentVersion) {
this.migrationStatus = 1
return
}
console.log('\x1b[35mCurrent DB version', currentVersion, '\x1b[0m')
// run up migrations
if (currentVersion < newVersion) {
console.log('\x1b[35m', 'Migrating up to', newVersion, '\x1b[0m')
const migrationsToRun = migrations.filter((it) => it.date > currentVersion && it.date <= newVersion)
for (const migration of migrationsToRun) {
console.log('\x1b[35m', 'Migrating from', currentVersion, 'to', migration.date, '\x1b[0m')
await migration.up(this)
await this.setVersion(migration.date)
currentVersion = migration.date
}
} else if (currentVersion > newVersion) { // run down migrations
console.log('\x1b[35m', 'Migrating down to', newVersion, '\x1b[0m')
const migrationsToRun = migrations.filter((it) => it.date < currentVersion && it.date >= newVersion)
.toReversed()
for (const migration of migrationsToRun) {
console.log('\x1b[35m', 'Migrating from', currentVersion, 'to', migration.date, '\x1b[0m')
await migration.down?.(this)
await this.setVersion(migration.date)
currentVersion = migration.date
}
}
console.log('\x1b[32mDone migrating\x1b[0m')
this.migrationStatus = 1
}
// public getStatus(): Promise<ClientStatus>
// public abstract isMigrated(): Promise<boolean>
/**
* indicate if the client is ready for new requests (not if migrations are done or not)
*/
public abstract isReady(): Promise<boolean>
/**
* wait until every migrations are done or fail
*/
public async waitForMigrations(): Promise<void> {
if (this.migrationStatus === -1) {
await this.migrateToLatest()
}
while (!await this.isMigrated()) {
console.log('waiting...')
await new Promise((res) => setTimeout(res, 100))
}
}
public isMigrating(): boolean {
return this.migrationStatus === 0
}
public async isMigrated(): Promise<boolean> {
return this.migrationStatus === 1
// if (this.migrationStatus < 1) {
// return false
// } else if (this.migrationStatus === 1) {
// return
// }
// const migrations = this.getMigrations()
// const last = migrations[migrations.length - 1]
// if (!last) {
// return true
// }
// return last.date === await this.getVersion()
}
}

View File

@ -1,21 +1,20 @@
import { objectLoop, objectRemap } from '@dzeio/object-util'
import type DaoAdapter from './DaoAdapter'
import type { DBPull } from './DaoAdapter'
import { type Query } from './Query'
import type Schema from './Schema'
import { type Impl, type Implementation } from './Schema'
import type Schema from 'libs/Schema'
import type { SchemaInfer } from 'libs/Schema'
import type DaoAdapter from './Adapters/DaoAdapter'
import type { DBPull } from './Adapters/DaoAdapter'
import type { Query } from './Query'
/**
* the Dao is the object that connect the Database or source to the application layer
*
* you MUST call it through the `DaoFactory` file
*/
export default class Dao<S extends Schema<any>> {
export default class Dao<S extends Schema = Schema> {
public constructor(
public readonly schema: S,
public readonly adapter: DaoAdapter<S['model']>
) {}
public readonly adapter: DaoAdapter<S>
) { }
/**
* insert a new object into the source
@ -23,7 +22,7 @@ export default class Dao<S extends Schema<any>> {
* @param obj the object to create
* @returns the object with it's id filled if create or null otherwise
*/
public async create(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
public async create(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
if (!this.adapter.create) {
throw new Error('the Adapter does not allow you to create elements')
}
@ -36,8 +35,8 @@ export default class Dao<S extends Schema<any>> {
* @param obj the object to create
* @returns the object with it's id filled if create or null otherwise
*/
public async insert(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
return this.create(obj as any)
public async insert(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
return this.create(obj)
}
/**
@ -46,12 +45,12 @@ export default class Dao<S extends Schema<any>> {
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
* @returns an array containing the list of elements that match with the query
*/
// eslint-disable-next-line complexity
public async findAll(query?: Query<Impl<S>>, ...args: Array<any>): Promise<DBPull<S['model']>> {
public async findAll(query?: Query<SchemaInfer<S>>): Promise<DBPull<S>> {
if (!this.adapter.read) {
throw new Error('the Adapter does not allow you to read from the remote source')
}
return this.adapter.read(query as Query<Impl<S>>, ...args)
return this.adapter.read(query)
}
/**
@ -60,8 +59,8 @@ export default class Dao<S extends Schema<any>> {
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
* @returns an array containing the list of elements that match with the query
*/
public async find(query: Parameters<this['findAll']>[0], ...args: Array<any>) {
return this.findAll(query, ...args)
public async find(query: Parameters<this['findAll']>[0]) {
return this.findAll(query)
}
/**
@ -72,8 +71,8 @@ export default class Dao<S extends Schema<any>> {
* @param id the id of the object
* @returns
*/
public findById(id: any, ...args: Array<any>): Promise<Implementation<S['model']> | null> {
return this.findOne({id: id}, ...args)
public findById(id: any): Promise<SchemaInfer<S> | null> {
return this.findOne({ id: id })
}
/**
@ -94,8 +93,8 @@ export default class Dao<S extends Schema<any>> {
* @param query a partial object which filter depending on the elements, if not set it will fetch everything
* @returns the first element matching with the query or null otherwise
*/
public async findOne(query?: Parameters<this['findAll']>[0], ...args: Array<any>): Promise<Implementation<S['model']> | null> {
return (await this.findAll(query, ...args)).data[0] ?? null
public async findOne(query?: Parameters<this['findAll']>[0]): Promise<SchemaInfer<S> | null> {
return (await this.findAll(query)).data[0] ?? null
}
/**
@ -106,7 +105,7 @@ export default class Dao<S extends Schema<any>> {
* @param obj the object to update
* @returns an object if it was able to update or null otherwise
*/
public async update(obj: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
public async update(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
if (!this.adapter.update) {
throw new Error('the Adapter does not allow you to update to the remote source')
}
@ -118,13 +117,13 @@ export default class Dao<S extends Schema<any>> {
* @param id the id of the object
* @param changes the change to make
*/
public async patch(id: string, changes: Partial<Implementation<S['model']>>): Promise<Implementation<S['model']> | null> {
public async patch(id: string, changes: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
if (!this.adapter.patch) {
const query = await this.findById(id)
if (!query) {
return null
}
return await this.update({...query, ...changes})
return await this.update({ ...query, ...changes })
}
return await this.adapter.patch(id, changes)
}
@ -134,7 +133,7 @@ export default class Dao<S extends Schema<any>> {
* @param obj the object to update/insert
* @returns the object is updated/inserted or null otherwise
*/
public async upsert(object: Partial<Implementation<S['model']>>): Promise<Partial<Implementation<S['model']>> | null> {
public async upsert(object: Partial<SchemaInfer<S>>): Promise<Partial<SchemaInfer<S>> | null> {
if (!this.adapter.upsert) {
throw new Error('the Adapter does not allow you to upsert to the remote source')
}
@ -147,7 +146,7 @@ export default class Dao<S extends Schema<any>> {
*
* @returns if the object was deleted or not (if object is not in db it will return true)
*/
public async delete(obj: Partial<Implementation<S['model']>>): Promise<boolean> {
public async delete(obj: Partial<SchemaInfer<S>>): Promise<boolean> {
if (!this.adapter.delete) {
throw new Error('the Adapter does not allow you to delete on the remote source')
}

View File

@ -1,29 +1,19 @@
import PostgresAdapter from './Adapters/PostgresAdapter'
import Dao from './Dao'
import Issue from './Issue'
import Project from './Project'
import State from './State'
/**
* the different Daos that can be initialized
*
* Touch this interface to define which key is linked to which Dao
*/
interface DaoItem {
project: Dao<typeof Project>
issue: Dao<typeof Issue>
state: Dao<typeof State>
}
import type Dao from './Dao'
import config from './config'
/**
* Class to get any DAO
*/
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
export default class DaoFactory {
/**
* reference of the different Daos for a correct singleton implementation
* get the total list of daos available
* @returns return the list of daos available
*/
private static daos: Partial<DaoItem> = {}
public static getAll(): Record<string, Dao> {
return config.models
}
/**
* Get a a dao by its key
@ -33,30 +23,15 @@ export default class DaoFactory {
* @param key the dao key to get
* @returns the Dao you want as a singleton
*/
public static get<Key extends keyof DaoItem>(key: Key): DaoItem[Key] {
if (!(key in this.daos)) {
const dao = this.initDao(key)
if (!dao) {
throw new Error(`${key} has no valid Dao`)
}
this.daos[key] = dao as DaoItem[Key]
}
return this.daos[key] as DaoItem[Key]
public static get<Key extends keyof typeof config['models']>(key: Key): typeof config['models'][Key] {
return config.models[key]
}
/**
* init a dao by its key, it does not care if it exists or not
*
* @param item the element to init
* @returns a new initialized dao or undefined if no dao is linked
* get the main client linked to migrations
* @returns the main client
*/
// eslint-disable-next-line complexity
private static initDao(item: keyof DaoItem): any | undefined {
switch (item) {
case 'project': return new Dao(Project, new PostgresAdapter(Project, 'project', 'id'))
case 'issue': return new Dao(Issue, new PostgresAdapter(Issue, 'issue', 'id'))
case 'state': return new Dao(State, new PostgresAdapter(State, 'state', 'id'))
default: return undefined
}
public static async client(): ReturnType<(typeof config.mainClient)['get']> {
return config.mainClient.get()
}
}

18
src/models/History.ts Normal file
View File

@ -0,0 +1,18 @@
import Schema, { s, type SchemaInfer } from 'libs/Schema'
const schema = new Schema({
/**
* the project ID
*/
id: s.string().attr('db:unique', 'db:auto'),
issue: s.string(),
user: s.nullable(s.string()),
description: s.string().nullable(),
created: s.date().attr('db:created')
})
export default schema
export type History = SchemaInfer<typeof schema>

View File

@ -1,42 +1,42 @@
import Schema, { s, type SchemaInfer } from 'libs/Schema'
import Priority from 'models/Priority'
import Schema, { type Impl } from 'models/Schema'
const schema = new Schema({
/**
* the project ID
*/
id: {type: String, database: {unique: true, index: true, auto: true}},
localid: Number,
project: String,
id: s.string().attr('db:unique', 'db:auto'),
localid: s.number(),
project: s.string(),
/**
* the email the project was created from
*/
name: { type: String, nullable: true },
name: s.string().nullable(),
description: { type: String, nullable: true },
description: s.string().nullable(),
state: String, //state id
priority: {type: Number, defaultValue: Priority.NONE},
begin: { type: Date, nullable: true },
due: { type: Date, nullable: true },
state: s.string(), //state id
priority: s.number().defaultValue(Priority.NONE),
begin: s.nullable(s.date()),
due: s.nullable(s.date()),
/**
* Parent issue
*/
parent: { type: String, nullable: true },
parent: s.string().nullable(),
/**
* issue labels
*/
labels: [String],
labels: s.array(s.string()),
/**
* is the issue archived
*/
archived: { type: Boolean, defaultValue: false }
archived: s.boolean().defaultValue(false)
})
export default schema
export type ProjectObj = Impl<typeof schema>
export type ProjectObj = SchemaInfer<typeof schema>

View File

@ -1,22 +0,0 @@
import { type MigrationObj } from '.'
export default {
date: new Date(0),
async up(client): Promise<boolean> {
await client.query(`CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
value TEXT
);`)
await client.query(`INSERT INTO settings (id, value) VALUES ('db_created', ${new Date().getTime().toString()})`)
await client.query(`INSERT INTO settings (id, value) VALUES ('db_version', -1)`)
await client.query(`UPDATE settings SET value = ${new Date().getTime().toString()} WHERE id = 'db_created';`)
return true
},
async down(client) {
await client.query(`DROP TABLE settings`)
return true
},
} as MigrationObj

10
src/models/Migrations/Migration.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import type Client from 'models/Clients'
export default interface Migration {
/**
* timestamp in UTC
*/
date: number
up(client: Client): Promise<boolean>
down?(client: Client): Promise<boolean>
}

View File

@ -0,0 +1,22 @@
import type Migration from './Migration'
export default {
date: 0,
async up(client): Promise<boolean> {
await client.execute(`CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
value TEXT
);`)
await client.execute(`INSERT INTO settings (id, value) VALUES ('db_created', ${new Date().getTime().toString()})`)
await client.execute(`INSERT INTO settings (id, value) VALUES ('db_version', -1)`)
await client.execute(`UPDATE settings SET value = ${new Date().getTime().toString()} WHERE id = 'db_created';`)
return true
},
async down(client) {
await client.execute(`DROP TABLE settings`)
return true
},
} as Migration

View File

@ -1,9 +1,9 @@
import { type MigrationObj } from '.'
import type Migration from "./Migration"
export default {
date: new Date('2024-05-15T09:57:48'),
date: Date.UTC(2024, 5 + 1, 15, 9, 57, 48),
async up(client): Promise<boolean> {
await client.query(`CREATE TABLE IF NOT EXISTS project (
await client.execute(`CREATE TABLE IF NOT EXISTS project (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
@ -12,7 +12,7 @@ export default {
archived BOOL
);`)
await client.query(`CREATE TABLE IF NOT EXISTS state (
await client.execute(`CREATE TABLE IF NOT EXISTS state (
id TEXT PRIMARY KEY,
project TEXT,
name TEXT,
@ -20,7 +20,7 @@ export default {
preset BOOL
)`)
await client.query(`CREATE TABLE IF NOT EXISTS issue (
await client.execute(`CREATE TABLE IF NOT EXISTS issue (
id TEXT PRIMARY KEY,
localID INT,
project TEXT,
@ -38,10 +38,10 @@ export default {
return true
},
async down(client) {
await client.query(`DROP TABLE project`)
await client.query(`DROP TABLE state`)
await client.query(`DROP TABLE issue`)
await client.execute(`DROP TABLE project`)
await client.execute(`DROP TABLE state`)
await client.execute(`DROP TABLE issue`)
return true
},
} as MigrationObj
} as Migration

View File

@ -1,98 +0,0 @@
import Client from 'models/Clients/PostgresClient'
import type { Client as Postgres } from 'pg'
import migration0 from './0'
import migration20240515T95748 from './2024-05-15T9:57:48'
export interface MigrationObj {
date: Date
up(client: Postgres): Promise<boolean>
down?(client: Postgres): Promise<boolean>
}
export default abstract class CassandraMigrations {
public abstract version: number
public static async getVersion(): Promise<Date | null> {
const client = await Client.require(true)
try {
const res = await client.query(`SELECT value FROM settings WHERE id = 'db_version'`)
const date = new Date(res.rows[0]?.value)
if (isNaN(date.getTime())) {
return null
}
return date
} catch (e) {
// table does not exists
console.log('Settings table does not exists', e)
return null
}
}
/**
* Migrate the database to the latest version
*/
public static async migrateToLatest() {
const migrations = this.getMigrations()
const latest = migrations[migrations.length - 1]
return await this.migrateTo(latest!.date)
}
/**
* migrate to a specific date in time
* @param date the date to try to migrate to
*/
public static async migrateTo(date: number | Date) {
const client = await Client.require(true)
if (typeof date === 'number') {
date = new Date(date)
}
let version = await this.getVersion()
const migrations = this.getMigrations()
const time = !version ? -1 : version.getTime()
// same version, don't to anything
if (date.getTime() === time) {
return
}
console.log('Current DB version', version)
// run up migrations
if (time < date.getTime()) {
console.log('Migrating up to', date)
const migrationsToRun = migrations.filter((it) => it.date.getTime() > time && it.date.getTime() <= (date as Date).getTime())
for (const migration of migrationsToRun) {
console.log('Migrating from', version, 'to', migration.date)
await migration.up(client)
await this.setVersion(migration.date)
version = migration.date
}
} else { // run down migrations
console.log('Migrating down to', date)
const migrationsToRun = migrations.filter((it) => it.date.getTime() <= time && it.date.getTime() >= (date as Date).getTime())
.toReversed()
for (const migration of migrationsToRun) {
console.log('Migrating from', version, 'to', migration.date)
await migration.down?.(client)
await this.setVersion(migration.date)
version = migration.date
}
}
console.log('Done migrating')
}
private static async setVersion(version: Date) {
const client = await Client.require(true)
await client.query(`UPDATE settings SET value = $1 WHERE id = 'db_version';`, [version.toISOString()])
}
private static getMigrations() {
return [
migration0,
migration20240515T95748
].sort((a, b) => a.date.getTime() - b.date.getTime())
}
}

View File

@ -1,22 +1,22 @@
import Schema, { type Impl } from 'models/Schema'
import Schema, { s, type SchemaInfer } from 'libs/Schema'
const schema = new Schema({
/**
* the project ID
*/
id: {type: String, database: {unique: true, index: true, auto: true}},
id: s.string().attr('db:unique', 'db:auto'),
/**
* the email the project was created from
*/
name: { type: String, nullable: true },
name: s.string().nullable(),
description: { type: String, nullable: true },
description: s.string().nullable(),
displayid: { type: String, nullable: true },
visibility: { type: String, nullable: true },
archived: { type: Boolean, defaultValue: false }
displayid: s.string().nullable(),
visibility: s.string().nullable(),
archived: s.boolean().defaultValue(false)
})
export default schema
export type ProjectObj = Impl<typeof schema>
export type ProjectObj = SchemaInfer<typeof schema>

View File

@ -1,4 +1,4 @@
interface QueryRootFilters<Obj extends Record<string, any>> {
interface QueryRootFilters<Obj extends Record<string, unknown>> {
/**
* one of the results should be true to be true
*/
@ -108,7 +108,7 @@ export type QueryComparisonOperator<Value> = {
$inc: Value | null
}
export type QueryList<Obj extends Record<string, any>> = {
export type QueryList<Obj extends Record<string, unknown>> = {
[Key in keyof Obj]?: QueryValues<Obj[Key]>
}
@ -128,12 +128,12 @@ export type QueryValues<Value> = Value |
/**
* The query element that allows you to query different elements
*/
export type Query<Obj extends Record<string, any>> = QueryList<Obj> & QueryRootFilters<Obj>
export type Query<Obj extends Record<string, unknown>> = QueryList<Obj> & QueryRootFilters<Obj>
/**
* sorting interface with priority
*/
export type SortInterface<Obj extends Record<string, any>> = {
export type SortInterface<Obj extends Record<string, unknown>> = {
[Key in keyof Obj]?: Sort
}

View File

@ -1,588 +0,0 @@
/* eslint-disable max-depth */
import { isObject, objectClean, objectKeys, objectLoop, objectRemap } from '@dzeio/object-util'
// eslint-disable-next-line no-undef
export type DBBaseType = StringConstructor | BooleanConstructor | NumberConstructor | DateConstructor | BufferConstructor | File
// Advanced Schema item
export interface SchemaItem<T extends DBBaseType = DBBaseType> {
/**
* the field type
*/
type: T
// TODO: Infer the value type based on the `type`
/**
* set a default value when creating the row
*/
defaultValue?: Infer<T> | (() => Infer<T>)
/**
* parameters related to database management
*/
database?: {
/**
* this field is filled automatically depending on it's type to a random unique value
*/
auto?: 'uuid' | true
/**
* indicate that this field is a date field that is updated for each DB updates
*/
updated?: boolean
/**
* value set when it is created in the DB
*/
created?: boolean
/**
* must the field be unique in the DB?
*/
unique?: boolean
/**
* is the field indexed in the DB (if supported by the DB)
*/
index?: boolean
}
/**
* is the field nullable (allow additionnal values like null, undefined, '')
*/
nullable?: boolean
/***************************
* Number specific filters *
***************************/
/**
* minimum value of the field
*/
min?: number
/**
* maximum value of the field
*/
max?: number
/***************************
* String specific filters *
***************************/
/**
* minimum length of the field
*/
minLength?: number
/**
* maximum length of the field
*/
maxLength?: number
/**
* the field must correspond to the Regex below
*/
regex?: RegExp
/**
* the value MUST be in the corresponding array just like an ENUM
*/
choices?: Array<Infer<T>>
}
// the possible types an item can have as a type
type Type = DBBaseType | SchemaItem
// an item
export type Item = Type | Array<Type> | Record<string, Type> | undefined
// the schema
export type Model = Record<string, Item>
// decode the schema
type DecodeSchemaItem<T> = T extends SchemaItem ? T['type'] : T
// infer the correct type based on the final item
type StringInfer<T> = T extends StringConstructor ? string : T
type NumberInfer<T> = T extends NumberConstructor ? number : T
type BooleanInfer<T> = T extends BooleanConstructor ? boolean : T
type DateInfer<T> = T extends DateConstructor ? Date : T
type BufferInfer<T> = T extends BufferConstructor ? Buffer : T
// @ts-expect-error fuck you
type GetSchema<T extends DBBaseType | SchemaItem> = T extends SchemaItem ? T : SchemaItem<T>
type ToSchemaItem<T extends DBBaseType | SchemaItem> = GetSchema<T>
// @ts-expect-error fuck you
export type SchemaToValue<T extends SchemaItem> = Infer<T['type']> | (T['validation']['nullable'] extends true ? (T['defaultValue'] extends Infer<T['type']> ? Infer<T['type']> : undefined) : Infer<T['type']>)
// more advanced types infers
type RecordInfer<T> = T extends Record<string, Type> ? Implementation<T> : T
type ArrayInfer<T> = T extends Array<Type> ? Array<Infer<T[0]>> : T
// Infer the final type for each elements
type Infer<T> = ArrayInfer<RecordInfer<StringInfer<NumberInfer<BooleanInfer<DateInfer<BufferInfer<DecodeSchemaItem<T>>>>>>>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
// @ts-ignore fuck you
type ImplementationItem<I extends Item> = I extends (DBBaseType | SchemaItem) ? SchemaToValue<ToSchemaItem<I>> : Infer<I>
// the final implementation for the front user
export type Implementation<S extends Model> = {
[key in keyof S]: ImplementationItem<S[key]>
}
/**
* shortcut type for model implementation
*/
export type Impl<S extends Schema<any>> = Implementation<S['model']>
/**
* validate a SchemaItem
* @param value the object to validate
* @returns if the object is a SchemaItem or not
*/
export function isSchemaItem(value: any): value is SchemaItem {
if (!isObject(value) || !('type' in value)) {
return false
}
const type = value.type
if (typeof type !== 'function') {
return false
}
// TODO: Add more checks
return true
}
export default class Schema<M extends Model> {
public constructor(
public readonly model: M,
private options?: {
/**
* show debug informations
*/
debug?: boolean
/**
* force the Schema parser to shut up (even warnings!!!)
*/
quiet?: boolean
}
) {}
/**
* validate the data imported
*
* WARNING: no modifications are done to the `data` object so `defaultValue` is not applied
* if you need it applied use `parse`
*
* @param data the data to validate
* @returns if data is compatible with the schema or not
*/
public validate(data: any): data is Implementation<M> {
if (!isObject(data)) {
this.log(data, 'is not an object')
return false
}
// validate that the data length is the same as the model length
const modelKeys = objectKeys(this.model)
const dataKeys = objectKeys(data)
if (dataKeys.length > modelKeys.length) {
this.log('It seems there is an excess amount of items in the data, unknown keys:', dataKeys.filter((key) => !modelKeys.includes(key as string)))
return false
}
return objectLoop(this.model, (item, field) => {
this.log('Validating', field)
return this.validateItem(item, data[field])
})
}
/**
* parse an object to be exactly compatible with the schema
* @param input the input object
* @returns an object containin ONLY the elements defined by the schema
*/
public parse(input: any): Implementation<M> | null {
// console.log(input)
if (!isObject(input)) {
return null
}
const data: Implementation<M> = {} as any
const res = objectLoop(this.model, (item, field) => {
const value = input[field]
try {
(data as any)[field] = this.parseItem(item, value)
} catch (e) {
this.warn(`the field "${field}" could not validate against "${value}"`)
return false
}
return true
})
// console.log('result', res)
if (!res) {
return null
}
return data
}
public parseQuery(query: URLSearchParams): Implementation<M> | null {
const data: Implementation<M> = {} as any
const res = objectLoop(this.model, (item, field) => {
// TODO: Handle search query arrays
const value = query.get(field)
try {
(data as any)[field] = this.parseItem(item, value)
} catch {
this.warn(`the field ${field} could not validate against ${value}`)
return false
}
return true
})
// console.log('result', res)
if (!res) {
return null
}
return data
}
public parseForm(form: HTMLFormElement): Implementation<M> | null {
const data: Implementation<M> = {} as any
const res = objectLoop(this.model, (item, field) => {
// if (Array.isArray(item) || typeof item === 'object' && !isSchemaItem(item)) {
// this.warn('idk what this check does')
// return false
// }
const value = form[field]?.value
try {
(data as any)[field] = this.parseItem(item, value)
} catch {
this.warn(`the field ${field} could not validate against ${value}`)
return false
}
return true
})
// console.log('result', res)
if (!res) {
return null
}
return data
}
public parseFormData(form: FormData): Implementation<M> | null {
const data: Implementation<M> = {} as any
const res = objectLoop(this.model, (item, field) => {
// if (Array.isArray(item) || typeof item === 'object' && !isSchemaItem(item)) {
// this.warn('idk what this check does')
// return false
// }
const value = form.get(field) ?? null
try {
(data as any)[field] = this.parseItem(item, value)
} catch {
this.warn(`the field ${field} could not validate against ${value}`)
return false
}
return true
})
// console.log('result', res)
if (!res) {
return null
}
objectClean(data, {deep: false, cleanNull: true})
return data
}
/**
* Try to parse the value based on the SchemaItem
*
* @throws {Error} if the value is not parseable
* @param schemaItem the schema for a specific value
* @param value the value to parse
* @returns the value parsed
*/
// eslint-disable-next-line complexity
public parseItem<T extends Item>(schemaItem: T, value: any): ImplementationItem<T> {
const isSchema = isSchemaItem(schemaItem)
if (isSchema) {
value = this.convertEmptyStringToNull(value)
value = this.parseStringifiedItem(schemaItem, value)
}
// parse an array
if (Array.isArray(schemaItem)) {
const item = schemaItem[0]
if (!item || schemaItem.length !== 1) {
throw new Error('Array does not have a child')
}
if (isSchemaItem(item)) {
value = this.convertEmptyStringToNull(value)
value = this.parseStringifiedItem(item, value, true)
}
return this.parseArray(item, value) as any
// parse an object
} else if (!isSchema && isObject(schemaItem)) {
return this.parseObject(schemaItem, value) as any
}
// set the default value is necessary
if (isSchema && !this.isNull(schemaItem.defaultValue) && this.isNull(value)) {
value = typeof schemaItem.defaultValue === 'function' ? schemaItem.defaultValue() : schemaItem.defaultValue as any
}
if (this.validateItem(schemaItem, value)) {
return value as any
}
throw new Error(`Could not parse item ${schemaItem}`)
}
private convertEmptyStringToNull(value: any) {
if (value === '') {
return null
}
return value
}
private parseStringifiedItem(item: SchemaItem, value: any, fromArray?: boolean): any {
if (typeof value !== 'string' || value === '') {
return value
}
if (fromArray) {
const split = value.split(',')
return split.map((val) => this.parseStringifiedItem(item, val))
}
switch (item.type) {
case Number:
return Number.parseInt(value, 10)
case Boolean:
return value.toLowerCase() === 'true'
case Date:
return new Date(Date.parse(value))
default:
return value
}
}
private parseObject<T extends Item>(schema: T, values: any): Record<string, ImplementationItem<T>> {
// validate that the values are an object
if (!isObject(values)) {
throw new Error('value is not an object')
}
// remap values based on them
return objectRemap(values, (value, key) => ({
key: key as string,
// @ts-expect-error f*ck you
value: this.parseItem(schema[key], value)
}))
}
private parseArray<T extends Item>(schema: T, values: any): Array<ImplementationItem<T>> {
const isSchema = isSchemaItem(schema)
// set the default value if necessary
if (isSchema && !this.isNull(schema.defaultValue) && this.isNull(values)) {
values = typeof schema.defaultValue === 'function' ? schema.defaultValue() : schema.defaultValue as any
}
// if values is nullable and is null
if (isSchema && schema.nullable && this.isNull(values)) {
return values as any
}
// the values are not an array
if (!Array.isArray(values)) {
throw new Error('value is not an array')
}
// map the values to their parsed values
return values.map((it) => this.parseItem(schema, it))
}
/**
* check if the value is null
*
* @param value the value to check
* @returns {boolean} if the value is null or undefined
*/
private isNull(value: any): value is undefined | null {
return typeof value === 'undefined' || value === null
}
/**
* Validate a specific value based on it's schema
*
* @param item the item schema
* @param value the value to validate
* @returns if the value is valid or not
*/
// eslint-disable-next-line complexity
private validateItem<T extends Item>(item: T, value: any): value is Infer<T> {
// the schema item is an array
if (Array.isArray(item)) {
this.log(item, 'is an array')
if (isSchemaItem(item[0]) && item[0].nullable && this.isNull(value)) {
this.log(value, 'is not an array but is null')
return true
}
if (!Array.isArray(value)) {
this.log(value, 'is not an array')
return false
}
for (const valueValue of value) {
if (item.length !== 1) {
this.warn('It seems that your item is not properly using the correct size', item)
return false
}
if (!this.validateItem(item[0] as Type, valueValue)) {
this.log(item[0], 'is invalid')
return false
}
}
this.log(item[0], 'is valid')
return true
} else if (typeof item === 'object') {
// object is a Record of objects OR is a SchemaItem
this.log(item, 'is an object')
if (!isSchemaItem(item)) {
this.log(item, 'is a Record of items')
for (const [valueKey, val] of Object.entries(item)) {
if (!value || typeof value !== 'object' || !(valueKey in value) || !this.validateItem(val, value[valueKey])) {
this.log(valueKey, 'is invalid')
return false
}
}
this.log(item, 'is valid')
return true
}
// this.log(item, 'is a schema item')
} else {
// this.log(item, 'should be a primitive')
}
const scheme = this.toSchemaItem(item as Type)
this.log(value, scheme.nullable)
if (scheme.nullable && this.isNull(value)) {
this.log(value, 'is valid as a null item')
return true
}
if (scheme.choices && !scheme.choices.includes(value)) {
this.log(value, 'is invalid as it is not in choices', scheme.choices)
return false
}
const valueType = typeof value
// item is a primitive
switch ((scheme.type as any).name) {
case 'Boolean':
if (valueType !== 'boolean') {
this.log(value, 'is not a boolean')
return false
}
break
case 'Number':
if (typeof value !== 'number') {
this.log(value, 'is not a a number')
return false
}
// number specific filters
if (
scheme.max && value > scheme.max ||
scheme.min && value < scheme.min
) {
this.log(value, 'does not respect the min/max', scheme.min, scheme.max)
return false
}
break
case 'String':
if (typeof value !== 'string') {
this.log(value, 'is not a a string')
return false
}
if (scheme.regex && !scheme.regex.test(value)) {
this.log(value, 'does not respect Regex', scheme.regex.toString())
return false
}
if (
scheme.maxLength && value.length > scheme.maxLength ||
scheme.minLength && value.length < scheme.minLength
) {
this.log(value, 'does not respect specified min/max lengths', scheme.minLength, scheme.maxLength)
return false
}
break
case 'Date':
if (!(value instanceof Date)) {
this.log(value, 'is not a Date')
return false
}
// check if the date is valid
if (isNaN(value.getTime())) {
this.log(value, 'is not a valid date (NaN)')
return false
}
break
default:
this.warn(item, 'does not match the Schema definition, please take a look at it')
return false
}
this.log(value, 'is valid')
return true
}
private toSchemaItem(item: Type): SchemaItem {
if (isSchemaItem(item)) {
return item
}
return {
type: item
}
}
private log(...msg: Array<any>) {
if (this.options?.debug) {
console.log(...msg)
}
}
private warn(...msg: Array<any>) {
if (!this.options?.quiet) {
console.warn(...msg)
}
}
}

View File

@ -1,22 +1,22 @@
import Schema, { type Impl } from 'models/Schema'
import Schema, { s, type SchemaInfer } from 'libs/Schema'
const schema = new Schema({
/**
* the project ID
*/
id: {type: String, database: {unique: true, index: true, auto: true}},
project: String, // project id
id: s.string().attr('db:unique', 'db:auto'),
project: s.string(), // project id
/**
* the email the project was created from
*/
name: { type: String, nullable: true },
name: s.string().nullable(),
color: { type: String, nullable: true },
color: s.string().nullable(),
preset: { type: Boolean, defaultValue: false }
preset: s.boolean().defaultValue(false)
})
export default schema
export type StateObj = Impl<typeof schema>
export type StateObj = SchemaInfer<typeof schema>

49
src/models/config.ts Normal file
View File

@ -0,0 +1,49 @@
import type { ClientStatic } from './Clients'
import PostgresClient from './Clients/PostgresClient'
import Dao from './Dao'
import Project from './Project'
import PostgresAdapter from './Adapters/PostgresAdapter'
import History from './History'
import Issue from './Issue'
import type Migration from './Migrations/Migration'
import Migration0 from './Migrations/Migration0'
import Migration20240515T95748 from './Migrations/Migration2024-05-15T9-57-48'
import State from './State'
// @ts-ignore
interface Config {
mainClient: ClientStatic
models: Record<string, Dao>
migrations: Array<Migration>
}
const config = {
mainClient: PostgresClient as ClientStatic<PostgresClient>,
migrations: ([
Migration0,
Migration20240515T95748,
{
date: Date.UTC(2024, 10, 4),
async up(client) {
await client.execute(`CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
description TEXT,
issue TEXT,
"user" TEXT,
created DATE
)`)
}
}
] as Array<Migration>).sort((a, b) => a.date - b.date),
models: {
project: new Dao(Project, new PostgresAdapter(Project, 'project')),
issue: new Dao(Issue, new PostgresAdapter(Issue, 'issue')),
state: new Dao(State, new PostgresAdapter(State, 'state')),
history: new Dao(History, new PostgresAdapter(History, 'history'))
}
} as const
export default config

View File

@ -1,4 +1,4 @@
import { objectOmit } from '@dzeio/object-util'
import { objectMap, objectOmit } from '@dzeio/object-util'
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory'
@ -9,14 +9,26 @@ export const GET: APIRoute = async (ctx) => {
const dao = DaoFactory.get('issue')
return new ResponseBuilder()
.body((await dao.findOne({project: projectId, id: taskId})))
.body((await dao.findOne({ project: projectId, id: taskId })))
.build()
}
export const POST: APIRoute = async (ctx) => {
const history = DaoFactory.get('history')
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})
const req = await dao.get(taskId)
const res = await dao.patch(taskId, { id: taskId, ...body })
await history.create({
user: '',
description: objectMap(body, (value, key) => {
if (req![key as 'id'] === value) {
return null
}
return `${String(key)} changed to ${value}`
}).filter((it) => !!it).join('\n'),
issue: taskId
})
return new ResponseBuilder()
.body(res)

View File

@ -0,0 +1,19 @@
import type { APIRoute } from 'astro'
import ResponseBuilder from 'libs/ResponseBuilder'
import DaoFactory from 'models/DaoFactory'
export const GET: APIRoute = async (ctx) => {
const projectId = ctx.params.id!
const taskId = ctx.params.issueId!
const dao = DaoFactory.get('issue')
const historyDao = DaoFactory.get('history')
const task = (await dao.findOne({ project: projectId, id: taskId }))
const history = await historyDao.findAll({ issue: taskId })
return new ResponseBuilder()
.body({
issue: task,
history: history.data
})
.build()
}

View File

@ -8,20 +8,37 @@ export const POST: APIRoute = async (ctx) => {
const projectId = ctx.params.id!
const dao = DaoFactory.get('issue')
const stateDao = DaoFactory.get('state')
const issueCount = await dao.findAll({
let { rows: issueCount } = await dao.findAll({
project: projectId
})
const defaultState = await stateDao.findOne({
project: projectId,
preset: true
})
const res = await dao.create({
...(await ctx.request.json()),
project: projectId,
localid: issueCount.rows + 1,
state: defaultState?.id ?? 'empty',
labels: []
})
const json = await ctx.request.json()
const isMultiple = json.multiple
delete json.multiple
let tasks: Array<string> = [json.name]
if (isMultiple) {
tasks = json.name.split('\n')
}
delete json.name
const res: Array<any> = []
for (const task of tasks) {
res.push(await dao.create({
...json,
name: task,
project: projectId,
localid: issueCount++,
state: defaultState?.id ?? 'empty',
labels: []
}))
}
return new ResponseBuilder().body(res).build()
}
@ -31,13 +48,14 @@ export const GET: APIRoute = async (ctx) => {
const dao = DaoFactory.get('issue')
const stateDao = DaoFactory.get('state')
const states = (await stateDao.findAll({ project: projectId })).data
const issues = (await dao.findAll({project: projectId, $sort: { [sort]: Sort.ASC}})).data
const issues = (await dao.findAll({ project: projectId, $sort: { [sort]: Sort.ASC } })).data
const res = issues.map((it) => ({
...it,
state: states.find((st) => st.id === it.state),
priority: getPriorityText(it.priority ?? Priority.NONE)
}))
return new ResponseBuilder()
.body(res)
.build()

View File

@ -4,7 +4,7 @@ import Input from 'components/global/Input.astro'
import Select from 'components/global/Select/index.astro'
import MainLayout from 'layouts/MainLayout.astro'
import DaoFactory from 'models/DaoFactory'
import Schema from 'models/Schema'
import Schema, { s } from 'libs/Schema'
import type { StateObj } from 'models/State'
import route from 'route'
@ -26,7 +26,7 @@ const defaultStates: Array<Partial<StateObj>> = [{
if (Astro.request.method === 'POST') {
const input = new Schema({
name: String
name: s.string()
}).parseFormData(await Astro.request.formData())
if (input) {
const project = await dao.create({
@ -62,13 +62,14 @@ const projects = await dao.findAll()
</form>
<form action="">
<Select
multiple
options={projects.data.map((it) => ({value: it.id, title: it.name}))}
autocomplete
name="name"
data-input="/api/v1/projects"
data-output="#projectItem ul#results inner"
data-trigger="keydown load after:100"
data-multiple
options={projects.data.map((it) => ({value: it.id, title: it.name}))}
hyp:action1="/api/v1/projects"
hyp:action2="#projectItem ul#results inner"
hyp:trigger="load after:100"
hyp:multiple
data-debug
/>
</form>
@ -83,9 +84,3 @@ const projects = await dao.findAll()
</li>
</template>
</MainLayout>
<script>
import Hyperion from 'libs/Hyperion'
Hyperion.setup()
</script>

View File

@ -44,18 +44,19 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
<MainLayout>
<main class="container gap-24 md:mt-6">
<!-- <div class="w-1/3">
<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> -->
<h1 class="text-6xl text-center font-bold">{project.name}</h1>
<Select data-trigger="change" data-output="hyp:tbody[data-input]" name="sort" options={[{value: 'localid', title: 'ID'}, {value: 'state', title: 'State'}]} />
<form
id="form"
>
<Select
name="sort"
hyp:trigger="change"
hyp:action="hyp:tbody[hyp\\:action0]"
options={[{value: 'localid', title: 'ID'}, {value: 'state', title: 'State'}]}
/>
</form>
<Table class="table-fixed">
<thead>
<tr>
<th class="w-1/12">id</th>
@ -67,17 +68,18 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
</tr>
</thead>
<tbody
data-input={route('/api/v1/projects/[id]/issues', {id: project.id})}
data-multiple
data-trigger="load"
data-output="template#issue"
hyp:action0="load:form#form"
hyp:action1={route('/api/v1/projects/[id]/issues', {id: project.id})}
hyp:action2="template#issue"
hyp:multiple
hyp:trigger="load"
></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-input={route('/api/v1/projects/[id]/issues/[issueId]/details', {id: project.id, issueId: '{id}'}, true)}
data-attribute="data-id:{id}"
>
<td data-attribute={`${project.displayid}-{localid}`}></td>
@ -95,47 +97,64 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
</tr>
</template>
<form data-input={`post:${route('/api/v1/projects/[id]/issues', {id: project.id})}`} data-output="hyp:tbody[data-input]">
<form data-input={`post:${route('/api/v1/projects/[id]/issues', {id: project.id})}`} data-output="hyp:tbody[hyp\\:action1]">
<Input name="name" label="Ajouter une Issue" placeholder="nom de la tache" />
<Input style="display: none" type="textarea" name="name" label="Ajouter une Issue" placeholder="nom de la tache" />
<label class="flex gap-2">
<input type="checkbox" name="multiple" id="switch-item" />
<p>Batch add</p>
</label>
<Button>Ajouter</Button>
</form>
<div class="absolute top-0 bg-gray-100 dark:bg-gray-900 h-screen z-20 2xl:w-5/12 xl:w-1/2 md:w-2/3 w-full transition-[right] 2xl:-right-5/12 xl:-right-1/2 md:-right-2/3 -right-full has-[#issue-details>form]:right-0">
<div class="flex gap-4 justify-end px-4 pt-4">
<EllipsisVertical class="cursor-pointer" />
<X class="issue-close cursor-pointer" />
</div>
<div class="fixed overflow-auto top-0 bg-gray-100 dark:bg-gray-900 h-screen z-20 2xl:w-5/12 xl:w-1/2 md:w-2/3 w-full transition-[right] 2xl:-right-5/12 xl:-right-1/2 md:-right-2/3 -right-full has-[#issue-details>form]:right-0">
<div id="issue-details"></div>
</div>
<template id="issueTemplate">
<form
data-trigger="keyup after:250"
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}`}
data-output="hyp:tbody[data-input]"
data-trigger="pointerup keyup after:250"
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{issue.id}'}, true)}`}
data-output="hyp:tbody[hyp\\:action1]"
class="flex flex-col gap-5 px-6 py-2"
>
<p>
<span data-attribute={`${project.displayid}-{localid}`}></span>
<span class="text-2xl" data-attribute="name"></span>
</p>
<Input data-attribute="value:name" name="name" />
<Input type="textarea" name="description" data-attribute="description"></Input>
<div class="flex gap-4 justify-end pt-4 items-center">
<span data-attribute={`${project.displayid}-{issue.localid}`}></span>
<div class="flex-grow" data-attribute="issue.name"></div>
<label>
<input type="checkbox" />
<EllipsisVertical class="cursor-pointer" />
</label>
<X class="issue-close cursor-pointer" hyp:action="clear:#issue-details > *" />
</div>
<Input data-attribute="value:issue.name" name="name" />
<Input type="textarea" name="description" data-attribute="issue.description"></Input>
<div class="flex flex-col gap-4">
<div class="flex gap-4 items-center"><p>State</p><Select name="state" data-attribute="value:state" options={states.map((state) => ({ value: state.id, title: state.name}))} /></div>
<div class="flex gap-4 items-center"><p>Priority</p><Select name="priority" data-attribute="value:priority" options={getPriorities().map((priority: Priority) => ({ value: priority, title: getPriorityText(priority)}))} /></div>
<div class="flex gap-4 items-center">
<p>State</p>
<Select
name="state"
data-attribute="value:issue.state"
autocomplete
options={states.map((state) => ({ value: state.id, title: state.name}))}
/>
</div>
<div class="flex gap-4 items-center"><p>Priority</p><Select
name="priority"
data-attribute="value:issue.priority"
value={Priority.NONE}
autocomplete
options={getPriorities().map((priority: Priority) => ({ value: priority.toString(), title: getPriorityText(priority)}))}
/></div>
<div class="flex gap-4 items-center"><p>Labels</p><Select
data-trigger="change"
name="label"
multiple
name="labels"
options={['a', 'b', 'c']}
data-output="hyp:[data-id='{id}']"
data-attribute="value:labels"
multiple
data-attribute="value:issue.labels"
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]/labels', {id: project.id, issueId: '{id}'}, true)}`}
/></div>
<ul class="flex gap-2 flex-row">
<li data-loop="labels" class="group flex gap-2 bg-slate-700 px-2 py-px rounded-full items-center">
<li data-loop="issue.labels" class="group flex gap-2 bg-slate-700 px-2 py-px rounded-full items-center">
<span data-attribute="this"></span>
<X
class="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
@ -149,6 +168,15 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
<p class="pl-2 text-lg text-center">History</p>
<ul class="border-l flex flex-col gap-2">
<li data-loop="history" class="py-2 px-4 bg-white dark:bg-gray-800 rounded-xl mb-2 mx-2">
<p class="flex justify-between">
<span data-attribute="this.user"></span>
<span data-attribute="this.created"></span>
</p>
<p class="italic" data-attribute="this.description"></p>
</li>
</ul>
<!-- <ul class="border-l flex flex-col gap-2">
<li class="py-2 px-4 bg-white dark:bg-gray-800 rounded-xl mb-2 mx-2">
<p class="flex justify-between">
<span>Username</span>
@ -157,27 +185,34 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
<p class="italic">changed state from "Todo" to "WIP"</p>
</li>
<li class="py-2 px-4 border-2 border-gray-50 rounded-xl mb-2 mx-2">
<li class="py-2 px-4 border-2 bg-white border-gray-50 rounded-xl mb-2 mx-2">
<p class="font-bold flex justify-between">
<span>Username</span>
<span>2024-06-09 14:45:58</span>
</p>
<p>Il faudrait voir pour changer la valeur de l'élément, cela fait un peu chelou dans l'état actuel...</p>
</li>
</ul>
</ul> -->
<Input type="textarea" label="Commentaire" />
</form>
</template>
</main>
</MainLayout>
<script>
const x = document.querySelector('.issue-close')
const details = document.querySelector('#issue-details')
x?.addEventListener('click', () => {
while (details?.firstElementChild) {
details?.firstElementChild.remove()
// swap element
const item = document.querySelector<HTMLInputElement>('#switch-item')
const input = document.querySelector<HTMLInputElement>('input[name="name"]')
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="name"]')
item?.addEventListener('click', () => {
if (item.checked) {
input!.name = ''
input!.style.display = 'none'
textarea!.style.display = ''
} else {
input!.name = 'name'
textarea!.name = ''
input!.style.display = ''
textarea!.style.display = 'none'
}
})
</script>

View File

@ -62,9 +62,3 @@ console.log(res)
<li data-attribute="this"></li>
</template>
</MainLayout>
<script>
import Hyperion from 'libs/Hyperion'
Hyperion.setup()
</script>