From 42a81a4558e8448b6b752158ad00929b26a4fabe Mon Sep 17 00:00:00 2001 From: chfhtw Date: Mon, 18 Aug 2025 13:06:59 +0200 Subject: [PATCH 1/4] API error clustering --- public/js/plugins/Api.js | 648 ++++++++++++++++++++++++--------------- system/phrasesupdate.php | 20 ++ 2 files changed, 423 insertions(+), 245 deletions(-) diff --git a/public/js/plugins/Api.js b/public/js/plugins/Api.js index 43c2b3064..091a81717 100644 --- a/public/js/plugins/Api.js +++ b/public/js/plugins/Api.js @@ -10,7 +10,232 @@ export default { if (!app.config.globalProperties.$fhcAlert) app.use(FhcAlert); - function _get_config(form, uri, data, config) { + const $fhcAlert = app.config.globalProperties.$fhcAlert; + + function _send_array_or_object(errors, func) { + if (Array.isArray(errors)) + errors.forEach(func); + else + Object.entries(errors).forEach( + ([title, errs]) => errs.forEach( + error => func(error, title) + ) + ); + } + let DEFAULT_ERROR_CONFIG = { + success: true, + fail: true, + combine: { + form: ['validation', 'general'], + toast: ['validation', 'general', 'not_found', 'site_failed'] + }, + handler: { + form(form, errors) { + form.clearValidation(); + errors.forEach(err => form.setFeedback( + false, + err.messages || err.message + )); + }, + async toast(errors) { + const $p = app.config.globalProperties.$p; + if (!$p) + return Promise.reject('Phrasen plugin not loaded!'); + + async function _format_toast(errors) { + errors = errors.reduce((result, err) => { + switch (err.type) { + case 'not_found': + case 'site_failed': + if (err.message) + result[err.message] = [err.url]; + else + result._default = [err.url]; + break; + case 'general': + if (!result._default) + result._default = []; + result._default.push(err.message); + break; + case 'validation': + Object.entries(err.messages) + .forEach(([field, msg]) => { + if (!result[field]) + result[field] = []; + if (Array.isArray(msg)) + result[field].push(...msg); + else + result[field].push(msg); + }); + break; + } + return result; + }, {}); + let counter = 0; + const msgs = await Promise.all(Object.entries(errors) + .sort((a, b) => ['_default'].indexOf(b[0]) - ['_default'].indexOf(a[0])) // sort _default first + .map(async ([field, msgs]) => { + if (field == '_default') { + await $p.loadCategory('dashboard'); + const general = $p.t('dashboard/general'); + field = '
' + general + '
'; + } else { + field = '
' + field + '
'; + } + counter += msgs.length; + return field + + '
' + + msgs.join('
') + + '
'; + })); + return { + counter, + msgs + } + } + + let counter, msgs; + if (Array.isArray(errors)) { + ({ counter, msgs } = await _format_toast(errors)); + } else { + ({ counter, msgs } = await Object.entries(errors) + .reduce(async (res, [title, errs]) => { + const result = await res; + const { counter, msgs } = await _format_toast(errs); + result.counter += counter; + result.msgs.push('
' + + title + + '
' + + msgs.join('') + + '
'); + return result; + }, Promise.resolve({ counter: 0, msgs: []}))); + } + + await $p.loadCategory('ui'); + const n_errors = $p.t('ui/n_errors', { n: counter }); + + $fhcAlert.alertDefault( + 'error', + n_errors, + '
' + msgs.join('') + '
', + true, + true + ); + }, + php(errors) { + _send_array_or_object(errors, (error, title) => { + var message = ''; + message += 'Message: ' + error.message + '\n\n'; + message += 'Filename: ' + error.filename + '\n'; + message += 'Line Number: ' + error.line + '\n'; + if (error.backtrace && error.backtrace.length) { + message += '\nBacktrace: '; + error.backtrace.forEach(err => { + message += '\n\tFile: ' + err.file + '\n'; + message += '\tLine: ' + err.line + '\n'; + message += '\tFunction: ' + err.function + '\n'; + }); + } + switch (error.severity) { + case 'Warning': + case 'Core Warning': + case 'Compile Warning': + case 'User Warning': + if (title) + title += ': PHP ' + error.severity; + else + title = 'PHP ' + error.severity; + $fhcAlert.alertDefault('warn', title, message, true); + break; + case 'Notice': + case 'User Notice': + case 'Runtime Notice': + if (title) + title += ': PHP ' + error.severity; + else + title = 'PHP ' + error.severity; + $fhcAlert.alertDefault('info', title, message, true); + break; + default: + message = 'Type: PHP ' + error.severity + '\n\n' + message; + if (title) + message = title + '\n\n' + message; + $fhcAlert.alertSystemError(message); + break; + } + }); + }, + exception(errors) { + _send_array_or_object(errors, (error, title) => { + var message = ''; + if (title) + message += title + '\n\n'; + message += 'Type: ' + error.class + '\n\n'; + message += 'Message: ' + error.message + '\n\n'; + message += 'Filename: ' + error.filename + '\n'; + message += 'Line Number: ' + error.line + '\n'; + if (error.backtrace && error.backtrace.length) { + message += '\nBacktrace: '; + error.backtrace.forEach(err => { + message += '\n\tFile: ' + err.file + '\n'; + message += '\tLine: ' + err.line + '\n'; + message += '\tFunction: ' + err.function + '\n'; + }); + } + $fhcAlert.alertSystemError(message); + }); + }, + db(errors) { + _send_array_or_object(errors, (error, title) => { + var message = ''; + if (title) + message += title + '\n\n'; + if (error.heading !== undefined) + message += error.heading + '\n\n'; + if (error.code !== undefined) + message += 'Code: ' + error.code + '\n\n'; + if (error.sql !== undefined) + message += 'SQL: ' + error.sql + '\n\n'; + if (error.message !== undefined) + message += 'Message: ' + error.message + '\n\n'; + else if (error.messages !== undefined) + message += 'Messages: ' + error.messages.join('\n\t') + '\n\n'; + if (error.filename !== undefined) + message += 'Filename: ' + error.filename + '\n'; + if (error.line !== undefined) + message += 'Line Number: ' + error.line + '\n'; + + $fhcAlert.alertSystemError(message); + }); + }, + auth(errors) { + _send_array_or_object(errors, (error, title) => { + if (title) + title += ': ' + error.message; + else + title = error.message; + + var message = ''; + message += 'Controller name: ' + error.controller + '\n'; + message += 'Method name: ' + error.method + '\n'; + message += 'Required permissions: ' + error.required_permissions; + + $fhcAlert.alertDefault( + 'error', + title, + message, + true + ); + }); + } + } + }; + + if (options?.errorHandling !== undefined) + DEFAULT_ERROR_CONFIG = _merge_error_config(options.errorHandling); + + function get_config(form, uri, data, config) { if (typeof form == 'string' && config === undefined) { [uri, data, config] = [form, uri, data]; form = undefined; @@ -40,10 +265,9 @@ export default { return [uri, data, config]; } - - function _clean_return_value(response) { + function clean_return_value(response) { if (typeof response.data === 'string' || response.data instanceof String) - return _clean_return_value({ data: response }); + return clean_return_value({ data: response }); const result = response.data; delete response.data; @@ -55,10 +279,111 @@ export default { result.meta.response = response; return result; } - const baseURL = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + "/"; + function _merge_error_config(config) { + if (config === false || config === 'off') + return { ...DEFAULT_ERROR_CONFIG, success: false, fail: false }; + + if (!config || config === true) + return { ...DEFAULT_ERROR_CONFIG }; + + if (config === 'success') + return { ...DEFAULT_ERROR_CONFIG, fail: false }; + + if (config === 'fail') + return { ...DEFAULT_ERROR_CONFIG, success: false }; + + const { success, fail, handler, combine } = config; + + config = { ...DEFAULT_ERROR_CONFIG }; + + Object.entries({ fail, success }).forEach(([key, value]) => { + if (value !== undefined) + config[key] = value; + }); + Object.entries({ handler, combine }).forEach(([key, value]) => { + if (value !== undefined) + config[key] = { ...config[key], ...value }; + }); + + return config; + } + function get_error_handler(config) { + const result = _merge_error_config(config?.errorHandling); + + if (!config?.form) { + result.combine.form = []; + } else { + const formHandler = result.handler.form; + result.handler.form = errors => formHandler(config.form, errors); + } + + return result; + } + function get_error_list(error) { + if (error.response) { + if (error.response.status == 404) { + return [{ + type: 'not_found', + message: error.message, + url: error.request.responseURL + }]; + } else { + return error.response.data.errors; + } + } else if (error.request) { + return [{ + type: 'site_failed', + message: error.message, + url: error.request.responseURL + }]; + } else { + return [{ + type: 'script', + message: error.message + }]; + } + } + function popHandleableErrors(errorHandling, errors) { + const result = {}; + const copy = []; + while (errors.length) + copy.push(errors.pop()); + for (var error of copy) { + let type = error.type; + let newType = null; + for (var t in errorHandling.combine) { + let newTypeCombinesType = errorHandling + .combine[t] + .includes(type); + let newTypeHasHandler = errorHandling.handler[t]; + if (newTypeCombinesType && newTypeHasHandler) { + newType = t; + if (newType == 'form') + break; + } + } + if (newType) + type = newType; + const handler = errorHandling.handler[type]; + if (handler) { + if (!result[type]) + result[type] = []; + if (Array.isArray(error)) + result[type].push(...error); + else + result[type].push(error); + continue; + } + errors.push(error); + } + return result; + } + const fhcApiAxios = axios.create({ timeout: 500000, - baseURL: FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + "/" + baseURL: FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + "/" }); fhcApiAxios.interceptors.request.use(config => { @@ -100,57 +425,51 @@ export default { return config; }); - fhcApiAxios.interceptors.response.use(response => { - if (response.config?.errorHandling == 'off' - || response.config?.errorHandling === false - || response.config?.errorHandling == 'fail') - return _clean_return_value(response); - - // NOTE(chris): loop through errors - if (response.data.errors) - response.data.errors = response.data.errors.filter( - err => (response.config[err.type + 'ErrorHandler'] || app.config.globalProperties.$api._defaultErrorHandlers[err.type])(err, response.config) - ); - - return _clean_return_value(response); - }, error => { - if (error.code == 'ERR_CANCELED') - return Promise.reject({...{handled: true}, ...error}); - - if (error.config?.errorHandling == 'off' - || error.config?.errorHandling === false - || error.config?.errorHandling == 'success') - return Promise.reject(error); - - if (error.response) { - if (error.response.status == 404) { - app.config.globalProperties.$fhcAlert.alertDefault('error', error.message, error.request.responseURL, true); - return Promise.reject({...{handled: true}, ...error}); - } + fhcApiAxios.interceptors.response.use( + response => { + if (response.config?.errorHandling == 'off' + || response.config?.errorHandling === false + || response.config?.errorHandling == 'fail') + return clean_return_value(response); // NOTE(chris): loop through errors - error.response.data.errors = error.response.data.errors.filter( - err => (error.config[err.type + 'ErrorHandler'] || app.config.globalProperties.$api._defaultErrorHandlers[err.type])(err, error.config) - ); - if (!error.response.data.errors.length) - return Promise.reject({...{handled: true}, ...error}); - } else if (error.request) { - app.config.globalProperties.$fhcAlert.alertDefault('error', error.message, error.request.responseURL); - return Promise.reject({...{handled: true}, ...error}); - } else { - app.config.globalProperties.$fhcAlert.alertError(error.message); - return Promise.reject({...{handled: true}, ...error}); - } + if (response.data.errors) + response.data.errors = response.data.errors.filter( + err => (response.config[err.type + 'ErrorHandler'] || app.config.globalProperties.$api._defaultErrorHandlers[err.type])(err, response.config) + ); - return Promise.reject(error); - }); + return clean_return_value(response); + }, + error => { + if (error.code == 'ERR_CANCELED') + return Promise.reject({ handled: true, ...error }); + + const errorConfig = get_error_handler(error.config); + + if (!errorConfig.fail) + return Promise.reject(error); + + const remaining = get_error_list(error); + + const errors = popHandleableErrors(errorConfig, remaining); + + for (var type in errors) { + errorConfig.handler[type](errors[type]); + } + + if (remaining.length) + return Promise.reject(error); + + return Promise.reject({ handled: true, ...error }); + } + ); app.config.globalProperties.$api = { getUri(url) { return fhcApiAxios.getUri({url}); }, get(form, uri, params, config) { - [uri, params, config] = _get_config(form, uri, params, config); + [uri, params, config] = get_config(form, uri, params, config); if (params) { if (config) config.params = params; @@ -160,82 +479,56 @@ export default { return fhcApiAxios.get(uri, config); }, post(form, uri, data, config) { - [uri, data, config] = _get_config(form, uri, data, config); + [uri, data, config] = get_config(form, uri, data, config); return fhcApiAxios.post(uri, data, config); }, call(factory, configoverwrite, form) { if (Array.isArray(factory)) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; const $api = app.config.globalProperties.$api; Promise .allSettled(factory.map((config, index) => { - if (Array.isArray(config)) - return $api.call(config[1], { - errorHeader: config[0], - errorHandling: false - }); - else - return $api.call(config, { - errorHeader: '#' + index, - errorHandling: false - }); - })) - .then(res => { - // TODO(chris): obey form & configoverwrite - let messagesError = []; - let messagesSuccessful = []; - - res.forEach(result => { - if (result.status === 'fulfilled') { - //console.log(JSON.parse(result.value.data)); - const successTitle = "
" + result.value.data + "
"; - messagesSuccessful.push(successTitle + "ok"); - } else { - const errorTitle = "
" + result.reason.config.errorHeader + "
"; - const errorMsg = JSON.parse(result.reason.request.response); - const fullMessage = errorMsg.errors.map(error => { - if (error.type == 'validation') { - // TODO(chris): do we want the keys? - return '
' + Object.values(error.messages).join("
") + '
'; - } - // TODO(chris): other types - if (error.message) - return '
' + error.message + '
'; - if (error.messages) - return '
' + error.messages.join("\n") + '
'; - // TODO(chris): what to do here - return '
' + "Generic Error" + '
'; // TODO(chris): translate - }).join("\n"); - messagesError.push(errorTitle + fullMessage); - } + if (!Array.isArray(config)) + config = ['#' + index, config]; + return $api.call(config[1], { + errorHeader: config[0], + errorHandling: false }); + })) + .then(result => { + const [ , , config ] = get_config(form, undefined, undefined, configoverwrite || {}); + const errorConfig = get_error_handler(config); - if (messagesError.length) - { - const test = document.createElement('b'); - $fhcAlert.alertDefault( - 'error', - messagesError.length + " Fehler", // TODO(chris): translate - '
' + messagesError.join("") + '
', - true, - true - ); + if (!errorConfig.success && !errorConfig.fail) { + return result; } - if (messagesSuccessful.length) - { - const test = document.createElement('b'); - $fhcAlert.alertDefault( - 'info', - 'Feedback', - messagesSuccessful.length + " erfolgreiche Statusänderung(en) durchgeführt", // TODO(chris): translate - false, - true - ); + + const typedErrors = {}; + for (var res of result) { + const [ allowed, item ] = res.status === 'fulfilled' + ? [ errorConfig.success, res.value ] + : [ errorConfig.fail, res.reason ]; + if (!allowed) + return; + + const errors = popHandleableErrors(errorConfig, get_error_list(item)); + + for (var type in errors) { + if (!typedErrors[type]) + typedErrors[type] = { + [item.config.errorHeader]: errors[type] + }; + else + typedErrors[type][item.config.errorHeader] = errors[type]; + } + }; + + for (var errType in typedErrors) { + errorConfig.handler[errType](typedErrors[errType]); } }); } - let {method, url, params, config} = factory; + let { method, url, params, config } = factory; if (configoverwrite !== undefined) { config = configoverwrite; } @@ -251,141 +544,6 @@ export default { } else { console.error("FhcApi: method not allowed:", method); } - }, - _defaultErrorHandlers: { - validation(error, config) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; - - if (config?.form) { - config.form.clearValidation(); - config.form.setFeedback(false, error.messages); - return false; - } - if (Array.isArray(error.messages)) { - error.messages.forEach($fhcAlert.alertError); - return false; - } else if (typeof error.messages == 'object') { - if (config?.errorHeader) - Object.values(error.messages).forEach( - value => $fhcAlert.alertDefault( - 'error', - Array.isArray(config.errorHeader) ? app.config.globalProperties.$p.t.apply(null, config.errorHeader) : config.errorHeader, - value, - true - ) - ); - else - Object.entries(error.messages).forEach( - ([key, value]) => $fhcAlert.alertDefault('error', key, value, true) - ); - return false; - } - return true; - }, - general(error, config) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; - - if (config?.form) - config.form.setFeedback(false, error.message); - else if (config?.errorHeader) - $fhcAlert.alertDefault( - 'error', - Array.isArray(config.errorHeader) ? app.config.globalProperties.$p.t.apply(null, config.errorHeader) : config.errorHeader, - error.message, - true - ); - else - $fhcAlert.alertError(error.message); - }, - php(error) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; - - var message = ''; - message += 'Message: ' + error.message + '\n\n'; - message += 'Filename: ' + error.filename + '\n'; - message += 'Line Number: ' + error.line + '\n'; - if (error.backtrace && error.backtrace.length) { - message += '\nBacktrace: '; - error.backtrace.forEach(err => { - message += '\n\tFile: ' + err.file + '\n'; - message += '\tLine: ' + err.line + '\n'; - message += '\tFunction: ' + err.function + '\n'; - }); - } - switch (error.severity) { - case 'Warning': - case 'Core Warning': - case 'Compile Warning': - case 'User Warning': - $fhcAlert.alertDefault('warn', 'PHP ' + error.severity, message, true); - break; - case 'Notice': - case 'User Notice': - case 'Runtime Notice': - $fhcAlert.alertDefault('info', 'PHP ' + error.severity, message, true); - break; - default: - message = 'Type: PHP ' + error.severity + '\n\n' + message; - $fhcAlert.alertSystemError(message); - break; - } - }, - exception(error) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; - - var message = ''; - message += 'Type: ' + error.class + '\n\n'; - message += 'Message: ' + error.message + '\n\n'; - message += 'Filename: ' + error.filename + '\n'; - message += 'Line Number: ' + error.line + '\n'; - if (error.backtrace && error.backtrace.length) { - message += '\nBacktrace: '; - error.backtrace.forEach(err => { - message += '\n\tFile: ' + err.file + '\n'; - message += '\tLine: ' + err.line + '\n'; - message += '\tFunction: ' + err.function + '\n'; - }); - } - $fhcAlert.alertSystemError(message); - }, - db(error) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; - - var message = ''; - if (error.heading !== undefined) - message += error.heading + '\n\n'; - if (error.code !== undefined) - message += 'Code: ' + error.code + '\n\n'; - if (error.sql !== undefined) - message += 'SQL: ' + error.sql + '\n\n'; - if (error.message !== undefined) - message += 'Message: ' + error.message + '\n\n'; - else if (error.messages !== undefined) - message += 'Messages: ' + error.messages.join('\n\t') + '\n\n'; - if (error.filename !== undefined) - message += 'Filename: ' + error.filename + '\n'; - if (error.line !== undefined) - message += 'Line Number: ' + error.line + '\n'; - - $fhcAlert.alertSystemError(message); - }, - auth(error, config) { - const $fhcAlert = app.config.globalProperties.$fhcAlert; - - var message = ''; - message += 'Controller name: ' + error.controller + '\n'; - message += 'Method name: ' + error.method + '\n'; - message += 'Required permissions: ' + error.required_permissions; - if (config?.errorHeader) - $fhcAlert.alertDefault( - 'error', - Array.isArray(config.errorHeader) ? app.config.globalProperties.$p.t.apply(null, config.errorHeader) : config.errorHeader, - error.message, - true - ); - else - $fhcAlert.alertDefault('error', error.message, message); - } } }; diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 23eb39a21..cb1a8756b 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -1475,6 +1475,26 @@ $phrases = array( ) ) ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'n_errors', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => '{n} Fehler', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => '{n} Error(s)', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'ui', From 1ef884de8c966613572f7523c4116db0fb57dd73 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 26 Aug 2025 14:25:04 +0200 Subject: [PATCH 2/4] API: return results on multicall --- public/js/plugins/Api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/js/plugins/Api.js b/public/js/plugins/Api.js index 091a81717..034c326e9 100644 --- a/public/js/plugins/Api.js +++ b/public/js/plugins/Api.js @@ -486,7 +486,7 @@ export default { if (Array.isArray(factory)) { const $api = app.config.globalProperties.$api; - Promise + return Promise .allSettled(factory.map((config, index) => { if (!Array.isArray(config)) config = ['#' + index, config]; @@ -526,6 +526,8 @@ export default { for (var errType in typedErrors) { errorConfig.handler[errType](typedErrors[errType]); } + + return result; }); } let { method, url, params, config } = factory; From f1c9e641bbe0b64d990cb026bc6ebaefc87494fa Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 26 Aug 2025 14:25:29 +0200 Subject: [PATCH 3/4] API: feedback for stv status dropdown --- .../Studentenverwaltung/Details/Status/Dropdown.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Status/Dropdown.js b/public/js/components/Stv/Studentenverwaltung/Details/Status/Dropdown.js index 8c2071c51..1f6f41900 100644 --- a/public/js/components/Stv/Studentenverwaltung/Details/Status/Dropdown.js +++ b/public/js/components/Stv/Studentenverwaltung/Details/Status/Dropdown.js @@ -151,7 +151,17 @@ export default { ApiStvStatus.addStudent(prestudent_id, data), { errorHeader: prestudent_id } ])) - .then(() => { + .then(result => { + const messagesSuccessful = result.filter(res => res.status == 'fulfilled'); + if (messagesSuccessful.length) { + this.$fhcAlert.alertDefault( + 'info', + 'Feedback', + messagesSuccessful.length + " erfolgreiche Statusänderung(en) durchgeführt", // TODO(chris): translate + false, + true + ); + } this.$emit('reloadTable'); this.$reloadList(); }); From 1024fc6cab1c40455334ddc92ef5e7709696a4c3 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Wed, 27 Aug 2025 14:41:50 +0200 Subject: [PATCH 4/4] Don't overwrite object => work with copies --- public/js/plugins/Api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/js/plugins/Api.js b/public/js/plugins/Api.js index 034c326e9..0b8e2fd17 100644 --- a/public/js/plugins/Api.js +++ b/public/js/plugins/Api.js @@ -311,10 +311,10 @@ export default { const result = _merge_error_config(config?.errorHandling); if (!config?.form) { - result.combine.form = []; + result.combine = { ...result.combine, form: [] }; } else { const formHandler = result.handler.form; - result.handler.form = errors => formHandler(config.form, errors); + result.handler = { ...result.handler, form: errors => formHandler(config.form, errors) }; } return result;