8
common/resources/client/http/backend-response/backend-response.ts
Executable file
8
common/resources/client/http/backend-response/backend-response.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import {MetaTag} from '@common/seo/meta-tag';
|
||||
|
||||
export interface BackendResponse {
|
||||
status?: string;
|
||||
seo?: MetaTag[];
|
||||
// whether seo tags were already set with initial response from server for this data
|
||||
set_seo?: boolean;
|
||||
}
|
||||
75
common/resources/client/http/backend-response/pagination-response.ts
Executable file
75
common/resources/client/http/backend-response/pagination-response.ts
Executable file
@@ -0,0 +1,75 @@
|
||||
import {BackendResponse} from './backend-response';
|
||||
|
||||
export interface LengthAwarePaginationResponse<T = unknown> {
|
||||
data: T[];
|
||||
from: number;
|
||||
to: number;
|
||||
total: number;
|
||||
per_page: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
next_page?: number;
|
||||
prev_page?: number;
|
||||
}
|
||||
|
||||
export interface SimplePaginationResponse<T = unknown> {
|
||||
data: T[];
|
||||
from: number;
|
||||
to: number;
|
||||
per_page: number;
|
||||
current_page: number;
|
||||
next_page?: number | null;
|
||||
prev_page?: number | null;
|
||||
}
|
||||
|
||||
interface CursorPaginationResponse<T> {
|
||||
data: T[];
|
||||
next_cursor: string | null;
|
||||
per_page: number;
|
||||
prev_cursor: string | null;
|
||||
}
|
||||
|
||||
export type PaginationResponse<T> =
|
||||
| LengthAwarePaginationResponse<T>
|
||||
| SimplePaginationResponse<T>
|
||||
| CursorPaginationResponse<T>;
|
||||
|
||||
export const EMPTY_PAGINATION_RESPONSE = {
|
||||
pagination: {data: [], from: 0, to: 0, per_page: 15, current_page: 1},
|
||||
};
|
||||
|
||||
export interface PaginatedBackendResponse<T> extends BackendResponse {
|
||||
pagination: PaginationResponse<T>;
|
||||
}
|
||||
|
||||
export function hasPreviousPage(
|
||||
pagination: PaginationResponse<unknown>,
|
||||
): boolean {
|
||||
if ('prev_cursor' in pagination) {
|
||||
return pagination.prev_cursor != null;
|
||||
}
|
||||
|
||||
if ('prev_page' in pagination) {
|
||||
return pagination.prev_page != null;
|
||||
}
|
||||
|
||||
return pagination.current_page > 1;
|
||||
}
|
||||
|
||||
export function hasNextPage(pagination: PaginationResponse<unknown>): boolean {
|
||||
if ('next_cursor' in pagination) {
|
||||
return pagination.next_cursor != null;
|
||||
}
|
||||
|
||||
if ('last_page' in pagination) {
|
||||
return pagination.current_page < pagination.last_page;
|
||||
}
|
||||
|
||||
if ('next_page' in pagination) {
|
||||
return pagination.next_page != null;
|
||||
}
|
||||
|
||||
return (
|
||||
pagination.data.length > 0 && pagination.data.length >= pagination.per_page
|
||||
);
|
||||
}
|
||||
19
common/resources/client/http/page-meta-tags.tsx
Executable file
19
common/resources/client/http/page-meta-tags.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {UseQueryResult} from '@tanstack/react-query';
|
||||
import {Helmet} from '@common/seo/helmet';
|
||||
import {DefaultMetaTags} from '@common/seo/default-meta-tags';
|
||||
import React from 'react';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
|
||||
interface Props {
|
||||
query: UseQueryResult<BackendResponse>;
|
||||
}
|
||||
export function PageMetaTags({query}: Props) {
|
||||
if (query.data?.set_seo) {
|
||||
return null;
|
||||
}
|
||||
return query.data?.seo ? (
|
||||
<Helmet tags={query.data.seo} />
|
||||
) : (
|
||||
<DefaultMetaTags />
|
||||
);
|
||||
}
|
||||
63
common/resources/client/http/page-status.tsx
Executable file
63
common/resources/client/http/page-status.tsx
Executable file
@@ -0,0 +1,63 @@
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
import {errorStatusIs} from '@common/utils/http/error-status-is';
|
||||
import {NotFoundPage} from '@common/ui/not-found-page/not-found-page';
|
||||
import {PageErrorMessage} from '@common/errors/page-error-message';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {UseQueryResult} from '@tanstack/react-query';
|
||||
import {Navigate} from 'react-router-dom';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import useSpinDelay from '@common/utils/hooks/use-spin-delay';
|
||||
|
||||
interface Props {
|
||||
query: UseQueryResult;
|
||||
show404?: boolean;
|
||||
redirectOn404?: string;
|
||||
loaderClassName?: string;
|
||||
loaderIsScreen?: boolean;
|
||||
loader?: ReactNode;
|
||||
delayedSpinner?: boolean;
|
||||
}
|
||||
export function PageStatus({
|
||||
query,
|
||||
show404 = true,
|
||||
loader,
|
||||
loaderClassName,
|
||||
loaderIsScreen = true,
|
||||
delayedSpinner = true,
|
||||
redirectOn404,
|
||||
}: Props) {
|
||||
const {isLoggedIn} = useAuth();
|
||||
|
||||
const showSpinner = useSpinDelay(query.isLoading, {
|
||||
delay: 500,
|
||||
minDuration: 200,
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
if (!showSpinner && delayedSpinner) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
loader || (
|
||||
<FullPageLoader className={loaderClassName} screen={loaderIsScreen} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
query.isError &&
|
||||
(errorStatusIs(query.error, 401) || errorStatusIs(query.error, 403)) &&
|
||||
!isLoggedIn
|
||||
) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (show404 && query.isError && errorStatusIs(query.error, 404)) {
|
||||
if (redirectOn404) {
|
||||
return <Navigate to={redirectOn404} replace />;
|
||||
}
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
return <PageErrorMessage />;
|
||||
}
|
||||
87
common/resources/client/http/query-client.ts
Executable file
87
common/resources/client/http/query-client.ts
Executable file
@@ -0,0 +1,87 @@
|
||||
import {QueryClient} from '@tanstack/react-query';
|
||||
import axios, {AxiosRequestConfig} from 'axios';
|
||||
import {getActiveWorkspaceId} from '../workspace/active-workspace-id';
|
||||
import {isAbsoluteUrl} from '../utils/urls/is-absolute-url';
|
||||
import {errorStatusIs} from '@common/utils/http/error-status-is';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30000,
|
||||
retry: (failureCount, err) => {
|
||||
return (
|
||||
!errorStatusIs(err, 401) &&
|
||||
!errorStatusIs(err, 403) &&
|
||||
!errorStatusIs(err, 404) &&
|
||||
failureCount < 2
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const apiClient = axios.create();
|
||||
apiClient.defaults.withCredentials = true;
|
||||
apiClient.defaults.responseType = 'json';
|
||||
// @ts-ignore
|
||||
apiClient.defaults.headers = {
|
||||
common: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
apiClient.interceptors.request.use((config: AxiosRequestConfig) => {
|
||||
if (
|
||||
!config.url?.startsWith('auth') &&
|
||||
!config.url?.startsWith('secure') &&
|
||||
!config.url?.startsWith('log-viewer') &&
|
||||
!isAbsoluteUrl(config?.url)
|
||||
) {
|
||||
config.url = `api/v1/${config.url}`;
|
||||
}
|
||||
|
||||
const method = config.method?.toUpperCase();
|
||||
|
||||
// transform array query params in GET request to comma separated string
|
||||
if (method === 'GET' && Array.isArray(config.params?.with)) {
|
||||
config.params.with = config.params.with.join(',');
|
||||
}
|
||||
if (method === 'GET' && Array.isArray(config.params?.load)) {
|
||||
config.params.load = config.params.load.join(',');
|
||||
}
|
||||
if (method === 'GET' && Array.isArray(config.params?.loadCount)) {
|
||||
config.params.loadCount = config.params.loadCount.join(',');
|
||||
}
|
||||
|
||||
// add workspace query parameter
|
||||
const workspaceId = getActiveWorkspaceId();
|
||||
if (workspaceId) {
|
||||
const method = config.method?.toLowerCase();
|
||||
if (['get', 'post', 'put'].includes(method!)) {
|
||||
config.params = {...config.params, workspaceId};
|
||||
}
|
||||
}
|
||||
|
||||
// override PUT, DELETE, PATCH methods, they might not be supported on the backend
|
||||
if (method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-HTTP-Method-Override': method,
|
||||
};
|
||||
config.method = 'POST';
|
||||
config.params = {
|
||||
...config.params,
|
||||
_method: method,
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.env.SSR) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
referer: 'http://localhost',
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
111
common/resources/client/http/value-lists.ts
Executable file
111
common/resources/client/http/value-lists.ts
Executable file
@@ -0,0 +1,111 @@
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from './backend-response/backend-response';
|
||||
import {Localization} from '../i18n/localization';
|
||||
import {CssTheme} from '../ui/themes/css-theme';
|
||||
import {Role} from '../auth/role';
|
||||
import {Permission} from '../auth/permission';
|
||||
import {apiClient, queryClient} from './query-client';
|
||||
import {MenuItemCategory} from '../admin/appearance/sections/menus/menu-item-category';
|
||||
import {CustomPage} from '../admin/custom-pages/custom-page';
|
||||
import {CustomDomain} from '../custom-domains/custom-domain';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
|
||||
export interface FetchValueListsResponse extends BackendResponse {
|
||||
countries?: CountryListItem[];
|
||||
timezones?: {[key: string]: Timezone[]};
|
||||
languages?: LanguageListItem[];
|
||||
localizations?: Localization[];
|
||||
currencies?: {[key: string]: Currency};
|
||||
domains?: CustomDomain[];
|
||||
pages?: CustomPage[];
|
||||
themes?: CssTheme[];
|
||||
permissions?: Permission[];
|
||||
workspacePermissions?: Permission[];
|
||||
roles?: Role[];
|
||||
menuItemCategories?: MenuItemCategory[];
|
||||
googleFonts?: FontConfig[];
|
||||
workspaceRoles?: Role[];
|
||||
}
|
||||
|
||||
export interface CountryListItem {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface LanguageListItem {
|
||||
name: string;
|
||||
nativeName?: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
name: string;
|
||||
decimal_digits: number;
|
||||
symbol: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface Timezone {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FontConfig {
|
||||
label?: MessageDescriptor;
|
||||
family: string;
|
||||
category?: string;
|
||||
google?: boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function useValueLists(
|
||||
names: (keyof FetchValueListsResponse)[],
|
||||
params?: Record<string, string | number | undefined>,
|
||||
options: Options = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['value-lists', names, params],
|
||||
queryFn: () => fetchValueLists(names, params),
|
||||
// if there are params, make sure we update lists when they change
|
||||
staleTime: !params ? Infinity : undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: !options.disabled,
|
||||
initialData: () => {
|
||||
// check if we have already fetched value lists for all specified names previously,
|
||||
// if so, return cached response for this query, as there's no need to fetch it again
|
||||
const previousData = queryClient
|
||||
.getQueriesData<FetchValueListsResponse>({queryKey: ['ValueLists']})
|
||||
.find(([, response]) => {
|
||||
if (response && names.every(n => response[n])) {
|
||||
return response;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (previousData) {
|
||||
return previousData[1];
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function prefetchValueLists(
|
||||
names: (keyof FetchValueListsResponse)[],
|
||||
params?: Record<string, string | number | undefined>,
|
||||
) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['value-lists', names, params],
|
||||
queryFn: () => fetchValueLists(names, params),
|
||||
});
|
||||
}
|
||||
|
||||
function fetchValueLists(
|
||||
names: (keyof FetchValueListsResponse)[],
|
||||
params?: Record<string, string | number | undefined>,
|
||||
): Promise<FetchValueListsResponse> {
|
||||
return apiClient
|
||||
.get(`value-lists/${names}`, {params})
|
||||
.then(response => response.data);
|
||||
}
|
||||
Reference in New Issue
Block a user