first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
export async function asyncIterableToArray<T>(
iterator: AsyncIterable<T>
): Promise<T[]> {
const items: T[] = [];
for await (const item of iterator) {
items.push(item);
}
return items;
}

View File

@@ -0,0 +1,13 @@
export function chunkArray<T>(array: T[], chunkSize: number): T[][] {
return array.reduce<any>((resultArray, item, index) => {
const chunkIndex = Math.floor(index / chunkSize);
if (!resultArray[chunkIndex]) {
resultArray[chunkIndex] = [];
}
resultArray[chunkIndex].push(item);
return resultArray;
}, []);
}

View File

@@ -0,0 +1,26 @@
interface Options<T> {
map?: (item: T) => T;
}
export function groupArrayBy<T>(
arr: T[],
cb: (item: any) => string,
options?: Options<T>,
): {[key: string]: T[]} {
const result: {[key: string]: T[]} = {};
for (let i = 0; i < arr.length; i++) {
let item = arr[i];
const bucketCategory = cb(item);
const bucket = result[bucketCategory];
item = options?.map ? options.map(arr[i]) : arr[i];
if (!Array.isArray(bucket)) {
result[bucketCategory] = [item];
} else {
result[bucketCategory].push(item);
}
}
return result;
}

View File

@@ -0,0 +1,25 @@
import {clamp} from '../number/clamp';
export function moveItemInArray<T = any>(
array: T[],
fromIndex: number,
toIndex: number
): T[] {
const from = clamp(fromIndex, 0, array.length - 1);
const to = clamp(toIndex, 0, array.length - 1);
if (from === to) {
return array;
}
const target = array[from];
const delta = to < from ? -1 : 1;
for (let i = from; i !== to; i += delta) {
array[i] = array[i + delta];
}
array[to] = target;
return array;
}

View File

@@ -0,0 +1,14 @@
export function moveItemInNewArray<T>(
array: T[],
from: number,
to: number
): T[] {
const newArray = array.slice();
newArray.splice(
to < 0 ? newArray.length + to : to,
0,
newArray.splice(from, 1)[0]
);
return newArray;
}

View File

@@ -0,0 +1,35 @@
export function moveMultipleItemsInArray<T>(
array: T[],
indexOrIndexes: number | number[],
newIndex: number
) {
const indexes = Array.isArray(indexOrIndexes)
? indexOrIndexes
: [indexOrIndexes];
const insertBefore = array[newIndex + (newIndex < indexes[0] ? 0 : 1)];
const itemsToBeMoved = indexes.map(i => array[i]);
// in original sequence order, check for presence in the removal
// list, *and* remove them from the original array
const moved = [];
for (let i = 0; i < array.length; ) {
const value = array[i];
if (itemsToBeMoved.indexOf(value) >= 0) {
moved.push(value);
array.splice(i, 1);
} else {
++i;
}
}
// find the new index of the insertion point
let insertionIndex = array.indexOf(insertBefore);
if (insertionIndex < 0) {
insertionIndex = array.length;
}
// and add the elements back in
array.splice(insertionIndex, 0, ...moved);
return array;
}

View File

@@ -0,0 +1,5 @@
export function prependToArrayAtIndex<T>(array: T[], toAdd: T[], index = 0): T[] {
const copyOfArray = [...array];
const tail = copyOfArray.splice(index + 1);
return [...copyOfArray, ...toAdd, ...tail];
}

View File

@@ -0,0 +1,22 @@
export function shuffleArray(items: any[], keepFirst = false) {
let first = keepFirst ? items.shift() : null;
let currentIndex = items.length,
temporaryValue,
randomIndex;
while (0 !== currentIndex) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex -= 1;
temporaryValue = items[currentIndex];
items[currentIndex] = items[randomIndex];
items[randomIndex] = temporaryValue;
}
if (first) {
items.unshift(first);
}
return [...items];
}

View File

@@ -0,0 +1,77 @@
import dot from 'dot-object';
const MAX_SAFE_INTEGER = 9007199254740991;
export function sortArrayOfObjects<T extends object>(
data: T[],
orderBy: string,
orderDir: 'asc' | 'desc' = 'desc'
): T[] {
return data.sort((a, b) => {
let valueA = sortingDataAccessor(a, orderBy);
let valueB = sortingDataAccessor(b, orderBy);
// If there are data in the column that can be converted to a number,
// it must be ensured that the rest of the data
// is of the same type so as not to order incorrectly.
const valueAType = typeof valueA;
const valueBType = typeof valueB;
if (valueAType !== valueBType) {
if (valueAType === 'number') {
valueA += '';
}
if (valueBType === 'number') {
valueB += '';
}
}
// If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if
// one value exists while the other doesn't. In this case, existing value should come last.
// This avoids inconsistent results when comparing values to undefined/null.
// If neither value exists, return 0 (equal).
let comparatorResult = 0;
if (valueA != null && valueB != null) {
// Check if one value is greater than the other; if equal, comparatorResult should remain 0.
if (valueA > valueB) {
comparatorResult = 1;
} else if (valueA < valueB) {
comparatorResult = -1;
}
} else if (valueA != null) {
comparatorResult = 1;
} else if (valueB != null) {
comparatorResult = -1;
}
return comparatorResult * (orderDir === 'asc' ? 1 : -1);
});
}
/**
* Data accessor function that is used for accessing data properties for sorting through
* the default sortData function.
* This default function assumes that the sort header IDs (which defaults to the column name)
* matches the data's properties (e.g. column Xyz represents data['Xyz']).
* May be set to a custom function for different behavior.
*/
function sortingDataAccessor(data: object, key: string): string {
const value = dot.pick(key, data);
if (isNumberValue(value)) {
const numberValue = Number(value);
// Numbers beyond `MAX_SAFE_INTEGER` can't be compared reliably, so we
// leave them as strings. For more info: https://goo.gl/y5vbSg
return numberValue < MAX_SAFE_INTEGER ? numberValue : value;
}
return value;
}
function isNumberValue(value: any): boolean {
// parseFloat(value) handles most of the cases we're interested in (it treats null, empty string,
// and other non-number values as NaN, where Number just uses 0) but it considers the string
// '123hello' to be a valid number. Therefore, we also check if Number(value) is NaN.
return !isNaN(parseFloat(value as any)) && !isNaN(Number(value));
}

