2024-07-07 17:32:30 +02:00
import { showTemporaryTooltip } from '../../modules/tippy.ts' ;
import { POST } from '../../modules/fetch.ts' ;
2026-02-26 16:16:11 +01:00
import { registerGlobalInitFunc } from '../../modules/observer.ts' ;
import { queryElems } from '../../utils/dom.ts' ;
import { submitFormFetchAction } from '../common-fetch-action.ts' ;
2022-10-17 07:29:26 +08:00
2023-10-05 09:08:19 +08:00
const { appSubUrl } = window . config ;
2022-10-17 07:29:26 +08:00
2026-02-28 00:39:26 +08:00
function collectCheckboxBooleanValue ( el : HTMLInputElement ) : boolean {
const valType = el . getAttribute ( 'data-config-value-type' ) as ConfigValueType ;
if ( valType === 'boolean' ) return el . checked ;
if ( valType === 'flipped' ) return ! el . checked ;
requireExplicitValueType ( el ) ;
}
2026-02-26 16:16:11 +01:00
function initSystemConfigAutoCheckbox ( el : HTMLInputElement ) {
el . addEventListener ( 'change' , async ( ) = > {
// if the checkbox is inside a form, we assume it's handled by the form submit and do not send an individual request
if ( el . closest ( 'form' ) ) return ;
try {
2026-02-28 00:39:26 +08:00
const data = new URLSearchParams ( {
key : el.getAttribute ( 'data-config-dyn-key' ) ! ,
value : String ( collectCheckboxBooleanValue ( el ) ) ,
2026-02-26 16:16:11 +01:00
} ) ;
2026-02-28 00:39:26 +08:00
const resp = await POST ( ` ${ appSubUrl } /-/admin/config ` , { data } ) ;
2026-02-26 16:16:11 +01:00
const json : Record < string , any > = await resp . json ( ) ;
if ( json . errorMessage ) throw new Error ( json . errorMessage ) ;
} catch ( ex ) {
showTemporaryTooltip ( el , ex . toString ( ) ) ;
el . checked = ! el . checked ;
}
} ) ;
}
type GeneralFormFieldElement = HTMLInputElement ;
function unsupportedElement ( el : Element ) : never {
// HINT: for future developers: if you need to handle a config that cannot be directly mapped to a form element, you should either:
// * Add a "hidden" input to store the value (not configurable)
// * Design a new "component" to handle the config
throw new Error ( ` Unsupported config form value mapping for ${ el . nodeName } (name= ${ ( el as HTMLInputElement ) . name } ,type= ${ ( el as HTMLInputElement ) . type } ), please add more and design carefully ` ) ;
}
function requireExplicitValueType ( el : Element ) : never {
throw new Error ( ` Unsupported config form value type for ${ el . nodeName } (name= ${ ( el as HTMLInputElement ) . name } ,type= ${ ( el as HTMLInputElement ) . type } ), please add explicit value type with "data-config-value-type" attribute ` ) ;
}
// try to extract the subKey for the config value from the element name
// * return '' if the element name exactly matches the config key, which means the value is directly stored in the element
// * return null if the config key not match
function extractElemConfigSubKey ( el : GeneralFormFieldElement , dynKey : string ) : string | null {
if ( el . name === dynKey ) return '' ;
if ( el . name . startsWith ( ` ${ dynKey } . ` ) ) return el . name . slice ( dynKey . length + 1 ) ; // +1 for the dot
return null ;
}
2022-10-17 07:29:26 +08:00
2026-02-26 16:16:11 +01:00
// Due to the different design between HTML form elements and the JSON struct of the config values, we need to explicitly define some types.
// * checkbox can be used for boolean value, it can also be used for multiple values (array)
2026-02-28 00:39:26 +08:00
type ConfigValueType = 'boolean' | 'flipped' | 'string' | 'number' | 'timestamp' ; // TODO: support more types like array, not used at the moment.
2026-02-26 16:16:11 +01:00
function toDatetimeLocalValue ( unixSeconds : number ) {
const d = new Date ( unixSeconds * 1000 ) ;
return new Date ( d . getTime ( ) - d . getTimezoneOffset ( ) * 60000 ) . toISOString ( ) . slice ( 0 , 16 ) ;
}
export class ConfigFormValueMapper {
form : HTMLFormElement ;
presetJsonValues : Record < string , any > = { } ;
presetValueTypes : Record < string , ConfigValueType > = { } ;
constructor ( form : HTMLFormElement ) {
this . form = form ;
for ( const el of queryElems < HTMLInputElement > ( form , '[data-config-value-json]' ) ) {
const dynKey = el . getAttribute ( 'data-config-dyn-key' ) ! ;
const jsonStr = el . getAttribute ( 'data-config-value-json' ) ;
2023-10-05 09:08:19 +08:00
try {
2026-02-26 16:16:11 +01:00
this . presetJsonValues [ dynKey ] = JSON . parse ( jsonStr || '{}' ) ; // empty string also is valid, default to an empty object
} catch ( error ) {
this . presetJsonValues [ dynKey ] = { } ; // in case the value in database is corrupted, don't break the whole form
console . error ( ` Error parsing JSON for config ${ dynKey } : ` , error ) ;
}
}
for ( const el of queryElems < HTMLInputElement > ( form , '[data-config-value-type]' ) ) {
const valKey = el . getAttribute ( 'data-config-dyn-key' ) || el . name ;
this . presetValueTypes [ valKey ] = el . getAttribute ( 'data-config-value-type' ) ! as ConfigValueType ;
}
}
// try to assign the config value to the form element, return true if assigned successfully,
// otherwise return false (e.g. the element is not related to the config key)
assignConfigValueToFormElement ( el : GeneralFormFieldElement , dynKey : string , cfgVal : any ) {
const subKey = extractElemConfigSubKey ( el , dynKey ) ;
if ( subKey === null ) return false ; // if not match, skip
const val = subKey ? cfgVal ! [ subKey ] : cfgVal ;
if ( val === null ) return true ; // if name matches, but no value to assign, also succeed because the form element does exist
const valType = this . presetValueTypes [ el . name ] ;
if ( el . matches ( '[type="checkbox"]' ) ) {
if ( valType !== 'boolean' ) requireExplicitValueType ( el ) ;
el . checked = Boolean ( val ? ? el . checked ) ;
} else if ( el . matches ( '[type="datetime-local"]' ) ) {
if ( valType !== 'timestamp' ) requireExplicitValueType ( el ) ;
if ( val ) el . value = toDatetimeLocalValue ( val ) ;
} else if ( el . matches ( 'textarea' ) ) {
el . value = String ( val ? ? el . value ) ;
} else if ( el . matches ( 'input' ) && ( el . getAttribute ( 'type' ) ? ? 'text' ) === 'text' ) {
el . value = String ( val ? ? el . value ) ;
} else {
unsupportedElement ( el ) ;
}
return true ;
}
2026-02-28 00:39:26 +08:00
collectConfigValueFromElement ( el : GeneralFormFieldElement ) {
2026-02-26 16:16:11 +01:00
let val : any ;
const valType = this . presetValueTypes [ el . name ] ;
if ( el . matches ( '[type="checkbox"]' ) ) {
2026-02-28 00:39:26 +08:00
// TODO: if it needs to support array values in the future,
// it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly,
// and set the namedElems[matchedIdx] to null to avoid duplicate processing.
val = collectCheckboxBooleanValue ( el ) ;
2026-02-26 16:16:11 +01:00
} else if ( el . matches ( '[type="datetime-local"]' ) ) {
if ( valType !== 'timestamp' ) requireExplicitValueType ( el ) ;
val = Math . floor ( new Date ( el . value ) . getTime ( ) / 1000 ) ? ? 0 ; // NaN is fine to JSON.stringify, it becomes null.
} else if ( el . matches ( 'textarea' ) ) {
val = el . value ;
} else if ( el . matches ( 'input' ) && ( el . getAttribute ( 'type' ) ? ? 'text' ) === 'text' ) {
val = el . value ;
} else {
unsupportedElement ( el ) ;
}
return val ;
}
collectConfigSubValues ( namedElems : Array < GeneralFormFieldElement | null > , dynKey : string , cfgVal : Record < string , any > ) {
for ( let idx = 0 ; idx < namedElems . length ; idx ++ ) {
const el = namedElems [ idx ] ;
if ( ! el ) continue ;
const subKey = extractElemConfigSubKey ( el , dynKey ) ;
if ( ! subKey ) continue ; // if not match, skip
2026-02-28 00:39:26 +08:00
cfgVal [ subKey ] = this . collectConfigValueFromElement ( el ) ;
2026-02-26 16:16:11 +01:00
namedElems [ idx ] = null ;
}
}
fillFromSystemConfig() {
for ( const [ dynKey , cfgVal ] of Object . entries ( this . presetJsonValues ) ) {
const elems = this . form . querySelectorAll < GeneralFormFieldElement > ( ` [name^=" ${ CSS . escape ( dynKey ) } "] ` ) ;
let assigned = false ;
for ( const el of elems ) {
if ( this . assignConfigValueToFormElement ( el , dynKey , cfgVal ) ) {
assigned = true ;
}
2022-10-17 07:29:26 +08:00
}
2026-02-26 16:16:11 +01:00
if ( ! assigned ) throw new Error ( ` Could not find form element for config ${ dynKey } , please check the form design and json struct ` ) ;
}
}
// TODO: OPEN-WITH-EDITOR-APP-JSON: need to use the same logic as backend
marshalConfigValueOpenWithEditorApps ( cfgVal : string ) : string {
const apps : Array < { DisplayName : string , OpenURL : string } > = [ ] ;
const lines = cfgVal . split ( '\n' ) ;
for ( const line of lines ) {
let [ displayName , openUrl ] = line . split ( '=' , 2 ) ;
displayName = displayName . trim ( ) ;
openUrl = openUrl ? . trim ( ) ? ? '' ;
if ( ! displayName || ! openUrl ) continue ;
apps . push ( { DisplayName : displayName , OpenURL : openUrl } ) ;
}
return JSON . stringify ( apps ) ;
}
marshalConfigValue ( dynKey : string , cfgVal : any ) : string {
if ( dynKey === 'repository.open-with.editor-apps' ) return this . marshalConfigValueOpenWithEditorApps ( cfgVal ) ;
return JSON . stringify ( cfgVal ) ;
2023-10-05 09:08:19 +08:00
}
2026-02-26 16:16:11 +01:00
collectToFormData ( ) : FormData {
const namedElems : Array < GeneralFormFieldElement | null > = [ ] ;
queryElems ( this . form , '[name]' , ( el ) = > namedElems . push ( el as GeneralFormFieldElement ) ) ;
// first, process the config options with sub values, for example:
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
const formData = new FormData ( ) ;
for ( const [ dynKey , cfgVal ] of Object . entries ( this . presetJsonValues ) ) {
this . collectConfigSubValues ( namedElems , dynKey , cfgVal ) ;
formData . append ( 'key' , dynKey ) ;
formData . append ( 'value' , this . marshalConfigValue ( dynKey , cfgVal ) ) ;
}
// now, the namedElems should only contain the config options without sub values,
// directly store the value in formData with key as the element name, for example:
2026-02-28 00:39:26 +08:00
// "foo.enabled" => "true"
2026-02-26 16:16:11 +01:00
for ( const el of namedElems ) {
if ( ! el ) continue ;
const dynKey = el . name ;
const newVal = this . collectConfigValueFromElement ( el ) ;
formData . append ( 'key' , dynKey ) ;
formData . append ( 'value' , this . marshalConfigValue ( dynKey , newVal ) ) ;
}
return formData ;
}
}
function initSystemConfigForm ( form : HTMLFormElement ) {
const formMapper = new ConfigFormValueMapper ( form ) ;
formMapper . fillFromSystemConfig ( ) ;
form . addEventListener ( 'submit' , async ( e ) = > {
if ( ! form . reportValidity ( ) ) return ;
e . preventDefault ( ) ;
const formData = formMapper . collectToFormData ( ) ;
await submitFormFetchAction ( form , { formData } ) ;
} ) ;
}
export function initAdminConfigs ( ) : void {
registerGlobalInitFunc ( 'initAdminConfigSettings' , ( el ) = > {
queryElems ( el , 'input[type="checkbox"][data-config-dyn-key]' , initSystemConfigAutoCheckbox ) ;
queryElems ( el , 'form.system-config-form' , initSystemConfigForm ) ;
} ) ;
2022-10-17 07:29:26 +08:00
}