Merge remote-tracking branch 'github/feat/1.1.2/ui' into feat/1.1.2/user-center

This commit is contained in:
LinkinStars 2023-05-04 19:24:07 +08:00
commit 85e370d7ff
16 changed files with 386 additions and 240 deletions

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#0033FF" />
<meta name="generator" content="Answer - https://github.com/answerdev/answer">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
</head>

View File

@ -1,5 +1,3 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface FormValue<T = any> {
value: T;
isInvalid: boolean;
@ -565,27 +563,6 @@ export interface UserOauthConnectorItem extends OauthConnectorItem {
binding: boolean;
external_id: string;
}
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}
export interface QuestionOperationReq {
id: string;

View File

@ -11,6 +11,9 @@ const Index: FC = () => {
let primaryColor;
if (theme_config?.[theme]?.primary_color) {
primaryColor = Color(theme_config[theme].primary_color);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', primaryColor.hex());
}
return (

View File

@ -10,7 +10,9 @@ import { useHotQuestions } from '@/services';
const HotQuestions: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'question' });
const { data: questionRes } = useHotQuestions();
if (!questionRes?.list?.length) {
return null;
}
return (
<Card>
<Card.Header className="text-nowrap text-capitalize">

View File

@ -1,15 +1,15 @@
import React, { FC, useState } from 'react';
import { Button, ButtonProps } from 'react-bootstrap';
import React, { FC, useLayoutEffect, useState } from 'react';
import { Button, ButtonProps, Spinner } from 'react-bootstrap';
import { request } from '@/utils';
import type * as Type from '@/common/interface';
import type { UIAction } from '../index.d';
import type { UIAction, FormKit } from '../types';
import { useToast } from '@/hooks';
interface Props {
fieldName: string;
text: string;
action: UIAction | undefined;
formData: Type.FormDataType;
formKit: FormKit;
readOnly: boolean;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
@ -17,24 +17,65 @@ interface Props {
const Index: FC<Props> = ({
fieldName,
action,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
formData,
formKit,
text = '',
readOnly = false,
variant = 'primary',
size,
}) => {
const Toast = useToast();
const [isLoading, setLoading] = useState(false);
const handleAction = async () => {
const handleToast = (msg, type: 'success' | 'danger' = 'success') => {
const tm = action?.on_complete?.toast_return_message;
if (tm === false || !msg) {
return;
}
Toast.onShow({
msg,
variant: type,
});
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleCallback = (resp) => {
const callback = action?.on_complete;
if (callback?.refresh_form_config) {
formKit.refreshConfig();
}
};
const handleAction = () => {
if (!action) {
return;
}
setLoading(true);
const method = action.method || 'get';
await request[method](action.url);
setLoading(false);
request
.request({
method: action.method,
url: action.url,
timeout: 0,
})
.then((resp) => {
if ('message' in resp) {
handleToast(resp.message, 'success');
}
handleCallback(resp);
})
.catch((ex) => {
if (ex && 'msg' in ex) {
handleToast(ex.msg, 'danger');
}
})
.finally(() => {
setLoading(false);
});
};
useLayoutEffect(() => {
if (action?.loading?.state === 'pending') {
setLoading(true);
}
}, []);
const loadingText = action?.loading?.text || text;
const disabled = isLoading || readOnly;
return (
<div className="d-flex">
<Button
@ -43,8 +84,19 @@ const Index: FC<Props> = ({
disabled={disabled}
size={size}
variant={variant}>
{text || fieldName}
{isLoading ? '...' : ''}
{isLoading ? (
<>
<Spinner
className="align-middle me-2"
animation="border"
size="sm"
variant={variant}
/>
{loadingText}
</>
) : (
text
)}
</Button>
</div>
);

View File

@ -1,6 +0,0 @@
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
event?: 'click' | 'change';
handler?: ({evt, formData, request}) => Promise<void>
}

View File

@ -4,14 +4,15 @@ import React, {
useImperativeHandle,
useEffect,
} from 'react';
import { Form, Button, ButtonProps } from 'react-bootstrap';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { isEmpty } from 'lodash';
import classnames from 'classnames';
import type * as Type from '@/common/interface';
import type { UIAction } from './index.d';
import type { JSONSchema, UISchema, BaseUIOptions, FormKit } from './types';
import {
Legend,
Select,
@ -21,122 +22,16 @@ import {
Upload,
Textarea,
Input,
Button as CtrlButton,
Button as SfButton,
} from './components';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
export interface BaseUIOptions {
empty?: string;
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
export * from './types';
interface IProps {
schema: JSONSchema;
schema: JSONSchema | null;
formData: Type.FormDataType | null;
uiSchema?: UISchema;
formData?: Type.FormDataType;
refreshConfig?: FormKit['refreshConfig'];
hiddenSubmit?: boolean;
onChange?: (data: Type.FormDataType) => void;
onSubmit?: (e: React.FormEvent) => void;
@ -148,6 +43,7 @@ interface IRef {
/**
* TODO:
* - [!] Standardised `Admin/Plugins/Config/index.tsx` method for generating dynamic form configurations.
* - Normalize and document `formData[key].hidden && 'd-none'`
* - Normalize and document `hiddenSubmit`
* - Improving field hints for `formData`
@ -168,7 +64,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
{
schema,
uiSchema = {},
formData = {},
refreshConfig,
formData,
onChange,
onSubmit,
hiddenSubmit = false,
@ -181,11 +78,10 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
const { required = [], properties = {} } = schema || {};
// check required field
const excludes = required.filter((key) => !properties[key]);
if (excludes.length > 0) {
console.error(t('not_found_props', { key: excludes.join(', ') }));
}
formData ||= {};
const keys = Object.keys(properties);
/**
* Prevent components such as `select` from having default values,
@ -193,14 +89,14 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
*/
const setDefaultValueAsDomBehaviour = () => {
keys.forEach((k) => {
const fieldVal = formData[k]?.value;
const fieldVal = formData![k]?.value;
const metaProp = properties[k];
const uiCtrl = uiSchema[k]?.['ui:widget'];
if (!metaProp || !uiCtrl || fieldVal !== undefined) {
return;
}
if (uiCtrl === 'select' && metaProp.enum?.[0] !== undefined) {
formData[k] = {
formData![k] = {
errorMsg: '',
isInvalid: false,
value: metaProp.enum?.[0],
@ -212,10 +108,22 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
setDefaultValueAsDomBehaviour();
}, [formData]);
const formKitWithContext: FormKit = {
refreshConfig() {
if (typeof refreshConfig === 'function') {
refreshConfig();
}
},
};
/**
* Form validation
* - Currently only dynamic forms are in use, the business form validation has been handed over to the server
*/
const requiredValidator = () => {
const errors: string[] = [];
required.forEach((key) => {
if (!formData[key] || !formData[key].value) {
if (!formData![key] || !formData![key].value) {
errors.push(key);
}
});
@ -231,7 +139,7 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
keys.forEach((key) => {
const { validator } = uiSchema[key]?.['ui:options'] || {};
if (validator instanceof Function) {
const value = formData[key]?.value;
const value = formData![key]?.value;
promises.push({
key,
promise: validator(value, formData),
@ -269,14 +177,14 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (errors.length > 0) {
formData = errors.reduce((acc, cur) => {
acc[cur] = {
...formData[cur],
...formData![cur],
isInvalid: true,
errorMsg:
uiSchema[cur]?.['ui:options']?.empty ||
`${schema.properties[cur]?.title} ${t('empty')}`,
`${properties[cur]?.title} ${t('empty')}`,
};
return acc;
}, formData);
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
@ -286,13 +194,12 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
if (syncErrors.length > 0) {
formData = syncErrors.reduce((acc, cur) => {
acc[cur.key] = {
...formData[cur.key],
...formData![cur.key],
isInvalid: true,
errorMsg:
cur.msg || `${schema.properties[cur.key].title} ${t('invalid')}`,
errorMsg: cur.msg || `${properties[cur.key].title} ${t('invalid')}`,
};
return acc;
}, formData);
}, formData || {});
if (onChange instanceof Function) {
onChange({ ...formData });
}
@ -308,12 +215,12 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
return;
}
Object.keys(formData).forEach((key) => {
formData[key].isInvalid = false;
formData[key].errorMsg = '';
Object.keys(formData!).forEach((key) => {
formData![key].isInvalid = false;
formData![key].errorMsg = '';
});
if (onChange instanceof Function) {
onChange(formData);
onChange(formData!);
}
if (onSubmit instanceof Function) {
onSubmit(e);
@ -323,9 +230,10 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
useImperativeHandle(ref, () => ({
validator,
}));
if (!formData || !schema || !schema.properties) {
if (!formData || !schema || isEmpty(schema.properties)) {
return null;
}
return (
<Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => {
@ -336,7 +244,8 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
enumNames = [],
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema[key] || {};
uiSchema?.[key] || {};
formData ||= {};
const fieldState = formData[key];
const uiSimplify = widget === 'legend' || uiOpt?.simplify;
let groupClassName: BaseUIOptions['fieldClassName'] = uiOpt?.simplify
@ -441,11 +350,11 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
/>
) : null}
{widget === 'button' ? (
<CtrlButton
<SfButton
fieldName={key}
text={uiOpt && 'text' in uiOpt ? uiOpt.text : ''}
action={uiOpt && 'action' in uiOpt ? uiOpt.action : undefined}
formData={formData}
formKit={formKitWithContext}
readOnly={readOnly}
variant={
uiOpt && 'variant' in uiOpt ? uiOpt.variant : undefined
@ -473,8 +382,9 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
};
export const initFormData = (schema: JSONSchema): Type.FormDataType => {
const formData: Type.FormDataType = {};
Object.keys(schema.properties).forEach((key) => {
const prop = schema.properties[key];
const props: JSONSchema['properties'] = schema?.properties || {};
Object.keys(props).forEach((key) => {
const prop = props[key];
const defaultVal = prop?.default;
formData[key] = {
@ -486,4 +396,27 @@ export const initFormData = (schema: JSONSchema): Type.FormDataType => {
return formData;
};
export const mergeFormData = (
target: Type.FormDataType | null,
origin: Type.FormDataType | null,
) => {
if (!target) {
return origin;
}
if (!origin) {
return target;
}
Object.keys(target).forEach((k) => {
const oi = origin[k];
if (oi && oi.value !== undefined) {
target[k] = {
value: oi.value,
isInvalid: false,
errorMsg: '',
};
}
});
return target;
};
export default forwardRef(SchemaForm);

View File

@ -0,0 +1,152 @@
import { ButtonProps } from 'react-bootstrap';
import classnames from 'classnames';
import * as Type from '@/common/interface';
export interface JSONSchema {
title: string;
description?: string;
required?: string[];
properties: {
[key: string]: {
type: 'string' | 'boolean' | 'number';
title: string;
description?: string;
enum?: Array<string | boolean | number>;
enumNames?: string[];
default?: string | boolean | number;
};
};
}
export interface BaseUIOptions {
empty?: string;
// Will be appended to the className of the form component itself
className?: classnames.Argument;
// The className that will be attached to a form field container
fieldClassName?: classnames.Argument;
// Make a form component render into simplified mode
readOnly?: boolean;
simplify?: boolean;
validator?: (
value,
formData?,
) => Promise<string | true | void> | true | string;
}
export interface InputOptions extends BaseUIOptions {
placeholder?: string;
inputType?:
| 'color'
| 'date'
| 'datetime-local'
| 'email'
| 'month'
| 'number'
| 'password'
| 'range'
| 'search'
| 'tel'
| 'text'
| 'time'
| 'url'
| 'week';
}
export interface SelectOptions extends BaseUIOptions {}
export interface UploadOptions extends BaseUIOptions {
acceptType?: string;
imageType?: Type.UploadType;
}
export interface SwitchOptions extends BaseUIOptions {
label?: string;
}
export interface TimezoneOptions extends BaseUIOptions {
placeholder?: string;
}
export interface CheckboxOptions extends BaseUIOptions {}
export interface RadioOptions extends BaseUIOptions {}
export interface TextareaOptions extends BaseUIOptions {
placeholder?: string;
rows?: number;
}
export interface ButtonOptions extends BaseUIOptions {
text: string;
icon?: string;
action?: UIAction;
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
}
export type UIOptions =
| InputOptions
| SelectOptions
| UploadOptions
| SwitchOptions
| TimezoneOptions
| CheckboxOptions
| RadioOptions
| TextareaOptions
| ButtonOptions;
export type UIWidget =
| 'textarea'
| 'input'
| 'checkbox'
| 'radio'
| 'select'
| 'upload'
| 'timezone'
| 'switch'
| 'legend'
| 'button';
export interface UISchema {
[key: string]: {
'ui:widget'?: UIWidget;
'ui:options'?: UIOptions;
};
}
/**
* A few notes on button control
* - Mainly used to send a request and notify the result of the request, and to update the data as required
* - A scenario where a message notification is displayed directly after a click without sending a request, implementing a dedicated control
* - Scenarios where the page jumps directly after a click without sending a request, implementing a dedicated control
*
* @field url : Target address for sending requests
* @field method : Method for sending requests, default `get`
* @field callback: Button event handler function that will fully take over the button events when this field is configured
* *** Incomplete, DO NOT USE ***
* @field loading: Set button loading information
* @field on_complete: What needs to be done when the `Action` completes
* @field on_complete.toast_return_message: Does toast show the returned message
* @field on_complete.refresh_form_config: Whether to refresh the form configuration (configuration only, no data included)
*/
export interface UIAction {
url: string;
method?: 'get' | 'post' | 'put' | 'delete';
loading?: {
text: string;
state?: 'none' | 'pending' | 'completed';
};
on_complete?: {
toast_return_message?: boolean;
refresh_form_config?: boolean;
};
}
/**
* Form tools
* - Used to get or set the configuration of forms and form items, the value of a form item
* * @method refreshConfig(): void
*/
export interface FormKit {
refreshConfig(): void;
}

View File

@ -2,18 +2,23 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { useToast } from '@/hooks';
import type * as Types from '@/common/interface';
import { SchemaForm, JSONSchema, initFormData, UISchema } from '@/components';
import { SchemaForm, JSONSchema, UISchema } from '@/components';
import { useQueryPluginConfig, updatePluginConfig } from '@/services';
import { InputOptions } from '@/components/SchemaForm';
import {
InputOptions,
FormKit,
initFormData,
mergeFormData,
} from '@/components/SchemaForm';
const Config = () => {
const { t } = useTranslation('translation');
const { slug_name } = useParams<{ slug_name: string }>();
const { data } = useQueryPluginConfig({ plugin_slug_name: slug_name });
const { data, mutate: refreshPluginConfig } = useQueryPluginConfig({
plugin_slug_name: slug_name,
});
const Toast = useToast();
const [schema, setSchema] = useState<JSONSchema | null>(null);
const [uiSchema, setUISchema] = useState<UISchema>();
@ -62,7 +67,7 @@ const Config = () => {
};
setSchema(result);
setUISchema(uiConf);
setFormData(initFormData(result));
setFormData(mergeFormData(initFormData(result), formData));
}, [data?.config_fields]);
const onSubmit = (evt) => {
@ -86,24 +91,19 @@ const Config = () => {
});
});
};
const refreshConfig: FormKit['refreshConfig'] = async () => {
refreshPluginConfig();
};
const handleOnChange = (form) => {
setFormData(form);
};
if (!data || !schema || !formData) {
return null;
}
if (isEmpty(schema.properties)) {
return <h3 className="mb-4">{data?.name}</h3>;
}
return (
<>
<h3 className="mb-4">{data?.name}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
refreshConfig={refreshConfig}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}

23
ui/src/plugins/types.ts Normal file
View File

@ -0,0 +1,23 @@
import { UIOptions, UIWidget } from '@/components/SchemaForm';
export interface PluginOption {
label: string;
value: string;
}
export interface PluginItem {
name: string;
type: UIWidget;
title: string;
description: string;
ui_options?: UIOptions;
options?: PluginOption[];
value?: string;
required?: boolean;
}
export interface PluginConfig {
name: string;
slug_name: string;
config_fields: PluginItem[];
}

View File

@ -1,5 +1,5 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { useLocation, useNavigate, useLoaderData } from 'react-router-dom';
import { useNavigate, useLoaderData } from 'react-router-dom';
import { floppyNavigation } from '@/utils';
import { TGuardFunc, TGuardResult } from '@/utils/guard';
@ -13,7 +13,6 @@ const RouteGuard: FC<{
page?: string;
}> = ({ children, onEnter, path, page }) => {
const navigate = useNavigate();
const location = useLocation();
const loaderData = useLoaderData();
const [gk, setKeeper] = useState<TGuardResult>({
ok: true,
@ -23,6 +22,7 @@ const RouteGuard: FC<{
if (typeof onEnter !== 'function') {
return;
}
const gr = onEnter({
loaderData,
path,
@ -48,12 +48,10 @@ const RouteGuard: FC<{
};
useEffect(() => {
/**
* NOTICE:
* Must be put in `useEffect`,
* otherwise `guard` may not get `loggedUserInfo` correctly
* By detecting changes to location.href, many unnecessary tests can be avoided
*/
applyGuard();
}, [location]);
}, [window.location.href]);
let asOK = gk.ok;
if (gk.ok === false && gk.redirect) {
@ -62,10 +60,8 @@ const RouteGuard: FC<{
* but the current page is already the target page for the route guard jump
* This should render `children`!
*/
asOK = floppyNavigation.equalToCurrentHref(gk.redirect);
}
return (
<>
{asOK ? children : null}

View File

@ -269,7 +269,7 @@ const routes: RouteNode[] = [
path: 'admin',
page: 'pages/Admin',
loader: async () => {
await guard.pullLoggedUser(true);
await guard.pullLoggedUser();
return null;
},
guard: () => {

View File

@ -1,8 +1,8 @@
import qs from 'qs';
import useSWR from 'swr';
import type * as Types from '@/common/interface';
import request from '@/utils/request';
import type { PluginConfig } from '@/plugins/types';
export const useQueryPlugins = (params) => {
const apiUrl = `/answer/admin/api/plugins?${qs.stringify(params)}`;
@ -24,7 +24,7 @@ export const updatePluginStatus = (params) => {
export const useQueryPluginConfig = (params) => {
const apiUrl = `/answer/admin/api/plugin/config?${qs.stringify(params)}`;
const { data, error, mutate } = useSWR<Types.PluginConfig, Error>(
const { data, error, mutate } = useSWR<PluginConfig, Error>(
apiUrl,
request.instance.get,
);

View File

@ -71,6 +71,9 @@ const navigate = (to: string | number, config: NavigateConfig = {}) => {
if (!isRoutableLink(to) && handler !== 'href' && handler !== 'replace') {
handler = 'href';
}
if (handler === 'href' && config.options?.replace) {
handler = 'replace';
}
if (handler === 'href') {
window.location.href = to;
} else if (handler === 'replace') {

View File

@ -20,7 +20,7 @@ import Storage from '@/utils/storage';
import { setupAppLanguage, setupAppTimeZone } from './localize';
import { floppyNavigation, NavigateConfig } from './floppyNavigation';
import { pullUcAgent, getLoginUrl, getSignUpUrl } from './userCenter';
import { pullUcAgent, getSignUpUrl } from './userCenter';
type TLoginState = {
isLogged: boolean;
@ -105,15 +105,18 @@ export const isIgnoredPath = (ignoredPath: string | string[]) => {
return !!matchingPath;
};
let pluLock = false;
let pluTimestamp = 0;
export const pullLoggedUser = async (forceRePull = false) => {
// only pull once if not force re-pull
if (pluLock && !forceRePull) {
return;
}
// dedupe pull requests in this time span in 10 seconds
if (Date.now() - pluTimestamp < 1000 * 10) {
export const pullLoggedUser = async (isInitPull = false) => {
/**
* WARN:
* - dedupe pull requests in this time span in 10 seconds
* - isInitPull:
* Requests sent by the initialisation method cannot be throttled
* and may cause Promise.allSettled to complete early in React development mode,
* resulting in inaccurate application data.
*/
//
if (!isInitPull && Date.now() - pluTimestamp < 1000 * 10) {
return;
}
pluTimestamp = Date.now();
@ -125,7 +128,6 @@ export const pullLoggedUser = async (forceRePull = false) => {
console.error(ex);
});
if (loggedUserInfo) {
pluLock = true;
loggedUserInfoStore.getState().update(loggedUserInfo);
}
};
@ -241,16 +243,6 @@ export const allowNewRegistration = () => {
return gr;
};
export const loginAgent = () => {
const gr: TGuardResult = { ok: true };
const loginUrl = getLoginUrl();
if (loginUrl !== RouteAlias.login) {
gr.ok = false;
gr.redirect = loginUrl;
}
return gr;
};
export const singUpAgent = () => {
const gr: TGuardResult = { ok: true };
const signUpUrl = getSignUpUrl();
@ -367,7 +359,6 @@ export const handleLoginWithToken = (
/**
* Initialize app configuration
*/
let appInitialized = false;
export const initAppSettingsStore = async () => {
const appSettings = await getAppSettings();
if (appSettings) {
@ -389,7 +380,13 @@ export const initAppSettingsStore = async () => {
}
};
let appInitialized = false;
export const setupApp = async () => {
/**
* This cannot be removed:
* clicking on the current navigation link will trigger a call to the routing loader,
* even though the page is not refreshed.
*/
if (appInitialized) {
return;
}
@ -398,9 +395,14 @@ export const setupApp = async () => {
* 1. must pre init logged user info for router guard
* 2. must pre init app settings for app render
*/
await Promise.allSettled([pullLoggedUser(), initAppSettingsStore()]);
await Promise.allSettled([initAppSettingsStore(), pullLoggedUser(true)]);
await Promise.allSettled([pullUcAgent()]);
setupAppLanguage();
setupAppTimeZone();
/**
* WARN:
* Initialization must be completed after all initialization actions,
* otherwise the problem of rendering twice in React development mode can lead to inaccurate data or flickering pages
*/
appInitialized = true;
};

View File

@ -52,19 +52,31 @@ class Request {
// no content
return true;
}
return data;
},
(error) => {
const {
status,
data: errModel,
data: errBody,
config: errConfig,
} = error.response || {};
const { data = {}, msg = '' } = errModel || {};
const { data = {}, msg = '' } = errBody || {};
const errorObject: {
code: any;
msg: string;
data: any;
// Currently only used for form errors
isError?: boolean;
// Currently only used for form errors
list?: any[];
} = {
code: status,
msg,
data,
};
if (status === 400) {
if (data?.err_type && errConfig?.passingError) {
return errModel;
return Promise.reject(errorObject);
}
if (data?.err_type) {
if (data.err_type === 'toast') {
@ -94,12 +106,9 @@ class Request {
if (data instanceof Array && data.length > 0) {
// handle form error
return Promise.reject({
code: status,
msg,
isError: true,
list: data,
});
errorObject.isError = true;
errorObject.list = data;
return Promise.reject(errorObject);
}
if (!data || Object.keys(data).length <= 0) {
@ -177,7 +186,7 @@ class Request {
`Request failed with status code ${status}, ${msg || ''}`,
);
}
return Promise.reject(false);
return Promise.reject(errorObject);
},
);
}