refactor: AccordionNav, SchemaForm

This commit is contained in:
haitaoo 2023-04-17 15:08:51 +08:00
parent c62436a703
commit 10afa0a47b
20 changed files with 1025 additions and 250 deletions

View File

@ -1104,8 +1104,9 @@ ui:
seo: SEO seo: SEO
customize: Customize customize: Customize
themes: Themes themes: Themes
css-html: CSS/HTML css_html: CSS/HTML
login: Login login: Login
privileges: Privileges
plugins: Plugins plugins: Plugins
installed_plugins: Installed Plugins installed_plugins: Installed Plugins
website_welcome: Welcome to {{site_name}} website_welcome: Welcome to {{site_name}}
@ -1308,9 +1309,6 @@ ui:
label: Timezone label: Timezone
msg: Timezone cannot be empty. msg: Timezone cannot be empty.
text: Choose a city in the same timezone as you. text: Choose a city in the same timezone as you.
avatar:
label: Default Avatar
text: For users without a custom avatar of their own.
smtp: smtp:
page_title: SMTP page_title: SMTP
from_email: from_email:
@ -1454,7 +1452,13 @@ ui:
deactivate: Deactivate deactivate: Deactivate
activate: Activate activate: Activate
settings: Settings settings: Settings
settings_users:
title: Users
avatar:
label: Default Avatar
text: For users without a custom avatar of their own.
profile_editable:
title: Profile Editable
form: form:
optional: (optional) optional: (optional)

View File

