初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import {checkAppUrl} from '../common-page.ts';
|
||||
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {pathEscape} from '../../utils/url.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
function onSecurityProtocolChange(): void {
|
||||
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
|
||||
showElem('.has-tls');
|
||||
} else {
|
||||
hideElem('.has-tls');
|
||||
}
|
||||
}
|
||||
|
||||
export function initAdminCommon(): void {
|
||||
if (!document.querySelector('.page-content.admin')) return;
|
||||
|
||||
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
|
||||
checkAppUrl();
|
||||
|
||||
initAdminUser();
|
||||
initAdminAuthentication();
|
||||
initAdminNotice();
|
||||
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
|
||||
}
|
||||
|
||||
function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
|
||||
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
|
||||
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
|
||||
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
|
||||
if (!selectAll) return;
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
}
|
||||
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
|
||||
refresh();
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
|
||||
const collectSelectedIds = () => {
|
||||
const ids = [];
|
||||
for (const cb of rowCheckboxes) {
|
||||
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
|
||||
}
|
||||
return ids.join(',');
|
||||
};
|
||||
formRunnerIds.value = collectSelectedIds();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
|
||||
if (!pageContent) return;
|
||||
|
||||
document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
|
||||
if (this.value?.startsWith('0')) {
|
||||
document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
|
||||
document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
|
||||
hideElem('.non-local');
|
||||
showElem('.local');
|
||||
document.querySelector<HTMLInputElement>('#user_name')?.focus();
|
||||
|
||||
if (this.getAttribute('data-password') === 'required') {
|
||||
document.querySelector('#password')?.setAttribute('required', 'required');
|
||||
}
|
||||
} else {
|
||||
if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
|
||||
document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
|
||||
showElem('.non-local');
|
||||
hideElem('.local');
|
||||
document.querySelector<HTMLInputElement>('#login_name')?.focus();
|
||||
|
||||
document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAdminAuthentication() {
|
||||
const pageContent = document.querySelector('.page-content.admin.authentication');
|
||||
if (!pageContent) return;
|
||||
|
||||
const isNewPage = pageContent.classList.contains('new');
|
||||
const isEditPage = pageContent.classList.contains('edit');
|
||||
if (!isNewPage && !isEditPage) return;
|
||||
|
||||
function onUsePagedSearchChange() {
|
||||
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
|
||||
if (document.querySelector<HTMLInputElement>('#use_paged_search')!.checked) {
|
||||
showElem('.search-page-size');
|
||||
for (const el of searchPageSizeElements) {
|
||||
el.querySelector('input')?.setAttribute('required', 'required');
|
||||
}
|
||||
} else {
|
||||
hideElem('.search-page-size');
|
||||
for (const el of searchPageSizeElements) {
|
||||
el.querySelector('input')?.removeAttribute('required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOAuth2Change(applyDefaultValues: boolean) {
|
||||
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
|
||||
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
||||
switch (provider) {
|
||||
case 'openidConnect':
|
||||
case 'aws-cognito':
|
||||
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
|
||||
showElem('.open_id_connect_auto_discovery_url');
|
||||
showElem('.open_id_connect_external_id_claim');
|
||||
break;
|
||||
default: {
|
||||
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
|
||||
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
|
||||
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
|
||||
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
|
||||
if (couldChangeCustomURLs) {
|
||||
showElem('.oauth2_use_custom_url'); // show the checkbox
|
||||
}
|
||||
if (mustProvideCustomURLs) {
|
||||
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
|
||||
toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
|
||||
onOAuth2UseCustomURLChange(applyDefaultValues);
|
||||
}
|
||||
|
||||
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
|
||||
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
||||
hideElem('.oauth2_use_custom_url_field');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
|
||||
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
|
||||
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked) {
|
||||
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
|
||||
if (applyDefaultValues) {
|
||||
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
|
||||
}
|
||||
const customInput = document.querySelector(`#${provider}_${custom}`);
|
||||
if (customInput?.getAttribute('data-available') === 'true') {
|
||||
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
showElem(`.oauth2_${custom}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEnableLdapGroupsChange() {
|
||||
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
|
||||
toggleElem(document.querySelector('#ldap-group-options')!, checked);
|
||||
}
|
||||
|
||||
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
|
||||
|
||||
// New authentication
|
||||
if (isNewPage) {
|
||||
const onAuthTypeChange = function () {
|
||||
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
|
||||
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
|
||||
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
|
||||
|
||||
const authType = elAuthType.value;
|
||||
switch (authType) {
|
||||
case '2': // LDAP
|
||||
showElem('.ldap');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
document.querySelector('.binddnrequired')?.classList.add('required');
|
||||
break;
|
||||
case '3': // SMTP
|
||||
showElem('.smtp');
|
||||
showElem('.has-tls');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.smtp div.required input, .has-tls')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
case '4': // PAM
|
||||
showElem('.pam');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
case '5': // LDAP
|
||||
showElem('.dldap');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
case '6': // OAuth2
|
||||
showElem('.oauth2');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
onOAuth2Change(true);
|
||||
break;
|
||||
case '7': // SSPI
|
||||
showElem('.sspi');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.sspi div.required input')) {
|
||||
input.setAttribute('required', 'required');
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (authType === '2' || authType === '5') {
|
||||
onSecurityProtocolChange();
|
||||
onEnableLdapGroupsChange();
|
||||
}
|
||||
if (authType === '2') {
|
||||
onUsePagedSearchChange();
|
||||
}
|
||||
};
|
||||
elAuthType.addEventListener('change', onAuthTypeChange);
|
||||
onAuthTypeChange();
|
||||
|
||||
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
|
||||
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
|
||||
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
|
||||
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
|
||||
|
||||
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
|
||||
}
|
||||
// Edit authentication
|
||||
if (isEditPage) {
|
||||
const authType = elAuthType.value;
|
||||
if (authType === '2' || authType === '5') {
|
||||
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
|
||||
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
|
||||
onEnableLdapGroupsChange();
|
||||
if (authType === '2') {
|
||||
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
|
||||
}
|
||||
} else if (authType === '6') {
|
||||
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
|
||||
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
|
||||
onOAuth2Change(false);
|
||||
}
|
||||
}
|
||||
|
||||
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
|
||||
const onAuthNameChange = function () {
|
||||
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
|
||||
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
|
||||
};
|
||||
elAuthName.addEventListener('input', onAuthNameChange);
|
||||
onAuthNameChange();
|
||||
}
|
||||
|
||||
function initAdminNotice() {
|
||||
const pageContent = document.querySelector('.page-content.admin.notice');
|
||||
if (!pageContent) return;
|
||||
|
||||
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
|
||||
|
||||
// Attach view detail modals
|
||||
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
|
||||
const elModalDesc = detailModal.querySelector('.content pre')!;
|
||||
elModalDesc.textContent = elNoticeDesc.textContent;
|
||||
showFomanticModal(detailModal);
|
||||
}));
|
||||
|
||||
// Select actions
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
|
||||
|
||||
queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
|
||||
switch (el.getAttribute('data-action')) {
|
||||
case 'select-all':
|
||||
for (const checkbox of checkboxes) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
break;
|
||||
case 'deselect-all':
|
||||
for (const checkbox of checkboxes) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
break;
|
||||
case 'inverse':
|
||||
for (const checkbox of checkboxes) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}));
|
||||
|
||||
document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
|
||||
e.preventDefault();
|
||||
this.classList.add('is-loading', 'disabled');
|
||||
const data = new FormData();
|
||||
for (const checkbox of checkboxes) {
|
||||
if (checkbox.checked) {
|
||||
data.append('ids[]', checkbox.closest('.ui.checkbox')!.getAttribute('data-id')!);
|
||||
}
|
||||
}
|
||||
await POST(this.getAttribute('data-link')!, {data});
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {ConfigFormValueMapper} from './config.ts';
|
||||
|
||||
test('ConfigFormValueMapper', () => {
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<input id="checkbox-unrelated" type="checkbox" value="v-unrelated" checked>
|
||||
|
||||
<!-- top-level key -->
|
||||
<input name="k1" type="checkbox" value="v-key-only" data-config-dyn-key="k1" data-config-value-json="true" data-config-value-type="boolean">
|
||||
|
||||
<input type="hidden" data-config-dyn-key="k2" data-config-value-json='"k2-val"'>
|
||||
<input name="k2">
|
||||
|
||||
<textarea name="repository.open-with.editor-apps"> a = b\n</textarea>
|
||||
|
||||
<input name="k-flipped-true" type="checkbox" data-config-value-type="flipped">
|
||||
<input name="k-flipped-false" type="checkbox" checked data-config-value-type="flipped">
|
||||
|
||||
<!-- sub key -->
|
||||
<input type="hidden" data-config-dyn-key="struct" data-config-value-json='{"SubBoolean": true, "SubTimestamp": 123456789, "OtherKey": "other-value"}'>
|
||||
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
|
||||
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
|
||||
<textarea name="struct.NewKey">new-value</textarea>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const form = document.querySelector('form')!;
|
||||
const mapper = new ConfigFormValueMapper(form);
|
||||
mapper.fillFromSystemConfig();
|
||||
const formData = mapper.collectToFormData();
|
||||
const result: Record<string, string> = {};
|
||||
const keys: string[] = [], values: string[] = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'key') keys.push(value as string);
|
||||
if (key === 'value') values.push(value as string);
|
||||
}
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
result[keys[i]] = values[i];
|
||||
}
|
||||
expect(result).toEqual({
|
||||
'k1': 'true',
|
||||
'k2': '"k2-val"',
|
||||
'k-flipped-false': 'false',
|
||||
'k-flipped-true': 'true',
|
||||
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
|
||||
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {errorMessage} from '../../modules/errors.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
const data = new URLSearchParams({
|
||||
key: el.getAttribute('data-config-dyn-key')!,
|
||||
value: String(collectCheckboxBooleanValue(el)),
|
||||
});
|
||||
const resp = await POST(`${appSubUrl}/-/admin/config`, {data});
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, errorMessage(ex));
|
||||
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;
|
||||
}
|
||||
|
||||
// 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)
|
||||
type ConfigValueType = 'boolean' | 'flipped' | 'string' | 'number' | 'timestamp'; // TODO: support more types like array, not used at the moment.
|
||||
|
||||
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');
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
collectConfigValueFromElement(el: GeneralFormFieldElement) {
|
||||
let val: any;
|
||||
const valType = this.presetValueTypes[el.name];
|
||||
if (el.matches('[type="checkbox"]')) {
|
||||
// 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);
|
||||
} 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
|
||||
cfgVal[subKey] = this.collectConfigValueFromElement(el);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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:
|
||||
// "foo.enabled" => "true"
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
export async function initAdminSelfCheck() {
|
||||
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
|
||||
if (!elCheckByFrontend) return;
|
||||
|
||||
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content')!;
|
||||
|
||||
// send frontend self-check request
|
||||
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
|
||||
data: new URLSearchParams({
|
||||
location_origin: window.location.origin,
|
||||
now: String(Date.now()), // TODO: check time difference between server and client
|
||||
}),
|
||||
});
|
||||
const json: Record<string, any> = await resp.json();
|
||||
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
|
||||
for (const problem of json.problems ?? []) {
|
||||
const elProblem = document.createElement('div');
|
||||
elProblem.classList.add('ui', 'warning', 'message');
|
||||
elProblem.textContent = problem;
|
||||
elCheckByFrontend.append(elProblem);
|
||||
}
|
||||
|
||||
// only show the "no problem" if there is no visible "self-check-problem"
|
||||
const hasProblem = Boolean(elContent.querySelectorAll('.self-check-problem:not(.tw-hidden)').length);
|
||||
toggleElem(elContent.querySelector('.self-check-no-problem')!, !hasProblem);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function initAdminUserListSearchForm(): void {
|
||||
const searchForm = window.config.pageData.adminUserListSearchForm;
|
||||
if (!searchForm) return;
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
|
||||
if (!form) return;
|
||||
|
||||
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
if (searchForm.StatusFilterMap) {
|
||||
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
|
||||
if (!v) continue;
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const radio of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
|
||||
radio.addEventListener('click', () => {
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
|
||||
for (const button of resetButtons) {
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
|
||||
if (input.name.startsWith('status_filter[')) {
|
||||
input.checked = false;
|
||||
}
|
||||
}
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user