diff --git a/docs/zh-CN/components/crud.md b/docs/zh-CN/components/crud.md index b47ac32e74c..04824e50679 100755 --- a/docs/zh-CN/components/crud.md +++ b/docs/zh-CN/components/crud.md @@ -401,7 +401,62 @@ interface ParsePrimitiveQueryOptions { ### 查 -查,就不单独介绍了,这个文档绝大部分都是关于查的。 +除了列表查询外,还支持查看详情场景,与编辑不同的地方主要在于弹窗中改成放展示类组件,或者表单项配置静态展示。 + +```schema: scope="body" +{ + "type": "crud", + "api": "/api/mock2/sample?orderBy=id&orderDir=desc", + "syncLocation": false, + "columns": [ + { + "name": "id", + "label": "ID" + }, + { + "name": "engine", + "label": "Rendering engine" + }, + { + "name": "browser", + "label": "Browser" + }, + { + "type": "operation", + "label": "操作", + "buttons": [ + { + "label": "详情", + "type": "button", + "actionType": "dialog", + "dialog": { + "title": "查看数据「${id}」", + "body": { + "type": "form", + "initApi": "/api/mock2/sample/${id}", + "body": [ + { + "type": "static", + "name": "engine", + "label": "Engine" + }, + { + "type": "input-text", + "name": "browser", + "label": "Browser", + "static": true + } + ] + } + } + } + ] + } + ] +} +``` + +弹框里面可用数据自动就是点击的那一行的行数据,如果列表没有返回,可以在 form 里面再配置个 initApi 初始化数据,如果行数据里面有倒是不需要再拉取了。表单项的 name 跟数据 key 对应上便自动回显了。 ## 展示模式 @@ -2503,6 +2558,261 @@ interface CRUDMatchFunc { } ``` +### 悬浮操作栏 + +通过配置 `itemActions` 可以启用悬浮操作栏,鼠标悬停到行上,右侧会出现对应的操作按钮。 + +```schema: scope="body" +{ + "type": "crud", + "syncLocation": false, + "api": "/api/mock2/sample", + "checkOnItemClick": true, + "itemActions": [ + { + "type": "button", + "label": "按钮 1", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + }, + { + "type": "button", + "label": "按钮 2", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + } + ], + "columns": [ + { + "name": "id", + "label": "ID" + }, + { + "name": "engine", + "label": "Rendering engine" + }, + { + "name": "browser", + "label": "Browser" + }, + { + "name": "platform", + "label": "Platform(s)" + }, + { + "name": "version", + "label": "Engine version" + }, + { + "name": "grade", + "label": "CSS grade" + } + ] +} +``` + +当同时配置 `itemActions` 和 `bulkActions`, 顶部工具栏会根据选择的条数来切换显示按钮。 + +```schema: scope="body" +{ + "type": "crud", + "syncLocation": false, + "api": "/api/mock2/sample", + "checkOnItemClick": true, + "bulkActions": [ + { + "type": "button", + "label": "批量按钮 1", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + }, + { + "type": "button", + "label": "批量按钮 2", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + } + ], + "itemActions": [ + { + "type": "button", + "label": "按钮 1", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + }, + { + "type": "button", + "label": "按钮 2", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + } + ], + "columns": [ + { + "name": "id", + "label": "ID" + }, + { + "name": "engine", + "label": "Rendering engine" + }, + { + "name": "browser", + "label": "Browser" + }, + { + "name": "platform", + "label": "Platform(s)" + }, + { + "name": "version", + "label": "Engine version" + }, + { + "name": "grade", + "label": "CSS grade" + } + ] +} +``` + +如果同时启用时,只想把按钮展示在顶部,而不是悬浮,则需要给按钮上配置 `hiddenOnHover`。 + +```schema: scope="body" +{ + "type": "crud", + "syncLocation": false, + "api": "/api/mock2/sample", + "checkOnItemClick": true, + "bulkActions": [ + { + "type": "button", + "label": "批量按钮 1", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + }, + { + "type": "button", + "label": "批量按钮 2", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + } + ], + "itemActions": [ + { + "type": "button", + "label": "按钮 1", + "hiddenOnHover": true, + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + }, + { + "type": "button", + "label": "按钮 2", + "hiddenOnHover": true, + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${&|json}" + } + ] + } + } + ], + "columns": [ + { + "name": "id", + "label": "ID" + }, + { + "name": "engine", + "label": "Rendering engine" + }, + { + "name": "browser", + "label": "Browser" + }, + { + "name": "platform", + "label": "Platform(s)" + }, + { + "name": "version", + "label": "Engine version" + }, + { + "name": "grade", + "label": "CSS grade" + } + ] +} +``` + ### 数据统计 在`headerToolbar`或者`footerToolbar`数组中添加`statistics`字符串,可以实现简单的数据统计功能 @@ -3918,6 +4228,61 @@ itemAction 里的 onClick 还能通过 `data` 参数拿到当前行的数据, } ``` +## 开启点选 + +当配置了 `bulkActions` 后,CRUD 会自动变成可点选状态。如果想直接开启可点选,可以配置 `selectable`,同时可以配置 `multiple` 来配置是单选还是多选。但是这个时候没有任何交互,需要配置事件动作,或者在工具栏中添加行为按钮完成交互逻辑。 + +```schema: scope="body" +{ + "type": "crud", + "api": "/api/mock2/sample", + "syncLocation": false, + "selectable": true, + "headerToolbar": [ + { + "type": "button", + "label": "按钮", + "visibleOn": "${selectedItems.length}", + "actionType": "toast", + "toast": { + "items": [ + { + "level": "info", + "body": "${selectedItems|json}" + } + ] + } + } + ], + "columns": [ + { + "name": "id", + "label": "ID" + }, + { + "name": "engine", + "label": "Rendering engine" + }, + { + "name": "browser", + "label": "Browser" + }, + { + "name": "platform", + "label": "Platform(s)" + }, + { + "name": "version", + "label": "Engine version" + }, + { + "name": "grade", + "label": "CSS grade" + } + ] +} +``` + ## 属性表 | 属性名 | 类型 | 默认值 | 说明 | 版本 | @@ -3992,13 +4357,13 @@ itemAction 里的 onClick 还能通过 `data` 参数拿到当前行的数据, 除了 Table 组件默认支持的列配置,CRUD 的列配置还额外支持以下属性: -| 属性名 | 类型 | 默认值 | 说明 | 版本 | -| ------------------ | ------------------------------------------------------------ | ------- | --------------------------------------------------------------------------- | ---- | -| sortable | `boolean` | `false` | 是否可排序 | -| searchable | `boolean` \| `Schema` | `false` | 是否可快速搜索,开启`autoGenerateFilter`后,`searchable`支持配置`Schema` | -| filterable | `boolean` \| [`QuickFilterConfig`](./crud#quickfilterconfig) | `false` | 是否可快速搜索,`options`属性为静态选项,支持设置`source`属性从接口获取选项 | -| quickEdit | `boolean` \| [`QuickEditConfig`](./crud#quickeditconfig) | - | 快速编辑,一般需要配合`quickSaveApi`接口使用 | -| quickEditEnabledOn | `SchemaExpression` | - | 开启快速编辑条件[表达式](../../docs/concepts/expression) | | +| 属性名 | 类型 | 默认值 | 说明 | 版本 | +| ------------------ | ------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------- | ------- | +| sortable | `boolean` | `false` | 是否可排序 | +| searchable | `boolean` \| `Schema` | `false` | 是否可快速搜索,开启`autoGenerateFilter`后,`searchable`支持配置`Schema` | +| filterable | `boolean` \| [`QuickFilterConfig`](./crud#quickfilterconfig) | `false` | 是否可快速搜索,`options`属性为静态选项,支持设置`source`属性从接口获取选项 | +| quickEdit | `boolean` \| [`QuickEditConfig`](./crud#quickeditconfig) | - | 快速编辑,一般需要配合`quickSaveApi`接口使用 | +| quickEditEnabledOn | `SchemaExpression` | - | 开启快速编辑条件[表达式](../../docs/concepts/expression) | | | textOverflow | `string` | `default` | 文本溢出后展示形式,默认换行处理。可选值 `ellipsis` 溢出隐藏展示, `noWrap` 不换行展示(仅在列为静态文本时生效) | `6.9.0` | #### QuickFilterConfig diff --git a/packages/amis-core/src/RootRenderer.tsx b/packages/amis-core/src/RootRenderer.tsx index 847784e6c0d..36cc911046a 100644 --- a/packages/amis-core/src/RootRenderer.tsx +++ b/packages/amis-core/src/RootRenderer.tsx @@ -234,11 +234,11 @@ export class RootRenderer extends React.Component { ); }); } else if (action.actionType === 'toast') { - action.toast?.items?.forEach((item: any) => { + action.toast?.items?.forEach(({level, body, title, ...item}: any) => { env.notify( - item.level || 'info', - item.body - ? render('body', item.body, { + level || 'info', + body + ? render('body', body, { ...this.props, data: ctx, context: store.context @@ -247,8 +247,8 @@ export class RootRenderer extends React.Component { { ...action.toast, ...item, - title: item.title - ? render('title', item.title, { + title: title + ? render('title', title, { ...this.props, data: ctx, context: store.context diff --git a/packages/amis-ui/scss/_mixins.scss b/packages/amis-ui/scss/_mixins.scss index f4c902b12d1..8cda387d219 100644 --- a/packages/amis-ui/scss/_mixins.scss +++ b/packages/amis-ui/scss/_mixins.scss @@ -661,7 +661,8 @@ line-height: calc( var(--Form-input-lineHeight) * var(--Form-input-fontSize) - #{px2rem(2px)} ); - display: inline-block; + display: inline-flex; + align-items: center; font-size: var(--Pick-base-value-fontSize); color: var(--Pick-base-value-color); font-weight: var(--Pick-base-value-fontWeight); @@ -682,9 +683,6 @@ var(--Pick-base-top-right-border-radius) var(--Pick-base-bottom-right-border-radius) var(--Pick-base-bottom-left-border-radius); - margin-right: var(--gap-xs); - margin-bottom: var(--gap-xs); - margin-top: var(--gap-xs); max-width: px2rem(150px); overflow: hidden; text-overflow: ellipsis; @@ -696,7 +694,10 @@ &.is-disabled { pointer-events: none; - opacity: var(--Button-onDisabled-opacity); + + .#{$ns}#{$component-prefix}-valueIcon { + opacity: var(--Button-onDisabled-opacity); + } } } @@ -704,7 +705,7 @@ color: var(--Pick-base-value-icon-color); cursor: pointer; border-right: px2rem(1px) solid var(--Form-selectValue-borderColor); - padding: 1px 5px; + padding: 0 5px; &:hover { background: var(--Pick-base-value-hover-icon-color); diff --git a/packages/amis-ui/scss/_properties.scss b/packages/amis-ui/scss/_properties.scss index 668498456d4..51873ee173e 100644 --- a/packages/amis-ui/scss/_properties.scss +++ b/packages/amis-ui/scss/_properties.scss @@ -179,6 +179,7 @@ $Table-strip-bg: transparent; --ButtonGroup-divider-width: #{px2rem(1px)}; --ButtonGroup-divider-color: #fff; --ButtonGroup-borderWidth: var(--borders-width-2); + --Button-onDisabled-opacity: 0.3; --Breadcrumb-item-fontSize: var(--fontSizeMd); --Breadcrumb-item-default-color: var(--colors-neutral-text-5); diff --git a/packages/amis-ui/scss/components/_crud.scss b/packages/amis-ui/scss/components/_crud.scss index fceeddf2f25..d8fb7d24ad4 100644 --- a/packages/amis-ui/scss/components/_crud.scss +++ b/packages/amis-ui/scss/components/_crud.scss @@ -9,6 +9,10 @@ &-selection { margin-bottom: var(--gap-base); + display: flex; + flex-wrap: wrap; + gap: var(--gap-xs); + line-height: 1; &-overflow { &-wrapper { @@ -25,6 +29,7 @@ (var(--Picker-tag-height) + var(--Picker-tag-marginBottom)) * 5 ); + gap: var(--gap-xs); @include tag-item(Crud); } } diff --git a/packages/amis-ui/scss/components/form/_picker.scss b/packages/amis-ui/scss/components/form/_picker.scss index 44f2bc5cfc2..d1ddd720bae 100644 --- a/packages/amis-ui/scss/components/form/_picker.scss +++ b/packages/amis-ui/scss/components/form/_picker.scss @@ -71,7 +71,8 @@ font-size: var(--Pick-base-placeholder-fontSize); font-weight: var(--Pick-base-placeholder-fontWeight); user-select: none; - position: absolute; + flex: 1; + min-width: 0; // margin-top: 2 * var(--Form-input-borderWidth); line-height: var(--Form-input-lineHeight); padding: var(--Pick-base-paddingTop) var(--Pick-base-paddingRight) @@ -95,15 +96,15 @@ var(--Pick-base-left-border-color); } - .#{$ns}Picker-values { - display: inline; + // .#{$ns}Picker-values { + // display: inline; - .#{$ns}OverflowTpl { - .#{$ns}Picker-valueLabel { - pointer-events: auto; - } - } - } + // .#{$ns}OverflowTpl { + // .#{$ns}Picker-valueLabel { + // pointer-events: auto; + // } + // } + // } &-valueWrap { flex-grow: 1; @@ -117,8 +118,9 @@ } .#{$ns}Picker-valueWrap { - margin-bottom: calc(var(--gap-xs) * -1); - line-height: 1; + display: flex; + flex-wrap: wrap; + gap: var(--gap-xs); } /* tag 样式 */ @@ -176,6 +178,7 @@ (var(--Picker-tag-height) + var(--Picker-tag-marginBottom)) * 5 ); + gap: var(--gap-xs); @include tag-item(Picker); } } diff --git a/packages/amis/__tests__/renderers/Picker.test.tsx b/packages/amis/__tests__/renderers/Picker.test.tsx index ff47b73e018..389ad81ecb4 100644 --- a/packages/amis/__tests__/renderers/Picker.test.tsx +++ b/packages/amis/__tests__/renderers/Picker.test.tsx @@ -296,13 +296,15 @@ describe('5. Renderer:Picker with overflowConfig', () => { await wait(500); - const tags = container.querySelector('.cxd-Picker-values'); + const tags = container.querySelector('.cxd-Picker-valueWrap'); expect(tags).toBeInTheDocument(); /** tag 元素数量正确 */ - expect(tags?.childElementCount).toEqual(3); + expect(tags?.childElementCount).toEqual(4); // 还有个 input /** 收纳标签文案正确 */ - expect(tags?.lastElementChild).toHaveTextContent('+ 1 ...'); + expect(tags?.lastElementChild?.previousSibling).toHaveTextContent( + '+ 1 ...' + ); }); test('5-2. Renderer:Picker embeded', async () => { diff --git a/packages/amis/__tests__/renderers/__snapshots__/Picker.test.tsx.snap b/packages/amis/__tests__/renderers/__snapshots__/Picker.test.tsx.snap index 49cf54bec3e..1a7a4ed50a7 100644 --- a/packages/amis/__tests__/renderers/__snapshots__/Picker.test.tsx.snap +++ b/packages/amis/__tests__/renderers/__snapshots__/Picker.test.tsx.snap @@ -38,16 +38,6 @@ exports[`1. Renderer:Picker base 1`] = ` > picker-placeholder -
-
- -
@@ -267,22 +257,18 @@ exports[`1. Renderer:Picker base 2`] = ` class="cxd-Picker-valueWrap" >
-
- - × - - - B - -
+ × + + + B +
= [ 'selected' ]; -export default class CRUD extends React.Component { +export default class CRUD extends React.Component { static propsList: Array = [ 'bulkActions', 'itemActions', @@ -528,7 +548,7 @@ export default class CRUD extends React.Component { omitBy(onEvent, (event, key: any) => !INNER_EVENTS.includes(key)) ); - constructor(props: CRUDProps) { + constructor(props: T) { super(props); this.controlRef = this.controlRef.bind(this); @@ -811,6 +831,11 @@ export default class CRUD extends React.Component { const redirect = action.redirect && filter(action.redirect, data); redirect && action.blank && env.jumpTo(redirect, action, data); + // 如果 api 无效,或者不满足发送条件,则直接返回 + if (!isEffectiveApi(action.api, data)) { + return; + } + return store .saveRemote(action.api!, data, { successMessage: @@ -977,7 +1002,7 @@ export default class CRUD extends React.Component { handleFilterInit(values: object) { const {defaultParams, data, store, orderBy, orderDir, dispatchEvent} = this.props; - const params = {...defaultParams}; + const params: any = {...defaultParams}; if (orderBy) { params['orderBy'] = orderBy; @@ -1967,11 +1992,14 @@ export default class CRUD extends React.Component { } clearSelection() { - const {store} = this.props; - const selected = store.selectedItems.concat(); - const unSelected = store.unSelectedItems.concat(selected); + const {store, itemCheckableOn} = this.props; + const [unchecked, checked] = partition( + store.selectedItems, + item => !itemCheckableOn || evalExpression(itemCheckableOn, item) + ); + const unSelected = store.unSelectedItems.concat(unchecked); - store.setSelectedItems([]); + store.setSelectedItems(checked); store.setUnSelectedItems(unSelected); } @@ -2395,7 +2423,7 @@ export default class CRUD extends React.Component { }); } else if (Array.isArray(toolbar)) { const children: Array = toolbar - .filter((toolbar: any) => isVisible(toolbar, store.filterData)) + .filter((toolbar: any) => isVisible(toolbar, store.toolbarData)) .map((toolbar, index) => ({ dom: this.renderToolbar(toolbar, index, childProps, toolbarRenderer), toolbar @@ -2468,12 +2496,12 @@ export default class CRUD extends React.Component { if (toolbar) { if (Array.isArray(headerToolbar)) { headerToolbar = toolbarInline - ? headerToolbar.concat(toolbar) - : [headerToolbar, toolbar]; + ? headerToolbar.concat(toolbar as any) + : ([headerToolbar, toolbar] as any); } else if (headerToolbar) { - headerToolbar = [headerToolbar, toolbar]; + headerToolbar = [headerToolbar, toolbar] as any; } else { - headerToolbar = toolbar; + headerToolbar = toolbar as any; } } @@ -2493,13 +2521,15 @@ export default class CRUD extends React.Component { if (toolbar) { if (Array.isArray(footerToolbar)) { - footerToolbar = toolbarInline - ? footerToolbar.concat(toolbar) - : [footerToolbar, toolbar]; + footerToolbar = ( + toolbarInline + ? footerToolbar.concat(toolbar as any) + : [footerToolbar, toolbar] + ) as any; } else if (footerToolbar) { - footerToolbar = [footerToolbar, toolbar]; + footerToolbar = [footerToolbar, toolbar] as any; } else { - footerToolbar = toolbar; + footerToolbar = toolbar as any; } } @@ -2514,11 +2544,19 @@ export default class CRUD extends React.Component { primaryField, valueField, translate: __, - env + env, + itemCheckableOn } = this.props; + const checkable = itemCheckableOn + ? evalExpression(itemCheckableOn, item) + : true; + return ( -
+
{ testIdBuilder, id, filterCanAccessSuperData = true, + selectable = false, ...rest } = this.props; @@ -2744,7 +2783,8 @@ export default class CRUD extends React.Component { autoFillHeight: autoFillHeight, selectable: !!( (this.hasBulkActionsToolbar() && this.hasBulkActions()) || - pickerMode + pickerMode || + selectable ), itemActions, multiple: @@ -2804,15 +2844,10 @@ export default class CRUD extends React.Component { } } -@Renderer({ - type: 'crud', - storeType: CRUDStore.name, - isolateScope: true -}) -export class CRUDRenderer extends CRUD { +export class CRUDRendererBase extends CRUD { static contextType = ScopedContext; - constructor(props: CRUDProps, context: IScopedContext) { + constructor(props: T, context: IScopedContext) { super(props); const scoped = context; @@ -2908,3 +2943,10 @@ export class CRUDRenderer extends CRUD { return store.getData(data); } } + +@Renderer({ + type: 'crud', + storeType: CRUDStore.name, + isolateScope: true +}) +export class CRUDRenderer extends CRUDRendererBase {} diff --git a/packages/amis/src/renderers/Form/Picker.tsx b/packages/amis/src/renderers/Form/Picker.tsx index 5bae47343d3..02ed3994a81 100644 --- a/packages/amis/src/renderers/Form/Picker.tsx +++ b/packages/amis/src/renderers/Form/Picker.tsx @@ -157,13 +157,13 @@ export default class PickerControl extends React.PureComponent< placement: 'top', trigger: 'hover', showArrow: false, - offset: [0, -10] + offset: [0, -5] }, overflowTagPopoverInCRUD: { placement: 'bottom', trigger: 'hover', showArrow: false, - offset: [0, 10] + offset: [0, 0] } } }; @@ -641,7 +641,7 @@ export default class PickerControl extends React.PureComponent< } return ( -
+ <> {tags.map((item, index) => { if (enableOverflow && index === maxTagCount) { return ( @@ -697,7 +697,7 @@ export default class PickerControl extends React.PureComponent< return this.renderTag(item, index); })} -
+ ); } @@ -804,24 +804,24 @@ export default class PickerControl extends React.PureComponent<
{__(placeholder)}
- ) : null} - -
- {this.renderValues()} - - -
+ ) : ( +
+ {this.renderValues()} + + +
+ )} {clearable && !disabled && selectedOptions.length ? (