@ -74,7 +74,8 @@ export const ADMIN_NAV_MENUS = [
name: 'themes', name: 'themes',
}, },
{ {
name: 'css-html', name: 'css_html',
path: 'css-html',
}, },
], ],
}, },
@ -89,6 +90,8 @@ export const ADMIN_NAV_MENUS = [
{ name: 'write' }, { name: 'write' },
{ name: 'seo' }, { name: 'seo' },
{ name: 'login' }, { name: 'login' },
{ name: 'users', path: 'settings-users' },
{ name: 'privileges' },
], ],
}, },
{ {
@ -96,6 +99,7 @@ export const ADMIN_NAV_MENUS = [
children: [ children: [
{ {
name: 'installed_plugins', name: 'installed_plugins',
path: 'installed-plugins',
}, },
], ],
}, },

View File

@ -312,7 +312,6 @@ export interface HelmetUpdate extends Omit<HelmetBase, 'pageTitle'> {
export interface AdminSettingsInterface { export interface AdminSettingsInterface {
language: string; language: string;
time_zone?: string; time_zone?: string;
default_avatar?: string;
} }
export interface AdminSettingsSmtp { export interface AdminSettingsSmtp {

View File

@ -18,12 +18,12 @@ function MenuNode({
}) { }) {
const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' });
const isLeaf = !menu.children.length; const isLeaf = !menu.children.length;
const href = isLeaf ? `${path}${menu.name}` : '#'; const href = isLeaf ? `${path}${menu.path}` : '#';
return ( return (
<Nav.Item key={menu.name} className="w-100"> <Nav.Item key={menu.path} className="w-100">
<Nav.Link <Nav.Link
eventKey={menu.name} eventKey={menu.path}
as={isLeaf ? 'a' : 'button'} as={isLeaf ? 'a' : 'button'}
onClick={(evt) => { onClick={(evt) => {
callback(evt, menu, href, isLeaf); callback(evt, menu, href, isLeaf);
@ -31,7 +31,7 @@ function MenuNode({
href={href} href={href}
className={classNames( className={classNames(
'text-nowrap d-flex flex-nowrap align-items-center w-100', 'text-nowrap d-flex flex-nowrap align-items-center w-100',
{ expanding, 'link-dark': activeKey !== menu.name }, { expanding, 'link-dark': activeKey !== menu.path },
)}> )}>
<span className="me-auto text-truncate"> <span className="me-auto text-truncate">
{menu.displayName ? menu.displayName : t(menu.name)} {menu.displayName ? menu.displayName : t(menu.name)}
@ -44,7 +44,7 @@ function MenuNode({
)} )}
</Nav.Link> </Nav.Link>
{menu.children.length ? ( {menu.children.length ? (
<Accordion.Collapse eventKey={menu.name} className="ms-3"> <Accordion.Collapse eventKey={menu.path} className="ms-3">
<> <>
{menu.children.map((leaf) => { {menu.children.map((leaf) => {
return ( return (
@ -53,7 +53,7 @@ function MenuNode({
callback={callback} callback={callback}
activeKey={activeKey} activeKey={activeKey}
path={path} path={path}
key={leaf.name} key={leaf.path}
/> />
); );
})} })}
@ -73,17 +73,24 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
const pathMatch = useMatch(`${path}*`); const pathMatch = useMatch(`${path}*`);
// auto set menu fields // auto set menu fields
menus.forEach((m) => { menus.forEach((m) => {
if (!m.path) {
m.path = m.name;
}
if (!Array.isArray(m.children)) { if (!Array.isArray(m.children)) {
m.children = []; m.children = [];
} }
m.children.forEach((sm) => { m.children.forEach((sm) => {
if (!sm.path) {
sm.path = sm.name;
}
if (!Array.isArray(sm.children)) { if (!Array.isArray(sm.children)) {
sm.children = []; sm.children = [];
} }
}); });
}); });
const splat = pathMatch && pathMatch.params['*']; const splat = pathMatch && pathMatch.params['*'];
let activeKey = menus[0].name; let activeKey = menus[0].path;
if (splat) { if (splat) {
activeKey = splat; activeKey = splat;
} }
@ -92,10 +99,10 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
menus.forEach((li) => { menus.forEach((li) => {
if (li.children.length) { if (li.children.length) {
const matchedChild = li.children.find((el) => { const matchedChild = li.children.find((el) => {
return el.name === activeKey; return el.path === activeKey;
}); });
if (matchedChild) { if (matchedChild) {
openKey = li.name; openKey = li.path;
} }
} }
}); });
@ -111,7 +118,7 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
navigate(href); navigate(href);
} }
} else { } else {
setOpenKey(openKey === menu.name ? '' : menu.name); setOpenKey(openKey === menu.path ? '' : menu.path);
} }
}; };
useEffect(() => { useEffect(() => {
@ -127,8 +134,8 @@ const AccordionNav: FC<AccordionProps> = ({ menus = [], path = '/' }) => {
path={path} path={path}
callback={menuClick} callback={menuClick}
activeKey={activeKey} activeKey={activeKey}
expanding={openKey === li.name} expanding={openKey === li.path}
key={li.name} key={li.path}
/> />
); );
})} })}

View File

@ -0,0 +1,58 @@
import React, { FC } from 'react';
import { Form, Stack } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
type: 'radio' | 'checkbox';
title: string;
desc: string | undefined;
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLInputElement>, ...rest) => void;
enumValues: (string | boolean | number)[];
enumNames: string[];
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
type = 'radio',
title,
desc,
fieldName,
onChange,
enumValues,
enumNames,
formData,
}) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<Stack direction="horizontal">
{enumValues?.map((item, index) => {
return (
<Form.Check
key={String(item)}
inline
required
type={type}
name={fieldName}
id={`form-${String(item)}`}
label={enumNames?.[index]}
checked={(fieldObject?.value || '') === item}
feedback={fieldObject?.errorMsg}
feedbackType="invalid"
isInvalid={fieldObject?.isInvalid}
onChange={(evt) => onChange(evt, index)}
/>
);
})}
</Stack>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,45 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
title: string;
desc: string | undefined;
type: string | undefined;
placeholder: string | undefined;
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLInputElement>, ...rest) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
title,
type = 'text',
desc,
placeholder = '',
fieldName,
onChange,
formData,
}) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<Form.Control
name={fieldName}
placeholder={placeholder}
type={type}
value={fieldObject?.value || ''}
onChange={onChange}
style={type === 'color' ? { width: '6rem' } : {}}
isInvalid={fieldObject?.isInvalid}
/>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,11 @@
import { FC } from 'react';
import { Form } from 'react-bootstrap';
interface Props {
title: string;
}
const Index: FC<Props> = ({ title }) => {
return <Form.Label>{title}</Form.Label>;
};
export default Index;

View File

@ -0,0 +1,50 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
title: string;
desc: string | undefined;
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLSelectElement>) => void;
enumValues: (string | boolean | number)[];
enumNames: string[];
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
title,
desc,
fieldName,
onChange,
enumValues,
enumNames,
formData,
}) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<Form.Select
aria-label={desc}
name={fieldName}
value={fieldObject?.value || ''}
onChange={onChange}
isInvalid={fieldObject?.isInvalid}>
{enumValues?.map((item, index) => {
return (
<option value={String(item)} key={String(item)}>
{enumNames?.[index]}
</option>
);
})}
</Form.Select>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,46 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
interface Props {
title: string;
desc: string | undefined;
label: string | undefined;
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLInputElement>, ...rest) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
title,
desc,
fieldName,
onChange,
label,
formData,
}) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<Form.Check
required
id={`switch-${title}`}
name={fieldName}
type="switch"
label={label}
checked={fieldObject?.value || ''}
feedback={fieldObject?.errorMsg}
feedbackType="invalid"
isInvalid={fieldObject.isInvalid}
onChange={onChange}
/>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,50 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import classnames from 'classnames';
import type * as Type from '@/common/interface';
interface Props {
title: string;
desc: string | undefined;
placeholder: string | undefined;
rows: number | undefined;
className: classnames.Argument;
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLInputElement>, ...rest) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
title,
desc,
placeholder = '',
rows = 3,
className,
fieldName,
onChange,
formData,
}) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<Form.Control
as="textarea"
name={fieldName}
placeholder={placeholder}
value={fieldObject?.value || ''}
onChange={onChange}
isInvalid={fieldObject?.isInvalid}
rows={rows}
className={classnames(className)}
/>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,33 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
import TimeZonePicker from '@/components/TimeZonePicker';
interface Props {
title: string;
desc: string | undefined;
fieldName: string;
onChange: (evt: React.ChangeEvent<HTMLSelectElement>, ...rest) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({ title, desc, fieldName, onChange, formData }) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<TimeZonePicker
value={fieldObject?.value || ''}
isInvalid={fieldObject?.isInvalid}
name={fieldName}
onChange={onChange}
/>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,48 @@
import React, { FC } from 'react';
import { Form } from 'react-bootstrap';
import type * as Type from '@/common/interface';
import BrandUpload from '@/components/BrandUpload';
interface Props {
title: string;
type: Type.UploadType | undefined;
acceptType: string | undefined;
desc: string | undefined;
fieldName: string;
onChange: (key, val) => void;
formData: Type.FormDataType;
}
const Index: FC<Props> = ({
title,
type = 'avatar',
acceptType = '',
desc,
fieldName,
onChange,
formData,
}) => {
const fieldObject = formData[fieldName];
return (
<>
<Form.Label>{title}</Form.Label>
<BrandUpload
type={type}
acceptType={acceptType}
value={fieldObject?.value}
onChange={(value) => onChange(fieldName, value)}
/>
<Form.Control
name={fieldName}
className="d-none"
isInvalid={fieldObject?.isInvalid}
/>
<Form.Control.Feedback type="invalid">
{fieldObject?.errorMsg}
</Form.Control.Feedback>
{desc ? <Form.Text className="text-muted">{desc}</Form.Text> : null}
</>
);
};
export default Index;

View File

@ -0,0 +1,10 @@
import Legend from './Legend';
import Select from './Select';
import Check from './Check';
import Switch from './Switch';
import Timezone from './Timezone';
import Upload from './Upload';
import Textarea from './Textarea';
import Input from './Input';
export { Legend, Select, Check, Switch, Timezone, Upload, Textarea, Input };

View File

@ -4,15 +4,24 @@ import {
useImperativeHandle, useImperativeHandle,
useEffect, useEffect,
} from 'react'; } from 'react';
import { Form, Button, Stack } from 'react-bootstrap'; import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import classnames from 'classnames'; import classnames from 'classnames';
import BrandUpload from '../BrandUpload';
import TimeZonePicker from '../TimeZonePicker';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
import {
Legend,
Select,
Check,
Switch,
Timezone,
Upload,
Textarea,
Input,
} from './components';
export interface JSONSchema { export interface JSONSchema {
title: string; title: string;
description?: string; description?: string;
@ -32,6 +41,7 @@ export interface JSONSchema {
export interface BaseUIOptions { export interface BaseUIOptions {
empty?: string; empty?: string;
className?: string | string[]; className?: string | string[];
simplify?: boolean;
validator?: ( validator?: (
value, value,
formData?, formData?,
@ -96,7 +106,8 @@ export type UIWidget =
| 'select' | 'select'
| 'upload' | 'upload'
| 'timezone' | 'timezone'
| 'switch'; | 'switch'
| 'legend';
export interface UISchema { export interface UISchema {
[key: string]: { [key: string]: {
'ui:widget'?: UIWidget; 'ui:widget'?: UIWidget;
@ -117,6 +128,11 @@ interface IRef {
validator: () => Promise<boolean>; validator: () => Promise<boolean>;
} }
/**
* TODO:
* 1. Normalize and document `formData[key].hidden && 'd-none'`
*/
/** /**
* json schema form * json schema form
* @param schema json schema * @param schema json schema
@ -349,217 +365,317 @@ const SchemaForm: ForwardRefRenderFunction<IRef, IProps> = (
return ( return (
<Form noValidate onSubmit={handleSubmit}> <Form noValidate onSubmit={handleSubmit}>
{keys.map((key) => { {keys.map((key) => {
const { title, description } = properties[key]; const {
title,
description,
enum: enumValues = [],
enumNames = [],
} = properties[key];
const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } = const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } =
uiSchema[key] || {}; uiSchema[key] || {};
if (widget === 'select') {
return (
<Form.Group
key={title}
controlId={key}
className={classnames('mb-3', formData[key].hidden && 'd-none')}>
<Form.Label>{title}</Form.Label>
<Form.Select
aria-label={description}
name={key}
value={formData[key]?.value || ''}
onChange={handleSelectChange}
isInvalid={formData[key].isInvalid}>
{properties[key].enum?.map((item, index) => {
return (
<option value={String(item)} key={String(item)}>
{properties[key].enumNames?.[index]}
</option>
);
})}
</Form.Select>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'checkbox' || widget === 'radio') {
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<Stack direction="horizontal">
{properties[key].enum?.map((item, index) => {
return (
<Form.Check
key={String(item)}
inline
required
type={widget}
name={key}
id={`form-${String(item)}`}
label={properties[key].enumNames?.[index]}
checked={(formData[key]?.value || '') === item}
feedback={formData[key]?.errorMsg}
feedbackType="invalid"
isInvalid={formData[key].isInvalid}
onChange={(e) => handleInputCheck(e, index)}
/>
);
})}
</Stack>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'switch') {
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<Form.Check
required
id={`switch-${title}`}
name={key}
type="switch"
label={(uiOpt as SwitchOptions)?.label}
checked={formData[key]?.value || ''}
feedback={formData[key]?.errorMsg}
feedbackType="invalid"
isInvalid={formData[key].isInvalid}
onChange={handleSwitchChange}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'timezone') {
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<TimeZonePicker
value={formData[key]?.value || ''}
name={key}
onChange={handleSelectChange}
/>
<Form.Control
name={key}
className="d-none"
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'upload') {
const options: UploadOptions = uiSchema[key]?.['ui:options'] || {};
return (
<Form.Group
key={title}
className={classnames('mb-3', formData[key].hidden && 'd-none')}
controlId={key}>
<Form.Label>{title}</Form.Label>
<BrandUpload
type={options.imageType || 'avatar'}
acceptType={options.acceptType || ''}
value={formData[key]?.value}
onChange={(value) => handleUploadChange(key, value)}
/>
<Form.Control
name={key}
className="d-none"
isInvalid={formData[key].isInvalid}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
if (widget === 'textarea') {
const options: TextareaOptions = uiSchema[key]?.['ui:options'] || {};
return (
<Form.Group
controlId={`form-${key}`}
key={key}
className={classnames('mb-3', formData[key].hidden && 'd-none')}>
<Form.Label>{title}</Form.Label>
<Form.Control
as="textarea"
name={key}
placeholder={options?.placeholder || ''}
value={formData[key]?.value || ''}
onChange={handleInputChange}
isInvalid={formData[key].isInvalid}
rows={options?.rows || 3}
className={classnames(options.className)}
/>
<Form.Control.Feedback type="invalid">
{formData[key]?.errorMsg}
</Form.Control.Feedback>
{description && (
<Form.Text className="text-muted">{description}</Form.Text>
)}
</Form.Group>
);
}
const options: InputOptions = uiSchema[key]?.['ui:options'] || {};
return ( return (
<Form.Group <Form.Group
key={title}
controlId={key} controlId={key}
key={key} className={classnames({
className={classnames('mb-3', formData[key].hidden && 'd-none')}> 'mb-3': widget !== 'legend' && !uiOpt?.simplify,
<Form.Label>{title}</Form.Label> 'd-none': formData[key].hidden,
<Form.Control })}>
name={key} {widget === 'legend' ? <Legend title={title} /> : null}
placeholder={options?.placeholder || ''} {widget === 'select' ? (
type={options?.inputType || 'text'} <Select
value={formData[key]?.value || ''} title={title}
onChange={handleInputChange} desc={description}
style={options?.inputType === 'color' ? { width: '6rem' } : {}} fieldName={key}
isInvalid={formData[key].isInvalid} onChange={handleSelectChange}
/> enumValues={enumValues}
<Form.Control.Feedback type="invalid"> enumNames={enumNames}
{formData[key]?.errorMsg} formData={formData}
</Form.Control.Feedback> />
) : null}
{description && ( {widget === 'radio' || widget === 'checkbox' ? (
<Form.Text className="text-muted">{description}</Form.Text> <Check
)} type={widget}
title={title}
desc={description}
fieldName={key}
onChange={handleInputCheck}
enumValues={enumValues}
enumNames={enumNames}
formData={formData}
/>
) : null}
{widget === 'switch' ? (
<Switch
title={title}
desc={description}
label={uiOpt && 'label' in uiOpt ? uiOpt.label : ''}
fieldName={key}
onChange={handleSwitchChange}
formData={formData}
/>
) : null}
{widget === 'timezone' ? (
<Timezone
title={title}
desc={description}
fieldName={key}
onChange={handleSelectChange}
formData={formData}
/>
) : null}
{widget === 'upload' ? (
<Upload
title={title}
type={
uiOpt && 'imageType' in uiOpt ? uiOpt.imageType : undefined
}
acceptType={
uiOpt && 'acceptType' in uiOpt ? uiOpt.acceptType : ''
}
desc={description}
fieldName={key}
onChange={handleUploadChange}
formData={formData}
/>
) : null}
{widget === 'textarea' ? (
<Textarea
title={title}
desc={description}
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
rows={uiOpt && 'rows' in uiOpt ? uiOpt.rows : 3}
className={uiOpt && 'className' in uiOpt ? uiOpt.className : ''}
fieldName={key}
onChange={handleInputChange}
formData={formData}
/>
) : null}
{widget === 'input' ? (
<Input
title={title}
desc={description}
type={uiOpt && 'inputType' in uiOpt ? uiOpt.inputType : 'text'}
placeholder={
uiOpt && 'placeholder' in uiOpt ? uiOpt.placeholder : ''
}
fieldName={key}
onChange={handleInputChange}
formData={formData}
/>
) : null}
</Form.Group> </Form.Group>
); );
// if (widget === 'select') {
// return (
// <Form.Group
// key={title}
// controlId={key}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}>
// <Form.Label>{title}</Form.Label>
// <Form.Select
// aria-label={description}
// name={key}
// value={formData[key]?.value || ''}
// onChange={handleSelectChange}
// isInvalid={formData[key].isInvalid}>
// {properties[key].enum?.map((item, index) => {
// return (
// <option value={String(item)} key={String(item)}>
// {properties[key].enumNames?.[index]}
// </option>
// );
// })}
// </Form.Select>
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
// }
// if (widget === 'checkbox' || widget === 'radio') {
// return (
// <Form.Group
// key={title}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}
// controlId={key}>
// <Form.Label>{title}</Form.Label>
// <Stack direction="horizontal">
// {properties[key].enum?.map((item, index) => {
// return (
// <Form.Check
// key={String(item)}
// inline
// required
// type={widget}
// name={key}
// id={`form-${String(item)}`}
// label={properties[key].enumNames?.[index]}
// checked={(formData[key]?.value || '') === item}
// feedback={formData[key]?.errorMsg}
// feedbackType="invalid"
// isInvalid={formData[key].isInvalid}
// onChange={(e) => handleInputCheck(e, index)}
// />
// );
// })}
// </Stack>
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
// }
// if (widget === 'switch') {
// return (
// <Form.Group
// key={title}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}
// controlId={key}>
// <Form.Label>{title}</Form.Label>
// <Form.Check
// required
// id={`switch-${title}`}
// name={key}
// type="switch"
// label={(uiOpt as SwitchOptions)?.label}
// checked={formData[key]?.value || ''}
// feedback={formData[key]?.errorMsg}
// feedbackType="invalid"
// isInvalid={formData[key].isInvalid}
// onChange={handleSwitchChange}
// />
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
// }
// if (widget === 'timezone') {
// return (
// <Form.Group
// key={title}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}
// controlId={key}>
// <Form.Label>{title}</Form.Label>
// <TimeZonePicker
// value={formData[key]?.value || ''}
// name={key}
// onChange={handleSelectChange}
// />
// <Form.Control
// name={key}
// className="d-none"
// isInvalid={formData[key].isInvalid}
// />
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
// }
// if (widget === 'upload') {
// const options: UploadOptions = uiSchema[key]?.['ui:options'] || {};
// return (
// <Form.Group
// key={title}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}
// controlId={key}>
// <Form.Label>{title}</Form.Label>
// <BrandUpload
// type={options.imageType || 'avatar'}
// acceptType={options.acceptType || ''}
// value={formData[key]?.value}
// onChange={(value) => handleUploadChange(key, value)}
// />
// <Form.Control
// name={key}
// className="d-none"
// isInvalid={formData[key].isInvalid}
// />
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
// }
// if (widget === 'textarea') {
// const options: TextareaOptions = uiSchema[key]?.['ui:options'] || {};
//
// return (
// <Form.Group
// controlId={`form-${key}`}
// key={key}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}>
// <Form.Label>{title}</Form.Label>
// <Form.Control
// as="textarea"
// name={key}
// placeholder={options?.placeholder || ''}
// value={formData[key]?.value || ''}
// onChange={handleInputChange}
// isInvalid={formData[key].isInvalid}
// rows={options?.rows || 3}
// className={classnames(options.className)}
// />
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
//
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
// }
// const options: InputOptions = uiSchema[key]?.['ui:options'] || {};
//
// return (
// <Form.Group
// controlId={key}
// key={key}
// className={classnames('mb-3', formData[key].hidden && 'd-none')}>
// <Form.Label>{title}</Form.Label>
// <Form.Control
// name={key}
// placeholder={options?.placeholder || ''}
// type={options?.inputType || 'text'}
// value={formData[key]?.value || ''}
// onChange={handleInputChange}
// style={options?.inputType === 'color' ? { width: '6rem' } : {}}
// isInvalid={formData[key].isInvalid}
// />
// <Form.Control.Feedback type="invalid">
// {formData[key]?.errorMsg}
// </Form.Control.Feedback>
//
// {description && (
// <Form.Text className="text-muted">{description}</Form.Text>
// )}
// </Form.Group>
// );
})} })}
{!hiddenSubmit && ( {!hiddenSubmit && (
<Button variant="primary" type="submit"> <Button variant="primary" type="submit">

View File

@ -9,7 +9,7 @@ import {
} from '@/common/interface'; } from '@/common/interface';
import { interfaceStore, loggedUserInfoStore } from '@/stores'; import { interfaceStore, loggedUserInfoStore } from '@/stores';
import { JSONSchema, SchemaForm, UISchema } from '@/components'; import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants'; import { DEFAULT_TIMEZONE } from '@/common/constants';
import { import {
updateInterfaceSetting, updateInterfaceSetting,
useInterfaceSetting, useInterfaceSetting,
@ -48,13 +48,6 @@ const Interface: FC = () => {
description: t('time_zone.text'), description: t('time_zone.text'),
default: setting?.time_zone || DEFAULT_TIMEZONE, default: setting?.time_zone || DEFAULT_TIMEZONE,
}, },
default_avatar: {
type: 'string',
title: t('avatar.label'),
description: t('avatar.text'),
enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value),
enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label),
},
}, },
}; };
@ -69,11 +62,6 @@ const Interface: FC = () => {
isInvalid: false, isInvalid: false,
errorMsg: '', errorMsg: '',
}, },
default_avatar: {
value: setting?.default_avatar || 'system',
isInvalid: false,
errorMsg: '',
},
}); });
const uiSchema: UISchema = { const uiSchema: UISchema = {
@ -83,9 +71,6 @@ const Interface: FC = () => {
time_zone: { time_zone: {
'ui:widget': 'timezone', 'ui:widget': 'timezone',
}, },
default_avatar: {
'ui:widget': 'select',
},
}; };
const getLangs = async () => { const getLangs = async () => {
const res: LangsType[] = await loadLanguageOptions(true); const res: LangsType[] = await loadLanguageOptions(true);
@ -118,7 +103,6 @@ const Interface: FC = () => {
const reqParams: AdminSettingsInterface = { const reqParams: AdminSettingsInterface = {
language: formData.language.value, language: formData.language.value,
time_zone: formData.time_zone.value, time_zone: formData.time_zone.value,
default_avatar: formData.default_avatar.value,
}; };
updateInterfaceSetting(reqParams) updateInterfaceSetting(reqParams)
@ -147,9 +131,6 @@ const Interface: FC = () => {
const formMeta = {}; const formMeta = {};
Object.keys(setting).forEach((k) => { Object.keys(setting).forEach((k) => {
formMeta[k] = { ...formData[k], value: setting[k] }; formMeta[k] = { ...formData[k], value: setting[k] };
if (k === 'default_avatar') {
formMeta[k].value = setting[k] || 'system';
}
}); });
setFormData({ ...formData, ...formMeta }); setFormData({ ...formData, ...formMeta });
} }

View File

@ -0,0 +1,177 @@
import { FC, FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@/hooks';
import {
LangsType,
FormDataType,
AdminSettingsInterface,
} from '@/common/interface';
import { interfaceStore, loggedUserInfoStore } from '@/stores';
import { JSONSchema, SchemaForm, UISchema } from '@/components';
import { DEFAULT_TIMEZONE, SYSTEM_AVATAR_OPTIONS } from '@/common/constants';
import {
updateInterfaceSetting,
useInterfaceSetting,
getLoggedUserInfo,
} from '@/services';
import {
setupAppLanguage,
loadLanguageOptions,
setupAppTimeZone,
} from '@/utils/localize';
import { handleFormError } from '@/utils';
const Interface: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.interface',
});
const storeInterface = interfaceStore.getState().interface;
const Toast = useToast();
const [langs, setLangs] = useState<LangsType[]>();
const { data: setting } = useInterfaceSetting();
const schema: JSONSchema = {
title: t('page_title'),
properties: {
language: {
type: 'string',
title: t('language.label'),
description: t('language.text'),
enum: langs?.map((lang) => lang.value),
enumNames: langs?.map((lang) => lang.label),
default: setting?.language || storeInterface.language,
},
time_zone: {
type: 'string',
title: t('time_zone.label'),
description: t('time_zone.text'),
default: setting?.time_zone || DEFAULT_TIMEZONE,
},
default_avatar: {
type: 'string',
title: t('avatar.label'),
description: t('avatar.text'),
enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value),
enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label),
},
},
};
const [formData, setFormData] = useState<FormDataType>({
language: {
value: setting?.language || storeInterface.language,
isInvalid: false,
errorMsg: '',
},
time_zone: {
value: setting?.time_zone || DEFAULT_TIMEZONE,
isInvalid: false,
errorMsg: '',
},
default_avatar: {
value: 'system',
isInvalid: false,
errorMsg: '',
},
});
const uiSchema: UISchema = {
language: {
'ui:widget': 'select',
},
time_zone: {
'ui:widget': 'timezone',
},
default_avatar: {
'ui:widget': 'select',
},
};
const getLangs = async () => {
const res: LangsType[] = await loadLanguageOptions(true);
setLangs(res);
};
const checkValidated = (): boolean => {
let ret = true;
const { language } = formData;
const formCheckData = { ...formData };
if (!language.value) {
ret = false;
formCheckData.language = {
value: '',
isInvalid: true,
errorMsg: t('language.msg'),
};
}
setFormData({
...formCheckData,
});
return ret;
};
const onSubmit = (evt: FormEvent) => {
evt.preventDefault();
evt.stopPropagation();
if (checkValidated() === false) {
return;
}
const reqParams: AdminSettingsInterface = {
language: formData.language.value,
time_zone: formData.time_zone.value,
};
updateInterfaceSetting(reqParams)
.then(() => {
interfaceStore.getState().update(reqParams);
setupAppLanguage();
setupAppTimeZone();
getLoggedUserInfo().then((info) => {
loggedUserInfoStore.getState().update(info);
});
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
if (setting) {
const formMeta = {};
Object.keys(setting).forEach((k) => {
formMeta[k] = { ...formData[k], value: setting[k] };
if (k === 'default_avatar') {
formMeta[k].value = setting[k] || 'system';
}
});
setFormData({ ...formData, ...formMeta });
}
}, [setting]);
useEffect(() => {
getLangs();
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('page_title')}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</>
);
};
export default Interface;

View File

@ -0,0 +1,110 @@
import { FC, FormEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '@/hooks';
import { FormDataType } from '@/common/interface';
import { JSONSchema, SchemaForm, UISchema, initFormData } from '@/components';
import { SYSTEM_AVATAR_OPTIONS } from '@/common/constants';
import {
getUsersSetting,
putUsersSetting,
AdminSettingsUsers,
} from '@/services';
import { handleFormError } from '@/utils';
import * as Type from '@/common/interface';
const Interface: FC = () => {
const { t } = useTranslation('translation', {
keyPrefix: 'admin.settings_users',
});
const Toast = useToast();
const schema: JSONSchema = {
title: t('title'),
properties: {
default_avatar: {
type: 'string',
title: t('avatar.label'),
description: t('avatar.text'),
enum: SYSTEM_AVATAR_OPTIONS?.map((v) => v.value),
enumNames: SYSTEM_AVATAR_OPTIONS?.map((v) => v.label),
default: 'system',
},
profile_editable: {
type: 'string',
title: t('profile_editable.title'),
},
},
};
const [formData, setFormData] = useState<FormDataType>(initFormData(schema));
const uiSchema: UISchema = {
default_avatar: {
'ui:widget': 'select',
},
profile_editable: {
'ui:widget': 'legend',
},
profile_displayname: {
'ui:widget': 'legend',
},
};
const onSubmit = (evt: FormEvent) => {
evt.preventDefault();
evt.stopPropagation();
// @ts-ignore
const reqParams: AdminSettingsUsers = {
default_avatar: '',
};
// @ts-ignore
putUsersSetting(reqParams)
.then(() => {
Toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
})
.catch((err) => {
if (err.isError) {
const data = handleFormError(err, formData);
setFormData({ ...data });
}
});
};
useEffect(() => {
getUsersSetting().then((resp) => {
const formMeta: Type.FormDataType = {};
Object.keys(formData).forEach((k) => {
let v = resp[k];
if (k === 'default_avatar' && !v) {
v = 'system';
}
formMeta[k] = { ...formData[k], value: v };
});
setFormData({ ...formData, ...formMeta });
});
}, []);
const handleOnChange = (data) => {
setFormData(data);
};
return (
<>
<h3 className="mb-4">{t('title')}</h3>
<SchemaForm
schema={schema}
uiSchema={uiSchema}
formData={formData}
onSubmit={onSubmit}
onChange={handleOnChange}
/>
</>
);
};
export default Interface;

View File

@ -19,7 +19,7 @@ const g10Paths = [
'answers', 'answers',
'users', 'users',
'flags', 'flags',
'installed_plugins', 'installed-plugins',
]; ];
const Index: FC = () => { const Index: FC = () => {
const { t } = useTranslation('translation', { keyPrefix: 'page_title' }); const { t } = useTranslation('translation', { keyPrefix: 'page_title' });
@ -80,7 +80,7 @@ const Index: FC = () => {
<Col lg={2}> <Col lg={2}>
<AccordionNav menus={menus} path="/admin/" /> <AccordionNav menus={menus} path="/admin/" />
</Col> </Col>
<Col lg={g10Paths.find((v) => curPath.includes(v)) ? 10 : 6}> <Col lg={g10Paths.find((v) => curPath === v) ? 10 : 6}>
<Outlet /> <Outlet />
</Col> </Col>
</Row> </Row>

View File

@ -345,7 +345,15 @@ const routes: RouteNode[] = [
page: 'pages/Admin/Login', page: 'pages/Admin/Login',
}, },
{ {
path: 'installed_plugins', path: 'settings-users',
page: 'pages/Admin/SettingsUsers',
},
{
path: 'privileges',
page: 'pages/Admin/Privileges',
},
{
path: 'installed-plugins',
page: 'pages/Admin/Plugins/Installed', page: 'pages/Admin/Plugins/Installed',
}, },
{ {

View File

@ -3,6 +3,16 @@ import useSWR from 'swr';
import request from '@/utils/request'; import request from '@/utils/request';
import type * as Type from '@/common/interface'; import type * as Type from '@/common/interface';
export interface AdminSettingsUsers {
allow_update_avatar: boolean;
allow_update_bio: boolean;
allow_update_display_name: boolean;
allow_update_location: boolean;
allow_update_username: boolean;
allow_update_website: boolean;
default_avatar: string;
}
export const useGeneralSetting = () => { export const useGeneralSetting = () => {
const apiUrl = `/answer/admin/api/siteinfo/general`; const apiUrl = `/answer/admin/api/siteinfo/general`;
const { data, error } = useSWR<Type.AdminSettingsGeneral, Error>( const { data, error } = useSWR<Type.AdminSettingsGeneral, Error>(
@ -126,3 +136,11 @@ export const getLoginSetting = () => {
export const putLoginSetting = (params: Type.AdminSettingsLogin) => { export const putLoginSetting = (params: Type.AdminSettingsLogin) => {
return request.put('/answer/admin/api/siteinfo/login', params); return request.put('/answer/admin/api/siteinfo/login', params);
}; };
export const getUsersSetting = () => {
return request.get<AdminSettingsUsers>('/answer/admin/api/siteinfo/users');
};
export const putUsersSetting = (params: AdminSettingsUsers) => {
return request.put('/answer/admin/api/siteinfo/users', params);
};