import React from 'react'; import {findDOMNode} from 'react-dom'; import {Renderer, RendererProps} from '../factory'; import {SchemaNode, Schema, Action} from '../types'; import {filter} from '../utils/tpl'; import cx from 'classnames'; import Button from '../components/Button'; import Checkbox from '../components/Checkbox'; import {ListStore, IListStore, IItem} from '../store/list'; import {observer} from 'mobx-react'; import { anyChanged, getScrollParent, difference, isVisible, isDisabled, noop } from '../utils/helper'; import {resolveVariable} from '../utils/tpl-builtin'; import QuickEdit, {SchemaQuickEdit} from './QuickEdit'; import PopOver, {SchemaPopOver} from './PopOver'; import Sortable from 'sortablejs'; import {TableCell} from './Table'; import Copyable, {SchemaCopyable} from './Copyable'; import {Icon} from '../components/icons'; import { BaseSchema, SchemaClassName, SchemaCollection, SchemaExpression, SchemaObject, SchemaTokenizeableString, SchemaTpl, SchemaUrlPath } from '../Schema'; import {ActionSchema} from './Action'; import {SchemaRemark} from './Remark'; /** * 不指定类型默认就是文本 */ export type ListBodyFieldObject = { /** * 列标题 */ label?: string; /** * label 类名 */ labelClassName?: SchemaClassName; /** * 绑定字段名 */ name?: string; /** * 配置查看详情功能 */ popOver?: SchemaPopOver; /** * 配置快速编辑功能 */ quickEdit?: SchemaQuickEdit; /** * 配置点击复制功能 */ copyable?: SchemaCopyable; }; export type ListBodyField = SchemaObject & ListBodyFieldObject; export interface ListItemSchema extends Omit { actions?: Array; /** * 操作位置,默认在右侧,可以设置成左侧。 */ actionsPosition?: 'left' | 'right'; /** * 图片地址 */ avatar?: SchemaUrlPath; /** * 内容区域 */ body?: Array; /** * 描述 */ desc?: SchemaTpl; /** * tooltip 说明 */ remark?: SchemaRemark; /** * 标题 */ title?: SchemaTpl; /** * 副标题 */ subTitle?: SchemaTpl; } /** * List 列表展示控件。 * 文档:https://baidu.gitee.io/amis/docs/components/card */ export interface ListSchema extends BaseSchema { /** * 指定为 List 列表展示控件。 */ type: 'list' | 'static-list'; /** * 标题 */ title?: SchemaTpl; /** * 底部区域 */ footer?: SchemaCollection; /** * 底部区域类名 */ footerClassName?: SchemaClassName; /** * 顶部区域 */ header?: SchemaCollection; /** * 顶部区域类名 */ headerClassName?: SchemaClassName; /** * 单条数据展示内容配置 */ listItem?: ListItemSchema; /** * 数据源: 绑定当前环境变量 * * @default ${items} */ source?: SchemaTokenizeableString; /** * 是否显示底部 */ showFooter?: boolean; /** * 是否显示头部 */ showHeader?: boolean; /** * 无数据提示 * * @default 暂无数据 */ placeholder?: SchemaTpl; /** * 是否隐藏勾选框 */ hideCheckToggler?: boolean; /** * 是否固顶 */ affixHeader?: boolean; /** * 配置某项是否可以点选 */ itemCheckableOn?: SchemaExpression; /** * 配置某项是否可拖拽排序,前提是要开启拖拽功能 */ itemDraggableOn?: SchemaExpression; /** * 点击卡片的时候是否勾选卡片。 */ checkOnItemClick?: boolean; /** * 可以用来作为值的字段 */ valueField?: string; /** * 大小 */ size?: 'sm' | 'base'; } export interface Column { type: string; [propName: string]: any; } export interface ListProps extends RendererProps, Omit { store: IListStore; selectable?: boolean; selected?: Array; draggable?: boolean; onSelect: ( selectedItems: Array, unSelectedItems: Array ) => void; onSave?: ( items: Array | object, diff: Array | object, rowIndexes: Array | number, unModifiedItems?: Array, rowOrigins?: Array | object, resetOnFailed?: boolean ) => void; onSaveOrder?: (moved: Array, items: Array) => void; onQuery: (values: object) => void; } export default class List extends React.Component { static propsList: Array = [ 'header', 'headerToolbarRender', 'footer', 'footerToolbarRender', 'placeholder', 'source', 'selectable', 'headerClassName', 'footerClassName', 'hideQuickSaveBtn', 'hideCheckToggler', 'itemCheckableOn', 'itemDraggableOn', 'actions', 'items', 'valueField' ]; static defaultProps: Partial = { className: '', placeholder: 'placeholder.noData', source: '$items', selectable: false, headerClassName: '', footerClassName: '', affixHeader: true }; dragTip?: HTMLElement; sortable?: Sortable; parentNode?: any; body?: any; renderedToolbars: Array; constructor(props: ListProps) { super(props); this.handleAction = this.handleAction.bind(this); this.handleCheck = this.handleCheck.bind(this); this.handleCheckAll = this.handleCheckAll.bind(this); this.handleQuickChange = this.handleQuickChange.bind(this); this.handleSave = this.handleSave.bind(this); this.handleSaveOrder = this.handleSaveOrder.bind(this); this.reset = this.reset.bind(this); this.dragTipRef = this.dragTipRef.bind(this); this.getPopOverContainer = this.getPopOverContainer.bind(this); this.affixDetect = this.affixDetect.bind(this); this.bodyRef = this.bodyRef.bind(this); this.renderToolbar = this.renderToolbar.bind(this); } static syncItems(store: IListStore, props: ListProps, prevProps?: ListProps) { const source = props.source; const value = props.value || props.items; let items: Array = []; let updateItems = true; if (Array.isArray(value)) { items = value; } else if (typeof source === 'string') { const resolved = resolveVariable(source, props.data); const prev = prevProps ? resolveVariable(source, prevProps.data) : null; if (prev && prev === resolved) { updateItems = false; } else if (Array.isArray(resolved)) { items = resolved; } } updateItems && store.initItems(items); Array.isArray(props.selected) && store.updateSelected(props.selected, props.valueField); } componentWillMount() { const { store, selectable, draggable, orderBy, orderDir, multiple, hideCheckToggler, itemCheckableOn, itemDraggableOn } = this.props; store.update({ multiple, selectable, draggable, orderBy, orderDir, hideCheckToggler, itemCheckableOn, itemDraggableOn }); List.syncItems(store, this.props); this.syncSelected(); } componentDidMount() { let parent: HTMLElement | Window | null = getScrollParent( findDOMNode(this) as HTMLElement ); if (!parent || parent === document.body) { parent = window; } this.parentNode = parent; this.affixDetect(); parent.addEventListener('scroll', this.affixDetect); window.addEventListener('resize', this.affixDetect); } componentWillReceiveProps(nextProps: ListProps) { const props = this.props; const store = nextProps.store; if ( anyChanged( [ 'selectable', 'draggable', 'orderBy', 'orderDir', 'multiple', 'hideCheckToggler', 'itemCheckableOn', 'itemDraggableOn' ], props, nextProps ) ) { store.update({ multiple: nextProps.multiple, selectable: nextProps.selectable, draggable: nextProps.draggable, orderBy: nextProps.orderBy, orderDir: nextProps.orderDir, hideCheckToggler: nextProps.hideCheckToggler, itemCheckableOn: nextProps.itemCheckableOn, itemDraggableOn: nextProps.itemDraggableOn }); } if ( anyChanged(['source', 'value', 'items'], props, nextProps) || (!nextProps.value && !nextProps.items && nextProps.data !== props.data) ) { List.syncItems(store, nextProps, props); this.syncSelected(); } else if (props.selected !== nextProps.selected) { store.updateSelected(nextProps.selected || [], nextProps.valueField); } } componentWillUnmount() { const parent = this.parentNode; parent && parent.removeEventListener('scroll', this.affixDetect); window.removeEventListener('resize', this.affixDetect); } bodyRef(ref: HTMLDivElement) { this.body = ref; } affixDetect() { if (!this.props.affixHeader || !this.body) { return; } const ns = this.props.classPrefix; const dom = findDOMNode(this) as HTMLElement; const afixedDom = dom.querySelector(`.${ns}List-fixedTop`) as HTMLElement; if (!afixedDom) { return; } const clip = (this.body as HTMLElement).getBoundingClientRect(); const offsetY = this.props.affixOffsetTop ?? this.props.env.affixOffsetTop ?? 0; const affixed = clip.top < offsetY && clip.top + clip.height - 40 > offsetY; this.body.offsetWidth && (afixedDom.style.cssText = `top: ${offsetY}px;width: ${this.body.offsetWidth}px;`); affixed ? afixedDom.classList.add('in') : afixedDom.classList.remove('in'); // store.markHeaderAffix(clip.top < offsetY && (clip.top + clip.height - 40) > offsetY); } getPopOverContainer() { return findDOMNode(this); } handleAction(e: React.UIEvent, action: Action, ctx: object) { const {onAction} = this.props; // todo onAction(e, action, ctx); } handleCheck(item: IItem) { item.toggle(); this.syncSelected(); } handleCheckAll() { const {store} = this.props; store.toggleAll(); this.syncSelected(); } syncSelected() { const {store, onSelect} = this.props; onSelect && onSelect( store.selectedItems.map(item => item.data), store.unSelectedItems.map(item => item.data) ); } handleQuickChange( item: IItem, values: object, saveImmediately?: boolean | any, savePristine?: boolean, resetOnFailed?: boolean ) { item.change(values, savePristine); if (!saveImmediately || savePristine) { return; } if (saveImmediately && saveImmediately.api) { this.props.onAction( null, { actionType: 'ajax', api: saveImmediately.api }, values ); return; } const {onSave, primaryField} = this.props; if (!onSave) { return; } onSave( item.data, difference(item.data, item.pristine, ['id', primaryField]), item.index, undefined, item.pristine, resetOnFailed ); } handleSave() { const {store, onSave, primaryField} = this.props; if (!onSave || !store.modifiedItems.length) { return; } const items = store.modifiedItems.map(item => item.data); const itemIndexes = store.modifiedItems.map(item => item.index); const diff = store.modifiedItems.map(item => difference(item.data, item.pristine, ['id', primaryField]) ); const unModifiedItems = store.items .filter(item => !item.modified) .map(item => item.data); onSave( items, diff, itemIndexes, unModifiedItems, store.modifiedItems.map(item => item.pristine) ); } handleSaveOrder() { const {store, onSaveOrder} = this.props; if (!onSaveOrder || !store.movedItems.length) { return; } onSaveOrder( store.movedItems.map(item => item.data), store.items.map(item => item.data) ); } reset() { const {store} = this.props; store.reset(); } bulkUpdate(value: object, items: Array) { const {store} = this.props; const items2 = store.items.filter(item => ~items.indexOf(item.pristine)); items2.forEach(item => item.change(value)); } getSelected() { const {store} = this.props; return store.selectedItems.map(item => item.data); } dragTipRef(ref: any) { if (!this.dragTip && ref) { this.initDragging(); } else if (this.dragTip && !ref) { this.destroyDragging(); } this.dragTip = ref; } initDragging() { const store = this.props.store; const dom = findDOMNode(this) as HTMLElement; const ns = this.props.classPrefix; this.sortable = new Sortable( dom.querySelector(`.${ns}List-items`) as HTMLElement, { group: 'table', animation: 150, handle: `.${ns}ListItem-dragBtn`, ghostClass: 'is-dragging', onEnd: (e: any) => { // 没有移动 if (e.newIndex === e.oldIndex) { return; } const parent = e.to as HTMLElement; if (e.oldIndex < parent.childNodes.length - 1) { parent.insertBefore(e.item, parent.childNodes[e.oldIndex]); } else { parent.appendChild(e.item); } store.exchange(e.oldIndex, e.newIndex); } } ); } destroyDragging() { this.sortable && this.sortable.destroy(); } renderActions(region: string) { let { actions, render, store, multiple, selectable, env, classPrefix: ns, classnames: cx } = this.props; let btn; actions = Array.isArray(actions) ? actions.concat() : []; if ( !~this.renderedToolbars.indexOf('check-all') && (btn = this.renderCheckAll()) ) { actions.unshift({ type: 'button', children: btn }); } if ( region === 'header' && !~this.renderedToolbars.indexOf('drag-toggler') && (btn = this.renderDragToggler()) ) { actions.unshift({ type: 'button', children: btn }); } return Array.isArray(actions) && actions.length ? (
{actions.map((action, key) => render( `action/${key}`, { type: 'button', ...action }, { onAction: this.handleAction, key, btnDisabled: store.dragging } ) )}
) : null; } renderHeading() { let {title, store, hideQuickSaveBtn, classnames: cx, data} = this.props; if (title || (store.modified && !hideQuickSaveBtn) || store.moved) { return (
{store.modified && !hideQuickSaveBtn ? ( {`当前有 ${store.modified} 条记录修改了内容, 但并没有提交。请选择:`} ) : store.moved ? ( {`当前有 ${store.moved} 条记录修改了顺序, 但并没有提交。请选择:`} ) : title ? ( filter(title, data) ) : ( '' )}
); } return null; } renderHeader() { const { header, headerClassName, headerToolbar, headerToolbarRender, render, showHeader, store, classnames: cx } = this.props; if (showHeader === false) { return null; } const child = headerToolbarRender ? headerToolbarRender( { ...this.props, selectedItems: store.selectedItems.map(item => item.data), items: store.items.map(item => item.data), unSelectedItems: store.unSelectedItems.map(item => item.data) }, this.renderToolbar ) : null; const actions = this.renderActions('header'); const toolbarNode = actions || child || store.dragging ? (
{actions} {child} {store.dragging ? (
请拖动左边的按钮进行排序
) : null}
) : null; const headerNode = header && (!Array.isArray(header) || header.length) ? (
{render('header', header)}
) : null; return headerNode && toolbarNode ? [headerNode, toolbarNode] : headerNode || toolbarNode || null; } renderFooter() { const { footer, footerClassName, footerToolbar, footerToolbarRender, render, showFooter, store, classnames: cx } = this.props; if (showFooter === false) { return null; } const child = footerToolbarRender ? footerToolbarRender( { ...this.props, selectedItems: store.selectedItems.map(item => item.data), items: store.items.map(item => item.data), unSelectedItems: store.unSelectedItems.map(item => item.data) }, this.renderToolbar ) : null; const actions = this.renderActions('footer'); const toolbarNode = actions || child ? (
{actions} {child}
) : null; const footerNode = footer && (!Array.isArray(footer) || footer.length) ? (
{render('footer', footer)}
) : null; return footerNode && toolbarNode ? [toolbarNode, footerNode] : footerNode || toolbarNode || null; } renderCheckAll() { const {store, multiple, selectable} = this.props; if ( !store.selectable || !multiple || !selectable || store.dragging || !store.items.length ) { return null; } return ( ); } renderDragToggler() { const {store, multiple, selectable, env} = this.props; if (!store.draggable || store.items.length < 2) { return null; } return ( ); } renderToolbar(toolbar: SchemaNode, index: number) { const type = (toolbar as Schema).type || (toolbar as string); if (type === 'drag-toggler') { this.renderedToolbars.push(type); return this.renderDragToggler(); } else if (type === 'check-all') { this.renderedToolbars.push(type); return this.renderCheckAll(); } return void 0; } render() { const { className, itemClassName, store, placeholder, render, multiple, listItem, onAction, hideCheckToggler, checkOnItemClick, affixHeader, classnames: cx, size, translate: __ } = this.props; this.renderedToolbars = []; const heading = this.renderHeading(); const header = this.renderHeader(); return (
{affixHeader && heading && header ? (
{heading} {header}
) : null} {heading} {header} {store.items.length ? (
{store.items.map((item, index) => render( `${index}`, { type: 'list-item', ...listItem }, { key: item.index, className: cx(itemClassName, { 'is-checked': item.checked, 'is-modified': item.modified, 'is-moved': item.moved }), selectable: store.selectable, checkable: item.checkable, multiple, item, itemIndex: item.index, hideCheckToggler, checkOnItemClick, selected: item.checked, onCheck: this.handleCheck, dragging: store.dragging, onAction, data: item.locals, onQuickChange: store.dragging ? null : this.handleQuickChange, popOverContainer: this.getPopOverContainer } ) )}
) : (
{render('placeholder', __(placeholder))}
)} {this.renderFooter()}
); } } @Renderer({ type: 'list', storeType: ListStore.name, name: 'list' }) export class ListRenderer extends List { dragging: boolean; selectable: boolean; selected: boolean; title?: string; subTitle?: string; desc?: string; avatar?: string; avatarClassName?: string; body?: SchemaNode; actions?: Array; onCheck: (item: IItem) => void; } export interface ListItemProps extends RendererProps, Omit { hideCheckToggler?: boolean; item: IItem; itemIndex?: number; checkable?: boolean; checkOnItemClick?: boolean; } export class ListItem extends React.Component { static defaultProps: Partial = { avatarClassName: 'thumb-sm avatar m-r', titleClassName: 'h5' }; static propsList: Array = ['avatarClassName', 'titleClassName']; constructor(props: ListItemProps) { super(props); this.itemRender = this.itemRender.bind(this); this.handleAction = this.handleAction.bind(this); this.handleQuickChange = this.handleQuickChange.bind(this); this.handleClick = this.handleClick.bind(this); this.handleCheck = this.handleCheck.bind(this); } handleClick(e: React.MouseEvent) { const target: HTMLElement = e.target as HTMLElement; const ns = this.props.classPrefix; let formItem; if ( !e.currentTarget.contains(target) || ~['INPUT', 'TEXTAREA'].indexOf(target.tagName) || ((formItem = target.closest(`button, a, .${ns}Form-item`)) && e.currentTarget.contains(formItem)) ) { return; } const item = this.props.item; this.props.onCheck && this.props.onCheck(item); } handleCheck() { const item = this.props.item; this.props.onCheck && this.props.onCheck(item); } handleAction(e: React.UIEvent, action: Action, ctx: object) { const {onAction, item} = this.props; onAction && onAction(e, action, ctx || item.data); } handleQuickChange( values: object, saveImmediately?: boolean, savePristine?: boolean, resetOnFailed?: boolean ) { const {onQuickChange, item} = this.props; onQuickChange && onQuickChange(item, values, saveImmediately, savePristine, resetOnFailed); } renderLeft() { const { dragging, selectable, selected, checkable, multiple, hideCheckToggler, checkOnItemClick, classnames: cx, classPrefix: ns } = this.props; if (dragging) { return (
); } else if (selectable && !hideCheckToggler) { return (
); } return null; } renderRight() { const {actions, render, data, dragging, classnames: cx} = this.props; if (Array.isArray(actions)) { return (
{actions.map((action, index) => { if (!isVisible(action, data)) { return null; } return render( `action/${index}`, { size: 'sm', level: 'link', type: 'button', ...(action as any) // todo 等后面修复了干掉 https://github.com/microsoft/TypeScript/pull/38577 }, { key: index, disabled: dragging || isDisabled(action, data), onAction: this.handleAction } ); })}
); } return null; } renderChild( node: SchemaNode, region: string = 'body', key: any = 0 ): React.ReactNode { const {render} = this.props; /*if (Array.isArray(node)) { return (
{node.map((item, index) => (
{this.renderChild(item, `${region}/${index}`)}
))}
); } else */ if ( typeof node === 'string' || typeof node === 'number' ) { return render(region, node, {key}) as JSX.Element; } const childNode: Schema = node as Schema; if (childNode.type === 'hbox' || childNode.type === 'grid') { return render(region, node, { key, itemRender: this.itemRender }) as JSX.Element; } return this.renderFeild(region, childNode, key, this.props); } itemRender(field: any, index: number, props: any) { return this.renderFeild(`column/${index}`, field, index, props); } renderFeild(region: string, field: any, key: any, props: any) { const render = props.render || this.props.render; const data = this.props.data; const cx = this.props.classnames; const itemIndex = this.props.itemIndex; const $$id = field.$$id ? `${field.$$id}-field` : ''; if (!isVisible(field, data)) { return null; } return (
{field && field.label ? ( ) : null} { render( region, { ...field, field: field, $$id, type: 'list-item-field' }, { rowIndex: itemIndex, colIndex: key, className: cx('ListItem-fieldValue', field.className), value: field.name ? resolveVariable(field.name, data) : `-`, onAction: this.handleAction, onQuickChange: this.handleQuickChange } ) as JSX.Element }
); } renderBody() { const {body} = this.props; if (!body) { return null; } else if (Array.isArray(body)) { return body.map((child, index) => this.renderChild( { type: 'plain', ...(typeof child === 'string' ? {type: 'tpl', tpl: child} : child) }, `body/${index}`, index ) ); } return this.renderChild(body, 'body'); } render() { const { className, data, avatar: avatarTpl, title: titleTpl, titleClassName, subTitle: subTitleTpl, desc: descTpl, avatarClassName, checkOnItemClick, render, checkable, classnames: cx, actionsPosition } = this.props; const avatar = filter(avatarTpl, data); const title = filter(titleTpl, data); const subTitle = filter(subTitleTpl, data); const desc = filter(descTpl, data); return (
{this.renderLeft()} {this.renderRight()} {avatar ? ( ... ) : null}
{title ? (

{title}

) : null} {subTitle ? (
{subTitle}
) : null} {desc ? render('description', desc) : null} {this.renderBody()}
); } } @Renderer({ test: /(^|\/)(?:list|list-group)\/(?:.*\/)?list-item$/, name: 'list-item' }) export class ListItemRenderer extends ListItem { static propsList = ['multiple', ...ListItem.propsList]; } @Renderer({ type: 'list-item-field', name: 'list-item-field' }) @QuickEdit() @PopOver() @Copyable() export class ListItemFieldRenderer extends TableCell { static defaultProps = { ...TableCell.defaultProps, wrapperComponent: 'div' }; static propsList = [ 'quickEdit', 'quickEditEnabledOn', 'popOver', 'copyable', 'inline', ...TableCell.propsList ]; render() { let { className, render, style, wrapperComponent: Component, labelClassName, value, data, children, width, innerClassName, label, tabIndex, onKeyUp, field, ...rest } = this.props; const schema = { ...field, className: innerClassName, type: (field && field.type) || 'plain' }; let body = children ? children : render('field', schema, { ...rest, value, data }); if (width) { style = style || {}; style.width = style.width || width; body = (
{body}
); } if (!Component) { return body as JSX.Element; } return ( {body} ); } }