import React from 'react'; import {findDOMNode} from 'react-dom'; import {Renderer, RendererProps} from '../factory'; import {SchemaNode, Action, Schema} from '../types'; import Button from '../components/Button'; import {ListStore, IListStore, IItem} from '../store/list'; import {observer} from 'mobx-react'; import { anyChanged, getScrollParent, difference, ucFirst } from '../utils/helper'; import {resolveVariable} from '../utils/tpl-builtin'; import Sortable from 'sortablejs'; import {filter} from '../utils/tpl'; import {Icon} from '../components/icons'; import { BaseSchema, SchemaClassName, SchemaCollection, SchemaExpression, SchemaTpl, SchemaTokenizeableString } from '../Schema'; import {CardSchema} from './Card'; /** * Cards 卡片集合渲染器。 * 文档:https://baidu.gitee.io/amis/docs/components/card */ export interface CardsSchema extends BaseSchema { /** * 指定为 cards 类型 */ type: 'cards'; card?: Omit; /** * 头部 CSS 类名 */ headerClassName?: SchemaClassName; /** * 底部 CSS 类名 */ footerClassName?: SchemaClassName; /** * 卡片 CSS 类名 * * @default Grid-col--sm6 Grid-col--md4 Grid-col--lg3 */ itemClassName?: SchemaClassName; /** * 无数据提示 * * @default 暂无数据 */ placeholder?: SchemaTpl; /** * 是否显示底部 */ showFooter?: boolean; /** * 是否显示头部 */ showHeader?: boolean; /** * 数据源: 绑定当前环境变量 * * @default ${items} */ source?: SchemaTokenizeableString; /** * 标题 */ title?: SchemaTpl; /** * 是否隐藏勾选框 */ hideCheckToggler?: boolean; /** * 是否固顶 */ affixHeader?: boolean; /** * 顶部区域 */ header?: SchemaCollection; /** * 底部区域 */ footer?: SchemaCollection; /** * 配置某项是否可以点选 */ itemCheckableOn?: SchemaExpression; /** * 配置某项是否可拖拽排序,前提是要开启拖拽功能 */ itemDraggableOn?: SchemaExpression; /** * 点击卡片的时候是否勾选卡片。 */ checkOnItemClick?: boolean; /** * 是否为瀑布流布局? */ masonryLayout?: boolean; /** * 可以用来作为值的字段 */ valueField?: string; } export interface Column { type: string; [propName: string]: any; } export interface GridProps extends RendererProps, Omit { store: IListStore; selectable?: boolean; selected?: Array; multiple?: boolean; valueField?: string; 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 Cards extends React.Component { static propsList: Array = [ 'header', 'headerToolbarRender', 'footer', 'footerToolbarRender', 'placeholder', 'source', 'selectable', 'headerClassName', 'footerClassName', 'fixAlignment', 'hideQuickSaveBtn', 'hideCheckToggler', 'itemCheckableOn', 'itemDraggableOn', 'masonryLayout', 'items', 'valueField' ]; static defaultProps: Partial = { className: '', placeholder: 'placeholder.noData', source: '$items', selectable: false, headerClassName: '', footerClassName: '', itemClassName: 'Grid-col--sm6 Grid-col--md4 Grid-col--lg3', // fixAlignment: false, hideCheckToggler: false, masonryLayout: false, affixHeader: true, itemsClassName: '' }; dragTip?: HTMLElement; sortable?: Sortable; parentNode?: any; body?: any; // fixAlignmentLazy: Function; unSensor: Function; renderedToolbars: Array; constructor(props: GridProps) { 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.bodyRef = this.bodyRef.bind(this); this.affixDetect = this.affixDetect.bind(this); this.itemsRef = this.itemsRef.bind(this); this.renderToolbar = this.renderToolbar.bind(this); // this.fixAlignmentLazy = debounce(this.fixAlignment.bind(this), 250, { // trailing: true, // leading: false // }) } static syncItems(store: IListStore, props: GridProps, prevProps?: GridProps) { 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); typeof props.selected !== 'undefined' && store.updateSelected(props.selected, props.valueField); } componentWillMount() { const { store, selectable, draggable, orderBy, orderDir, multiple, hideCheckToggler, itemCheckableOn, itemDraggableOn } = this.props; store.update({ selectable, draggable, orderBy, orderDir, multiple, hideCheckToggler, itemCheckableOn, itemDraggableOn }); Cards.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: GridProps) { const props = this.props; const store = nextProps.store; if ( anyChanged( [ 'selectable', 'draggable', 'orderBy', 'orderDir', 'multiple', 'hideCheckToggler', 'itemCheckableOn', 'itemDraggableOn' ], props, nextProps ) ) { store.update({ selectable: nextProps.selectable, draggable: nextProps.draggable, orderBy: nextProps.orderBy, orderDir: nextProps.orderDir, multiple: nextProps.multiple, hideCheckToggler: nextProps.hideCheckToggler, itemCheckableOn: nextProps.itemCheckableOn, itemDraggableOn: nextProps.itemDraggableOn }); } if ( anyChanged(['source', 'value', 'items'], props, nextProps) || (!nextProps.value && !nextProps.items && nextProps.data !== props.data) ) { Cards.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); } // fixAlignment() { // if (!this.props.fixAlignment || this.props.masonryLayout) { // return; // } // const dom = this.body as HTMLElement; // const ns = this.props.classPrefix; // const cards = [].slice.apply(dom.querySelectorAll(`.${ns}Cards-body > div`)); // if (!cards.length) { // return; // } // let maxHeight = cards.reduce((maxHeight:number, item:HTMLElement) => Math.max(item.offsetHeight, maxHeight), 0); // cards.forEach((item: HTMLElement) => item.style.cssText += `min-height: ${maxHeight}px;`); // } bodyRef(ref: HTMLDivElement) { this.body = ref; } itemsRef(ref: HTMLDivElement) { if (ref) { // this.unSensor = resizeSensor(ref.parentNode as HTMLElement, this.fixAlignmentLazy); } else { this.unSensor && this.unSensor(); // @ts-ignore; delete this.unSensor; } } affixDetect() { if (!this.props.affixHeader || !this.body) { return; } const ns = this.props.classPrefix; const dom = findDOMNode(this) as HTMLElement; const clip = (this.body as HTMLElement).getBoundingClientRect(); const offsetY = this.props.affixOffsetTop ?? this.props.env.affixOffsetTop ?? 0; const affixed = clip.top - 10 < offsetY && clip.top + clip.height - 40 > offsetY; const afixedDom = dom.querySelector(`.${ns}Cards-fixedTop`) as HTMLElement; 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); } handleAction(e: React.UIEvent, action: Action, ctx: object) { const {onAction} = this.props; // 需要支持特殊事件吗? 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, saveSilent?: boolean, resetOnFailed?: boolean ) { item.change(values, saveSilent); if (!saveImmediately || saveSilent) { 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}Cards-body`) as HTMLElement, { group: 'table', animation: 150, handle: `.${ns}Card-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, classnames: cx, classPrefix: ns, env } = 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, showHeader, render, store, classnames: cx, translate: __ } = 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 ? (
{__('Card.dragTip')}
) : null}
) : null; const headerNode = header ? (
{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 ? (
{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, translate: __} = 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, store, columnsCount, itemClassName, placeholder, render, affixHeader, card, onAction, multiple, hideCheckToggler, checkOnItemClick, masonryLayout, itemsClassName, classnames: cx, data, translate: __ } = this.props; this.renderedToolbars = []; // 用来记录哪些 toolbar 已经渲染了,已经渲染了就不重复渲染了。 let itemFinalClassName: string = columnsCount ? `Grid-col--sm${Math.round(12 / columnsCount)}` : itemClassName || ''; const header = this.renderHeader(); const heading = this.renderHeading(); const footer = this.renderFooter(); let masonryClassName = ''; if (masonryLayout) { masonryClassName = 'Cards--masonry ' + itemFinalClassName .split(/\s/) .map(item => { if (/^Grid-col--(xs|sm|md|lg)(\d+)/.test(item)) { return `Cards--masonry${ucFirst(RegExp.$1)}${RegExp.$2}`; } return item; }) .join(' '); } return (
{affixHeader ? (
{heading} {header}
) : null} {heading} {header} {store.items.length ? (
{store.items.map((item, index) => { return (
{render( `${index}`, { // @ts-ignore type: 'card', ...card }, { className: cx((card && card.className) || '', { 'is-checked': item.checked, 'is-modified': item.modified, 'is-moved': item.moved }), item, intemIndex: item.index, multiple, hideCheckToggler, selectable: store.selectable, checkable: item.checkable, draggable: item.draggable, selected: item.checked, onSelect: item.toggle, dragging: store.dragging, data: item.locals, checkOnItemClick, onAction, onCheck: this.handleCheck, onQuickChange: store.dragging ? null : this.handleQuickChange } )}
); })}
) : (
{render('placeholder', __(placeholder))}
)} {footer}
); } } @Renderer({ test: /(^|\/)(?:crud\/body\/grid|cards)$/, name: 'cards', storeType: ListStore.name, weight: -100 // 默认的 grid 不是这样,这个只识别 crud 下面的 grid }) export class CardsRenderer extends Cards { dragging: boolean; selectable: boolean; selected: boolean; onSelect: boolean; title?: string; subTitle?: string; desc?: string; avatar?: string; avatarClassName?: string; body?: SchemaNode; actions?: Array; }