Mapping 修复 boolean 值的映射问题 (#1837)

* 优化 pc 样式

* 样式微调

* Nav 支持 lazyreload

* 修复 mapping boolean值映射问题

* 多提交了文件

* 还原 example
This commit is contained in:
liaoxuezhi 2021-04-21 00:08:16 +08:00 committed by GitHub
parent c6f951976e
commit cede563628
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 906 additions and 336 deletions

View File

@ -119,6 +119,50 @@ List 的内容、Card 卡片的内容配置同上
}
```
### 布尔值映射
```schema: scope="body"
{
"type": "form",
"data": {
"type": true
},
"controls": [
{
"type": "static-mapping",
"name": "type",
"label": "映射",
"map": {
"1": "<span class='label label-info'></span>",
"0": "<span class='label label-default'></span>"
}
}
]
}
```
或者
```schema: scope="body"
{
"type": "form",
"data": {
"type": true
},
"controls": [
{
"type": "static-mapping",
"name": "type",
"label": "映射",
"map": {
"true": "<span class='label label-info'></span>",
"false": "<span class='label label-default'></span>"
}
}
]
}
```
### 远程拉取字典
> since 1.1.6

View File

@ -239,8 +239,8 @@ export class App extends React.PureComponent<{
<div
className={`${theme.ns}Layout-headerBar ${
docPage ? 'DocLayout-headerBar' : ''
} pc:flex items-center`}
docPage ? 'DocLayout-headerBar pc:inline-flex' : 'pc:flex'
} items-center`}
>
{docPage ? null : (
<Button

View File

@ -5,12 +5,14 @@ export default {
type: 'form',
wrapWithPanel: false,
target: 'window', // location
submitOnInit: true,
controls: [
{
type: 'tree',
name: 'cat',
inputClassName: 'no-border',
submitOnChange: true,
selectFirst: true,
options: [
{
label: '分类1',
@ -106,7 +108,10 @@ export default {
body: {
type: 'crud',
draggable: true,
api: '/api/sample',
api: {
url: '/api/sample',
sendOn: 'this.cat'
},
filter: {
title: '条件搜索',
submitText: '',

View File

@ -0,0 +1,306 @@
export default {
$schema: 'https://houtai.baidu.com/v2/schemas/page.json#',
title: '带边栏联动',
aside: {
type: 'nav',
stacked: true,
source: '/api/mock2/options/nav?parentId=${value}&waitSeconds=2'
},
toolbar: [
{
type: 'button',
actionType: 'dialog',
label: '新增',
primary: true,
dialog: {
title: '新增',
body: {
type: 'form',
name: 'sample-edit-form',
api: 'post:/api/sample',
controls: [
{
type: 'text',
name: 'engine',
label: 'Engine',
required: true
},
{
type: 'divider'
},
{
type: 'text',
name: 'browser',
label: 'Browser',
required: true
},
{
type: 'divider'
},
{
type: 'text',
name: 'platform',
label: 'Platform(s)',
required: true
},
{
type: 'divider'
},
{
type: 'text',
name: 'version',
label: 'Engine version'
},
{
type: 'divider'
},
{
type: 'text',
name: 'grade',
label: 'CSS grade'
}
]
}
}
}
],
body: {
type: 'crud',
draggable: true,
api: {
url: '/api/sample',
sendOn: 'this.cat'
},
filter: {
title: '条件搜索',
submitText: '',
controls: [
{
type: 'text',
name: 'keywords',
placeholder: '通过关键字搜索',
addOn: {
label: '搜索',
type: 'submit'
}
},
{
type: 'plain',
text: '这里的表单项可以配置多个'
}
]
},
bulkActions: [
{
label: '批量删除',
actionType: 'ajax',
api: 'delete:/api/sample/$ids',
confirmText: '确定要批量删除?'
},
{
label: '批量修改',
actionType: 'dialog',
dialog: {
title: '批量编辑',
name: 'sample-bulk-edit',
body: {
type: 'form',
api: '/api/sample/bulkUpdate2',
controls: [
{
type: 'hidden',
name: 'ids'
},
{
type: 'text',
name: 'engine',
label: 'Engine'
}
]
}
}
}
],
quickSaveApi: '/api/sample/bulkUpdate',
quickSaveItemApi: '/api/sample/$id',
columns: [
{
name: 'id',
label: 'ID',
width: 20,
sortable: true,
type: 'text',
toggled: true
},
{
name: 'engine',
label: 'Rendering engine',
sortable: true,
searchable: true,
type: 'text',
toggled: true
},
{
name: 'browser',
label: 'Browser',
sortable: true,
type: 'text',
toggled: true
},
{
name: 'platform',
label: 'Platform(s)',
sortable: true,
type: 'text',
toggled: true
},
{
name: 'version',
label: 'Engine version',
quickEdit: true,
type: 'text',
toggled: true
},
{
name: 'grade',
label: 'CSS grade',
quickEdit: {
mode: 'inline',
type: 'select',
options: ['A', 'B', 'C', 'D', 'X'],
saveImmediately: true
},
type: 'text',
toggled: true
},
{
type: 'operation',
label: '操作',
width: 130,
buttons: [
{
type: 'button',
icon: 'fa fa-eye',
actionType: 'dialog',
dialog: {
title: '查看',
body: {
type: 'form',
controls: [
{
type: 'static',
name: 'engine',
label: 'Engine'
},
{
type: 'divider'
},
{
type: 'static',
name: 'browser',
label: 'Browser'
},
{
type: 'divider'
},
{
type: 'static',
name: 'platform',
label: 'Platform(s)'
},
{
type: 'divider'
},
{
type: 'static',
name: 'version',
label: 'Engine version'
},
{
type: 'divider'
},
{
type: 'static',
name: 'grade',
label: 'CSS grade'
},
{
type: 'divider'
},
{
type: 'html',
html:
'<p>添加其他 <span>Html 片段</span> 需要支持变量替换todo.</p>'
}
]
}
}
},
{
type: 'button',
icon: 'fa fa-pencil',
actionType: 'dialog',
dialog: {
title: '编辑',
body: {
type: 'form',
name: 'sample-edit-form',
api: '/api/sample/$id',
controls: [
{
type: 'text',
name: 'engine',
label: 'Engine',
required: true
},
{
type: 'divider'
},
{
type: 'text',
name: 'browser',
label: 'Browser',
required: true
},
{
type: 'divider'
},
{
type: 'text',
name: 'platform',
label: 'Platform(s)',
required: true
},
{
type: 'divider'
},
{
type: 'text',
name: 'version',
label: 'Engine version'
},
{
type: 'divider'
},
{
type: 'text',
name: 'grade',
label: 'CSS grade'
}
]
}
}
},
{
type: 'button',
icon: 'fa fa-times text-danger',
actionType: 'ajax',
confirmText: '您确认要删除?',
api: 'delete:/api/sample/$id'
}
],
toggled: true
}
]
}
};

View File

@ -39,6 +39,7 @@ import LoadMoreSchema from './CRUD/LoadMore';
import TestCrudSchema from './CRUD/test';
import FixedCrudSchema from './CRUD/Fix';
import AsideCrudSchema from './CRUD/Aside';
import Aside2CrudSchema from './CRUD/Aside2';
import FieldsCrudSchema from './CRUD/Fields';
import JumpNextCrudSchema from './CRUD/JumpNext';
import PopOverCrudSchema from './CRUD/PopOver';
@ -335,10 +336,15 @@ export const examples = [
component: makeSchemaRenderer(HeaderHideSchema)
},
{
label: '带边栏',
label: '带边栏(用 tree',
path: '/examples/crud/aside',
component: makeSchemaRenderer(AsideCrudSchema)
},
{
label: '带边栏(用 Nav',
path: '/examples/crud/aside2',
component: makeSchemaRenderer(Aside2CrudSchema)
},
{
label: '固定表头/列',
path: '/examples/crud/fixed',

View File

@ -123,6 +123,7 @@ body {
&-header {
height: 64px !important;
box-shadow: none !important;
white-space: nowrap;
&::before {
position: fixed;
@ -206,9 +207,8 @@ body {
// padding-left: 10px;
> a {
position: absolute;
top: 20px;
left: 20px;
display: inline-block;
padding: 20px;
cursor: pointer;
color: #666;
@ -278,6 +278,7 @@ body {
margin: 0;
padding: 0;
line-height: 50px;
white-space: nowrap;
> a {
display: inline-block;
@ -305,6 +306,10 @@ body {
}
}
#Header-toolbar {
white-space: nowrap;
}
.a-Layout-offScreen-btn,
.cxd-Layout-offScreen-btn,
.antd-Layout-offScreen-btn,
@ -365,7 +370,7 @@ body {
}
&-nav {
width: 200px;
width: 180px;
}
&-toc {
@ -383,7 +388,7 @@ body {
}
&-label {
font-size: px2rem(14px);
font-size: px2rem(12px);
font-weight: 700;
margin-bottom: px2rem(10px);
margin-top: px2rem(30px);
@ -402,7 +407,7 @@ body {
position: relative;
a {
font-size: px2rem(14px);
font-size: px2rem(12px);
font-weight: 700;
color: #666;
padding: px2rem(3px) 0;
@ -799,6 +804,12 @@ body {
text-align: left;
}
}
.app-wrapper .DocLayout-brandBar,
.app-wrapper .DocLayout-searchBar {
float: none;
display: inline-block;
}
}
@include media-breakpoint-up(lg) {
@ -845,6 +856,9 @@ body {
}
}
&-brandBar {
width: 200px;
}
&-brandBar > div {
width: 220px;
position: fixed;

63
mock/options/nav.js Normal file
View File

@ -0,0 +1,63 @@
module.exports = function (req, res) {
let repeat = 2 + Math.round(Math.random() * 5);
let options = [];
if (req.query.parentId) {
while (repeat--) {
const value = Math.round(Math.random() * 1000000);
const label = value + '';
options.push({
label: label,
to: `?cat=${value}`,
value: `${value}`,
defer: Math.random() > 0.7
});
}
} else {
options = [
{
label: 'Nav 1',
to: '?cat=1',
value: '1',
icon: 'fa fa-user'
},
{
label: 'Nav 2',
unfolded: true,
children: [
{
label: 'Nav 2-1',
children: [
{
label: 'Nav 2-1-1',
to: '?cat=2-1',
value: '2-1'
}
]
},
{
label: 'Nav 2-2',
to: '?cat=2-2',
value: '2-2'
}
]
},
{
label: 'Nav 3',
to: '?cat=3',
value: '3',
defer: true
}
];
}
res.json({
status: 0,
msg: '',
data: {
links: options,
value: req.query.parentId ? undefined : '?cat=1'
}
});
};

View File

@ -941,11 +941,12 @@
--Nav-item-color: var(--text-color);
--Nav-item-fontSize: var(--fontSizeBase);
--Nav-item-onActive-bg: var(--info);
--Nav-item-onActive-borderLeft: 4px solid transparent;
--Nav-item-onActive-borderLeft: 3px solid transparent;
--Nav-item-onActive-color: var(--white);
--Nav-item-onDisabled-color: var(--text--muted-color);
--Nav-item-onHover-bg: rgba(0, 0, 0, 0.05);
--Nav-item-onHover-color: var(--text--loud-color);
--Nav-subNav-bg: #fafafa;
--Nav-subItem-fontSize: var(--fontSizeBase);
--Nav-subItem-onActiveBeforeBg: #e5eaeb;

View File

@ -3,9 +3,10 @@
user-select: none;
margin: 0;
padding: 0;
position: relative;
.#{$ns}Nav-itemIcon {
margin-right: var(--gap-xs);
margin-right: var(--gap-sm);
}
img.#{$ns}Nav-itemIcon {
@ -71,6 +72,24 @@
cursor: pointer;
background: var(--Nav-item-bg);
border-radius: var(--Nav-item-borderRadius);
text-overflow: ellipsis;
&::after {
border-left: var(--Nav-item-onActive-borderLeft);
position: absolute;
left: 0;
top: 0;
content: '';
width: 1px;
height: 100%;
transform: scaleY(0.0001);
transition: transform 0.15s cubic-bezier(0.215, 0.61, 0.355, 1),
opacity 0.15s cubic-bezier(0.215, 0.61, 0.355, 1);
}
}
&.has-sub > a {
padding-right: calc(var(--gap-base) + var(--gap-sm));
}
> a:hover,
@ -91,8 +110,12 @@
&.is-active > a {
color: var(--Nav-item-onActive-color);
background: var(--Nav-item-onActive-bg);
border-left: var(--Nav-item-onActive-borderLeft);
padding-left: px2rem(12px);
position: relative;
&::after {
transform: scaleY(1);
}
}
&.is-unfolded > {
@ -102,19 +125,25 @@
.#{$ns}Nav-subItems {
display: block;
margin-left: var(--gap-sm);
// margin-left: var(--gap-sm);
}
}
.#{$ns}Nav-spinner {
position: absolute;
right: px2rem(10px);
top: px2rem(8px);
}
.#{$ns}Nav-itemToggler {
position: absolute;
right: 0;
top: px2rem(3px);
width: px2rem(30px);
height: px2rem(30px);
text-align: center;
line-height: px2rem(30px);
vertical-align: middle;
top: px2rem(4px);
right: px2rem(2px);
cursor: pointer;
transform: scale(0.8);
transition: transform var(--animation-duration);
@ -127,6 +156,7 @@
}
.#{$ns}Nav-subItems {
background: var(--Nav-subNav-bg);
display: none;
padding-left: 0;
list-style: none;
@ -135,21 +165,21 @@
.#{$ns}Nav-item {
font-size: var(--Nav-subItem-fontSize);
a:before {
content: '';
display: inline-block;
width: px2rem(4px);
height: px2rem(4px);
background: #ccc;
border-radius: px2rem(500px);
margin-right: px2rem(8px);
vertical-align: middle;
}
// a:before {
// content: '';
// display: inline-block;
// width: px2rem(4px);
// height: px2rem(4px);
// background: #ccc;
// border-radius: px2rem(500px);
// margin-right: px2rem(8px);
// vertical-align: middle;
// }
&.active > a:before,
&.is-active > a:before {
background: var(--Nav-subItem-onActiveBeforeBg);
}
// &.active > a:before,
// &.is-active > a:before {
// background: var(--Nav-subItem-onActiveBeforeBg);
// }
}
}
}

View File

@ -253,33 +253,4 @@
&-placeholder {
color: var(--text--muted-color);
}
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
> &-itemLabel {
padding-left: calc(var(--Tree-indent) * 9);
}
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
&-item
> &-itemLabel {
padding-left: calc(var(--Tree-indent) * 10);
}
}

View File

@ -379,7 +379,7 @@ $Wizard-steps-liAfterBorder: none !important;
--Nav-item-borderRadius: 0;
--Nav-item-onActive-bg: #f6f7fb;
--Nav-item-onActive-color: var(--primary);
--Nav-item-onActive-borderLeft: #{px2rem(4px)} solid var(--primary);
--Nav-item-onActive-borderLeft: #{px2rem(3px)} solid var(--primary);
--Nav-subItem-fontSize: #{px2rem(12px)};
--Nav-subItem-onActiveBeforeBg: var(--primary);

View File

@ -117,4 +117,6 @@ $link-color: $info;
--Checkbox-onDisabled-bg: var(--Panel-bg);
--Radio-onDisabled-color: #4e4e4e;
--Radio-onDisabled-bg: var(--Panel-bg);
--Nav-subNav-bg: var(--Panel-bg);
}

View File

@ -1038,7 +1038,7 @@ export const SelectWithRemoteOptions = withRemoteConfig<Array<Options>>({
React.ComponentProps<typeof EnhancedSelect>
> {
render() {
const {loading, config, ...rest} = this.props;
const {loading, config, deferLoad, updateConfig, ...rest} = this.props;
return (
<EnhancedSelect
{...rest}

View File

@ -18,9 +18,10 @@ import {
tokenize
} from '../utils/tpl-builtin';
import {reaction} from 'mobx';
import {createObject, findIndex, findTreeIndex} from '../utils/helper';
export const Store = types
.model('OptionsStore')
.model('RemoteConfigStore')
.props({
fetching: false,
errorMsg: '',
@ -28,20 +29,26 @@ export const Store = types
data: types.frozen({})
})
.actions(self => {
let component: any = undefined;
const load: (
env: RendererEnv,
api: Api,
data: any,
ctx: any,
config: WithRemoteConfigSettings
) => Promise<any> = flow(function* (env, api, data, config = {}) {
) => Promise<any> = flow(function* (env, api, ctx, config = {}): any {
try {
self.fetching = true;
const ret: Payload = yield env.fetcher(api, data);
const ret: Payload = yield env.fetcher(api, ctx);
if (ret.ok) {
const data = ret.data || {};
let options = config.adaptor ? config.adaptor(data) : data;
(self as any).setConfig(options, config);
let options = config.adaptor
? config.adaptor(data, component.props)
: data;
(self as any).setConfig(options, config, 'remote');
config.afterLoad?.(ret, self.config, component.props);
return ret;
} else {
throw new Error(ret.msg || 'fetch error');
}
@ -53,13 +60,27 @@ export const Store = types
});
return {
setComponent(c: any) {
component = c;
},
load,
setData(data: any) {
self.data = data || {};
},
setConfig(options: any, config: WithRemoteConfigSettings) {
setConfig(
options: any,
config: WithRemoteConfigSettings,
motivation?: any
) {
if (config.normalizeConfig) {
options = config.normalizeConfig(options, self.config) || options;
options =
config.normalizeConfig(
options,
self.config,
component.props,
motivation
) || options;
}
self.config = options;
@ -73,17 +94,65 @@ export interface OutterProps {
env?: RendererEnv;
data: any;
source?: SchemaApi | SchemaTokenizeableString;
deferApi?: SchemaApi;
remoteConfigRef?: (
instance:
| {
loadConfig: () => Promise<any> | void;
setConfig: (value: any) => void;
}
| undefined
) => void;
}
export interface RemoteOptionsProps<T = any> {
config: T;
loading?: boolean;
deferLoad: (item: any) => Promise<any>;
updateConfig: (value: T, ctx?: any) => void;
}
export interface WithRemoteConfigSettings {
configField?: string;
adaptor?: (json: any) => any;
normalizeConfig?: (config: any, origin: any) => any;
/**
*
*/
adaptor?: (json: any, props: any) => any;
/**
*
*/
normalizeConfig?: (
config: any,
origin: any,
props: any,
motivation?: any
) => any;
/**
*
*/
afterLoad?: (ret: any, config: any, props: any) => void;
/**
*
*/
beforeDeferLoad?: (
item: any,
indexes: Array<number>,
config: any,
props: any
) => any;
/**
*
*/
afterDeferLoad?: (
item: any,
indexes: Array<number>,
reponse: Payload,
config: any,
props: any
) => any;
}
export function withRemoteConfig<P = any>(
@ -114,6 +183,19 @@ export function withRemoteConfig<P = any>(
static contextType = EnvContext;
toDispose: Array<() => void> = [];
constructor(
props: FinalOutterProps & {
store: IStore;
}
) {
super(props);
this.setConfig = this.setConfig.bind(this);
props.store.setComponent(this);
this.deferLoadConfig = this.deferLoadConfig.bind(this);
props.remoteConfigRef?.(this);
}
componentDidMount() {
const env: RendererEnv = this.props.env || this.context;
const {store, source, data} = this.props;
@ -121,7 +203,7 @@ export function withRemoteConfig<P = any>(
store.setData(data);
if (isPureVariable(source)) {
this.syncOptions();
this.syncConfig();
this.toDispose.push(
reaction(
() =>
@ -130,11 +212,11 @@ export function withRemoteConfig<P = any>(
store.data,
'| raw'
),
() => this.syncOptions()
() => this.syncConfig()
)
);
} else if (env && isEffectiveApi(source, data)) {
this.loadOptions();
this.loadConfig();
this.toDispose.push(
reaction(
() => {
@ -145,7 +227,7 @@ export function withRemoteConfig<P = any>(
ignoreData: true
}).url;
},
() => this.loadOptions()
() => this.loadConfig()
)
);
}
@ -162,38 +244,86 @@ export function withRemoteConfig<P = any>(
componentWillUnmount() {
this.toDispose.forEach(fn => fn());
this.toDispose = [];
this.props.remoteConfigRef?.(undefined);
}
loadOptions() {
async loadConfig(ctx = this.props.data) {
const env: RendererEnv = this.props.env || this.context;
const {store, source, data} = this.props;
const {store, source} = this.props;
if (env && isEffectiveApi(source, data)) {
store.load(env, source, data, config);
if (env && isEffectiveApi(source, ctx)) {
await store.load(env, source, ctx, config);
}
}
syncOptions() {
setConfig(value: any, ctx?: any) {
const {store} = this.props;
store.setConfig(value, config, ctx);
}
syncConfig() {
const {store, source, data} = this.props;
if (isPureVariable(source)) {
store.setConfig(
resolveVariableAndFilter(source as string, data, '| raw') || [],
config
config,
'syncConfig'
);
}
}
async deferLoadConfig(item: any) {
const {store, source, data, deferApi} = this.props;
const env: RendererEnv = this.props.env || this.context;
const indexes = findTreeIndex(store.config, a => a === item)!;
const ret = config.beforeDeferLoad?.(
item,
indexes,
store.config,
this.props
);
ret && store.setConfig(ret, config, 'before-defer-load');
let response: Payload;
try {
response = await env.fetcher(
item.deferApi || deferApi || source,
createObject(data, item)
);
} catch (e) {
response = {
ok: false,
msg: e.message,
status: 500,
data: undefined
};
}
const ret2 = config.afterDeferLoad?.(
item,
indexes, // 只能假定还是那个 index 了
response,
store.config,
this.props
);
ret2 && store.setConfig(ret2, config, 'after-defer-load');
}
render() {
const store = this.props.store;
const injectedProps: RemoteOptionsProps<P> = {
config: store.config,
loading: store.fetching
loading: store.fetching,
deferLoad: this.deferLoadConfig,
updateConfig: this.setConfig
};
const {remoteConfigRef, ...rest} = this.props;
return (
<ComposedComponent
{...(this.props as JSX.LibraryManagedAttributes<
{...(rest as JSX.LibraryManagedAttributes<
T,
React.ComponentProps<T>
>)}

View File

@ -66,7 +66,7 @@ const ConditionBuilderWithRemoteOptions = withRemoteConfig({
RemoteOptionsProps & React.ComponentProps<typeof ConditionBuilder>
> {
render() {
const {loading, config, ...rest} = this.props;
const {loading, config, deferLoad, ...rest} = this.props;
return (
<ConditionBuilder
{...rest}

View File

@ -194,20 +194,21 @@ export const MappingField = withStore(props =>
<span className="text-muted">{placeholder}</span>
);
key =
typeof key === 'string'
? key.trim()
: key === true
? '1'
: key === false
? '0'
: key; // trim 一下,干掉一些空白字符。
key = typeof key === 'string' ? key.trim() : key; // trim 一下,干掉一些空白字符。
let value: any = undefined;
if (typeof key !== 'undefined' && map && (map[key] ?? map['*'])) {
viewValue = render(
'tpl',
map[key] ?? map['*'] // 兼容平台旧用法:即 value 为 true 时映射 1 ,为 false 时映射 0
);
if (
typeof key !== 'undefined' &&
map &&
(value =
map[key] ??
(key === true && map['1']
? map['1']
: key === false && map['0']
? map['0']
: map['*'])) !== undefined
) {
viewValue = render('tpl', value);
}
return <span className={cx('MappingField', className)}>{viewValue}</span>;

View File

@ -1,19 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Renderer, RendererProps} from '../factory';
import {ServiceStore, IServiceStore} from '../store/service';
import cx from 'classnames';
import {Renderer, RendererEnv, RendererProps} from '../factory';
import getExprProperties from '../utils/filter-schema';
import {filter, evalExpression} from '../utils/tpl';
import {createObject, mapTree, someTree} from '../utils/helper';
import {resolveVariable, isPureVariable} from '../utils/tpl-builtin';
import {isApiOutdated, isEffectiveApi} from '../utils/api';
import {
autobind,
findTree,
mapTree,
someTree,
spliceTree
} from '../utils/helper';
import {ScopedContext, IScopedContext} from '../Scoped';
import {Api} from '../types';
import {ClassNamesFn, themeable, ThemeProps} from '../theme';
import {themeable, ThemeProps} from '../theme';
import {Icon} from '../components/icons';
import {BaseSchema, SchemaApi, SchemaIcon, SchemaUrlPath} from '../Schema';
import {generateIcon} from '../utils/icon';
import {
RemoteOptionsProps,
withRemoteConfig
} from '../components/WithRemoteConfig';
import {Payload} from '../types';
import Spinner from '../components/Spinner';
export type NavItemSchema = {
/**
@ -46,6 +52,11 @@ export interface NavSchema extends BaseSchema {
*/
links?: Array<NavItemSchema>;
/**
* @default 24
*/
indentSize: number;
/**
* API
*/
@ -66,6 +77,8 @@ export interface Link {
activeOn?: string;
unfolded?: boolean;
children?: Links;
defer?: boolean;
loading?: boolean;
[propName: string]: any;
}
export interface Links extends Array<Link> {}
@ -76,178 +89,138 @@ export interface NavigationState {
}
export interface NavigationProps
extends RendererProps,
Omit<ThemeProps, 'className'>,
extends ThemeProps,
Omit<NavSchema, 'type' | 'className'> {
onSelect?: (item: Link) => any;
onSelect?: (item: Link) => void | false;
onToggle?: (item: Link) => void;
togglerClassName?: string;
links?: Array<Link>;
loading?: boolean;
}
export class Navigation extends React.Component<
NavigationProps,
NavigationState
> {
static defaultProps: Partial<NavigationProps> = {};
static defaultProps = {
indentSize: 24
};
mounted: boolean = true;
constructor(props: NavigationProps) {
super(props);
this.renderItem = this.renderItem.bind(this);
this.state = {
links: this.syncLinks(
props,
(props.source &&
typeof props.source === 'string' &&
isPureVariable(props.source) &&
resolveVariable(props.source, props.data)) ||
props.links
)
};
@autobind
handleClick(link: Link) {
this.props.onSelect?.(link);
}
componentDidMount() {
const {source} = this.props;
@autobind
toggleLink(target: Link) {
this.props.onToggle?.(target);
}
if (source && !isPureVariable(source as string)) {
this.reload();
renderItem(link: Link, index: number, depth = 1) {
if (link.hidden === true || link.visible === false) {
return null;
}
const isActive: boolean = !!link.active;
const {disabled, togglerClassName, classnames: cx, indentSize} = this.props;
const hasSub = link.defer || (link.children && link.children.length);
return (
<li
key={index}
className={cx('Nav-item', link.className, {
'is-disabled': disabled || link.disabled || link.loading,
'is-active': isActive,
'is-unfolded': link.unfolded,
'has-sub': hasSub
})}
>
<a
onClick={this.handleClick.bind(this, link)}
style={{paddingLeft: depth * (parseInt(indentSize as any, 10) ?? 24)}}
>
{generateIcon(cx, link.icon, 'Nav-itemIcon')}
{link.label}
</a>
{link.loading ? (
<Spinner
size="sm"
show
icon="reload"
spinnerClassName={cx('Nav-spinner')}
/>
) : hasSub ? (
<span
onClick={() => this.toggleLink(link)}
className={cx('Nav-itemToggler', togglerClassName)}
>
<Icon icon="caret" className="icon" />
</span>
) : null}
{Array.isArray(link.children) && link.children.length ? (
<ul className={cx('Nav-subItems')}>
{link.children.map((link, index) =>
this.renderItem(link, index, depth + 1)
)}
</ul>
) : null}
</li>
);
}
componentWillReceiveProps(nextProps: NavigationProps) {
const props = this.props;
render(): JSX.Element {
const {className, stacked, classnames: cx, links, loading} = this.props;
if (nextProps.source && isPureVariable(nextProps.source as string)) {
if (nextProps.source !== props.source) {
this.setState({
links: this.syncLinks(nextProps)
});
} else {
const links = resolveVariable(
nextProps.source as string,
nextProps.data
);
const prevLinks = resolveVariable(props.source as string, props.data);
return (
<ul
className={cx('Nav', className, stacked ? 'Nav--stacked' : 'Nav--tabs')}
>
{Array.isArray(links)
? links.map((item, index) => this.renderItem(item, index))
: null}
if (links !== prevLinks) {
this.setState({
links: this.syncLinks(nextProps, links)
});
}
}
} else if (props.links !== nextProps.links) {
this.setState({
links: this.syncLinks(nextProps)
});
} else if (nextProps.location && props.location !== nextProps.location) {
this.setState({
links: this.syncLinks(nextProps, this.state.links, true)
});
}
<Spinner show={loading} overlay icon="reload" />
</ul>
);
}
}
componentDidUpdate(prevProps: NavigationProps) {
const props = this.props;
const ThemedNavigation = themeable(Navigation);
if (props.source && !isPureVariable(props.source as string)) {
isApiOutdated(
prevProps.source,
props.source,
prevProps.data,
props.data
) && this.reload();
}
}
const ConditionBuilderWithRemoteOptions = withRemoteConfig({
adaptor: (config: any, props: any) => {
const links = Array.isArray(config)
? config
: config.links || config.options || config.items || config.rows;
componentWillUnmount() {
this.mounted = false;
}
reload(target?: string, query?: any, values?: object) {
if (query) {
return this.receive(query);
if (!Array.isArray(links)) {
throw new Error('payload.data.options is not array.');
}
const {data, env, source, translate: __} = this.props;
const finalData = values ? createObject(data, values) : data;
if (!isEffectiveApi(source, data)) {
return;
return links;
},
afterLoad: (response: any, config: any, props: any) => {
if (response.value && !someTree(config, item => item.active)) {
const {env} = props;
env.jumpTo(filter(response.value as string, props.data));
}
},
normalizeConfig(
links: Array<Link>,
origin: Array<Link> | undefined,
props: any,
motivation?: string
) {
if (Array.isArray(links) && motivation !== 'toggle') {
const {data, env} = props;
env
.fetcher(source as Api, finalData)
.then(payload => {
if (!this.mounted) {
return;
}
if (!payload.ok) {
this.setState({
error: payload.msg || __('Nav.sourceError')
});
} else {
const links = Array.isArray(payload.data)
? payload.data
: payload.data.links ||
payload.data.options ||
payload.data.items ||
payload.data.rows;
if (!Array.isArray(links)) {
throw new Error('payload.data.options is not array.');
}
this.setState(
{
links: this.syncLinks(this.props, links)
},
() => {
if (
payload.data &&
payload.data.value &&
!someTree(this.state.links, (item: any) => item.active)
) {
env.jumpTo(filter(payload.data.value as string, data));
}
}
);
}
})
.catch(
e =>
this.mounted &&
this.setState({
error: e.message
})
);
}
receive(values: object) {
const {store, initApi} = this.props;
this.reload(undefined, undefined, values);
}
syncLinks(
props: NavigationProps,
links = props.links,
clearActive?: boolean
): Links {
const {data, env} = props;
if (!Array.isArray(links) || !links.length) {
return [];
}
return mapTree(
links,
(link: Link) => {
links = mapTree(links, (link: Link) => {
return {
...link,
...getExprProperties(link, data as object),
active:
(!clearActive && link.active) ||
(motivation !== 'location-change' && link.active) ||
(link.activeOn
? evalExpression(link.activeOn as string, data)
: !!(
@ -259,106 +232,122 @@ export class Navigation extends React.Component<
link.unfolded ||
(link.children && link.children.some(link => !!link.active))
};
},
1,
true
);
}
handleClick(link: {
label?: string;
to?: string;
icon?: string;
children?: Links;
}) {
const {env, data, onSelect} = this.props;
if (onSelect && onSelect(link) === false) {
return;
});
}
if (!link.to) {
link.children && link.children.length && this.toggleLink(link);
return;
}
return links;
},
env && env.jumpTo(filter(link.to as string, data), link as any);
}
toggleLink(target: Link) {
this.setState({
links: mapTree(this.state.links, (link: Link) =>
target === link
? {
...link,
unfolded: !link.unfolded
}
: link
)
beforeDeferLoad(item: Link, indexes: Array<number>, links: Array<Link>) {
return spliceTree(links, indexes, 1, {
...item,
defer: undefined,
loading: true
});
}
},
renderItem(link: Link, index: number) {
if (link.hidden === true || link.visible === false) {
return null;
afterDeferLoad(
item: Link,
indexes: Array<number>,
ret: Payload,
links: Array<Link>
) {
const newItem = {
...item,
defer: false,
loading: false,
error: ret.ok ? undefined : ret.msg
};
const children = Array.isArray(ret.data)
? ret.data
: ret.data.links || ret.data.options || ret.data.items || ret.data.rows;
if (Array.isArray(children)) {
newItem.children = children.concat();
newItem.unfolded = true;
}
const isActive: boolean = !!link.active;
const {disabled, togglerClassName, classnames: cx} = this.props;
return (
<li
key={index}
className={cx('Nav-item', link.className, {
'is-disabled': disabled || link.disabled,
'is-active': isActive,
'is-unfolded': link.unfolded
})}
>
<a onClick={this.handleClick.bind(this, link)}>
{generateIcon(cx, link.icon, 'Nav-itemIcon')}
{link.label}
</a>
{link.children && link.children.length ? (
<span
onClick={() => this.toggleLink(link)}
className={cx('Nav-itemToggler', togglerClassName)}
>
<Icon icon="caret" className="icon" />
</span>
) : null}
{link.children && link.children.length ? (
<ul className={cx('Nav-subItems')}>
{link.children.map((link, index) => this.renderItem(link, index))}
</ul>
) : null}
</li>
);
return spliceTree(links, indexes, 1, newItem);
}
})(
class extends React.Component<
RemoteOptionsProps &
React.ComponentProps<typeof ThemedNavigation> & {
location?: any;
env?: RendererEnv;
data?: any;
}
> {
constructor(props: any) {
super(props);
this.toggleLink = this.toggleLink.bind(this);
this.handleSelect = this.handleSelect.bind(this);
}
render(): JSX.Element {
const {className, stacked, classnames: cx} = this.props;
componentDidUpdate(prevProps: any) {
if (this.props.location !== prevProps.location) {
this.props.updateConfig(this.props.config, 'location-change');
}
}
const links = this.state.links;
toggleLink(target: Link) {
const {config, updateConfig, deferLoad} = this.props;
return (
<ul
className={cx('Nav', className, stacked ? 'Nav--stacked' : 'Nav--tabs')}
>
{links.map(this.renderItem)}
</ul>
);
if (target.defer) {
deferLoad(target);
} else {
updateConfig(
mapTree(config, (link: Link) =>
target === link
? {
...link,
unfolded: !link.unfolded
}
: link
),
'toggle'
);
}
}
handleSelect(link: Link) {
const {onSelect, env, data} = this.props;
if (onSelect && onSelect(link) === false) {
return;
}
if (!link.to && ((link.children && link.children.length) || link.defer)) {
this.toggleLink(link);
return;
}
env?.jumpTo(filter(link.to as string, data), link as any);
}
render() {
const {loading, config, deferLoad, updateConfig, ...rest} = this.props;
return (
<ThemedNavigation
{...rest}
loading={loading}
links={config || rest.links || []}
disabled={loading}
onSelect={this.handleSelect}
onToggle={this.toggleLink}
/>
);
}
}
}
export default themeable(Navigation);
);
export default ThemedNavigation;
@Renderer({
test: /(^|\/)(?:nav|navigation)$/,
name: 'nav'
})
export class NavigationRenderer extends Navigation {
export class NavigationRenderer extends React.Component<RendererProps> {
static contextType = ScopedContext;
componentWillMount() {
@ -369,6 +358,14 @@ export class NavigationRenderer extends Navigation {
componentWillUnmount() {
const scoped = this.context as IScopedContext;
scoped.unRegisterComponent(this);
super.componentWillUnmount();
}
// reload() {}
// reciever
render() {
const {...rest} = this.props;
return <ConditionBuilderWithRemoteOptions {...rest} />;
}
}

View File

@ -189,7 +189,7 @@ const StepsWithRemoteConfig = withRemoteConfig({
RemoteOptionsProps & React.ComponentProps<typeof Steps>
> {
render() {
const {config, ...rest} = this.props;
const {config, deferLoad, loading, updateConfig, ...rest} = this.props;
return <Steps config={config} {...rest} />;
}
}