View File

@@ -0,0 +1,10 @@
import {ZonedDateTime} from '@internationalized/date';
export function endOfDay(date: ZonedDateTime): ZonedDateTime {
return date.set({
hour: 24 - 1,
minute: 60 - 1,
second: 60 - 1,
millisecond: 1000 - 1,
});
}

View File

@@ -0,0 +1,5 @@
import {ZonedDateTime} from '@internationalized/date';
export function startOfDay(date: ZonedDateTime): ZonedDateTime {
return date.set({hour: 0, minute: 0, second: 0, millisecond: 0});
}

View File

@@ -0,0 +1,12 @@
import {EventHandler, SyntheticEvent} from 'react';
export function createEventHandler(handler?: EventHandler<SyntheticEvent>) {
if (!handler) return handler;
return (e: SyntheticEvent) => {
// ignore events bubbling up from portals
if (e.currentTarget.contains(e.target as HTMLElement)) {
handler(e);
}
};
}

View File

@@ -0,0 +1,35 @@
export function createRafLoop(callback: () => void) {
let id: number | undefined;
function start() {
// Time updates are already in progress.
if (!isUndefined(id)) return;
loop();
}
function loop() {
id = window.requestAnimationFrame(function rafLoop() {
if (isUndefined(id)) return;
callback();
loop();
});
}
function stop() {
if (isNumber(id)) window.cancelAnimationFrame(id);
id = undefined;
}
return {
start,
stop,
};
}
function isUndefined(value: unknown): value is undefined {
return typeof value === 'undefined';
}
function isNumber(value: any): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}

View File

@@ -0,0 +1,20 @@
export interface PlainRect {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
}
export function getBoundingClientRect(el: HTMLElement | Range) {
const rect = el.getBoundingClientRect();
return {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
};
}

View File

@@ -0,0 +1,9 @@
export function isAnyInputFocused(doc?: Document): boolean {
if (!doc) {
doc = document;
}
return doc.activeElement
? ['INPUT', 'TEXTAREA'].includes(doc.activeElement.tagName) ||
(doc.activeElement as HTMLElement).isContentEditable
: false;
}

View File

@@ -0,0 +1,3 @@
export function isSsr() {
return import.meta.env.SSR;
}

View File

@@ -0,0 +1,21 @@
import {RefObject} from 'react';
type Callback = (e: {width: number; height: number}) => void;
export function observeSize(
ref: RefObject<HTMLElement>,
callback: Callback
): () => void {
const observer = new ResizeObserver(entries => {
const rect = entries[0].contentRect;
callback({width: rect.width, height: rect.height});
});
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}

View File

@@ -0,0 +1,13 @@
import {useIsSSR} from '@react-aria/ssr';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
const MOBILE_SCREEN_WIDTH = 768;
export function useIsMobileDevice(): boolean {
const isSSR = useIsSSR();
if (isSSR || typeof window === 'undefined') {
return getBootstrapData().is_mobile_device;
}
return window.screen.width <= MOBILE_SCREEN_WIDTH;
}

View File

@@ -0,0 +1,5 @@
import { useMediaQuery, UseMediaQueryOptions } from "./use-media-query";
export function useIsMobileMediaQuery(options?: UseMediaQueryOptions) {
return useMediaQuery("(max-width: 768px)", options);
}

View File

@@ -0,0 +1,5 @@
import {useMediaQuery, UseMediaQueryOptions} from './use-media-query';
export function useIsTabletMediaQuery(options?: UseMediaQueryOptions) {
return useMediaQuery('(max-width: 1024px)', options);
}

View File

@@ -0,0 +1,5 @@
import {useMediaQuery} from './use-media-query';
export function useIsTouchDevice() {
return useMediaQuery('((pointer: coarse))');
}

View File

@@ -0,0 +1,75 @@
import {useEffect, useState} from 'react';
interface StoreEvent {
detail: {
key: string;
newValue: any;
};
}
export function useLocalStorage<T>(key: string, initialValue: T | null = null) {
const [storedValue, setStoredValue] = useState<T>(() => {
return getFromLocalStorage<T>(key, initialValue);
});
const setValue = (value: T | ((val: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
setInLocalStorage(key, valueToStore);
};
// update state value using custom storage event. This will re-render
// component even if local storage value was set from different hook instance
useEffect(() => {
const handleStorageChange = (event: StoreEvent) => {
if (event.detail?.key === key) {
setStoredValue(event.detail.newValue);
}
};
window.addEventListener('storage', handleStorageChange as any);
return () =>
window.removeEventListener('storage', handleStorageChange as any);
}, [key]);
return [storedValue, setValue] as const;
}
export function getFromLocalStorage<T>(
key: string,
initialValue: T | null = null,
) {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item != null ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
}
export function setInLocalStorage<T>(key: string, value: T) {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(value));
window.dispatchEvent(
new CustomEvent('storage', {
detail: {key, newValue: value},
}),
);
}
} catch (error) {
//
}
}
export function removeFromLocalStorage(key: string) {
try {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(key);
}
} catch (error) {
//
}
}

