9
common/resources/client/utils/array/async-iterable-to-array.ts
Executable file
9
common/resources/client/utils/array/async-iterable-to-array.ts
Executable 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;
|
||||
}
|
||||
13
common/resources/client/utils/array/chunk-array.ts
Executable file
13
common/resources/client/utils/array/chunk-array.ts
Executable 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;
|
||||
}, []);
|
||||
}
|
||||
26
common/resources/client/utils/array/group-array-by.ts
Executable file
26
common/resources/client/utils/array/group-array-by.ts
Executable 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;
|
||||
}
|
||||
25
common/resources/client/utils/array/move-item-in-array.ts
Executable file
25
common/resources/client/utils/array/move-item-in-array.ts
Executable 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;
|
||||
}
|
||||
14
common/resources/client/utils/array/move-item-in-new-array.ts
Executable file
14
common/resources/client/utils/array/move-item-in-new-array.ts
Executable 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;
|
||||
}
|
||||
35
common/resources/client/utils/array/move-multiple-items-in-array.ts
Executable file
35
common/resources/client/utils/array/move-multiple-items-in-array.ts
Executable 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;
|
||||
}
|
||||
5
common/resources/client/utils/array/prepend-to-array-at-index.ts
Executable file
5
common/resources/client/utils/array/prepend-to-array-at-index.ts
Executable 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];
|
||||
}
|
||||
22
common/resources/client/utils/array/shuffle-array.ts
Executable file
22
common/resources/client/utils/array/shuffle-array.ts
Executable 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];
|
||||
}
|
||||
77
common/resources/client/utils/array/sort-array-of-objects.ts
Executable file
77
common/resources/client/utils/array/sort-array-of-objects.ts
Executable 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));
|
||||
}
|
||||
10
common/resources/client/utils/date/end-of-day.ts
Executable file
10
common/resources/client/utils/date/end-of-day.ts
Executable 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,
|
||||
});
|
||||
}
|
||||
5
common/resources/client/utils/date/start-of-day.ts
Executable file
5
common/resources/client/utils/date/start-of-day.ts
Executable 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});
|
||||
}
|
||||
12
common/resources/client/utils/dom/create-event-handler.ts
Executable file
12
common/resources/client/utils/dom/create-event-handler.ts
Executable 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
35
common/resources/client/utils/dom/create-ref-loop.ts
Executable file
35
common/resources/client/utils/dom/create-ref-loop.ts
Executable 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);
|
||||
}
|
||||
20
common/resources/client/utils/dom/get-bounding-client-rect.ts
Executable file
20
common/resources/client/utils/dom/get-bounding-client-rect.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
9
common/resources/client/utils/dom/is-any-input-focused.ts
Executable file
9
common/resources/client/utils/dom/is-any-input-focused.ts
Executable 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;
|
||||
}
|
||||
3
common/resources/client/utils/dom/is-ssr.ts
Executable file
3
common/resources/client/utils/dom/is-ssr.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export function isSsr() {
|
||||
return import.meta.env.SSR;
|
||||
}
|
||||
21
common/resources/client/utils/dom/observe-size.ts
Executable file
21
common/resources/client/utils/dom/observe-size.ts
Executable 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
13
common/resources/client/utils/hooks/is-mobile-device.ts
Executable file
13
common/resources/client/utils/hooks/is-mobile-device.ts
Executable 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;
|
||||
}
|
||||
5
common/resources/client/utils/hooks/is-mobile-media-query.ts
Executable file
5
common/resources/client/utils/hooks/is-mobile-media-query.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import { useMediaQuery, UseMediaQueryOptions } from "./use-media-query";
|
||||
|
||||
export function useIsMobileMediaQuery(options?: UseMediaQueryOptions) {
|
||||
return useMediaQuery("(max-width: 768px)", options);
|
||||
}
|
||||
5
common/resources/client/utils/hooks/is-tablet-media-query.ts
Executable file
5
common/resources/client/utils/hooks/is-tablet-media-query.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import {useMediaQuery, UseMediaQueryOptions} from './use-media-query';
|
||||
|
||||
export function useIsTabletMediaQuery(options?: UseMediaQueryOptions) {
|
||||
return useMediaQuery('(max-width: 1024px)', options);
|
||||
}
|
||||
5
common/resources/client/utils/hooks/is-touch-device.ts
Executable file
5
common/resources/client/utils/hooks/is-touch-device.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import {useMediaQuery} from './use-media-query';
|
||||
|
||||
export function useIsTouchDevice() {
|
||||
return useMediaQuery('((pointer: coarse))');
|
||||
}
|
||||
75
common/resources/client/utils/hooks/local-storage.ts
Executable file
75
common/resources/client/utils/hooks/local-storage.ts
Executable 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) {
|
||||
//
|
||||
}
|
||||
}
|
||||
22
common/resources/client/utils/hooks/sticky-sentinel.ts
Executable file
22
common/resources/client/utils/hooks/sticky-sentinel.ts
Executable 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};
|
||||
}
|
||||
14
common/resources/client/utils/hooks/use-block-body-overflow.ts
Executable file
14
common/resources/client/utils/hooks/use-block-body-overflow.ts
Executable 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]);
|
||||
}
|
||||
18
common/resources/client/utils/hooks/use-callback-ref.ts
Executable file
18
common/resources/client/utils/hooks/use-callback-ref.ts
Executable 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, []);
|
||||
}
|
||||
104
common/resources/client/utils/hooks/use-cookie.ts
Executable file
104
common/resources/client/utils/hooks/use-cookie.ts
Executable 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;
|
||||
}
|
||||
14
common/resources/client/utils/hooks/use-linkified-string.ts
Executable file
14
common/resources/client/utils/hooks/use-linkified-string.ts
Executable 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]);
|
||||
}
|
||||
42
common/resources/client/utils/hooks/use-media-query.ts
Executable file
42
common/resources/client/utils/hooks/use-media-query.ts
Executable 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;
|
||||
}
|
||||
27
common/resources/client/utils/hooks/use-navigate.ts
Executable file
27
common/resources/client/utils/hooks/use-navigate.ts
Executable 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;
|
||||
}
|
||||
11
common/resources/client/utils/hooks/use-previous.ts
Executable file
11
common/resources/client/utils/hooks/use-previous.ts
Executable 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;
|
||||
}
|
||||
62
common/resources/client/utils/hooks/use-spin-delay.ts
Executable file
62
common/resources/client/utils/hooks/use-spin-delay.ts
Executable 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;
|
||||
14
common/resources/client/utils/hooks/use-stable-scrollbar.ts
Executable file
14
common/resources/client/utils/hooks/use-stable-scrollbar.ts
Executable 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]);
|
||||
}
|
||||
5
common/resources/client/utils/http/error-status-is.ts
Executable file
5
common/resources/client/utils/http/error-status-is.ts
Executable 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;
|
||||
}
|
||||
18
common/resources/client/utils/http/get-axios-error-message.ts
Executable file
18
common/resources/client/utils/http/get-axios-error-message.ts
Executable 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
129
common/resources/client/utils/http/lazy-loader.ts
Executable file
129
common/resources/client/utils/http/lazy-loader.ts
Executable 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();
|
||||
24
common/resources/client/utils/http/load-image.ts
Executable file
24
common/resources/client/utils/http/load-image.ts
Executable 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});
|
||||
});
|
||||
25
common/resources/client/utils/http/show-http-error-toast.ts
Executable file
25
common/resources/client/utils/http/show-http-error-toast.ts
Executable 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
15
common/resources/client/utils/keybinds/is-ctrl-key-pressed.ts
Executable file
15
common/resources/client/utils/keybinds/is-ctrl-key-pressed.ts
Executable 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;
|
||||
}
|
||||
12
common/resources/client/utils/keybinds/is-ctrl-or-shift-pressed.ts
Executable file
12
common/resources/client/utils/keybinds/is-ctrl-or-shift-pressed.ts
Executable 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);
|
||||
}
|
||||
55
common/resources/client/utils/keybinds/use-keybind.ts
Executable file
55
common/resources/client/utils/keybinds/use-keybind.ts
Executable 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);
|
||||
}
|
||||
3
common/resources/client/utils/number/clamp.ts
Executable file
3
common/resources/client/utils/number/clamp.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export function clamp(num: number, min: number, max: number) {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
3
common/resources/client/utils/number/is-number.ts
Executable file
3
common/resources/client/utils/number/is-number.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export function isNumber(value: any): value is number {
|
||||
return typeof value === 'number' && !Number.isNaN(value);
|
||||
}
|
||||
29
common/resources/client/utils/objects/remove-empty-values-from-object.ts
Executable file
29
common/resources/client/utils/objects/remove-empty-values-from-object.ts
Executable 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;
|
||||
}
|
||||
15
common/resources/client/utils/platform.ts
Executable file
15
common/resources/client/utils/platform.ts
Executable 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));
|
||||
32
common/resources/client/utils/shallow-equal.ts
Executable file
32
common/resources/client/utils/shallow-equal.ts
Executable 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;
|
||||
}
|
||||
8
common/resources/client/utils/string/is-email.ts
Executable file
8
common/resources/client/utils/string/is-email.ts
Executable 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);
|
||||
}
|
||||
4
common/resources/client/utils/string/lower-first.ts
Executable file
4
common/resources/client/utils/string/lower-first.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export function lowerFirst(string: string): string {
|
||||
if (!string) return '';
|
||||
return string.charAt(0).toLowerCase() + string.slice(1);
|
||||
}
|
||||
11
common/resources/client/utils/string/random-number.ts
Executable file
11
common/resources/client/utils/string/random-number.ts
Executable 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;
|
||||
}
|
||||
11
common/resources/client/utils/string/random-string.ts
Executable file
11
common/resources/client/utils/string/random-string.ts
Executable 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;
|
||||
}
|
||||
21
common/resources/client/utils/string/slugify-string.ts
Executable file
21
common/resources/client/utils/string/slugify-string.ts
Executable 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;
|
||||
}
|
||||
3
common/resources/client/utils/string/strip-tags.ts
Executable file
3
common/resources/client/utils/string/strip-tags.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export function stripTags(str: string) {
|
||||
return str.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
}
|
||||
6
common/resources/client/utils/string/truncate-string.ts
Executable file
6
common/resources/client/utils/string/truncate-string.ts
Executable 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;
|
||||
}
|
||||
4
common/resources/client/utils/string/uc-first.ts
Executable file
4
common/resources/client/utils/string/uc-first.ts
Executable 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;
|
||||
}
|
||||
1
common/resources/client/utils/ts/nullable.ts
Executable file
1
common/resources/client/utils/ts/nullable.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type Nullable<T> = {[K in keyof T]: T[K] | null};
|
||||
7
common/resources/client/utils/ts/obj-has-key.ts
Executable file
7
common/resources/client/utils/ts/obj-has-key.ts
Executable 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);
|
||||
}
|
||||
1
common/resources/client/utils/ts/partial-with-required.ts
Executable file
1
common/resources/client/utils/ts/partial-with-required.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type PartialWithRequired<T, K extends keyof T> = Pick<T, K> & Partial<T>;
|
||||
20
common/resources/client/utils/urls/get-asset-url.ts
Executable file
20
common/resources/client/utils/urls/get-asset-url.ts
Executable 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}`;
|
||||
}
|
||||
4
common/resources/client/utils/urls/is-absolute-url.ts
Executable file
4
common/resources/client/utils/urls/is-absolute-url.ts
Executable 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);
|
||||
}
|
||||
4
common/resources/client/utils/urls/remove-protocol.ts
Executable file
4
common/resources/client/utils/urls/remove-protocol.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export function removeProtocol(url: string) {
|
||||
if (!url) return url;
|
||||
return url.replace(/(^\w+:|^)\/\//, '');
|
||||
}
|
||||
72
common/resources/client/utils/urls/share-link-socially.ts
Executable file
72
common/resources/client/utils/urls/share-link-socially.ts
Executable 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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user