generated from avior/template-web-astro
9
package-lock.json
generated
9
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@dzeio/object-util": "^1",
|
"@dzeio/object-util": "^1",
|
||||||
"@dzeio/url-manager": "^1",
|
"@dzeio/url-manager": "^1",
|
||||||
"astro": "^4",
|
"astro": "^4",
|
||||||
|
"hyperions": "^1.0.0-beta.8",
|
||||||
"lucide-astro": "^0",
|
"lucide-astro": "^0",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"sharp": "^0",
|
"sharp": "^0",
|
||||||
@ -3739,6 +3740,14 @@
|
|||||||
"node": ">=16.17.0"
|
"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": {
|
"node_modules/import-meta-resolve": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"@dzeio/object-util": "^1",
|
"@dzeio/object-util": "^1",
|
||||||
"@dzeio/url-manager": "^1",
|
"@dzeio/url-manager": "^1",
|
||||||
"astro": "^4",
|
"astro": "^4",
|
||||||
|
"hyperions": "^1.0.0-beta.8",
|
||||||
"lucide-astro": "^0",
|
"lucide-astro": "^0",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"sharp": "^0",
|
"sharp": "^0",
|
||||||
|
23
src/Schema/Items/DzeioLiteral.ts
Normal file
23
src/Schema/Items/DzeioLiteral.ts
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
src/Schema/Items/SchemaArray.ts
Normal file
93
src/Schema/Items/SchemaArray.ts
Normal 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
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
8
src/Schema/Items/SchemaBoolean.ts
Normal file
8
src/Schema/Items/SchemaBoolean.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
62
src/Schema/Items/SchemaDate.ts
Normal file
62
src/Schema/Items/SchemaDate.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
21
src/Schema/Items/SchemaFile.ts
Normal file
21
src/Schema/Items/SchemaFile.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
65
src/Schema/Items/SchemaNullable.ts
Normal file
65
src/Schema/Items/SchemaNullable.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
89
src/Schema/Items/SchemaNumber.ts
Normal file
89
src/Schema/Items/SchemaNumber.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
88
src/Schema/Items/SchemaRecord.ts
Normal file
88
src/Schema/Items/SchemaRecord.ts
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
76
src/Schema/Items/SchemaString.ts
Normal file
76
src/Schema/Items/SchemaString.ts
Normal 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
25
src/Schema/README.md
Normal 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
169
src/Schema/SchemaItem.ts
Normal 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
216
src/Schema/index.ts
Normal 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
3
src/Schema/utils.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function isNull(value: unknown): value is undefined | null {
|
||||||
|
return typeof value === 'undefined' || value === null
|
||||||
|
}
|
@ -6,6 +6,7 @@ interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
|
|||||||
block?: boolean
|
block?: boolean
|
||||||
suffix?: string
|
suffix?: string
|
||||||
prefix?: string
|
prefix?: string
|
||||||
|
parentClass?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix')
|
const baseProps = objectOmit(Astro.props, 'label', 'block', 'suffix', 'prefix')
|
||||||
@ -16,7 +17,7 @@ if (baseProps.type === 'textarea') {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!-- input wrapper -->
|
<!-- 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 && (
|
{Astro.props.label && (
|
||||||
<div class="label">{Astro.props.label}</div>
|
<div class="label">{Astro.props.label}</div>
|
||||||
)}
|
)}
|
||||||
@ -26,7 +27,7 @@ if (baseProps.type === 'textarea') {
|
|||||||
<p class="prefix">{Astro.props.prefix}</p>
|
<p class="prefix">{Astro.props.prefix}</p>
|
||||||
)}
|
)}
|
||||||
{Astro.props.type === 'textarea' && (
|
{Astro.props.type === 'textarea' && (
|
||||||
<textarea 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} />
|
<input {...baseProps as any} />
|
||||||
)}
|
)}
|
||||||
|
58
src/components/global/Select/SelectController.ts
Normal file
58
src/components/global/Select/SelectController.ts
Normal 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)
|
||||||
|
|
||||||
|
}
|
@ -1,22 +1,38 @@
|
|||||||
---
|
---
|
||||||
import { objectOmit } from '@dzeio/object-util'
|
import { objectOmit } from '@dzeio/object-util'
|
||||||
|
import { ChevronDown, X } from 'lucide-astro'
|
||||||
|
|
||||||
export interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
|
export interface Props extends Omit<astroHTML.JSX.InputHTMLAttributes, 'type'> {
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
label?: 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
|
block?: boolean
|
||||||
suffix?: string
|
suffix?: string
|
||||||
prefix?: string
|
prefix?: string
|
||||||
|
filterable?: boolean
|
||||||
|
/**
|
||||||
|
* mutually exclusive with `autocomplete`
|
||||||
|
*/
|
||||||
multiple?: boolean
|
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(',') ?? [])
|
const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.props.value?.toString()?.split(',') ?? [])
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- input wrapper -->
|
<!-- 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 && (
|
{Astro.props.label && (
|
||||||
<div class="label">{Astro.props.label}</div>
|
<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 && (
|
{Astro.props.prefix && (
|
||||||
<p class="prefix">{Astro.props.prefix}</p>
|
<p class="prefix">{Astro.props.prefix}</p>
|
||||||
)}
|
)}
|
||||||
<input readonly {...objectOmit(baseProps, 'name') as any} />
|
{Astro.props.iconLeft && (
|
||||||
<ul class="list hidden">
|
<Astro.props.iconLeft class="prefix" />
|
||||||
{Astro.props.options.map((it) => {
|
)}
|
||||||
|
<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') {
|
if (typeof it !== 'object') {
|
||||||
it = {title: it}
|
it = {title: it}
|
||||||
}
|
}
|
||||||
const itemValue = it.value ?? it.title
|
const itemValue = it.value ?? it.title
|
||||||
const checked = values.includes(itemValue)
|
const checked = values.includes(itemValue)
|
||||||
return (
|
return (
|
||||||
<li>
|
<li class="flex">
|
||||||
<label class="flex gap-2">
|
<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} />
|
<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} />
|
||||||
<p>{it.title}</p>
|
<div class="flex gap-2 items-center">
|
||||||
{it.description && (
|
{it.image && (
|
||||||
<p class="desc">{it.description}</p>
|
<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>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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 && (
|
{Astro.props.suffix && (
|
||||||
<p class="suffix">{Astro.props.suffix}</p>
|
<p class="suffix">{Astro.props.suffix}</p>
|
||||||
)}
|
)}
|
||||||
@ -54,18 +91,22 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.parent {
|
.parent {
|
||||||
@apply flex flex-col cursor-text gap-2
|
@apply flex flex-col cursor-text gap-2 min-w-9
|
||||||
}
|
}
|
||||||
|
|
||||||
.suffix, .prefix {
|
.suffix, .prefix {
|
||||||
@apply select-none font-light text-gray-400
|
@apply select-none font-light text-gray-400
|
||||||
}
|
}
|
||||||
.input, .textarea {
|
.input, .textarea {
|
||||||
@apply px-4 w-full bg-gray-100 rounded-lg border-gray-200 min-h-0 border flex items-center gap-2 py-2
|
@apply px-4 w-full bg-gray-100 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 {
|
.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 {
|
.textarea {
|
||||||
@ -73,52 +114,119 @@ const values = Array.isArray(Astro.props.value) ? Astro.props.value : (Astro.pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.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
|
@apply block
|
||||||
}
|
}
|
||||||
|
ul :global(label) {
|
||||||
|
@apply px-4 py-2 cursor-pointer
|
||||||
|
}
|
||||||
ul :global(li) {
|
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) {
|
ul :global(li p) {
|
||||||
@apply text-gray-600
|
@apply text-gray-600 dark:text-gray-50
|
||||||
}
|
}
|
||||||
ul :global(li p.desc) {
|
ul :global(li p.desc) {
|
||||||
@apply text-sm font-light
|
@apply text-sm font-light
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input:placeholder-shown ~ .clearable {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Component from 'libs/Component'
|
import Component from 'libs/Component'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Component.addComponent<HTMLElement>('select', (it) => {
|
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 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')
|
const list = it.querySelector('ul')
|
||||||
if (!list || !displayInput) {
|
if (!list || !displayInput) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
function updateSelectValue() {
|
function updateSelectValue(updateText = true, blur = true) {
|
||||||
const checkedValues = inputs.filter((it) => it.checked).map((it) => it.value)
|
const checkedValues = entries.filter((it) => it.input.checked)
|
||||||
displayInput.value = checkedValues.toString()
|
|
||||||
|
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(',') ?? []
|
const defaultValue = (displayInput.value ?? displayInput.getAttribute('value'))
|
||||||
console.log('values', values)
|
const values = defaultValue?.split(',') ?? []
|
||||||
if (values.length > 0) {
|
if (values.length > 0 && values[0] !== '') {
|
||||||
const checkbox = list.querySelector(`input[value="${value}"]`)
|
let valueText = ''
|
||||||
if (checkbox) {
|
for (const value of values) {
|
||||||
checkbox.checked = true
|
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) => {
|
if (!displayInput.readOnly) {
|
||||||
console.log('')
|
displayInput.addEventListener('input', () => {
|
||||||
listItem.addEventListener('change', () => {
|
const value = displayInput.value
|
||||||
console.log(listItem, 'changed to', listItem.checked)
|
for (const entry of entries) {
|
||||||
updateSelectValue()
|
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>
|
</script>
|
||||||
|
34
src/env.d.ts
vendored
34
src/env.d.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
/// <reference path="../.astro/types.d.ts" />
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variables declaration
|
* Environment variables declaration
|
||||||
*/
|
*/
|
||||||
@ -21,5 +22,36 @@ declare namespace App {
|
|||||||
/**
|
/**
|
||||||
* Middlewares variables
|
* 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ export interface Props extends HeadProps {
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Component from 'libs/Component'
|
import Component from 'libs/Component'
|
||||||
import Hyperion from 'libs/Hyperion'
|
import Hyperion, { utils } from 'hyperions'
|
||||||
|
|
||||||
Hyperion.setup()
|
Hyperion.setup()
|
||||||
.on('error', ({ error }) => {
|
.on('error', ({ error }) => {
|
||||||
@ -28,4 +28,27 @@ import Hyperion from 'libs/Hyperion'
|
|||||||
.on('htmlChange', ({ newElement }) => {
|
.on('htmlChange', ({ newElement }) => {
|
||||||
Component.load(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>
|
</script>
|
||||||
|
241
src/libs/DOMElement.ts
Normal file
241
src/libs/DOMElement.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@ -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"}
|
|
||||||
```
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
23
src/libs/Schema/Items/DzeioLiteral.ts
Normal file
23
src/libs/Schema/Items/DzeioLiteral.ts
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
src/libs/Schema/Items/SchemaArray.ts
Normal file
93
src/libs/Schema/Items/SchemaArray.ts
Normal 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
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
8
src/libs/Schema/Items/SchemaBoolean.ts
Normal file
8
src/libs/Schema/Items/SchemaBoolean.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
62
src/libs/Schema/Items/SchemaDate.ts
Normal file
62
src/libs/Schema/Items/SchemaDate.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
21
src/libs/Schema/Items/SchemaFile.ts
Normal file
21
src/libs/Schema/Items/SchemaFile.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
65
src/libs/Schema/Items/SchemaNullable.ts
Normal file
65
src/libs/Schema/Items/SchemaNullable.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
89
src/libs/Schema/Items/SchemaNumber.ts
Normal file
89
src/libs/Schema/Items/SchemaNumber.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
88
src/libs/Schema/Items/SchemaRecord.ts
Normal file
88
src/libs/Schema/Items/SchemaRecord.ts
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
76
src/libs/Schema/Items/SchemaString.ts
Normal file
76
src/libs/Schema/Items/SchemaString.ts
Normal 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
25
src/libs/Schema/README.md
Normal 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/libs/Schema/SchemaItem.ts
Normal file
169
src/libs/Schema/SchemaItem.ts
Normal 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
216
src/libs/Schema/index.ts
Normal 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
3
src/libs/Schema/utils.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function isNull(value: unknown): value is undefined | null {
|
||||||
|
return typeof value === 'undefined' || value === null
|
||||||
|
}
|
35
src/middleware/database.ts
Normal file
35
src/middleware/database.ts
Normal 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()
|
||||||
|
})
|
@ -1,5 +1,6 @@
|
|||||||
import { sequence } from "astro/middleware"
|
import { sequence } from "astro/middleware"
|
||||||
|
|
||||||
|
import database from "./database"
|
||||||
import logger from './logger'
|
import logger from './logger'
|
||||||
|
|
||||||
export const onRequest = sequence(logger)
|
export const onRequest = sequence(logger, database)
|
||||||
|
@ -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 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) {
|
if (options?.debug) {
|
||||||
console.log('Query', query)
|
console.log('Query', query)
|
||||||
}
|
}
|
||||||
@ -36,27 +37,54 @@ export function filter<T extends object>(query: Query<T>, results: Array<T>, opt
|
|||||||
if (query.$sort) {
|
if (query.$sort) {
|
||||||
// temp until better solution is found
|
// temp until better solution is found
|
||||||
const first = objectFind(query.$sort, () => true)
|
const first = objectFind(query.$sort, () => true)
|
||||||
filtered = filtered.sort((a, b) => {
|
filtered = filtered.sort((objA, objB) => {
|
||||||
if (first?.value === Sort.ASC) {
|
const a = objA[first!.key]
|
||||||
return (a[first!.key] ?? 0) > (b[first!.key] ?? 0) ? 1 : -1
|
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) {
|
if (options?.debug) {
|
||||||
console.log('postSort', filtered)
|
console.log('postSort', filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// length of the query assuming a single page
|
||||||
|
const unpaginatedLength = filtered.length
|
||||||
// limit
|
// limit
|
||||||
if (query.$offset || query.$limit) {
|
if (query.$offset || query.$limit) {
|
||||||
const offset = query.$offset ?? 0
|
const offset = query.$offset ?? 0
|
||||||
filtered = filtered.slice(offset, offset + (query.$limit ?? 0))
|
filtered = filtered.slice(offset, offset + (query.$limit ?? Infinity))
|
||||||
}
|
}
|
||||||
if (options?.debug) {
|
if (options?.debug) {
|
||||||
console.log('postLimit', filtered)
|
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) {
|
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) {
|
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
|
return typeof value === 'number' && typeof comparedValue === 'number' && value <= comparedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('$len' in query && Array.isArray(value)) {
|
||||||
|
return value.length === query.$len
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logical Operators
|
* Logical Operators
|
||||||
|
55
src/models/Adapters/CSVAdapter.ts
Normal file
55
src/models/Adapters/CSVAdapter.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +1,14 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
|
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 crypto from 'node:crypto'
|
||||||
import Client from '../Client'
|
import Client from '../Clients/CassandraClient'
|
||||||
import type DaoAdapter from '../DaoAdapter'
|
|
||||||
import type { DBPull } from '../DaoAdapter'
|
|
||||||
import { Sort, type Query } from '../Query'
|
import { Sort, type Query } from '../Query'
|
||||||
import type Schema from '../Schema'
|
import type Schema from '../Schema'
|
||||||
import { isSchemaItem, type Implementation, type Item, type Model } from '../Schema'
|
import { isSchemaItem, type Implementation, type Item, type Model } from '../Schema'
|
||||||
import { filter } from './AdapterUtils'
|
import { filter } from './AdapterUtils'
|
||||||
|
import type DaoAdapter from './DaoAdapter'
|
||||||
|
import type { DBPull } from './DaoAdapter'
|
||||||
|
|
||||||
export default class CassandraAdapter<T extends Model> implements DaoAdapter<T> {
|
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
|
* additionnal options to make the adapter work
|
||||||
*/
|
*/
|
||||||
private readonly options?: {
|
public readonly options: {
|
||||||
/**
|
/**
|
||||||
* log the requests made to cassandra
|
* log the requests made to cassandra
|
||||||
*/
|
*/
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
}
|
} = {}
|
||||||
) {
|
) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
objectLoop(schema.model, (value, key) => {
|
objectLoop(schema.model, (value, key) => {
|
||||||
@ -174,7 +174,7 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
|||||||
console.log(req, params)
|
console.log(req, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
let res: types.ResultSet | undefined
|
let res: Array<Record<string, any>>
|
||||||
try {
|
try {
|
||||||
res = await client.execute(req.join(' '), params)
|
res = await client.execute(req.join(' '), params)
|
||||||
} catch (error) {
|
} 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) => ({
|
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
|
||||||
key,
|
key,
|
||||||
value: this.dbToValue(key, obj.get(key))
|
value: this.dbToValue(key, obj[key])
|
||||||
})))
|
})))
|
||||||
.map((obj) => {
|
.map((obj) => {
|
||||||
objectLoop(this.schema.model, (item, key) => {
|
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)
|
// 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
|
// temp modification of comportement to use the new and better query system
|
||||||
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
||||||
// temp fix for the sorting algorithm
|
// temp fix for the sorting algorithm
|
||||||
@ -259,17 +261,19 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (query) {
|
if (query) {
|
||||||
dataset = filter(query, dataset, this.options)
|
const { filtered, unpaginatedLength: ul } = filter(query, dataset, this.options)
|
||||||
|
dataset = filtered
|
||||||
|
unpaginatedLength = ul
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(res)
|
// console.log(res)
|
||||||
|
const pageLimit = query?.$limit ?? 10
|
||||||
|
const pageOffset = query?.$offset ?? 0
|
||||||
return {
|
return {
|
||||||
rows: res.rows.length,
|
rows: dataset.length,
|
||||||
rowsTotal: res.rowLength,
|
rowsTotal: unpaginatedLength,
|
||||||
page: 1,
|
page: Math.floor(pageOffset / pageLimit),
|
||||||
pageTotal: 1,
|
pageTotal: Math.max(1, Math.ceil(unpaginatedLength / pageLimit)),
|
||||||
// page: page,
|
|
||||||
// pageTotal: pageLimit ? res.rowLength / pageLimit : 1,
|
|
||||||
data: dataset
|
data: dataset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -303,7 +307,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
|||||||
const params: Array<any> = []
|
const params: Array<any> = []
|
||||||
|
|
||||||
// remove ids
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||||
delete obj[tmp]
|
delete obj[tmp]
|
||||||
}
|
}
|
||||||
@ -316,8 +321,8 @@ export default class CassandraAdapter<T extends Model> implements DaoAdapter<T>
|
|||||||
// filter by the ids
|
// filter by the ids
|
||||||
parts.push('WHERE')
|
parts.push('WHERE')
|
||||||
const read: Partial<any> = {}
|
const read: Partial<any> = {}
|
||||||
for (let idx = 0; idx < this.id.length; idx++) {
|
for (let idx = 0; idx < ids.length; idx++) {
|
||||||
const key = this.id[idx] as string
|
const key = ids[idx] as string
|
||||||
|
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
parts.push('AND')
|
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 parts = ['DELETE', 'FROM', this.table, 'WHERE']
|
||||||
const params: ArrayOrObject = []
|
const params: ArrayOrObject = []
|
||||||
|
|
||||||
objectLoop(obj as {}, (value, key, idx) => {
|
objectLoop(obj as {}, (value, key) => {
|
||||||
if (idx > 0) {
|
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('AND')
|
||||||
}
|
}
|
||||||
parts.push(`${key}=?`)
|
parts.push(`${key}=?`)
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { type Query } from './Query'
|
import type Schema from 'libs/Schema'
|
||||||
import type { Implementation, Model } from './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
|
* total number of rows that are valid with the specified query
|
||||||
*/
|
*/
|
||||||
@ -24,7 +25,7 @@ export interface DBPull<T extends Model> {
|
|||||||
/**
|
/**
|
||||||
* the data fetched
|
* 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
|
* 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
|
* create a new object in the remote source
|
||||||
*
|
*
|
||||||
* @param obj the object to create
|
* @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
|
* read from the remote source
|
||||||
*
|
*
|
||||||
* @param query the query to filter/sort results
|
* @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
|
* update an object to the remote source
|
||||||
*
|
*
|
||||||
* @param obj the object to update
|
* @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
|
* 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
|
* @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
|
* 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 id (DEPRECATED) the ID of the object
|
||||||
* @param obj the object to patch (MUST include ids, and changes)
|
* @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
|
* delete an object from the source
|
||||||
* @param obj the object ot delete (it must at least include the id(s))
|
* @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>
|
||||||
}
|
}
|
@ -1,26 +1,26 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import { objectLoop } from '@dzeio/object-util'
|
import { objectLoop } from '@dzeio/object-util'
|
||||||
import archiver from 'archiver'
|
import type Schema from 'libs/Schema'
|
||||||
import fs from 'fs/promises'
|
import type { Model, ModelInfer } from 'libs/Schema'
|
||||||
import file_system from 'fs'
|
import type SchemaBuffer from 'libs/Schema/Items/SchemaBuffer'
|
||||||
import type DaoAdapter from '../DaoAdapter'
|
import type SchemaNumber from 'libs/Schema/Items/SchemaNumber'
|
||||||
import type { DBPull } from '../DaoAdapter'
|
import type SchemaString from 'libs/Schema/Items/SchemaString'
|
||||||
import { type Query } from '../Query'
|
import fileSystem from 'node:fs'
|
||||||
import type Schema from '../Schema'
|
import fs from 'node:fs/promises'
|
||||||
import { isSchemaItem, type Implementation, type Model } from '../Schema'
|
import type { Query } from '../Query'
|
||||||
|
import type DaoAdapter from './DaoAdapter'
|
||||||
|
import type { DBPull } from './DaoAdapter'
|
||||||
|
|
||||||
interface FS extends Model {
|
interface FS extends Model {
|
||||||
filename: StringConstructor
|
filename: SchemaString
|
||||||
path: StringConstructor
|
path: SchemaString
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
data: BufferConstructor
|
data: SchemaBuffer
|
||||||
type: StringConstructor
|
type: SchemaString
|
||||||
size: NumberConstructor
|
size: SchemaNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
export default class FSAdapter<T extends FS> implements DaoAdapter<Schema<T>> {
|
||||||
|
|
||||||
private id!: string
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly schema: Schema<T>,
|
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
|
// 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 realPath = this.getFullPath(obj.path!)
|
||||||
|
|
||||||
const finalFolder = realPath.slice(0, realPath.lastIndexOf('/'))
|
const finalFolder = realPath.slice(0, realPath.lastIndexOf('/'))
|
||||||
@ -51,49 +51,16 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
|||||||
} else {
|
} else {
|
||||||
await fs.writeFile(realPath, data as string)
|
await fs.writeFile(realPath, data as string)
|
||||||
}
|
}
|
||||||
return obj as Implementation<T>
|
return obj as ModelInfer<T>
|
||||||
} else {
|
} else {
|
||||||
console.log('making the final directory', realPath)
|
console.log('making the final directory', realPath)
|
||||||
await fs.mkdir(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
|
// 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 ?? ''
|
const localPath = query?.path as string ?? ''
|
||||||
|
|
||||||
@ -104,38 +71,24 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
|||||||
try {
|
try {
|
||||||
const stats = await fs.stat(realPath)
|
const stats = await fs.stat(realPath)
|
||||||
|
|
||||||
const files: Array<Implementation<T>> = []
|
const files: Array<ModelInfer<T>> = []
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
const dirFiles = await fs.readdir(realPath)
|
const dirFiles = await fs.readdir(realPath)
|
||||||
// eslint-disable-next-line max-depth
|
for await (const file of dirFiles) {
|
||||||
if (toZip === true) { // put queried file/folder in a zip file
|
files.push(await this.readFile(`${localPath}/${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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
files.push(await this.readFile(localPath))
|
files.push(await this.readFile(localPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageLimit = query?.$limit ?? Infinity
|
||||||
|
const pageOffset = query?.$offset ?? 0
|
||||||
return {
|
return {
|
||||||
rows: files.length,
|
rows: files.length,
|
||||||
rowsTotal: files.length,
|
rowsTotal: files.length,
|
||||||
page: 0,
|
page: Math.floor(pageOffset / pageLimit),
|
||||||
pageTotal: 1,
|
pageTotal: Math.max(1, Math.ceil(files.length / pageLimit)),
|
||||||
data: files
|
data: files.slice(pageOffset, pageOffset + pageLimit)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
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')
|
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')
|
throw new Error('not implemented')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(_obj: Implementation<T>): Promise<boolean> {
|
public async delete(obj: ModelInfer<T>): Promise<boolean> {
|
||||||
throw new Error('not implemented')
|
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 {
|
private getFullPath(localPath?: string): string {
|
||||||
if (localPath && !localPath?.startsWith('/')) {
|
if (localPath && !localPath?.startsWith('/')) {
|
||||||
console.warn('Your path should start with a "/", adding it')
|
console.warn('Your path should start with a "/", adding it')
|
||||||
localPath = ('/' + localPath) as any
|
localPath = (`/${localPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let realPath = this.basePath + (localPath ? localPath : '')
|
let realPath = this.basePath + (localPath ? localPath : '')
|
||||||
@ -176,7 +138,7 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
|||||||
return realPath
|
return realPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readFile(localPath: string): Promise<Implementation<T>> {
|
private async readFile(localPath: string): Promise<ModelInfer<T>> {
|
||||||
|
|
||||||
const path = this.getFullPath(localPath)
|
const path = this.getFullPath(localPath)
|
||||||
console.log('reading file at', path)
|
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'
|
const type = stats.isFile() ? 'file' : 'directory'
|
||||||
console.log('file is a', type)
|
console.log('file is a', type)
|
||||||
|
|
||||||
const obj: Implementation<T> = {
|
const obj: ModelInfer<T> = {
|
||||||
path: localPath,
|
path: localPath,
|
||||||
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
|
filename: localPath.slice(localPath.lastIndexOf('/') + 1),
|
||||||
data: type === 'file' ? await fs.readFile(path) : '',
|
data: type === 'file' ? await fs.readFile(path) : '',
|
||||||
@ -193,10 +155,10 @@ export default class FSAdapter<T extends FS> implements DaoAdapter<T> {
|
|||||||
} as any
|
} as any
|
||||||
|
|
||||||
objectLoop(this.schema.model, (item, key) => {
|
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
|
// @ts-expect-error things get validated anyway
|
||||||
obj[key] = stats.ctime
|
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
|
// @ts-expect-error things get validated anyway
|
||||||
obj[key] = stats.mtime
|
obj[key] = stats.mtime
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,30 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* 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 ldap from 'ldapjs'
|
||||||
import type DaoAdapter from 'models/DaoAdapter'
|
import type Schema from 'libs/Schema'
|
||||||
import type { DBPull } from 'models/DaoAdapter'
|
import type { SchemaInfer } from 'libs/Schema'
|
||||||
import { type Query } from 'models/Query'
|
import type DaoAdapter from 'models/Adapters/DaoAdapter'
|
||||||
import Schema, { type Implementation, type Model } from 'models/Schema'
|
import type { DBPull } from 'models/Adapters/DaoAdapter'
|
||||||
type LDAPFields = 'uid' | 'mail' | 'givenname' | 'sn' | 'jpegPhoto' | 'password'
|
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 reverseReference: Partial<Record<LDAPFields | string, keyof T>> = {}
|
||||||
private attributes: Array<LDAPFields | string> = []
|
private attributes: Array<LDAPFields | string> = []
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly schema: Schema<T>,
|
public readonly schema: T,
|
||||||
public readonly options: {
|
public readonly options: {
|
||||||
url: string
|
url: string
|
||||||
dnSuffix: string
|
dnSuffix: string
|
||||||
adminUsername: string
|
admin: {
|
||||||
adminPassword: string
|
dn?: string | undefined
|
||||||
fieldsCorrespondance?: Partial<Record<keyof T, LDAPFields | string>>
|
username?: string | undefined
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
fieldsCorrespondance?: Partial<Record<keyof SchemaInfer<T>, LDAPFields | string>>
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
objectLoop(options.fieldsCorrespondance ?? {}, (value, key) => {
|
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
|
// 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')
|
throw new Error('not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line complexity
|
// 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 passwordField = this.options.fieldsCorrespondance?.password ?? 'password'
|
||||||
const doLogin = !!query?.[passwordField]
|
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)
|
?.filter((it) => it.slice(0, it.indexOf('=')) !== passwordField)
|
||||||
?.join(',')
|
?.join(',')
|
||||||
if (!doLogin) {
|
if (!doLogin) {
|
||||||
const client = await this.bind(`cn=${this.options.adminUsername},${this.options.dnSuffix}`, this.options.adminPassword)
|
const bind = this.options.admin.dn ?? `cn=${this.options.admin.username},${this.options.dnSuffix}`
|
||||||
// @ts-expect-error nique ta mere
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const client = await this.bind(bind, this.options.admin.password)
|
||||||
const results = (await this.ldapFind(client, objectMap(query, (value, key) => [key as string, '', value as string])
|
// @ts-expect-error nique ta mere
|
||||||
.map((it) => ({key: it[0] as LDAPFields, value: it[2]}))!
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
)).map((it) => this.schema.parse(
|
const results = (await this.ldapFind(client, objectMap(query, (value, key) => ({key: this.options.fieldsCorrespondance?.[key], value: value}))
|
||||||
objectRemap(it, (value, key) => ({key: this.reverseReference[key as string] as string, value: value}))
|
)).map((it) => this.schema.parse(
|
||||||
)).filter((it): it is Implementation<T> => !!it)
|
objectRemap(it, (value, key) => ({key: this.reverseReference[key.toLowerCase() as string] as string, value: value}))
|
||||||
|
)).filter((it): it is SchemaInfer<T> => !!it)
|
||||||
|
|
||||||
return {
|
const res = filter(query, results)
|
||||||
rows: results.length,
|
|
||||||
rowsTotal: results.length,
|
return {
|
||||||
page: 1,
|
rows: res.filtered.length,
|
||||||
pageTotal: 1,
|
rowsTotal: results.length,
|
||||||
data: results
|
page: 1,
|
||||||
|
pageTotal: 1,
|
||||||
|
data: res.filtered
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return emptyResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// password authentication
|
||||||
try {
|
try {
|
||||||
const clone = objectClone(query)
|
const clone = objectClone(query)
|
||||||
delete clone.password
|
delete clone.password
|
||||||
|
|
||||||
|
// find using admin privileges
|
||||||
const res = await this.read(clone)
|
const res = await this.read(clone)
|
||||||
const user = res.data[0]
|
const user = res.data[0]
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return emptyResult
|
return emptyResult
|
||||||
}
|
}
|
||||||
const password = query.password as string ?? ''
|
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
|
// @ts-expect-error nique x2
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// 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}))
|
const results = (await this.ldapFind(client, objectMap(clone, (value, key) => {
|
||||||
)).map((it) => this.schema.parse(
|
const finalKey = this.options.fieldsCorrespondance?.[key]
|
||||||
objectRemap(it, (value, key) => ({key: this.reverseReference[key as string] as string, value: value}))
|
|
||||||
)).filter((it): it is Implementation<T> => !!it)
|
|
||||||
|
|
||||||
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 emptyResult
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: results.length,
|
rows: final.filtered.length,
|
||||||
rowsTotal: results.length,
|
rowsTotal: results.length,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageTotal: 1,
|
pageTotal: 1,
|
||||||
data: results
|
data: final.filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} 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')
|
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')
|
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')
|
throw new Error('not implemented')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,11 +147,13 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
url: this.options.url
|
url: this.options.url
|
||||||
})
|
})
|
||||||
return new Promise<ldap.Client>((res, rej) => {
|
return new Promise<ldap.Client>((res, rej) => {
|
||||||
|
console.log('binding as', dn)
|
||||||
client.on('connect', () => {
|
client.on('connect', () => {
|
||||||
client.bind(dn, password, (err) => {
|
client.bind(dn, password, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('error binding as', dn, err)
|
console.error('error binding as', dn, err)
|
||||||
client.unbind()
|
client.unbind()
|
||||||
|
rej(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('binded as', dn)
|
console.log('binded as', dn)
|
||||||
@ -152,12 +175,15 @@ export default class LDAPAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
const firstFilter = filters.shift()!
|
const firstFilter = filters.shift()!
|
||||||
return new Promise<Array<Record<LDAPFields, string | Array<string> | undefined>>>((res, rej) => {
|
return new Promise<Array<Record<LDAPFields, string | Array<string> | undefined>>>((res, rej) => {
|
||||||
const users: Array<Record<LDAPFields, string | Array<string> | undefined>> = []
|
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(
|
client.search(
|
||||||
this.options.dnSuffix, {
|
this.options.dnSuffix, {
|
||||||
filter: new ldap.EqualityFilter({
|
filter: new ldap.EqualityFilter(filter),
|
||||||
attribute: firstFilter.key as any,
|
// filter: `${filter.attribute}:caseExactMatch:=${filter.value}`,
|
||||||
value: firstFilter.value,
|
|
||||||
}),
|
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
attributes: this.attributes
|
attributes: this.attributes
|
||||||
}, (err, search) => {
|
}, (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> {
|
private parseUser(usr: ldap.SearchEntry): Record<LDAPFields, string | Array<string> | undefined> {
|
||||||
const user: Record<string, string | Array<string> | undefined> = { dn: usr.objectName ?? undefined }
|
const user: Record<string, string | Array<string> | undefined> = { dn: usr.objectName ?? undefined }
|
||||||
usr.attributes.forEach((attribute) => {
|
|
||||||
user[attribute.type] =
|
for (const attribute of usr.attributes) {
|
||||||
attribute.values.length === 1 ? attribute.values[0] : attribute.values
|
user[attribute.type] = attribute.values.length === 1 ? attribute.values[0] : attribute.values
|
||||||
})
|
|
||||||
|
}
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import type DaoAdapter from 'models/DaoAdapter'
|
import type Schema from 'libs/Schema'
|
||||||
import Schema, { type Implementation, type Model } from 'models/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 constructor(
|
||||||
public readonly schema: Schema<T>,
|
public readonly schema: T,
|
||||||
public readonly adapters: Array<{
|
public readonly adapters: Array<{
|
||||||
adapter: DaoAdapter<Partial<T>>
|
adapter: DaoAdapter<T>
|
||||||
fields: Array<keyof T>
|
fields: Array<keyof T>
|
||||||
/**
|
/**
|
||||||
* a field from the main adapter that will backreference the child adapter
|
* 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
|
// 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> {
|
||||||
let final: Implementation<T> = {} as any
|
let final: SchemaInfer<T> = {} as any
|
||||||
// start by processing the childs
|
// start by processing the childs
|
||||||
for (const adapter of this.adapters.sort((a) => a.childReference ? -1 : 1)) {
|
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) {
|
for (const key of adapter.fields) {
|
||||||
partialObject[key] = obj[key]
|
partialObject[key] = obj[key]
|
||||||
}
|
}
|
||||||
@ -34,11 +35,11 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line complexity
|
// 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>> {
|
||||||
// let final: Implementation<T> = {} as any
|
// let final: SchemaInfer<T> = {} as any
|
||||||
// // start by processing the childs
|
// // start by processing the childs
|
||||||
// for (const adapter of this.adapters.sort((a) => a.childReference ? -1 : 1)) {
|
// 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) {
|
// for (const key of adapter.fields) {
|
||||||
// partialObject[key] = obj[key]
|
// partialObject[key] = obj[key]
|
||||||
// }
|
// }
|
||||||
@ -52,16 +53,16 @@ export default class MultiAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
// return final
|
// 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')
|
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')
|
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')
|
throw new Error('not implemented')
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { objectFind, objectKeys, objectLoop, objectMap, objectRemap, objectValues } from '@dzeio/object-util'
|
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 crypto from 'node:crypto'
|
||||||
import type { QueryResult } from 'pg'
|
import PostgresClient from '../Clients/PostgresClient'
|
||||||
import Client from '../Clients/PostgresClient'
|
|
||||||
import type DaoAdapter from '../DaoAdapter'
|
|
||||||
import type { DBPull } from '../DaoAdapter'
|
|
||||||
import { Sort, type Query } from '../Query'
|
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 { 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> = []
|
private id: Array<string> = []
|
||||||
|
|
||||||
@ -19,7 +21,7 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
/**
|
/**
|
||||||
* the schema used by Cassandra
|
* the schema used by Cassandra
|
||||||
*/
|
*/
|
||||||
public readonly schema: Schema<T>,
|
public readonly schema: T,
|
||||||
/**
|
/**
|
||||||
* the table name
|
* the table name
|
||||||
*/
|
*/
|
||||||
@ -36,43 +38,51 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
objectLoop(this.schema.model, (schema, key) => {
|
objectLoop(this.schema.model, (schema, key) => {
|
||||||
if (!isSchemaItem(schema)) {
|
if (schema.attributes.includes('db:auto')) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (schema.database?.auto) {
|
|
||||||
this.id.push(key)
|
this.id.push(key)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make it clearer what it does
|
// 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> {
|
||||||
console.log(obj)
|
// handle automated values
|
||||||
objectLoop(this.schema.model, (item, key) => {
|
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
|
// @ts-expect-error things get validated anyway
|
||||||
obj[key] = new Date()
|
obj[key] = new Date()
|
||||||
} else if (isSchemaItem(item) && item.database?.auto && !obj[key]) {
|
} else if (item.attributes.includes('db:auto') && !obj[key]) {
|
||||||
if (item.type === String) {
|
if (item.isOfType('')) {
|
||||||
// @ts-expect-error things get validated anyway
|
// @ts-expect-error things get validated anyway
|
||||||
obj[key] = crypto.randomBytes(16).toString('hex')
|
obj[key] = crypto.randomBytes(16).toString('hex')
|
||||||
} else {
|
} else if (item.isOfType(123)) {
|
||||||
// @ts-expect-error things get validated anyway
|
// @ts-expect-error things get validated anyway
|
||||||
obj[key] = crypto.randomBytes(16).readUint32BE()
|
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)
|
// parse the data with the Schema
|
||||||
if (!clone) {
|
const { object: clone, error} = this.schema.validate(obj)
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
throw new Error('Invalid data given to create the final object')
|
throw new Error('Invalid data given to create the final object')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepare the database query
|
||||||
const keys = objectKeys(clone)
|
const keys = objectKeys(clone)
|
||||||
|
.map((it) => {
|
||||||
|
if (specialKeywords.includes(it)) { // handle the special keyword
|
||||||
|
return `"${it}"`
|
||||||
|
}
|
||||||
|
return it
|
||||||
|
})
|
||||||
const keysStr = keys.join(', ')
|
const keysStr = keys.join(', ')
|
||||||
const values = keys.map((_, idx) => `$${idx+1}`).join(', ')
|
const values = keys.map((_, idx) => `$${idx+1}`).join(', ')
|
||||||
const req = `INSERT INTO ${this.table} (${keysStr}) VALUES (${values});`
|
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))
|
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)
|
console.log(req, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// send to the database
|
||||||
try {
|
try {
|
||||||
await client.query(req, params)
|
await client.execute(req, params)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e, req, params)
|
console.log(e, req, params)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return this.schema.parse(clone)
|
return this.schema.validate(clone).object ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line complexity
|
// 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]
|
let req: Array<string> = ['SELECT', '*', 'FROM', this.table]
|
||||||
|
|
||||||
const client = (await Client.get())!
|
const client = await PostgresClient.get()
|
||||||
|
|
||||||
if (this.options?.debug) {
|
if (this.options?.debug) {
|
||||||
console.log(req)
|
console.log(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
let res: QueryResult<any> | undefined
|
// read from the database
|
||||||
|
let res: Array<Record<string, any>>
|
||||||
try {
|
try {
|
||||||
res = await client.query(`${req.join(' ')}`)
|
res = await client.execute(`${req.join(' ')}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('error running request')
|
console.error('error running request')
|
||||||
console.error(req)
|
console.error(req)
|
||||||
@ -117,22 +130,28 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = res.rows
|
if (this.options?.debug) {
|
||||||
.map((obj) => objectRemap(this.schema.model, (_, key) => ({
|
console.log('preEdits', res)
|
||||||
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] = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 SchemaInfer<T> => !!it)
|
||||||
.filter((it): it is Implementation<T> => !!it)
|
|
||||||
|
|
||||||
// temp modification of comportement to use the new and better query system
|
// temp modification of comportement to use the new and better query system
|
||||||
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
if ((!query || !query?.$sort) && objectFind(this.schema.model, (_, key) => key === 'created')) {
|
||||||
@ -145,13 +164,18 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let dataset = raw
|
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 {
|
return {
|
||||||
rows: dataset.length ?? 0,
|
rows: dataset.length ?? 0,
|
||||||
rowsTotal: res.rowCount ?? 0,
|
rowsTotal: res.length ?? 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageTotal: 1,
|
pageTotal: 1,
|
||||||
// page: page,
|
// 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)
|
return this.patch(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async patch(id: 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<Implementation<T>>): Promise<Implementation<T> | null>
|
public async patch(id: string, obj: Partial<SchemaInfer<T>>): Promise<SchemaInfer<T> | null>
|
||||||
// eslint-disable-next-line complexity
|
// 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 (!obj) {
|
||||||
if (typeof id === 'string') {
|
if (typeof id === 'string') {
|
||||||
return null
|
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
|
// update the updated time
|
||||||
objectLoop(this.schema.model, (item, key) => {
|
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
|
// @ts-expect-error things get validated anyway
|
||||||
obj[key] = new Date()
|
obj[key] = new Date()
|
||||||
}
|
}
|
||||||
@ -195,7 +226,13 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// map the items to update
|
// map the items to update
|
||||||
const keys = objectMap(obj as {}, (_, key, 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(', '))
|
parts.push(keys.join(', '))
|
||||||
params.push(...objectValues(obj as {}))
|
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}`)
|
parts.push(`${key}=$${params.length+1}`)
|
||||||
const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
|
const value = obj[key] ?? (typeof id === 'string' ? id : id[key])
|
||||||
read[key] = this.valueToDB(key, value)
|
read[key] = this.valueToDB(key as any, value)
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(`Missing id (${key})`)
|
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 req = parts.join(' ')
|
||||||
const client = await Client.get()
|
const client = await PostgresClient.get()
|
||||||
|
|
||||||
if (this.options?.debug) {
|
if (this.options?.debug) {
|
||||||
console.log(req, params)
|
console.log(req, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await client!.query(req, params)
|
const res = await client!.execute(req, params)
|
||||||
// console.log(res, req)
|
// console.log(res, req)
|
||||||
if (this.options?.debug) {
|
if (this.options?.debug) {
|
||||||
console.log('post patch result', res, req)
|
console.log('post patch result', res, req)
|
||||||
@ -237,22 +274,34 @@ export default class PostgresAdapter<T extends Model> implements DaoAdapter<T> {
|
|||||||
return null
|
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']
|
const parts = ['DELETE', 'FROM', this.table, 'WHERE']
|
||||||
|
|
||||||
objectLoop(obj as {}, (value, key, idx) => {
|
objectLoop(obj as {}, (value, key, idx) => {
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
parts.push('AND')
|
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) {}
|
if (this.options?.debug) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client!.query(`${parts.join(' ')}`)
|
await client!.execute(`${parts.join(' ')}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e, parts)
|
console.error(e, parts)
|
||||||
throw e
|
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 {
|
private valueToDB(key: keyof T, value: any): string | number | boolean | Date {
|
||||||
const item = this.schema.model[key] as Item
|
const item: SchemaItem<unknown> = (this.schema.model as any)[key]
|
||||||
const type = isSchemaItem(item) ? item.type : item
|
|
||||||
|
|
||||||
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
if (item.isOfType({})) {
|
||||||
return JSON.stringify(value)
|
return JSON.stringify(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'undefined' || value === null) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
private dbToValue(key: keyof T, value: string | number | boolean | Date): any {
|
private dbToValue(key: keyof T, value: string | number | boolean | Date): any {
|
||||||
const item = this.schema.model[key] as Item
|
const item: SchemaItem<unknown> = (this.schema.model as any)[key]
|
||||||
const type = isSchemaItem(item) ? item.type : item
|
|
||||||
|
|
||||||
if (typeof type === 'object' && !Array.isArray(type) && !(value instanceof Date)) {
|
if (item.isOfType(543) && typeof value === 'string') {
|
||||||
return JSON.parse(value as string)
|
return parseFloat(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'undefined' || value === null) {
|
if (item.isOfType({}) && typeof value === 'string') {
|
||||||
return value
|
return JSON.parse(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
130
src/models/Clients/CassandraClient.ts
Normal file
130
src/models/Clients/CassandraClient.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -1,91 +1,70 @@
|
|||||||
import { wait } from 'libs/AsyncUtils'
|
|
||||||
import { getEnv, requireEnv } from 'libs/Env'
|
import { getEnv, requireEnv } from 'libs/Env'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import Migration from '../Migrations'
|
import Client from '.'
|
||||||
const Postgres = pg.Client
|
const Postgres = pg.Client
|
||||||
|
|
||||||
export default class Client {
|
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
|
||||||
private static client?: pg.Client | null
|
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
|
* get the connexion to cassandra, it will try until it succedeed
|
||||||
*/
|
*/
|
||||||
public static async get(skipMigrations = false) {
|
public static async get() {
|
||||||
while (this.migrated === 0 && !skipMigrations) {
|
return PostgresClient.instance
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
141
src/models/Clients/index.ts
Normal file
141
src/models/Clients/index.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@ -1,21 +1,20 @@
|
|||||||
import { objectLoop, objectRemap } from '@dzeio/object-util'
|
import type Schema from 'libs/Schema'
|
||||||
import type DaoAdapter from './DaoAdapter'
|
import type { SchemaInfer } from 'libs/Schema'
|
||||||
import type { DBPull } from './DaoAdapter'
|
import type DaoAdapter from './Adapters/DaoAdapter'
|
||||||
import { type Query } from './Query'
|
import type { DBPull } from './Adapters/DaoAdapter'
|
||||||
import type Schema from './Schema'
|
import type { Query } from './Query'
|
||||||
import { type Impl, type Implementation } from './Schema'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the Dao is the object that connect the Database or source to the application layer
|
* the Dao is the object that connect the Database or source to the application layer
|
||||||
*
|
*
|
||||||
* you MUST call it through the `DaoFactory` file
|
* 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 constructor(
|
||||||
public readonly schema: S,
|
public readonly schema: S,
|
||||||
public readonly adapter: DaoAdapter<S['model']>
|
public readonly adapter: DaoAdapter<S>
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* insert a new object into the source
|
* 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
|
* @param obj the object to create
|
||||||
* @returns the object with it's id filled if create or null otherwise
|
* @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) {
|
if (!this.adapter.create) {
|
||||||
throw new Error('the Adapter does not allow you to create elements')
|
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
|
* @param obj the object to create
|
||||||
* @returns the object with it's id filled if create or null otherwise
|
* @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> {
|
public async insert(obj: Partial<SchemaInfer<S>>): Promise<SchemaInfer<S> | null> {
|
||||||
return this.create(obj as any)
|
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
|
* @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
|
* @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) {
|
if (!this.adapter.read) {
|
||||||
throw new Error('the Adapter does not allow you to read from the remote source')
|
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
|
* @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
|
* @returns an array containing the list of elements that match with the query
|
||||||
*/
|
*/
|
||||||
public async find(query: Parameters<this['findAll']>[0], ...args: Array<any>) {
|
public async find(query: Parameters<this['findAll']>[0]) {
|
||||||
return this.findAll(query, ...args)
|
return this.findAll(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -72,8 +71,8 @@ export default class Dao<S extends Schema<any>> {
|
|||||||
* @param id the id of the object
|
* @param id the id of the object
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public findById(id: any, ...args: Array<any>): Promise<Implementation<S['model']> | null> {
|
public findById(id: any): Promise<SchemaInfer<S> | null> {
|
||||||
return this.findOne({id: id}, ...args)
|
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
|
* @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
|
* @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> {
|
public async findOne(query?: Parameters<this['findAll']>[0]): Promise<SchemaInfer<S> | null> {
|
||||||
return (await this.findAll(query, ...args)).data[0] ?? 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
|
* @param obj the object to update
|
||||||
* @returns an object if it was able to update or null otherwise
|
* @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) {
|
if (!this.adapter.update) {
|
||||||
throw new Error('the Adapter does not allow you to update to the remote source')
|
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 id the id of the object
|
||||||
* @param changes the change to make
|
* @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) {
|
if (!this.adapter.patch) {
|
||||||
const query = await this.findById(id)
|
const query = await this.findById(id)
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return await this.update({...query, ...changes})
|
return await this.update({ ...query, ...changes })
|
||||||
}
|
}
|
||||||
return await this.adapter.patch(id, 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
|
* @param obj the object to update/insert
|
||||||
* @returns the object is updated/inserted or null otherwise
|
* @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) {
|
if (!this.adapter.upsert) {
|
||||||
throw new Error('the Adapter does not allow you to upsert to the remote source')
|
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)
|
* @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) {
|
if (!this.adapter.delete) {
|
||||||
throw new Error('the Adapter does not allow you to delete on the remote source')
|
throw new Error('the Adapter does not allow you to delete on the remote source')
|
||||||
}
|
}
|
||||||
|
@ -1,29 +1,19 @@
|
|||||||
import PostgresAdapter from './Adapters/PostgresAdapter'
|
import type Dao from './Dao'
|
||||||
import Dao from './Dao'
|
import config from './config'
|
||||||
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>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class to get any DAO
|
* Class to get any DAO
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
|
||||||
|
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
|
||||||
export default class DaoFactory {
|
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
|
* Get a a dao by its key
|
||||||
@ -33,30 +23,15 @@ export default class DaoFactory {
|
|||||||
* @param key the dao key to get
|
* @param key the dao key to get
|
||||||
* @returns the Dao you want as a singleton
|
* @returns the Dao you want as a singleton
|
||||||
*/
|
*/
|
||||||
public static get<Key extends keyof DaoItem>(key: Key): DaoItem[Key] {
|
public static get<Key extends keyof typeof config['models']>(key: Key): typeof config['models'][Key] {
|
||||||
if (!(key in this.daos)) {
|
return config.models[key]
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* init a dao by its key, it does not care if it exists or not
|
* get the main client linked to migrations
|
||||||
*
|
* @returns the main client
|
||||||
* @param item the element to init
|
|
||||||
* @returns a new initialized dao or undefined if no dao is linked
|
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line complexity
|
public static async client(): ReturnType<(typeof config.mainClient)['get']> {
|
||||||
private static initDao(item: keyof DaoItem): any | undefined {
|
return config.mainClient.get()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
src/models/History.ts
Normal file
18
src/models/History.ts
Normal 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>
|
@ -1,42 +1,42 @@
|
|||||||
|
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||||
import Priority from 'models/Priority'
|
import Priority from 'models/Priority'
|
||||||
import Schema, { type Impl } from 'models/Schema'
|
|
||||||
|
|
||||||
const schema = new Schema({
|
const schema = new Schema({
|
||||||
/**
|
/**
|
||||||
* the project ID
|
* the project ID
|
||||||
*/
|
*/
|
||||||
id: {type: String, database: {unique: true, index: true, auto: true}},
|
id: s.string().attr('db:unique', 'db:auto'),
|
||||||
localid: Number,
|
localid: s.number(),
|
||||||
project: String,
|
project: s.string(),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the email the project was created from
|
* 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
|
state: s.string(), //state id
|
||||||
priority: {type: Number, defaultValue: Priority.NONE},
|
priority: s.number().defaultValue(Priority.NONE),
|
||||||
begin: { type: Date, nullable: true },
|
begin: s.nullable(s.date()),
|
||||||
due: { type: Date, nullable: true },
|
due: s.nullable(s.date()),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent issue
|
* Parent issue
|
||||||
*/
|
*/
|
||||||
parent: { type: String, nullable: true },
|
parent: s.string().nullable(),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* issue labels
|
* issue labels
|
||||||
*/
|
*/
|
||||||
labels: [String],
|
labels: s.array(s.string()),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* is the issue archived
|
* is the issue archived
|
||||||
*/
|
*/
|
||||||
archived: { type: Boolean, defaultValue: false }
|
archived: s.boolean().defaultValue(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default schema
|
export default schema
|
||||||
|
|
||||||
export type ProjectObj = Impl<typeof schema>
|
export type ProjectObj = SchemaInfer<typeof schema>
|
||||||
|
@ -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
10
src/models/Migrations/Migration.d.ts
vendored
Normal 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>
|
||||||
|
}
|
22
src/models/Migrations/Migration0.ts
Normal file
22
src/models/Migrations/Migration0.ts
Normal 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
|
@ -1,9 +1,9 @@
|
|||||||
import { type MigrationObj } from '.'
|
import type Migration from "./Migration"
|
||||||
|
|
||||||
export default {
|
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> {
|
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,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@ -12,7 +12,7 @@ export default {
|
|||||||
archived BOOL
|
archived BOOL
|
||||||
);`)
|
);`)
|
||||||
|
|
||||||
await client.query(`CREATE TABLE IF NOT EXISTS state (
|
await client.execute(`CREATE TABLE IF NOT EXISTS state (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
project TEXT,
|
project TEXT,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
@ -20,7 +20,7 @@ export default {
|
|||||||
preset BOOL
|
preset BOOL
|
||||||
)`)
|
)`)
|
||||||
|
|
||||||
await client.query(`CREATE TABLE IF NOT EXISTS issue (
|
await client.execute(`CREATE TABLE IF NOT EXISTS issue (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
localID INT,
|
localID INT,
|
||||||
project TEXT,
|
project TEXT,
|
||||||
@ -38,10 +38,10 @@ export default {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async down(client) {
|
async down(client) {
|
||||||
await client.query(`DROP TABLE project`)
|
await client.execute(`DROP TABLE project`)
|
||||||
await client.query(`DROP TABLE state`)
|
await client.execute(`DROP TABLE state`)
|
||||||
await client.query(`DROP TABLE issue`)
|
await client.execute(`DROP TABLE issue`)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
} as MigrationObj
|
} as Migration
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +1,22 @@
|
|||||||
import Schema, { type Impl } from 'models/Schema'
|
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||||
|
|
||||||
const schema = new Schema({
|
const schema = new Schema({
|
||||||
/**
|
/**
|
||||||
* the project ID
|
* 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
|
* 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 },
|
displayid: s.string().nullable(),
|
||||||
visibility: { type: String, nullable: true },
|
visibility: s.string().nullable(),
|
||||||
archived: { type: Boolean, defaultValue: false }
|
archived: s.boolean().defaultValue(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default schema
|
export default schema
|
||||||
|
|
||||||
export type ProjectObj = Impl<typeof schema>
|
export type ProjectObj = SchemaInfer<typeof schema>
|
||||||
|
@ -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
|
* one of the results should be true to be true
|
||||||
*/
|
*/
|
||||||
@ -108,7 +108,7 @@ export type QueryComparisonOperator<Value> = {
|
|||||||
$inc: Value | null
|
$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]>
|
[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
|
* 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
|
* 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
|
[Key in keyof Obj]?: Sort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +1,22 @@
|
|||||||
import Schema, { type Impl } from 'models/Schema'
|
import Schema, { s, type SchemaInfer } from 'libs/Schema'
|
||||||
|
|
||||||
const schema = new Schema({
|
const schema = new Schema({
|
||||||
/**
|
/**
|
||||||
* the project ID
|
* the project ID
|
||||||
*/
|
*/
|
||||||
id: {type: String, database: {unique: true, index: true, auto: true}},
|
id: s.string().attr('db:unique', 'db:auto'),
|
||||||
project: String, // project id
|
project: s.string(), // project id
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* the email the project was created from
|
* 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 default schema
|
||||||
|
|
||||||
export type StateObj = Impl<typeof schema>
|
export type StateObj = SchemaInfer<typeof schema>
|
||||||
|
49
src/models/config.ts
Normal file
49
src/models/config.ts
Normal 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
|
@ -1,4 +1,4 @@
|
|||||||
import { objectOmit } from '@dzeio/object-util'
|
import { objectMap, objectOmit } from '@dzeio/object-util'
|
||||||
import type { APIRoute } from 'astro'
|
import type { APIRoute } from 'astro'
|
||||||
import ResponseBuilder from 'libs/ResponseBuilder'
|
import ResponseBuilder from 'libs/ResponseBuilder'
|
||||||
import DaoFactory from 'models/DaoFactory'
|
import DaoFactory from 'models/DaoFactory'
|
||||||
@ -9,14 +9,26 @@ export const GET: APIRoute = async (ctx) => {
|
|||||||
const dao = DaoFactory.get('issue')
|
const dao = DaoFactory.get('issue')
|
||||||
|
|
||||||
return new ResponseBuilder()
|
return new ResponseBuilder()
|
||||||
.body((await dao.findOne({project: projectId, id: taskId})))
|
.body((await dao.findOne({ project: projectId, id: taskId })))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
export const POST: APIRoute = async (ctx) => {
|
export const POST: APIRoute = async (ctx) => {
|
||||||
|
const history = DaoFactory.get('history')
|
||||||
const taskId = ctx.params.issueId!
|
const taskId = ctx.params.issueId!
|
||||||
const dao = DaoFactory.get('issue')
|
const dao = DaoFactory.get('issue')
|
||||||
const body = objectOmit(await ctx.request.json(), 'label')
|
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()
|
return new ResponseBuilder()
|
||||||
.body(res)
|
.body(res)
|
||||||
|
19
src/pages/api/v1/projects/[id]/issues/[issueId]/details.ts
Normal file
19
src/pages/api/v1/projects/[id]/issues/[issueId]/details.ts
Normal 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()
|
||||||
|
}
|
@ -8,20 +8,37 @@ export const POST: APIRoute = async (ctx) => {
|
|||||||
const projectId = ctx.params.id!
|
const projectId = ctx.params.id!
|
||||||
const dao = DaoFactory.get('issue')
|
const dao = DaoFactory.get('issue')
|
||||||
const stateDao = DaoFactory.get('state')
|
const stateDao = DaoFactory.get('state')
|
||||||
const issueCount = await dao.findAll({
|
let { rows: issueCount } = await dao.findAll({
|
||||||
project: projectId
|
project: projectId
|
||||||
})
|
})
|
||||||
const defaultState = await stateDao.findOne({
|
const defaultState = await stateDao.findOne({
|
||||||
project: projectId,
|
project: projectId,
|
||||||
preset: true
|
preset: true
|
||||||
})
|
})
|
||||||
const res = await dao.create({
|
const json = await ctx.request.json()
|
||||||
...(await ctx.request.json()),
|
|
||||||
project: projectId,
|
const isMultiple = json.multiple
|
||||||
localid: issueCount.rows + 1,
|
delete json.multiple
|
||||||
state: defaultState?.id ?? 'empty',
|
|
||||||
labels: []
|
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()
|
return new ResponseBuilder().body(res).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,13 +48,14 @@ export const GET: APIRoute = async (ctx) => {
|
|||||||
const dao = DaoFactory.get('issue')
|
const dao = DaoFactory.get('issue')
|
||||||
const stateDao = DaoFactory.get('state')
|
const stateDao = DaoFactory.get('state')
|
||||||
const states = (await stateDao.findAll({ project: projectId })).data
|
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) => ({
|
const res = issues.map((it) => ({
|
||||||
...it,
|
...it,
|
||||||
state: states.find((st) => st.id === it.state),
|
state: states.find((st) => st.id === it.state),
|
||||||
priority: getPriorityText(it.priority ?? Priority.NONE)
|
priority: getPriorityText(it.priority ?? Priority.NONE)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return new ResponseBuilder()
|
return new ResponseBuilder()
|
||||||
.body(res)
|
.body(res)
|
||||||
.build()
|
.build()
|
||||||
|
@ -4,7 +4,7 @@ import Input from 'components/global/Input.astro'
|
|||||||
import Select from 'components/global/Select/index.astro'
|
import Select from 'components/global/Select/index.astro'
|
||||||
import MainLayout from 'layouts/MainLayout.astro'
|
import MainLayout from 'layouts/MainLayout.astro'
|
||||||
import DaoFactory from 'models/DaoFactory'
|
import DaoFactory from 'models/DaoFactory'
|
||||||
import Schema from 'models/Schema'
|
import Schema, { s } from 'libs/Schema'
|
||||||
import type { StateObj } from 'models/State'
|
import type { StateObj } from 'models/State'
|
||||||
import route from 'route'
|
import route from 'route'
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ const defaultStates: Array<Partial<StateObj>> = [{
|
|||||||
|
|
||||||
if (Astro.request.method === 'POST') {
|
if (Astro.request.method === 'POST') {
|
||||||
const input = new Schema({
|
const input = new Schema({
|
||||||
name: String
|
name: s.string()
|
||||||
}).parseFormData(await Astro.request.formData())
|
}).parseFormData(await Astro.request.formData())
|
||||||
if (input) {
|
if (input) {
|
||||||
const project = await dao.create({
|
const project = await dao.create({
|
||||||
@ -62,13 +62,14 @@ const projects = await dao.findAll()
|
|||||||
</form>
|
</form>
|
||||||
<form action="">
|
<form action="">
|
||||||
<Select
|
<Select
|
||||||
multiple
|
autocomplete
|
||||||
options={projects.data.map((it) => ({value: it.id, title: it.name}))}
|
|
||||||
name="name"
|
name="name"
|
||||||
data-input="/api/v1/projects"
|
options={projects.data.map((it) => ({value: it.id, title: it.name}))}
|
||||||
data-output="#projectItem ul#results inner"
|
hyp:action1="/api/v1/projects"
|
||||||
data-trigger="keydown load after:100"
|
hyp:action2="#projectItem ul#results inner"
|
||||||
data-multiple
|
hyp:trigger="load after:100"
|
||||||
|
hyp:multiple
|
||||||
|
data-debug
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -83,9 +84,3 @@ const projects = await dao.findAll()
|
|||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|
||||||
<script>
|
|
||||||
import Hyperion from 'libs/Hyperion'
|
|
||||||
|
|
||||||
Hyperion.setup()
|
|
||||||
</script>
|
|
||||||
|
@ -44,18 +44,19 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
|||||||
|
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<main class="container gap-24 md:mt-6">
|
<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>
|
<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">
|
<Table class="table-fixed">
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-1/12">id</th>
|
<th class="w-1/12">id</th>
|
||||||
@ -67,17 +68,18 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody
|
<tbody
|
||||||
data-input={route('/api/v1/projects/[id]/issues', {id: project.id})}
|
hyp:action0="load:form#form"
|
||||||
data-multiple
|
hyp:action1={route('/api/v1/projects/[id]/issues', {id: project.id})}
|
||||||
data-trigger="load"
|
hyp:action2="template#issue"
|
||||||
data-output="template#issue"
|
hyp:multiple
|
||||||
|
hyp:trigger="load"
|
||||||
></tbody>
|
></tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<template id="issue">
|
<template id="issue">
|
||||||
<tr
|
<tr
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
data-output="#issueTemplate #issue-details inner"
|
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}"
|
data-attribute="data-id:{id}"
|
||||||
>
|
>
|
||||||
<td data-attribute={`${project.displayid}-{localid}`}></td>
|
<td data-attribute={`${project.displayid}-{localid}`}></td>
|
||||||
@ -95,47 +97,64 @@ const states = dbStates.data.length > 0 ? dbStates.data : defaultStates
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</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 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>
|
<Button>Ajouter</Button>
|
||||||
</form>
|
</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="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 class="flex gap-4 justify-end px-4 pt-4">
|
|
||||||
|
|
||||||
<EllipsisVertical class="cursor-pointer" />
|
|
||||||
<X class="issue-close cursor-pointer" />
|
|
||||||
</div>
|
|
||||||
<div id="issue-details"></div>
|
<div id="issue-details"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="issueTemplate">
|
<template id="issueTemplate">
|
||||||
<form
|
<form
|
||||||
data-trigger="keyup after:250"
|
data-trigger="pointerup keyup after:250"
|
||||||
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{id}'}, true)}`}
|
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]', {id: project.id, issueId: '{issue.id}'}, true)}`}
|
||||||
data-output="hyp:tbody[data-input]"
|
data-output="hyp:tbody[hyp\\:action1]"
|
||||||
class="flex flex-col gap-5 px-6 py-2"
|
class="flex flex-col gap-5 px-6 py-2"
|
||||||
>
|
>
|
||||||
<p>
|
<div class="flex gap-4 justify-end pt-4 items-center">
|
||||||
<span data-attribute={`${project.displayid}-{localid}`}></span>
|
<span data-attribute={`${project.displayid}-{issue.localid}`}></span>
|
||||||
<span class="text-2xl" data-attribute="name"></span>
|
<div class="flex-grow" data-attribute="issue.name"></div>
|
||||||
</p>
|
<label>
|
||||||
<Input data-attribute="value:name" name="name" />
|
<input type="checkbox" />
|
||||||
<Input type="textarea" name="description" data-attribute="description"></Input>
|
<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 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">
|
||||||
<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>
|
<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
|
<div class="flex gap-4 items-center"><p>Labels</p><Select
|
||||||
data-trigger="change"
|
name="labels"
|
||||||
name="label"
|
|
||||||
multiple
|
|
||||||
options={['a', 'b', 'c']}
|
options={['a', 'b', 'c']}
|
||||||
data-output="hyp:[data-id='{id}']"
|
multiple
|
||||||
data-attribute="value:labels"
|
data-attribute="value:issue.labels"
|
||||||
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]/labels', {id: project.id, issueId: '{id}'}, true)}`}
|
data-input={`post:${route('/api/v1/projects/[id]/issues/[issueId]/labels', {id: project.id, issueId: '{id}'}, true)}`}
|
||||||
/></div>
|
/></div>
|
||||||
<ul class="flex gap-2 flex-row">
|
<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>
|
<span data-attribute="this"></span>
|
||||||
<X
|
<X
|
||||||
class="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
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>
|
<p class="pl-2 text-lg text-center">History</p>
|
||||||
<ul class="border-l flex flex-col gap-2">
|
<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">
|
<li class="py-2 px-4 bg-white dark:bg-gray-800 rounded-xl mb-2 mx-2">
|
||||||
<p class="flex justify-between">
|
<p class="flex justify-between">
|
||||||
<span>Username</span>
|
<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>
|
<p class="italic">changed state from "Todo" to "WIP"</p>
|
||||||
</li>
|
</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">
|
<p class="font-bold flex justify-between">
|
||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
<span>2024-06-09 14:45:58</span>
|
<span>2024-06-09 14:45:58</span>
|
||||||
</p>
|
</p>
|
||||||
<p>Il faudrait voir pour changer la valeur de l'élément, cela fait un peu chelou dans l'état actuel...</p>
|
<p>Il faudrait voir pour changer la valeur de l'élément, cela fait un peu chelou dans l'état actuel...</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul> -->
|
||||||
<Input type="textarea" label="Commentaire" />
|
<Input type="textarea" label="Commentaire" />
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
|
||||||
</MainLayout>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const x = document.querySelector('.issue-close')
|
// swap element
|
||||||
const details = document.querySelector('#issue-details')
|
const item = document.querySelector<HTMLInputElement>('#switch-item')
|
||||||
x?.addEventListener('click', () => {
|
const input = document.querySelector<HTMLInputElement>('input[name="name"]')
|
||||||
while (details?.firstElementChild) {
|
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[name="name"]')
|
||||||
details?.firstElementChild.remove()
|
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>
|
</script>
|
||||||
|
@ -62,9 +62,3 @@ console.log(res)
|
|||||||
<li data-attribute="this"></li>
|
<li data-attribute="this"></li>
|
||||||
</template>
|
</template>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|
||||||
<script>
|
|
||||||
import Hyperion from 'libs/Hyperion'
|
|
||||||
|
|
||||||
Hyperion.setup()
|
|
||||||
</script>
|
|
||||||
|
Reference in New Issue
Block a user