View File

@@ -0,0 +1,22 @@
import {useCallback, useRef, useState} from 'react';
export function useStickySentinel() {
const [isSticky, setIsSticky] = useState(false);
const observerRef = useRef<IntersectionObserver>();
const sentinelRef = useCallback((sentinel: HTMLDivElement | null) => {
if (sentinel) {
const observer = new IntersectionObserver(
([e]) => setIsSticky(e.intersectionRatio < 1),
{threshold: [1]}
);
observerRef.current = observer;
observer.observe(sentinel);
} else if (observerRef.current) {
observerRef.current?.disconnect();
}
}, []);
return {isSticky, sentinelRef};
}

View File

@@ -0,0 +1,14 @@
import {useEffect} from 'react';
export function useBlockBodyOverflow(disable: boolean = false) {
useEffect(() => {
if (disable) {
document.documentElement.classList.remove('no-page-overflow');
} else {
document.documentElement.classList.add('no-page-overflow');
}
return () => {
document.documentElement.classList.remove('no-page-overflow');
};
}, [disable]);
}

View File

@@ -0,0 +1,18 @@
import {useEffect, useMemo, useRef} from 'react';
/**
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
* prop or avoid re-executing effects when passed as a dependency
*/
export function useCallbackRef<T extends (...args: any[]) => any>(
callback: T | undefined
): T {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
});
// https://github.com/facebook/react/issues/19240
return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}

View File

@@ -0,0 +1,104 @@
import {useCallback, useEffect, useState} from 'react';
import {isSsr} from '@common/utils/dom/is-ssr';
interface Options {
days?: number;
path?: string;
domain?: string;
SameSite?: 'None' | 'Lax' | 'Strict';
Secure?: boolean;
HttpOnly?: boolean;
}
// used to notify different instances of useCookie hook about cookie changes
const listeners = new Set<{name: string; callback: any}>();
const listenForCookieChange = (
name: string,
callback: (value: string) => void,
) => {
if (isSsr()) return () => {};
const listener = {name, callback};
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export function stringifyOptions(options: Options) {
return Object.keys(options).reduce((acc, _key) => {
const key = _key as keyof Options;
if (key === 'days') {
return acc;
} else {
if (options[key] === false) {
return acc;
} else if (options[key] === true) {
return `${acc}; ${key}`;
} else {
return `${acc}; ${key}=${options[key]}`;
}
}
}, '');
}
export const setCookie = (name: string, value: string, options?: Options) => {
if (isSsr()) return;
const optionsWithDefaults = {
days: 7,
path: '/',
...options,
};
const expires = new Date(
Date.now() + optionsWithDefaults.days * 864e5,
).toUTCString();
document.cookie =
name +
'=' +
encodeURIComponent(value) +
'; expires=' +
expires +
stringifyOptions(optionsWithDefaults);
listeners.forEach(listener => {
if (listener.name === name) {
listener.callback(value);
}
});
};
export function getCookie(name: string, initialValue = '') {
return (
(!isSsr() &&
document.cookie &&
document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, '')) ||
initialValue
);
}
export function useCookie(key: string, initialValue?: string) {
const [item, setItem] = useState(() => {
return getCookie(key, initialValue);
});
useEffect(() => {
return listenForCookieChange(key, value => {
setItem(value);
});
}, [key]);
const updateItem = useCallback(
(value: string, options?: Options) => {
setItem(value);
setCookie(key, value, options);
},
[key],
);
return [item, updateItem] as const;
}

View File

@@ -0,0 +1,14 @@
import {useMemo} from 'react';
import linkifyStr from 'linkify-string';
export function useLinkifiedString(text: string | null | undefined) {
return useMemo(() => {
if (!text) {
return text;
}
return linkifyStr(text, {
nl2br: true,
attributes: {rel: 'nofollow'},
});
}, [text]);
}

View File

@@ -0,0 +1,42 @@
import {useEffect, useState} from 'react';
export interface UseMediaQueryOptions {
noSSR?: boolean;
}
export function useMediaQuery(
query: string,
{noSSR}: UseMediaQueryOptions = {noSSR: true}
) {
const supportsMatchMedia =
typeof window !== 'undefined' && typeof window.matchMedia === 'function';
const [matches, setMatches] = useState(
noSSR
? () => (supportsMatchMedia ? window.matchMedia(query).matches : false)
: null
);
useEffect(() => {
if (!supportsMatchMedia) {
return;
}
const mq = window.matchMedia(query);
const onChange = () => {
setMatches(mq.matches);
};
mq.addEventListener('change', onChange);
if (!noSSR) {
onChange();
}
return () => {
mq.removeEventListener('change', onChange);
};
}, [supportsMatchMedia, query, noSSR]);
// If in SSR, the media query should never match. Once the page hydrates,
// this will update and the real value will be returned.
return typeof window === 'undefined' ? null : matches;
}

