mirror of https://gitee.com/answerdev/answer.git
Merge remote-tracking branch 'github/feat/1.1.2/ui' into feat/1.1.2/user-center
This commit is contained in:
commit
85e370d7ff
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
export interface UIAction {
|
||||
url: string;
|
||||
method?: 'get' | 'post' | 'put' | 'delete';
|
||||
event?: 'click' | 'change';
|
||||
handler?: ({evt, formData, request}) => Promise<void>
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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[];
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -269,7 +269,7 @@ const routes: RouteNode[] = [
|
|||
path: 'admin',
|
||||
page: 'pages/Admin',
|
||||
loader: async () => {
|
||||
await guard.pullLoggedUser(true);
|
||||
await guard.pullLoggedUser();
|
||||
return null;
|
||||
},
|
||||
guard: () => {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue