mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-23 10:08:36 +00:00
- Enhanced the `formatApiError` function to better format failed API responses, preventing raw HTML error pages from being displayed in toasts. JSON error bodies are now prioritized for user-friendly messages, while HTML and empty bodies are collapsed to a concise status line. Long plain-text bodies are truncated for readability. - Updated tests to cover new error formatting behavior, ensuring accurate extraction of error messages from various JSON structures.
263 lines
6.4 KiB
JavaScript
263 lines
6.4 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { notifications } from '@mantine/notifications';
|
|
|
|
export default {
|
|
Limiter: (n, list) => {
|
|
if (!list || !list.length) {
|
|
return;
|
|
}
|
|
|
|
var tail = list.splice(n);
|
|
var head = list;
|
|
var resolved = [];
|
|
var processed = 0;
|
|
|
|
return new Promise(function (resolve) {
|
|
head.forEach(function (x) {
|
|
var res = x();
|
|
resolved.push(res);
|
|
res.then(function (y) {
|
|
runNext();
|
|
return y;
|
|
});
|
|
});
|
|
function runNext() {
|
|
if (processed == tail.length) {
|
|
resolve(Promise.all(resolved));
|
|
} else {
|
|
resolved.push(
|
|
tail[processed]().then(function (x) {
|
|
runNext();
|
|
return x;
|
|
})
|
|
);
|
|
processed++;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
};
|
|
|
|
// Custom debounce hook
|
|
export function useDebounce(value, delay = 500, callback = null) {
|
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
const isFirstRender = useRef(true);
|
|
const previousValueRef = useRef(JSON.stringify(value));
|
|
|
|
useEffect(() => {
|
|
const currentValueStr = JSON.stringify(value);
|
|
|
|
// Skip if value hasn't actually changed (prevents unnecessary state updates)
|
|
if (previousValueRef.current === currentValueStr) {
|
|
return;
|
|
}
|
|
|
|
const handler = setTimeout(() => {
|
|
setDebouncedValue(value);
|
|
// Only fire callback if not the first render
|
|
if (callback && !isFirstRender.current) {
|
|
callback();
|
|
}
|
|
isFirstRender.current = false;
|
|
previousValueRef.current = currentValueStr;
|
|
}, delay);
|
|
|
|
return () => clearTimeout(handler); // Cleanup timeout on unmount or value change
|
|
}, [value, delay]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
|
|
// Format a failed API response for toast display.
|
|
const ERROR_BODY_MAX_CHARS = 200;
|
|
|
|
const STATUS_LABELS = {
|
|
400: 'Bad Request',
|
|
401: 'Unauthorized',
|
|
403: 'Forbidden',
|
|
404: 'Not Found',
|
|
405: 'Method Not Allowed',
|
|
408: 'Request Timeout',
|
|
409: 'Conflict',
|
|
413: 'Payload Too Large',
|
|
429: 'Too Many Requests',
|
|
500: 'Internal Server Error',
|
|
502: 'Bad Gateway',
|
|
503: 'Service Unavailable',
|
|
504: 'Gateway Timeout',
|
|
};
|
|
|
|
const formatErrorValue = (value) => {
|
|
if (value == null) return '';
|
|
if (Array.isArray(value)) {
|
|
return value.map(formatErrorValue).filter(Boolean).join('; ');
|
|
}
|
|
if (typeof value === 'object') {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
return String(value);
|
|
};
|
|
|
|
const formatJsonErrorBody = (body) => {
|
|
if (Array.isArray(body)) {
|
|
return formatErrorValue(body) || null;
|
|
}
|
|
if (!body || typeof body !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
for (const key of ['detail', 'error', 'non_field_errors']) {
|
|
if (body[key] != null && body[key] !== '') {
|
|
const msg = formatErrorValue(body[key]);
|
|
if (msg) return msg;
|
|
}
|
|
}
|
|
|
|
const fieldParts = Object.entries(body)
|
|
.map(([key, value]) => {
|
|
const msg = formatErrorValue(value);
|
|
return msg ? `${key}: ${msg}` : null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
if (fieldParts.length) {
|
|
return fieldParts.join('; ');
|
|
}
|
|
|
|
try {
|
|
return JSON.stringify(body, null, 2);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const formatStatusLine = (status, response) => {
|
|
const reason = response?.statusText?.trim();
|
|
const label = reason || STATUS_LABELS[status] || 'Request failed';
|
|
return `${status} - ${label}`;
|
|
};
|
|
|
|
export const formatApiError = (error) => {
|
|
if (!error || !error.status) {
|
|
return (error && error.message) || 'Unknown error';
|
|
}
|
|
|
|
const { status, body, response } = error;
|
|
|
|
if (body && typeof body === 'object') {
|
|
const formatted = formatJsonErrorBody(body);
|
|
if (formatted) return formatted;
|
|
}
|
|
|
|
const text =
|
|
typeof body === 'string' ? body.trim() : body == null ? '' : String(body);
|
|
|
|
// Sniff leading '<' only when Content-Type is missing.
|
|
const contentType = (
|
|
response?.headers?.get?.('content-type') || ''
|
|
).toLowerCase();
|
|
const isMarkup =
|
|
contentType.includes('html') ||
|
|
contentType.includes('xml') ||
|
|
(!contentType && text.startsWith('<'));
|
|
|
|
if (!text || isMarkup) {
|
|
if (text) {
|
|
console.debug(`API error ${status} response body:`, text);
|
|
}
|
|
return formatStatusLine(status, response);
|
|
}
|
|
|
|
return text.length > ERROR_BODY_MAX_CHARS
|
|
? `${status} - ${text.slice(0, ERROR_BODY_MAX_CHARS)}...`
|
|
: `${status} - ${text}`;
|
|
};
|
|
|
|
export function sleep(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
export const getDescendantProp = (obj, path) =>
|
|
path.split('.').reduce((acc, part) => acc && acc[part], obj);
|
|
|
|
export const copyToClipboard = async (value, options = {}) => {
|
|
const {
|
|
successTitle = 'Copied!',
|
|
successMessage = 'Copied to clipboard',
|
|
failureTitle = 'Copy Failed',
|
|
failureMessage = 'Failed to copy to clipboard',
|
|
showNotification = true,
|
|
} = options;
|
|
|
|
let success = false;
|
|
|
|
if (navigator.clipboard) {
|
|
// Modern method, using navigator.clipboard
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
success = true;
|
|
} catch (err) {
|
|
console.error('Failed to copy: ', err);
|
|
}
|
|
}
|
|
|
|
if (!success) {
|
|
// Fallback method for environments without clipboard support
|
|
try {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = value;
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
const successful = document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
success = successful;
|
|
} catch (err) {
|
|
console.error('Failed to copy with fallback method: ', err);
|
|
success = false;
|
|
}
|
|
}
|
|
|
|
// Show notification if enabled
|
|
if (showNotification) {
|
|
notifications.show({
|
|
title: success ? successTitle : failureTitle,
|
|
message: success ? successMessage : failureMessage,
|
|
color: success ? 'green' : 'red',
|
|
});
|
|
}
|
|
|
|
return success;
|
|
};
|
|
|
|
export const setCustomProperty = (input, key, value, serialize = false) => {
|
|
let obj;
|
|
|
|
if (input == null) {
|
|
// matches null or undefined
|
|
obj = {};
|
|
} else if (typeof input === 'string') {
|
|
try {
|
|
obj = JSON.parse(input);
|
|
} catch (e) {
|
|
obj = {};
|
|
}
|
|
} else if (typeof input === 'object' && !Array.isArray(input)) {
|
|
obj = { ...input }; // shallow copy
|
|
} else {
|
|
obj = {};
|
|
}
|
|
|
|
obj[key] = value;
|
|
|
|
if (serialize === true) {
|
|
return JSON.stringify(obj);
|
|
}
|
|
|
|
return obj;
|
|
};
|