View File

@@ -0,0 +1,27 @@
import {
createPath,
NavigateFunction,
resolvePath,
useLocation,
useNavigate as useRouterNavigate
} from 'react-router-dom';
import {useCallback} from 'react';
export function useNavigate() {
const routerNavigate = useRouterNavigate();
const location = useLocation();
return useCallback(
(to, options) => {
// prevent duplicates in history when navigating to the same url
const replace =
createPath(location) === createPath(resolvePath(to, location.pathname));
routerNavigate(to, {
...options,
replace: options?.replace !== false && replace,
});
},
[routerNavigate, location]
) as NavigateFunction;
}

View File

@@ -0,0 +1,11 @@
import {useEffect, useRef} from 'react';
export function usePrevious<T>(value: T) {
const ref = useRef<T>();
// Store current value in ref
useEffect(() => {
ref.current = value;
}, [value]); // Only re-run if value changes
// Return previous value (happens before update in useEffect above)
return ref.current;
}

View File

@@ -0,0 +1,62 @@
import {useEffect, useRef, useState} from 'react';
interface SpinDelayOptions {
delay?: number;
minDuration?: number;
}
type State = 'IDLE' | 'DELAY' | 'DISPLAY' | 'EXPIRE';
export const defaultOptions = {
delay: 500,
minDuration: 200,
};
export function useSpinDelay(
loading: boolean,
options?: SpinDelayOptions,
): boolean {
options = Object.assign({}, defaultOptions, options);
const [state, setState] = useState<State>('IDLE');
const timeout = useRef<any>(null);
useEffect(() => {
if (loading && state === 'IDLE') {
clearTimeout(timeout.current);
timeout.current = setTimeout(
() => {
if (!loading) {
return setState('IDLE');
}
timeout.current = setTimeout(
() => {
setState('EXPIRE');
},
options?.minDuration,
);
setState('DISPLAY');
},
options?.delay,
);
setState('DELAY');
}
if (!loading && state !== 'DISPLAY') {
clearTimeout(timeout.current);
setState('IDLE');
}
}, [loading, state, options.delay, options.minDuration]);
useEffect(() => {
return () => clearTimeout(timeout.current);
}, []);
return state === 'DISPLAY' || state === 'EXPIRE';
}
export default useSpinDelay;

View File

@@ -0,0 +1,14 @@
import {useEffect} from 'react';
export function useStableScrollbar(disable: boolean = false) {
useEffect(() => {
if (disable) {
document.documentElement.classList.remove('stable-scrollbar');
} else {
document.documentElement.classList.add('stable-scrollbar');
}
return () => {
document.documentElement.classList.remove('stable-scrollbar');
};
}, [disable]);
}

View File

@@ -0,0 +1,5 @@
import axios from 'axios';
export function errorStatusIs(err: unknown, status: number): boolean {
return axios.isAxiosError(err) && err.response?.status == status;
}

View File

@@ -0,0 +1,18 @@
import axios from 'axios';
import {BackendErrorResponse} from '../../errors/backend-error-response';
export function getAxiosErrorMessage(
err: unknown,
field?: string | null
): string | undefined {
if (axios.isAxiosError(err) && err.response) {
const response = err.response.data as BackendErrorResponse;
if (field != null) {
const fieldMessage = response.errors?.[field];
return Array.isArray(fieldMessage) ? fieldMessage[0] : fieldMessage;
}
return response?.message;
}
}

View File

@@ -0,0 +1,164 @@
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {Trans} from '@common/i18n/trans';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {
IgnitionErrorPayload,
IgnitionFrame,
} from '@common/utils/http/ignition-error-dialog/ignition-error-payload';
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
import React, {memo, useEffect, useMemo, useRef, useState} from 'react';
import clsx from 'clsx';
import {ErrorIcon} from '@common/icons/material/Error';
import {highlightCode} from '@common/text-editor/highlight/highlight-code';
import {
IgnitionFilePath,
IgnitionStackTrace,
} from '@common/utils/http/ignition-error-dialog/ignition-stack-trace';
import {Button} from '@common/ui/buttons/button';
interface Props {
error: IgnitionErrorPayload;
}
export function IgnitionErrorDialog({error}: Props) {
const [selectedIndex, setSelectedIndex] = useState(() => {
for (const frame of error.trace) {
if (!('vendorGroup' in frame)) {
return frame.flatIndex;
}
}
return 0;
});
const selectedFrame = useMemo(() => {
for (const frame of error.trace) {
if ('vendorGroup' in frame) {
for (const vendorFrame of frame.items) {
if (vendorFrame.flatIndex === selectedIndex) {
return vendorFrame;
}
}
} else if (frame.flatIndex === selectedIndex) {
return frame;
}
}
}, [error, selectedIndex]);
return (
<Dialog size="fullscreen">
<DialogHeader
showDivider
leftAdornment={<ErrorIcon />}
color="text-danger"
actions={<DownloadButton />}
>
<Trans message="An error occured" />
</DialogHeader>
<DialogBody padding="p-0 stack">
<div className="sticky top-0 z-10 border-b bg p-24">
<Chip className="w-max" radius="rounded-panel">
{error.exception}
</Chip>
<div className="mt-16 line-clamp-2 text-lg font-semibold leading-snug">
{error.message}
</div>
</div>
<div className="flex items-stretch gap-10">
<IgnitionStackTrace
trace={error.trace}
onSelectedIndexChange={setSelectedIndex}
selectedIndex={selectedIndex}
totalVendorGroups={error.totalVendorGroups}
/>
{selectedFrame && <CodeSnippet frame={selectedFrame} />}
</div>
</DialogBody>
</Dialog>
);
}
interface CodeSnippetProps {
frame: IgnitionFrame;
}
function CodeSnippet({frame}: CodeSnippetProps) {
const codeRef = useRef<HTMLPreElement>(null!);
const lineNumbers = Object.keys(frame.codeSnippet).map(Number);
const highlightedIndex = lineNumbers.indexOf(frame.lineNumber);
const lines = Object.values(frame.codeSnippet);
return (
<div className="sticky top-120 flex-auto">
<div className="px-30 py-16 text-right text-muted">
<IgnitionFilePath frame={frame} />
</div>
<div className="flex">
<div className="mr-8 select-none text-right">
{lineNumbers.map((lineNumber, index) => (
<div
className={clsx(
'px-8 font-mono leading-loose text-muted',
index == highlightedIndex && 'bg-danger/30',
)}
key={index}
>
{lineNumber}
</div>
))}
</div>
<div className="compact-scrollbar flex-grow overflow-x-auto">
<pre>
<code className="language-php" ref={codeRef}>
{lines.map((line, index) => (
<CodeSnippetLine
isHighlighted={highlightedIndex === index}
key={`${frame.path}.${index}`}
line={line}
/>
))}
</code>
</pre>
</div>
</div>
</div>
);
}
interface CodeSnippetLineProps {
line: string;
isHighlighted: boolean;
}
const CodeSnippetLine = memo(({line, isHighlighted}: CodeSnippetLineProps) => {
const ref = useRef<HTMLSpanElement>(null!);
useEffect(() => {
const el = ref.current;
highlightCode(el, 'light');
return () => {
delete el.dataset.highlighted;
};
}, []);
return (
<span
className={clsx('block leading-loose', isHighlighted && 'bg-danger/20')}
>
<span className="language-php" ref={ref}>
{line + '\n'}
</span>
</span>
);
});
function DownloadButton() {
return (
<Button
variant="outline"
className="text-main"
elementType="a"
download
href="api/v1/logs/error/download-latest"
size="2xs"
>
<Trans message="Download log" />
</Button>
);
}

View File

@@ -0,0 +1,21 @@
export interface IgnitionVendorGroup {
vendorGroup: true;
items: IgnitionFrame[];
}
export interface IgnitionErrorPayload {
ignitionTrace: true;
message: string;
exception: string;
line: number;
trace: (IgnitionVendorGroup | IgnitionFrame)[];
totalVendorGroups: number;
}
export interface IgnitionFrame {
codeSnippet: Record<number, string>;
path: string[];
lineNumber: number;
method: string;
flatIndex: number;
}

View File

@@ -0,0 +1,143 @@
import {
IgnitionErrorPayload,
IgnitionFrame,
} from '@common/utils/http/ignition-error-dialog/ignition-error-payload';
import React, {Fragment, useState} from 'react';
import {Trans} from '@common/i18n/trans';
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
import clsx from 'clsx';
import {Button} from '@common/ui/buttons/button';
import {UnfoldMoreIcon} from '@common/icons/material/UnfoldMore';
import {UnfoldLessIcon} from '@common/icons/material/UnfoldLess';
interface StackTraceProps {
trace: IgnitionErrorPayload['trace'];
onSelectedIndexChange: (index: number) => void;
selectedIndex: number;
totalVendorGroups: number;
}
export function IgnitionStackTrace({
trace,
onSelectedIndexChange,
selectedIndex,
totalVendorGroups,
}: StackTraceProps) {
const [expandedVendorGroups, setExpandedVendorGroups] = useState<number[]>(
[],
);
const allVendorGroupsExpanded =
expandedVendorGroups.length === totalVendorGroups;
return (
<div className="max-w-440 border-r text-sm">
<div className="border-b px-30 py-16">
<Button
variant="outline"
size="2xs"
startIcon={
allVendorGroupsExpanded ? <UnfoldLessIcon /> : <UnfoldMoreIcon />
}
onClick={() => {
if (allVendorGroupsExpanded) {
setExpandedVendorGroups([]);
} else {
setExpandedVendorGroups(
trace
.map((frame, index) => ('vendorGroup' in frame ? index : -1))
.filter(index => index !== -1),
);
}
}}
>
{allVendorGroupsExpanded ? (
<Trans message="Collapse vendor frames" />
) : (
<Trans message="Expand vendor frames" />
)}
</Button>
</div>
{trace.map((frame, index) => {
if ('vendorGroup' in frame) {
// vendor group is expanded, display all vendor frames
if (expandedVendorGroups.includes(index)) {
return (
<Fragment key={index}>
{frame.items.map((vendorFrame, index) => (
<StackTrackItem
key={`vendor-${index}`}
frame={vendorFrame}
onClick={() => onSelectedIndexChange(vendorFrame.flatIndex)}
isSelected={selectedIndex === vendorFrame.flatIndex}
/>
))}
</Fragment>
);
}
// vendor group is collapsed, only show vendor group header
return (
<div
className="flex cursor-pointer items-center gap-4 border-b px-30 py-16 hover:bg-hover"
key={index}
onClick={() => setExpandedVendorGroups(prev => [...prev, index])}
>
<Trans
message=":count vendor [one frame|other frames]"
values={{count: frame.items.length}}
/>
<KeyboardArrowDownIcon className="text-muted" />
</div>
);
}
// app frame item
return (
<StackTrackItem
key={index}
frame={frame}
onClick={() => onSelectedIndexChange(frame.flatIndex)}
isSelected={selectedIndex === frame.flatIndex}
/>
);
})}
</div>
);
}
interface StackTrackItemProps {
frame: IgnitionFrame;
onClick: () => void;
isSelected: boolean;
}
function StackTrackItem({frame, onClick, isSelected}: StackTrackItemProps) {
return (
<div
onClick={onClick}
className={clsx(
'cursor-pointer border-b px-30 py-16',
isSelected ? 'bg-danger text-on-primary' : 'hover:bg-danger/10',
)}
>
<IgnitionFilePath frame={frame} />
<div className="font-semibold">{frame.method}</div>
</div>
);
}
interface IgnitionFilePath {
frame: IgnitionFrame;
}
export function IgnitionFilePath({frame}: IgnitionFilePath) {
return (
<div className="inline-flex flex-wrap items-baseline">
{frame.path.map((part, index) =>
frame.path.length - 1 === index ? (
<div key={index} className="font-semibold">
{part}
</div>
) : (
<div key={index}>{part}/</div>
),
)}
<div>:{frame.lineNumber}</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import {isAbsoluteUrl} from '../urls/is-absolute-url';
interface Options {
id?: string;
force?: boolean;
type?: 'js' | 'css';
parentEl?: HTMLElement;
document?: Document;
}
interface LoadAssetOptions {
url: string;
id: string;
resolve: (value?: any | PromiseLike<any>) => void;
parentEl?: HTMLElement;
document?: Document;
}
class LazyLoader {
private loadedAssets: Record<
string,
{
state: 'loaded' | Promise<void>;
doc?: Document;
}
> = {};
loadAsset(url: string, params: Options = {type: 'js'}): Promise<any> {
const currentState = this.loadedAssets[url]?.state;
// script is already loaded, return resolved promise
if (currentState === 'loaded' && !params.force) {
return new Promise<void>(resolve => resolve());
}
const neverLoaded =
!currentState || this.loadedAssets[url].doc !== params.document;
// script has never been loaded before, load it, return promise and resolve on script load event
if (neverLoaded || (params.force && currentState === 'loaded')) {
this.loadedAssets[url] = {
state: new Promise(resolve => {
const finalUrl = isAbsoluteUrl(url) ? url : `assets/${url}`;
const finalId = buildId(url, params.id);
const assetOptions: LoadAssetOptions = {
url: finalUrl,
id: finalId,
resolve,
parentEl: params.parentEl,
document: params.document,
};
if (params.type === 'css') {
this.loadStyleAsset(assetOptions);
} else {
this.loadScriptAsset(assetOptions);
}
}),
doc: params.document,
};
return this.loadedAssets[url].state as Promise<void>;
}
// script is still loading, return existing promise
return this.loadedAssets[url].state as Promise<void>;
}
/**
* Check whether asset is loading or has already loaded.
*/
isLoadingOrLoaded(url: string): boolean {
return this.loadedAssets[url] != null;
}
private loadStyleAsset(options: LoadAssetOptions) {
const doc = options.document || document;
const parentEl = options.parentEl || doc.head;
const style = doc.createElement('link');
const prefixedId = buildId(options.url, options.id);
style.rel = 'stylesheet';
style.id = prefixedId;
style.href = options.url;
try {
if (parentEl.querySelector(`#${prefixedId}`)) {
parentEl.querySelector(`#${prefixedId}`)?.remove();
}
} catch (e) {}
style.onload = () => {
this.loadedAssets[options.url].state = 'loaded';
options.resolve();
};
parentEl.appendChild(style);
}
private loadScriptAsset(options: LoadAssetOptions) {
const doc = options.document || document;
const parentEl = options.parentEl || doc.body;
const script: HTMLScriptElement = doc.createElement('script');
const prefixedId = buildId(options.url, options.id);
script.async = true;
script.id = prefixedId;
script.src = options.url;
try {
if (parentEl.querySelector(`#${prefixedId}`)) {
parentEl.querySelector(`#${prefixedId}`)?.remove();
}
} catch (e) {}
script.onload = () => {
this.loadedAssets[options.url].state = 'loaded';
options.resolve();
};
(parentEl || parentEl).appendChild(script);
}
}
function buildId(url: string, id?: string): string {
if (id) return id;
return btoa(url.split('/').pop() as string);
}
export default new LazyLoader();

View File

@@ -0,0 +1,24 @@
/**
* Load image avoiding xhr/fetch CORS issues. Server status can't be obtained this way
* unfortunately, so this uses "naturalWidth" to determine if the image has been loaded. By
* default, it checks if it is at least 1px.
*/
export const loadImage = (
src: string,
minWidth = 1
): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
const handler = () => {
// @ts-expect-error
delete image.onload;
// @ts-expect-error
delete image.onerror;
if (image.naturalWidth >= minWidth) {
resolve(image);
} else {
reject('Could not load youtube image');
}
};
Object.assign(image, {onload: handler, onerror: handler, src});
});

View File

@@ -0,0 +1,25 @@
import {toast} from '../../ui/toast/toast';
import {getAxiosErrorMessage} from './get-axios-error-message';
import {message} from '../../i18n/message';
import {ToastOptions} from '@common/ui/toast/toast-store';
import axios from 'axios';
import {openDialog} from '@common/ui/overlays/store/dialog-store';
import {IgnitionErrorDialog} from '@common/utils/http/ignition-error-dialog/ignition-error-dialog';
const defaultErrorMessage = message('There was an issue. Please try again.');
export function showHttpErrorToast(
err: unknown,
defaultMessage = defaultErrorMessage,
field?: string | null,
toastOptions?: ToastOptions,
) {
if (axios.isAxiosError(err) && err.response?.data?.ignitionTrace) {
openDialog(IgnitionErrorDialog, {error: err.response.data});
} else {
toast.danger(getAxiosErrorMessage(err, field) || defaultMessage, {
action: (err as any).response?.data?.action,
...toastOptions,
});
}
}

View File

@@ -0,0 +1,15 @@
import {isMac} from '@react-aria/utils';
interface Event {
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
}
export function isCtrlKeyPressed(e: Event) {
if (isMac()) {
return e.metaKey;
}
return e.ctrlKey;
}

View File

@@ -0,0 +1,12 @@
import {isCtrlKeyPressed} from './is-ctrl-key-pressed';
interface Event {
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
export function isCtrlOrShiftPressed(e: Event) {
return e.shiftKey || isCtrlKeyPressed(e);
}

View File

@@ -0,0 +1,55 @@
import {useGlobalListeners} from '@react-aria/utils';
import {useCallbackRef} from '@common/utils/hooks/use-callback-ref';
import {useEffect} from 'react';
import {isCtrlKeyPressed} from '@common/utils/keybinds/is-ctrl-key-pressed';
import {isAnyInputFocused} from '@common/utils/dom/is-any-input-focused';
interface Options {
allowedInputSelector?: string;
}
export function useKeybind(
el: HTMLElement | 'window',
shortcut: string,
userCallback: (e: KeyboardEvent) => void,
{allowedInputSelector}: Options = {},
) {
const {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
const callback = useCallbackRef(userCallback);
useEffect(() => {
const target = el === 'window' ? window : el;
addGlobalListener(target, 'keydown', (e: KeyboardEvent) => {
if (!shouldIgnoreActiveEl(allowedInputSelector) && isAnyInputFocused()) {
return;
}
const matches = shortcut.split('+').every(key => {
if (key === 'ctrl') {
return isCtrlKeyPressed(e);
} else {
return e.key === key;
}
});
if (matches) {
e.preventDefault();
e.stopPropagation();
callback(e);
}
});
return removeAllGlobalListeners;
}, [
addGlobalListener,
shortcut,
removeAllGlobalListeners,
callback,
el,
allowedInputSelector,
]);
}
function shouldIgnoreActiveEl(selector?: string) {
if (!selector || !document.activeElement) {
return false;
}
return (document.activeElement as HTMLElement).closest(selector);
}

View File

@@ -0,0 +1,3 @@
export function clamp(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max);
}

View File

@@ -0,0 +1,3 @@
export function isNumber(value: any): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}

View File

@@ -0,0 +1,29 @@
export function removeEmptyValuesFromObject<T extends Record<string, unknown>>(
obj: T,
options?: {copy?: boolean; deep?: boolean; arrays?: boolean},
): T {
const shouldCopy = options?.copy ?? true;
const newObj = shouldCopy ? {...obj} : obj;
Object.keys(newObj).forEach(_key => {
const key = _key as keyof T;
if (
options?.arrays &&
Array.isArray(newObj[key]) &&
(newObj[key] as any[]).length === 0
) {
delete newObj[key];
} else if (
options?.deep &&
newObj[key] &&
typeof newObj[key] === 'object'
) {
newObj[key] = removeEmptyValuesFromObject(newObj[key] as any, options);
if (Object.keys(newObj[key] as object).length === 0) {
delete newObj[key];
}
} else if (newObj[key] == null || newObj[key] === '') {
delete newObj[key];
}
});
return shouldCopy ? newObj : obj;
}

View File

@@ -0,0 +1,15 @@
export const IS_CLIENT = typeof window !== 'undefined';
export const UA = IS_CLIENT ? window.navigator?.userAgent.toLowerCase() : '';
export const IS_IOS = /iphone|ipad|ipod|ios|CriOS|FxiOS/.test(UA);
export const IS_ANDROID = /android/.test(UA);
export const IS_MOBILE = IS_CLIENT && (IS_IOS || IS_ANDROID);
export const IS_IPHONE =
IS_CLIENT && /(iPhone|iPod)/gi.test(window.navigator?.platform);
export const IS_FIREFOX = /firefox/.test(UA);
// @ts-ignore
export const IS_CHROME = IS_CLIENT && window.chrome;
export const IS_SAFARI =
IS_CLIENT &&
!IS_CHROME &&
// @ts-ignore
(window.safari || IS_IOS || /(apple|safari)/.test(UA));

View File

@@ -0,0 +1,32 @@
export function shallowEqual<
T extends Record<string, unknown> = Record<string, unknown>
>(objA?: T, objB?: T) {
if (objA === objB) {
return true;
}
if (!objA || !objB) {
return false;
}
const aKeys = Object.keys(objA);
const bKeys = Object.keys(objB);
const len = aKeys.length;
if (bKeys.length !== len) {
return false;
}
for (let i = 0; i < len; i++) {
const key = aKeys[i];
if (
objA[key] !== objB[key] ||
!Object.prototype.hasOwnProperty.call(objB, key)
) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,8 @@
const matcher =
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
export function isEmail(string?: string): boolean {
if (!string) return false;
if (string.length > 320) return false;
return matcher.test(string);
}

View File

@@ -0,0 +1,4 @@
export function lowerFirst(string: string): string {
if (!string) return '';
return string.charAt(0).toLowerCase() + string.slice(1);
}

View File

@@ -0,0 +1,11 @@
export function randomNumber(min: number = 1, max: number = 10000) {
const randomBuffer = new Uint32Array(1);
window.crypto.getRandomValues(randomBuffer);
const number = randomBuffer[0] / (0xffffffff + 1);
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(number * (max - min + 1)) + min;
}

View File

@@ -0,0 +1,11 @@
export function randomString(length: number = 36) {
let random = '';
const possible =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < length; i += 1) {
random += possible.charAt(Math.floor(Math.random() * possible.length));
}
return random;
}

View File

@@ -0,0 +1,21 @@
import slugify from 'slugify';
export function slugifyString(
text: string,
replacement = '-',
strict = false,
): string {
if (!text) return text;
let slugified = slugify(text, {
lower: true,
replacement,
strict,
remove: /[*+~.()'"!:@?\|/\\#]/g,
});
// some chinese text might not get slugified properly,
// just replace whitespace with dash in that case
if (!slugified) {
slugified = text.replace(/\s+/g, '-').toLowerCase();
}
return slugified;
}

View File

@@ -0,0 +1,3 @@
export function stripTags(str: string) {
return str.replace(/<\/?[^>]+(>|$)/g, '');
}

View File

@@ -0,0 +1,6 @@
export function truncateString(str: string, length: number, end = '...') {
if (length == null || length >= str.length) {
return str;
}
return str.slice(0, Math.max(0, length - end.length)) + end;
}

View File

@@ -0,0 +1,4 @@
export function ucFirst<T extends string>(string: T): T {
if (!string) return string;
return (string.charAt(0).toUpperCase() + string.slice(1)) as T;
}

View File

@@ -0,0 +1 @@
export type Nullable<T> = {[K in keyof T]: T[K] | null};

View File

@@ -0,0 +1,7 @@
export function objHasKey<X extends {}, Y extends PropertyKey>(
obj: X,
prop: Y
): obj is X & Record<Y, unknown> {
// eslint-disable-next-line no-prototype-builtins
return obj.hasOwnProperty(prop);
}

View File

@@ -0,0 +1 @@
export type PartialWithRequired<T, K extends keyof T> = Pick<T, K> & Partial<T>;

View File

@@ -0,0 +1,20 @@
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
import {isAbsoluteUrl} from '@common/utils/urls/is-absolute-url';
export function getAssetUrl(url: string) {
if (isAbsoluteUrl(url)) {
return url;
}
const assetUrl =
getBootstrapData().settings.asset_url ||
getBootstrapData().settings.base_url;
//remove leading slash
url = url.replace(/^\/+/g, '');
if (url.startsWith('assets/')) {
return `${assetUrl}/build/${url}`;
}
return `${assetUrl}/${url}`;
}

View File

@@ -0,0 +1,4 @@
export function isAbsoluteUrl(url?: string): boolean {
if (!url) return false;
return /^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(url);
}

View File

@@ -0,0 +1,4 @@
export function removeProtocol(url: string) {
if (!url) return url;
return url.replace(/(^\w+:|^)\/\//, '');
}

View File

@@ -0,0 +1,72 @@
export type ShareableNetworks =
| 'facebook'
| 'twitter'
| 'pinterest'
| 'tumblr'
| 'blogger'
| 'mail';
export function shareLinkSocially(
network: ShareableNetworks,
link: string,
name?: string,
image?: string
) {
const url = generateShareUrl(network, link, name, image);
if (network === 'mail') {
window.location.href = url;
} else {
openNewWindow(url);
}
}
function openNewWindow(url: string) {
const width = 575,
height = 400,
left = (window.innerWidth - width) / 2,
top = (window.innerHeight - height) / 2,
opts =
'status=1, scrollbars=1' +
',width=' +
width +
',height=' +
height +
',top=' +
top +
',left=' +
left;
window.open(url, 'share', opts);
}
function generateShareUrl(
type: ShareableNetworks,
link: string,
name?: string,
image?: string
): string {
switch (type) {
case 'facebook':
return 'https://www.facebook.com/sharer/sharer.php?u=' + link;
case 'twitter':
return `https://twitter.com/intent/tweet?text=${name}&url=${link}`;
case 'pinterest':
return (
'https://pinterest.com/pin/create/button/?url=' +
link +
'&media=' +
image
);
case 'tumblr':
const base =
'https://www.tumblr.com/widgets/share/tool?shareSource=legacy&canonicalUrl=&posttype=photo&title=&caption=';
return base + name + '&content=' + image + '&url=' + link;
case 'blogger':
return (
'https://www.blogger.com/blog_this.pyra?t&u=' + link + '&n=' + name
);
case 'mail':
return `mailto:?subject=Check out this link.&body=${link}`;
}
}