mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
Merge remote-tracking branch 'origin/next' into develop
This commit is contained in:
@@ -52,6 +52,11 @@
|
||||
"type": "custom-link",
|
||||
"label": "日历",
|
||||
"link": "/interface-builder/blocks/data-blocks/calendar"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "看板",
|
||||
"link": "/interface-builder/blocks/data-blocks/kanban"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
pkg: "@nocobase/plugin-kanban"
|
||||
title: "看板区块"
|
||||
description: "看板区块:按分组列展示数据记录,支持样式切换、快捷新增、弹窗配置、拖拽排序与卡片点击打开。"
|
||||
keywords: "看板区块,Kanban,数据分组,拖拽排序,快捷新增,弹窗设置,卡片布局,界面搭建,NocoBase"
|
||||
---
|
||||
|
||||
# 看板
|
||||
|
||||
## 介绍
|
||||
|
||||
看板区块按分组列展示数据记录,适合任务状态流转、销售阶段跟进、工单处理等需要按阶段查看与推进数据的场景。
|
||||
|
||||

|
||||
|
||||
## 区块配置项
|
||||
|
||||

|
||||
|
||||
### 分组设置
|
||||
|
||||
看板区块必须先指定分组字段,系统会按字段值将记录分配到不同列中。
|
||||
|
||||
- 分组字段支持单选字段、多对一字段。
|
||||
- 单选字段的列标题和列颜色会直接复用字段选项中配置的标签与颜色。
|
||||
- 多对一字段的分组选项会从关联表记录中加载。
|
||||
- 当分组字段为多对一字段时,可以额外配置:
|
||||
- 标题字段:决定列头显示哪一个关联字段值。
|
||||
- 颜色字段:决定列头和列容器背景色。
|
||||
- 通过“选择分组值”可以控制哪些列会显示,以及列的显示顺序。
|
||||
- 分组值为空的记录会显示在“未知”列中。
|
||||
|
||||
.png)
|
||||

|
||||
|
||||
### 样式
|
||||
|
||||
看板支持两种列样式:
|
||||
|
||||
- `Classic`:保留更轻量的默认列背景。
|
||||
- `Filled`:使用列颜色渲染列头和列容器背景,适合状态色更明确的场景。
|
||||
|
||||
.png)
|
||||
|
||||
### 拖拽设置
|
||||
|
||||
启用拖拽后,可以直接拖动卡片调整顺序。
|
||||
|
||||
- 启用“启用拖拽排序”后,可以进一步选择“拖拽排序字段”。
|
||||
- 拖拽排序依赖排序字段,排序字段需要与当前分组字段匹配。
|
||||
- 将卡片拖动到其他列时,会同时更新记录的分组字段值和排序位置。
|
||||
|
||||
更多内容参考 [排序字段](/data-sources/field-sort)
|
||||
|
||||

|
||||
|
||||
### 快捷新增
|
||||
|
||||
启用“快捷新增”后,每一列标题右侧都会显示一个加号按钮。
|
||||
|
||||
- 点击列头加号,会以当前列作为上下文打开新增弹窗。
|
||||
- 新增表单会自动带入当前列对应的分组值。
|
||||
- 如果当前列是“未知”列,则会把该分组字段预填为空值。
|
||||
|
||||

|
||||
|
||||
### 弹窗设置
|
||||
|
||||
区块级“弹窗设置”用于控制列头快捷新增按钮打开的弹窗行为。
|
||||
|
||||
- 可配置打开方式,例如抽屉、对话框或页面。
|
||||
- 可配置弹窗尺寸。
|
||||
- 可绑定弹窗模板或后续继续在弹窗中添加区块内容。
|
||||
|
||||
### 每列分页数量
|
||||
|
||||
用于控制每一列首次加载的记录数量。列内记录较多时,可继续滚动加载。
|
||||
|
||||
### 列宽
|
||||
|
||||
用于设置每一列的宽度,便于根据卡片内容密度调整展示效果。
|
||||
|
||||
### 设置数据范围
|
||||
|
||||
用于限制看板区块中显示的数据范围。
|
||||
|
||||
例如:只显示当前负责人创建的任务,或只显示某个项目下的记录。
|
||||
|
||||
更多内容参考 [设置数据范围](/interface-builder/blocks/block-settings/data-scope)
|
||||
|
||||
|
||||
## 配置字段
|
||||
|
||||
看板卡片内部使用详情式字段布局来展示记录摘要信息。
|
||||
|
||||
### 添加字段
|
||||
|
||||

|
||||
|
||||
字段配置项可参考 [详情字段](/interface-builder/fields/generic/detail-form-item)
|
||||
|
||||
### 卡片设置
|
||||
|
||||
卡片本身支持以下设置:
|
||||
|
||||
- 启用点击打开:开启后,点击卡片可打开当前记录。
|
||||
- 弹窗设置:可单独配置卡片点击后的打开方式、尺寸和弹窗模板。
|
||||
- 布局:可调整卡片内字段的展示布局。
|
||||
|
||||

|
||||
|
||||
.png)
|
||||
|
||||
## 配置操作
|
||||
|
||||
看板区块支持在顶部配置全局操作,具体可见的操作类型会随当前环境中已启用的动作能力而变化。
|
||||
|
||||

|
||||
|
||||
### 全局操作
|
||||
|
||||
- [新增](/interface-builder/actions/types/add-new)
|
||||
- [弹窗](/interface-builder/actions/types/pop-up)
|
||||
- [链接](/interface-builder/actions/types/link)
|
||||
- [刷新](/interface-builder/actions/types/refresh)
|
||||
- [自定义请求](/interface-builder/actions/types/custom-request)
|
||||
- [JS Action](/interface-builder/actions/types/js-action)
|
||||
- [AI 员工](/interface-builder/actions/types/ai-employee)
|
||||
@@ -52,6 +52,11 @@
|
||||
"type": "custom-link",
|
||||
"label": "Calendar",
|
||||
"link": "/interface-builder/blocks/data-blocks/calendar"
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Kanban",
|
||||
"link": "/interface-builder/blocks/data-blocks/kanban"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
pkg: "@nocobase/plugin-kanban"
|
||||
title: "Kanban Block"
|
||||
description: "Kanban block: Displays records in grouped columns, supporting style switching, quick create, popup configuration, drag-and-drop sorting, and click-to-open cards."
|
||||
keywords: "Kanban block,Kanban,data grouping,drag-and-drop sorting,quick create,popup settings,card layout,UI building,NocoBase"
|
||||
---
|
||||
|
||||
# Kanban
|
||||
|
||||
## Introduction
|
||||
|
||||
The Kanban block displays records in grouped columns. It is suitable for scenarios such as task status workflows, sales pipeline tracking, and ticket processing, where data needs to be viewed and progressed by stages.
|
||||
|
||||

|
||||
|
||||
## Block Settings
|
||||
|
||||

|
||||
|
||||
### Grouping Settings
|
||||
|
||||
The Kanban block requires a grouping field to be specified first. Records will be distributed into different columns based on the field values.
|
||||
|
||||
- The grouping field supports single select fields and many-to-one fields.
|
||||
- For single select fields, the column title and color reuse the label and color defined in the field options.
|
||||
- For many-to-one fields, grouping options are loaded from related records.
|
||||
- When the grouping field is a many-to-one field, additional configurations are available:
|
||||
- Title field: Determines which related field value is displayed in the column header.
|
||||
- Color field: Determines the background color of the column header and container.
|
||||
- “Select grouping values” allows you to control which columns are displayed and their order.
|
||||
- Records with empty grouping values will be displayed in the "Unknown" column.
|
||||
|
||||
.png)
|
||||

|
||||
|
||||
### Style
|
||||
|
||||
The Kanban block supports two column styles:
|
||||
|
||||
- `Classic`: Keeps a lighter default column background.
|
||||
- `Filled`: Uses the column color to render both the header and the column container background, suitable for scenarios with more distinct status colors.
|
||||
|
||||
.png)
|
||||
|
||||
### Drag-and-Drop Settings
|
||||
|
||||
After enabling drag-and-drop, cards can be moved directly to adjust their order.
|
||||
|
||||
- When “Enable drag-and-drop sorting” is turned on, you can further select a “sorting field”.
|
||||
- Drag-and-drop sorting relies on the sorting field, which must be compatible with the current grouping field.
|
||||
- When dragging a card to another column, both the grouping field value and the sorting position will be updated.
|
||||
|
||||
For more details, refer to [Sorting Field](/data-sources/field-sort)
|
||||
|
||||

|
||||
|
||||
### Quick Create
|
||||
|
||||
After enabling “Quick Create”, a plus button will appear on the right side of each column header.
|
||||
|
||||
- Clicking the plus button opens the create popup with the current column as context.
|
||||
- The form will automatically fill in the corresponding grouping value of the column.
|
||||
- If the current column is “Unknown”, the grouping field will be prefilled as empty.
|
||||
|
||||

|
||||
|
||||
### Popup Settings
|
||||
|
||||
The block-level “Popup Settings” control the behavior of the popup opened by the quick create button in the column header.
|
||||
|
||||
- Configure the opening method, such as drawer, modal, or page.
|
||||
- Configure the popup size.
|
||||
- Bind a popup template or continue adding block content within the popup.
|
||||
|
||||
### Items per Column
|
||||
|
||||
Controls the number of records initially loaded in each column. When there are many records, more can be loaded by scrolling.
|
||||
|
||||
### Column Width
|
||||
|
||||
Sets the width of each column, allowing adjustments based on card content density.
|
||||
|
||||
### Data Scope
|
||||
|
||||
Limits the range of data displayed in the Kanban block.
|
||||
|
||||
For example: only show tasks created by the current user, or only show records under a specific project.
|
||||
|
||||
For more details, refer to [Data Scope](/interface-builder/blocks/block-settings/data-scope)
|
||||
|
||||
## Field Configuration
|
||||
|
||||
The Kanban card uses a detail-style field layout to display summary information of each record.
|
||||
|
||||
### Add Fields
|
||||
|
||||

|
||||
|
||||
Field configuration options can refer to [Detail Field](/interface-builder/fields/generic/detail-form-item)
|
||||
|
||||
### Card Settings
|
||||
|
||||
The card itself supports the following settings:
|
||||
|
||||
- Enable click to open: When enabled, clicking a card opens the corresponding record.
|
||||
- Popup settings: Configure the opening method, size, and template for card click behavior.
|
||||
- Layout: Adjust the display layout of fields within the card.
|
||||
|
||||

|
||||
|
||||
.png)
|
||||
|
||||
## Action Configuration
|
||||
|
||||
The Kanban block supports configuring global actions at the top. The available action types depend on the enabled capabilities in the current environment.
|
||||
|
||||

|
||||
|
||||
### Global Actions
|
||||
|
||||
- [Add New](/interface-builder/actions/types/add-new)
|
||||
- [Popup](/interface-builder/actions/types/pop-up)
|
||||
- [Link](/interface-builder/actions/types/link)
|
||||
- [Refresh](/interface-builder/actions/types/refresh)
|
||||
- [Custom Request](/interface-builder/actions/types/custom-request)
|
||||
- [JS Action](/interface-builder/actions/types/js-action)
|
||||
- [AI Employee](/interface-builder/actions/types/ai-employee)
|
||||
@@ -288,6 +288,20 @@ export const useBlockAssociationContext = () => {
|
||||
return useContext(BlockAssociationContext) || association;
|
||||
};
|
||||
|
||||
export const getFilterByTkByCollection = (collection: any, recordData: Record<string, any>) => {
|
||||
const filterTargetKey = collection?.filterTargetKey || collection?.getPrimaryKey?.() || 'id';
|
||||
|
||||
if (isArray(filterTargetKey)) {
|
||||
const filterByTk = {};
|
||||
for (const key of filterTargetKey) {
|
||||
filterByTk[key] = recordData?.[key];
|
||||
}
|
||||
return filterByTk;
|
||||
}
|
||||
|
||||
return recordData?.[filterTargetKey];
|
||||
};
|
||||
|
||||
export const useFilterByTk = (blockProps?: any) => {
|
||||
const { resource, __parent } = useBlockRequestContext();
|
||||
const recordIndex = useRecordIndex();
|
||||
@@ -306,17 +320,13 @@ export const useFilterByTk = (blockProps?: any) => {
|
||||
|
||||
if (assoc) {
|
||||
const association = cm.getCollectionField(assoc);
|
||||
const filterByTk = getFilterByTkByCollection(collection, recordData);
|
||||
if (filterByTk !== undefined) {
|
||||
return filterByTk;
|
||||
}
|
||||
return recordData?.[association.targetKey || association.sourceKey || 'id'];
|
||||
}
|
||||
if (isArray(collection.filterTargetKey)) {
|
||||
const filterByTk = {};
|
||||
for (const key of collection.filterTargetKey) {
|
||||
filterByTk[key] = recordData?.[key];
|
||||
}
|
||||
return filterByTk;
|
||||
} else {
|
||||
return recordData?.[collection.filterTargetKey || 'id'];
|
||||
}
|
||||
return getFilterByTkByCollection(collection, recordData);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getFilterByTkByCollection } from '../../BlockProvider';
|
||||
|
||||
describe('getFilterByTkByCollection', () => {
|
||||
it('uses collection filterTargetKey when it differs from id', () => {
|
||||
const filterByTk = getFilterByTkByCollection(
|
||||
{
|
||||
filterTargetKey: 'uuid',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'staff-uuid-001',
|
||||
},
|
||||
);
|
||||
|
||||
expect(filterByTk).toBe('staff-uuid-001');
|
||||
});
|
||||
|
||||
it('supports array filterTargetKey', () => {
|
||||
const filterByTk = getFilterByTkByCollection(
|
||||
{
|
||||
filterTargetKey: ['tenant', 'uuid'],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
tenant: 't-1',
|
||||
uuid: 'staff-uuid-001',
|
||||
},
|
||||
);
|
||||
|
||||
expect(filterByTk).toEqual({
|
||||
tenant: 't-1',
|
||||
uuid: 'staff-uuid-001',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -311,6 +311,44 @@ describe('CollectionManager', () => {
|
||||
expect(result2).toBe('rolesName');
|
||||
});
|
||||
|
||||
it('should use target collection filterTargetKey for associations', () => {
|
||||
const app = new Application({
|
||||
dataSourceManager: {
|
||||
collections: [
|
||||
{
|
||||
name: 'staff',
|
||||
filterTargetKey: 'uuid',
|
||||
fields: [
|
||||
{ name: 'id', type: 'integer', interface: 'number', primaryKey: true },
|
||||
{ name: 'uuid', type: 'string', interface: 'input' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'orgs',
|
||||
filterTargetKey: 'id',
|
||||
fields: [
|
||||
{ name: 'id', type: 'integer', interface: 'number', primaryKey: true },
|
||||
{
|
||||
name: 'staffs',
|
||||
type: 'belongsToMany',
|
||||
interface: 'm2m',
|
||||
target: 'staff',
|
||||
targetKey: 'id',
|
||||
sourceKey: 'id',
|
||||
foreignKey: 'orgId',
|
||||
otherKey: 'staffId',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any,
|
||||
},
|
||||
});
|
||||
const cm = app.getCollectionManager();
|
||||
|
||||
const result = cm.getFilterByTK('orgs.staffs', { id: 1, uuid: 'staff-uuid-001' });
|
||||
expect(result).toBe('staff-uuid-001');
|
||||
});
|
||||
|
||||
it('should return undefined when collectionOrAssociation is not provided', () => {
|
||||
const result = collectionManager.getFilterByTK('', { id: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -192,6 +192,10 @@ export class CollectionManager {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const targetCollection = this.getCollection(field.target);
|
||||
if (targetCollection) {
|
||||
return buildFilterByTk(targetCollection.getFilterTargetKey(), collectionRecordOrAssociationRecord);
|
||||
}
|
||||
if (field.targetKey) {
|
||||
return collectionRecordOrAssociationRecord[field.targetKey];
|
||||
}
|
||||
|
||||
@@ -219,6 +219,22 @@ describe('FlowModel core behaviors (collected)', () => {
|
||||
expect(i2).toEqual({ ping: 1 });
|
||||
});
|
||||
|
||||
it('stepParams initialized before first beforeRender does not trigger an extra rerun', async () => {
|
||||
vi.useFakeTimers();
|
||||
const spy = vi.fn().mockResolvedValue([]);
|
||||
(engine as any).executor.dispatchEvent = spy;
|
||||
const model = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'm-flow-3-init' });
|
||||
|
||||
model.setStepParams('anyFlow', 'anyStep', { x: 1 });
|
||||
await model.dispatchEvent('beforeRender', { ping: 1 });
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const [, e1, i1] = spy.mock.calls[0];
|
||||
expect(e1).toBe('beforeRender');
|
||||
expect(i1).toEqual({ ping: 1 });
|
||||
});
|
||||
|
||||
it('applyFlow delegates to executor.runFlow', async () => {
|
||||
const spyRun = vi.fn().mockResolvedValue('ok');
|
||||
(engine as any).executor.runFlow = spyRun;
|
||||
|
||||
@@ -49,6 +49,47 @@ const getFieldPath = (variablePath: string, variablesStore: Record<string, Varia
|
||||
};
|
||||
};
|
||||
|
||||
const getAssociationSourceRecordKey = (associationField: CollectionFieldOptions_deprecated, collection) => {
|
||||
if (associationField?.sourceKey) {
|
||||
return associationField.sourceKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(collection?.filterTargetKey)) {
|
||||
if (collection.filterTargetKey.length === 1) {
|
||||
return collection.filterTargetKey[0];
|
||||
}
|
||||
return collection.filterTargetKey;
|
||||
}
|
||||
|
||||
return collection?.filterTargetKey || collection?.getPrimaryKey?.() || 'id';
|
||||
};
|
||||
|
||||
const getAssociationSourceId = (
|
||||
record: Record<string, any>,
|
||||
associationField: CollectionFieldOptions_deprecated,
|
||||
collection,
|
||||
) => {
|
||||
const sourceRecordKey = getAssociationSourceRecordKey(associationField, collection);
|
||||
|
||||
if (Array.isArray(sourceRecordKey)) {
|
||||
const filterByTk = sourceRecordKey.reduce((result, key) => {
|
||||
if (record?.[key] == null) {
|
||||
return result;
|
||||
}
|
||||
result[key] = record[key];
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
if (Object.keys(filterByTk).length !== sourceRecordKey.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(filterByTk));
|
||||
}
|
||||
|
||||
return record?.[sourceRecordKey];
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Note: There can only be one VariablesProvider in the entire context. It cannot be used in plugins.
|
||||
@@ -121,18 +162,13 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
|
||||
const currentVariablePath = list.slice(0, index + 1).join('.');
|
||||
const { fieldPath } = getFieldPath(currentVariablePath, _variableToCollectionName);
|
||||
const associationField: CollectionFieldOptions_deprecated = getCollectionJoinField(fieldPath, dataSource);
|
||||
const collectionPrimaryKey = getCollection(collectionName, dataSource)?.getPrimaryKey();
|
||||
const sourceCollection = getCollection(collectionName, dataSource);
|
||||
if (Array.isArray(current)) {
|
||||
const result = current.map((item) => {
|
||||
if (
|
||||
!options?.doNotRequest &&
|
||||
shouldToRequest(item?.[key], item, currentVariablePath) &&
|
||||
item?.[collectionPrimaryKey] != null
|
||||
) {
|
||||
const sourceId = getAssociationSourceId(item, associationField, sourceCollection);
|
||||
if (!options?.doNotRequest && shouldToRequest(item?.[key], item, currentVariablePath) && sourceId != null) {
|
||||
if (associationField?.target) {
|
||||
const url = `/${collectionName}/${
|
||||
item[associationField.sourceKey || collectionPrimaryKey]
|
||||
}/${key}:${getAction(associationField.type)}`;
|
||||
const url = `/${collectionName}/${sourceId}/${key}:${getAction(associationField.type)}`;
|
||||
if (hasRequested(url)) {
|
||||
return getRequested(url);
|
||||
}
|
||||
@@ -159,12 +195,11 @@ const VariablesProvider = ({ children, filterVariables }: any) => {
|
||||
} else if (
|
||||
!options?.doNotRequest &&
|
||||
shouldToRequest(current[key], current, currentVariablePath) &&
|
||||
current[collectionPrimaryKey] != null &&
|
||||
getAssociationSourceId(current, associationField, sourceCollection) != null &&
|
||||
associationField?.target
|
||||
) {
|
||||
const url = `/${collectionName}/${
|
||||
current[associationField.sourceKey || collectionPrimaryKey]
|
||||
}/${key}:${getAction(associationField.type)}`;
|
||||
const sourceId = getAssociationSourceId(current, associationField, sourceCollection);
|
||||
const url = `/${collectionName}/${sourceId}/${key}:${getAction(associationField.type)}`;
|
||||
let data = null;
|
||||
if (hasRequested(url)) {
|
||||
data = await getRequested(url);
|
||||
|
||||
@@ -71,8 +71,20 @@ vi.mock('../../collection-manager', async () => {
|
||||
target: 'test',
|
||||
};
|
||||
}
|
||||
if (path === 'staff.belongsToField') {
|
||||
return {
|
||||
type: 'belongsTo',
|
||||
target: 'test',
|
||||
};
|
||||
}
|
||||
},
|
||||
getCollection: () => {
|
||||
getCollection: (collectionName?: string) => {
|
||||
if (collectionName === 'staff') {
|
||||
return {
|
||||
filterTargetKey: ['uuid'],
|
||||
getPrimaryKey: () => 'id',
|
||||
};
|
||||
}
|
||||
return {
|
||||
getPrimaryKey: () => 'id',
|
||||
};
|
||||
@@ -187,6 +199,18 @@ mockRequest.onGet('/someBelongsToField/0/belongsToField:get').reply(() => {
|
||||
];
|
||||
});
|
||||
|
||||
mockRequest.onGet('/staff/staff-uuid-001/belongsToField:get').reply(() => {
|
||||
return [
|
||||
200,
|
||||
{
|
||||
data: {
|
||||
id: 2,
|
||||
name: '$staff.belongsToField',
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const Providers = ({ children }) => {
|
||||
const history = createMemoryHistory();
|
||||
return (
|
||||
@@ -976,6 +1000,29 @@ describe('useVariables', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses source collection filterTargetKey when lazy loading belongsTo variables', async () => {
|
||||
const { result } = renderHook(() => useVariables(), {
|
||||
wrapper: Providers,
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
result.current.registerVariable({
|
||||
name: '$staff',
|
||||
ctx: {
|
||||
id: 1,
|
||||
uuid: 'staff-uuid-001',
|
||||
},
|
||||
collectionName: 'staff',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await result.current.parseVariable('{{ $staff.belongsToField.name }}').then(({ value }) => value)).toBe(
|
||||
'$staff.belongsToField',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('getCollectionFiled with only variable name', async () => {
|
||||
const { result } = renderHook(() => useVariables(), {
|
||||
wrapper: Providers,
|
||||
|
||||
@@ -127,6 +127,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
||||
settingsMenuLevel?: number;
|
||||
extraToolbarItems?: ToolbarItemConfig[];
|
||||
fallback?: React.ReactNode;
|
||||
useCache?: boolean;
|
||||
}> = observer(
|
||||
({
|
||||
model,
|
||||
@@ -139,12 +140,12 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
||||
settingsMenuLevel,
|
||||
extraToolbarItems,
|
||||
fallback,
|
||||
useCache,
|
||||
}) => {
|
||||
// hidden 占位由模型自身处理;无需在此注入
|
||||
|
||||
const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
|
||||
throwOnError: false,
|
||||
useCache: model.context.useCache,
|
||||
useCache,
|
||||
});
|
||||
// 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
|
||||
setAutoFlowError(model, autoFlowsError || null);
|
||||
@@ -348,13 +349,15 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
||||
extraToolbarItems,
|
||||
useCache,
|
||||
}) => {
|
||||
const resolvedUseCache = typeof useCache === 'boolean' ? useCache : model?.context?.useCache;
|
||||
|
||||
useEffect(() => {
|
||||
if (model?.context) {
|
||||
if (model?.context && typeof resolvedUseCache !== 'undefined') {
|
||||
model.context.defineProperty('useCache', {
|
||||
value: typeof useCache === 'boolean' ? useCache : model.context.useCache,
|
||||
value: resolvedUseCache,
|
||||
});
|
||||
}
|
||||
}, [model?.context, useCache]);
|
||||
}, [model?.context, resolvedUseCache]);
|
||||
|
||||
if (!model || typeof model.render !== 'function') {
|
||||
// 可以选择渲染 null 或者一个错误/提示信息
|
||||
@@ -375,6 +378,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
||||
settingsMenuLevel={settingsMenuLevel}
|
||||
extraToolbarItems={extraToolbarItems}
|
||||
fallback={fallback}
|
||||
useCache={resolvedUseCache}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -38,9 +38,8 @@ describe('FlowModelRenderer', () => {
|
||||
test('should pass useCache to useApplyAutoFlows and set it on context', async () => {
|
||||
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={true} />);
|
||||
|
||||
// Check if dispatchEvent was called with useCache: true
|
||||
// useApplyAutoFlows calls dispatchEvent('beforeRender', inputArgs, { useCache })
|
||||
await waitFor(() => {
|
||||
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
||||
'beforeRender',
|
||||
undefined,
|
||||
@@ -58,6 +57,7 @@ describe('FlowModelRenderer', () => {
|
||||
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
||||
'beforeRender',
|
||||
undefined,
|
||||
@@ -74,6 +74,7 @@ describe('FlowModelRenderer', () => {
|
||||
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
||||
'beforeRender',
|
||||
undefined,
|
||||
@@ -86,4 +87,44 @@ describe('FlowModelRenderer', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('should clear stale beforeRender state after unmount when reusing the same model', async () => {
|
||||
const statefulEngine = new FlowEngine();
|
||||
|
||||
class StatefulModel extends FlowModel {
|
||||
render(): any {
|
||||
return <div>Stateful Content</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const statefulModel = new StatefulModel({
|
||||
uid: 'stateful-model',
|
||||
flowEngine: statefulEngine,
|
||||
});
|
||||
const executorSpy = vi.spyOn((statefulEngine as any).executor, 'dispatchEvent').mockResolvedValue([]);
|
||||
|
||||
const firstRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
|
||||
await waitFor(() => {
|
||||
expect(executorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
firstRender.unmount();
|
||||
|
||||
executorSpy.mockClear();
|
||||
statefulModel.setStepParams('anyFlow', 'anyStep', { x: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(executorSpy.mock.calls.length).toBe(0);
|
||||
|
||||
const secondRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
|
||||
await waitFor(() => {
|
||||
expect(executorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const [target, eventName, inputArgs, options] = executorSpy.mock.calls[0];
|
||||
expect(target).toBe(statefulModel);
|
||||
expect(eventName).toBe('beforeRender');
|
||||
expect(inputArgs).toBeUndefined();
|
||||
expect(options).toMatchObject({ useCache: true });
|
||||
|
||||
secondRender.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+15
-4
@@ -70,6 +70,15 @@ interface BaseFloatContextMenuProps {
|
||||
toolbarPosition?: ToolbarPosition;
|
||||
}
|
||||
|
||||
const getFloatMenuInstanceId = (model?: FlowModel | null) => {
|
||||
if (!model) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const forkId = (model as any)?.isFork ? (model as any)?.forkId : undefined;
|
||||
return forkId == null || forkId === '' ? String(model.uid || '') : `${String(model.uid || '')}::${String(forkId)}`;
|
||||
};
|
||||
|
||||
const hostContainerStyles = css`
|
||||
position: relative;
|
||||
|
||||
@@ -279,6 +288,7 @@ const detectButtonInDOM = (container: HTMLElement): boolean => {
|
||||
// 渲染工具栏项目,并让设置菜单与工具栏共享同一个 popup 容器。
|
||||
const renderToolbarItems = (
|
||||
model: FlowModel,
|
||||
modelInstanceId: string,
|
||||
showDeleteButton: boolean,
|
||||
showCopyUidButton: boolean,
|
||||
flowEngine: FlowEngine,
|
||||
@@ -304,7 +314,7 @@ const renderToolbarItems = (
|
||||
<ItemComponent
|
||||
key={itemConfig.key}
|
||||
model={model}
|
||||
id={model.uid}
|
||||
id={modelInstanceId}
|
||||
showDeleteButton={showDeleteButton}
|
||||
showCopyUidButton={showCopyUidButton}
|
||||
menuLevels={settingsMenuLevel}
|
||||
@@ -517,7 +527,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
||||
updatePortalRect: () => {},
|
||||
schedulePortalRectUpdate: () => {},
|
||||
});
|
||||
const modelUid = model?.uid || '';
|
||||
const modelUid = getFloatMenuInstanceId(model);
|
||||
const flowEngine = useFlowEngine();
|
||||
const updatePortalRectProxy = useCallback(() => {
|
||||
portalActionsRef.current.updatePortalRect();
|
||||
@@ -559,6 +569,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
||||
model
|
||||
? renderToolbarItems(
|
||||
model,
|
||||
modelUid,
|
||||
showDeleteButton,
|
||||
showCopyUidButton,
|
||||
flowEngine,
|
||||
@@ -629,7 +640,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
||||
ref={toolbarContainerRef}
|
||||
className={`nb-toolbar-container ${toolbarContainerClassName}`}
|
||||
style={toolbarContainerStyle}
|
||||
data-model-uid={model.uid}
|
||||
data-model-uid={modelUid}
|
||||
>
|
||||
{showTitle && (model.title || model.extraTitle) && (
|
||||
<div className="nb-toolbar-container-title">
|
||||
@@ -668,7 +679,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
||||
className={`${hostContainerStyles} ${hasButton ? 'has-button-child' : ''} ${className || ''}`}
|
||||
style={containerStyle}
|
||||
data-has-float-menu="true"
|
||||
data-float-menu-model-uid={model.uid}
|
||||
data-float-menu-model-uid={modelUid}
|
||||
onMouseMove={handleChildHover}
|
||||
onMouseEnter={handleHostMouseEnter}
|
||||
onMouseLeave={handleHostMouseLeave}
|
||||
|
||||
+62
@@ -544,4 +544,66 @@ describe('FlowsFloatContextMenu', () => {
|
||||
expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
|
||||
const engine = new FlowEngine();
|
||||
engine.flowSettings.forceEnable();
|
||||
const masterModel = new FlowModel({ uid: 'forked-model', flowEngine: engine });
|
||||
masterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
||||
masterModel.render = vi.fn(function (this: any) {
|
||||
return <div data-testid={`content-${String(this.forkId || this.uid)}`}>{String(this.forkId || this.uid)}</div>;
|
||||
});
|
||||
|
||||
const firstFork = masterModel.createFork({}, 'card-1') as FlowModel & { forkId?: string };
|
||||
const secondFork = masterModel.createFork({}, 'card-2') as FlowModel & { forkId?: string };
|
||||
const firstInstanceId = `forked-model::${String((firstFork as any).forkId)}`;
|
||||
const secondInstanceId = `forked-model::${String((secondFork as any).forkId)}`;
|
||||
const appContainer = createAppContainer();
|
||||
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
engine,
|
||||
<>
|
||||
<FlowsFloatContextMenu model={firstFork}>
|
||||
<div data-testid="fork-host-1">first</div>
|
||||
</FlowsFloatContextMenu>
|
||||
<FlowsFloatContextMenu model={secondFork}>
|
||||
<div data-testid="fork-host-2">second</div>
|
||||
</FlowsFloatContextMenu>
|
||||
</>,
|
||||
{ container: appContainer },
|
||||
);
|
||||
|
||||
const firstHost = getHost(getByTestId('fork-host-1'));
|
||||
const secondHost = getHost(getByTestId('fork-host-2'));
|
||||
mockRect(firstHost, { top: 20, left: 20, width: 180, height: 72 });
|
||||
mockRect(secondHost, { top: 120, left: 20, width: 180, height: 72 });
|
||||
|
||||
fireEvent.mouseEnter(firstHost);
|
||||
|
||||
const firstOverlay = await waitFor(() => {
|
||||
const nextOverlay = queryOverlay(appContainer, firstInstanceId);
|
||||
expect(nextOverlay).toBeTruthy();
|
||||
return nextOverlay as HTMLDivElement;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(firstOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.mouseEnter(secondHost);
|
||||
|
||||
const secondOverlay = await waitFor(() => {
|
||||
const nextOverlay = queryOverlay(appContainer, secondInstanceId);
|
||||
expect(nextOverlay).toBeTruthy();
|
||||
return nextOverlay as HTMLDivElement;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(secondOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
|
||||
expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import { IModelComponentProps, ReadonlyModelProps } from '../types';
|
||||
import { isInheritedFrom, setupRuntimeContextSteps } from '../utils';
|
||||
// import { FlowExitAllException } from '../utils/exceptions';
|
||||
import { Typography } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { observer } from '..';
|
||||
import { ModelActionRegistry } from '../action-registry/ModelActionRegistry';
|
||||
import { buildSubModelItem } from '../components/subModel/utils';
|
||||
import { ModelEventRegistry } from '../event-registry/ModelEventRegistry';
|
||||
@@ -44,8 +46,6 @@ import { FlowSettingsOpenOptions } from '../flowSettings';
|
||||
import type { ScheduleOptions } from '../scheduler/ModelOperationScheduler';
|
||||
import type { DispatchEventOptions, EventDefinition } from '../types';
|
||||
import { ForkFlowModel } from './forkFlowModel';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { observer } from '..';
|
||||
|
||||
// 使用 WeakMap 为每个类缓存一个 ModelActionRegistry 实例
|
||||
const classActionRegistries = new WeakMap<typeof FlowModel, ModelActionRegistry>();
|
||||
@@ -216,11 +216,14 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
||||
if (changed.type === 'set' && _.isEqual(changed.value, changed.oldValue)) {
|
||||
return;
|
||||
}
|
||||
const hasLastAutoRun = !!this._lastAutoRunParams;
|
||||
|
||||
if (this.flowEngine) {
|
||||
this.invalidateFlowCache('beforeRender');
|
||||
}
|
||||
this._rerunLastAutoRun();
|
||||
if (hasLastAutoRun) {
|
||||
this._rerunLastAutoRun();
|
||||
}
|
||||
this.forks.forEach((fork) => {
|
||||
fork.rerender();
|
||||
});
|
||||
@@ -866,6 +869,11 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
||||
}
|
||||
}, 100);
|
||||
|
||||
private resetAutoRunState(): void {
|
||||
this._rerunLastAutoRun?.cancel?.();
|
||||
this._lastAutoRunParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用事件分发钩子:开始
|
||||
* 子类可覆盖;beforeRender 事件可通过抛出 FlowExitException 提前终止。
|
||||
@@ -959,7 +967,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
||||
}
|
||||
|
||||
// 创建缓存的响应式包装器组件工厂(只创建一次)
|
||||
const createReactiveWrapper = (modelInstance: any) => {
|
||||
const createReactiveWrapper = (modelInstance: FlowModel) => {
|
||||
const ReactiveWrapper = observer(() => {
|
||||
// 触发响应式更新的关键属性访问(读取 run/渲染目标的 props)
|
||||
const renderTarget = modelInstance;
|
||||
@@ -985,6 +993,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
||||
model: renderTarget,
|
||||
});
|
||||
return () => {
|
||||
renderTarget.resetAutoRunState();
|
||||
if (typeof renderTarget.onUnmount === 'function') {
|
||||
renderTarget.onUnmount();
|
||||
}
|
||||
|
||||
+4
-4
@@ -155,7 +155,7 @@ CalendarCollectionActionGroupModel.defineChildren = async function defineChildre
|
||||
};
|
||||
|
||||
export class CalendarTodayActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
// static scene = 'collection' as const;
|
||||
|
||||
defaultProps = {
|
||||
type: 'default' as const,
|
||||
@@ -174,7 +174,7 @@ CalendarTodayActionModel.define({
|
||||
});
|
||||
|
||||
export class CalendarNavActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
// static scene = 'collection' as const;
|
||||
|
||||
enableEditTooltip = false;
|
||||
enableEditTitle = false;
|
||||
@@ -194,7 +194,7 @@ CalendarNavActionModel.define({
|
||||
});
|
||||
|
||||
export class CalendarTitleActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
// static scene = 'collection' as const;
|
||||
|
||||
enableEditTooltip = false;
|
||||
enableEditTitle = false;
|
||||
@@ -214,7 +214,7 @@ CalendarTitleActionModel.define({
|
||||
});
|
||||
|
||||
export class CalendarViewSelectActionModel extends ActionModel {
|
||||
static scene = 'collection' as const;
|
||||
// static scene = 'collection' as const;
|
||||
|
||||
enableEditTooltip = false;
|
||||
enableEditTitle = false;
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ describe('ChartResource', () => {
|
||||
collectionPath: ['main', 'orders'],
|
||||
measures: [{ field: ['price'], aggregation: 'sum', alias: 'Revenue' }],
|
||||
dimensions: [{ field: ['createdAt'], format: 'YYYY-MM', alias: 'Month' }],
|
||||
orders: [{ field: 'Revenue', order: 'DESC' }, { field: ['createdAt'], order: 'ASC' }],
|
||||
orders: [
|
||||
{ field: 'Revenue', order: 'DESC' },
|
||||
{ field: ['createdAt'], order: 'ASC' },
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
orders: [
|
||||
|
||||
+4
-1
@@ -51,7 +51,10 @@ describe('query builder service', () => {
|
||||
collectionPath: ['main', 'orders'],
|
||||
measures: [{ field: ['price'], aggregation: 'sum', alias: 'Revenue' }],
|
||||
dimensions: [{ field: ['createdAt'], format: 'YYYY-MM', alias: 'Month' }],
|
||||
orders: [{ field: 'Revenue', order: 'DESC' }, { field: 'Month', order: 'ASC' }],
|
||||
orders: [
|
||||
{ field: 'Revenue', order: 'DESC' },
|
||||
{ field: 'Month', order: 'ASC' },
|
||||
],
|
||||
}),
|
||||
).toEqual({ success: true, message: '' });
|
||||
});
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"peerDependencies": {
|
||||
"@nocobase/client": "2.x",
|
||||
"@nocobase/server": "2.x",
|
||||
"@nocobase/test": "2.x"
|
||||
"@nocobase/test": "2.x",
|
||||
"@nocobase/plugin-ai": "2.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-intersection-observer": "^9.8.1"
|
||||
@@ -23,4 +24,4 @@
|
||||
"keywords": [
|
||||
"Blocks"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { uid } from '@formily/shared';
|
||||
import { NAMESPACE } from '../locale';
|
||||
|
||||
export const CreateAndSelectSort = (props) => {
|
||||
export const CreateAndSelectSort = (props: any) => {
|
||||
const { sortFields, collectionFields, groupField, collectionName, dataSource, ...others } = props;
|
||||
const field: any = useField();
|
||||
const compile = useCompile();
|
||||
@@ -33,11 +33,11 @@ export const CreateAndSelectSort = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useGlobalTheme();
|
||||
const options = useContext(SchemaOptionsContext);
|
||||
const integerfields = collectionFields?.filter((v) => {
|
||||
const integerfields = collectionFields?.filter((v: any) => {
|
||||
return v.interface === 'integer';
|
||||
});
|
||||
const ScopekeyDescription = ({ scopeKey }) => {
|
||||
const field = scopeKey && collectionFields.find((v) => v.name === scopeKey);
|
||||
const ScopekeyDescription = ({ scopeKey }: { scopeKey?: string }) => {
|
||||
const field = scopeKey && collectionFields.find((v: any) => v.name === scopeKey);
|
||||
const result = scopeKey && compile(field?.['uiSchema']?.['title']);
|
||||
return (
|
||||
<span style={{ color: 'rgba(0, 0, 0, 0.25)' }}>
|
||||
@@ -53,7 +53,7 @@ export const CreateAndSelectSort = (props) => {
|
||||
//仅支持主数据源
|
||||
const handleCreateSortField = async () => {
|
||||
const values = await FormDialog(
|
||||
t('Create sort field'),
|
||||
t('Create sort field', { ns: NAMESPACE }),
|
||||
() => {
|
||||
return (
|
||||
<SchemaComponentOptions scope={options.scope} components={{ ...options.components }}>
|
||||
@@ -113,7 +113,7 @@ export const CreateAndSelectSort = (props) => {
|
||||
//第三方数据源
|
||||
const handleUpdateSortField = async () => {
|
||||
const values = await FormDialog(
|
||||
t('Create sort field'),
|
||||
t('Create sort field', { ns: NAMESPACE }),
|
||||
() => {
|
||||
return (
|
||||
<SchemaComponentOptions scope={options.scope} components={{ ...options.components }}>
|
||||
@@ -127,7 +127,7 @@ export const CreateAndSelectSort = (props) => {
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: integerfields.map((v) => {
|
||||
enum: integerfields.map((v: any) => {
|
||||
return {
|
||||
value: v.name,
|
||||
label: compile(v['uiSchema']?.['title'] || v.name),
|
||||
|
||||
+1323
File diff suppressed because it is too large
Load Diff
+280
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { KanbanCardItemModel } from '../models';
|
||||
import { KanbanCardViewActionModel } from '../models/actions/KanbanPopupModels';
|
||||
import { FlowEngine } from '@nocobase/flow-engine';
|
||||
|
||||
describe('KanbanCardItemModel.cardSettings', () => {
|
||||
test('registers click, popup and layout settings on the card model', async () => {
|
||||
const flow: any = (KanbanCardItemModel as any).globalFlowRegistry.getFlow('cardSettings');
|
||||
|
||||
expect(Object.keys(flow.steps)).toEqual(['click', 'popup', 'layout']);
|
||||
expect(flow.steps.popup.use).toBe('openView');
|
||||
expect(await flow.steps.popup.defaultParams({ model: { props: {} } } as any)).toMatchObject({
|
||||
mode: 'drawer',
|
||||
size: 'medium',
|
||||
});
|
||||
expect((await flow.steps.popup.defaultParams({ model: { uid: 'card-1', props: {} } } as any)).uid).toBeUndefined();
|
||||
expect(flow.steps.click.defaultParams({ model: { props: {} } } as any)).toEqual({ enableCardClick: true });
|
||||
});
|
||||
|
||||
test('card popup action uses the shared Details title', () => {
|
||||
const action = new KanbanCardViewActionModel({
|
||||
uid: 'kanban-card-view-action',
|
||||
flowEngine: new FlowEngine(),
|
||||
} as any);
|
||||
|
||||
expect(action.defaultPopupTitle).toContain('Details');
|
||||
});
|
||||
|
||||
test('popup settings persist from card forks back to the base card model', async () => {
|
||||
const flow: any = (KanbanCardItemModel as any).globalFlowRegistry.getFlow('cardSettings');
|
||||
const syncCardViewAction = vi.fn();
|
||||
const ensureCardViewAction = vi.fn().mockResolvedValue({ uid: 'card-view-action' });
|
||||
const masterBlock = {
|
||||
subModels: {
|
||||
cardViewAction: { uid: 'card-view-action' },
|
||||
},
|
||||
ensureCardViewAction,
|
||||
syncCardViewAction,
|
||||
};
|
||||
const forkBlock = {
|
||||
isFork: true,
|
||||
master: masterBlock,
|
||||
subModels: {
|
||||
cardViewAction: { uid: 'fork-card-view-action' },
|
||||
},
|
||||
};
|
||||
const masterItem = {
|
||||
props: {},
|
||||
parent: masterBlock,
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
const forkItem = {
|
||||
isFork: true,
|
||||
master: masterItem,
|
||||
parent: forkBlock,
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
|
||||
await flow.steps.popup.handler({ model: forkItem } as any, {
|
||||
mode: 'dialog',
|
||||
size: 'large',
|
||||
popupTemplateUid: 'tpl-card',
|
||||
uid: 'popup-card-1',
|
||||
});
|
||||
|
||||
expect(forkItem.setProps).toHaveBeenCalledWith({
|
||||
openMode: 'dialog',
|
||||
popupSize: 'large',
|
||||
popupTemplateUid: 'tpl-card',
|
||||
popupTargetUid: 'popup-card-1',
|
||||
});
|
||||
expect(masterItem.setProps).toHaveBeenCalledWith({
|
||||
openMode: 'dialog',
|
||||
popupSize: 'large',
|
||||
popupTemplateUid: 'tpl-card',
|
||||
popupTargetUid: 'popup-card-1',
|
||||
});
|
||||
expect(ensureCardViewAction).toHaveBeenCalledTimes(1);
|
||||
expect(syncCardViewAction).toHaveBeenCalledWith(masterBlock.subModels.cardViewAction);
|
||||
});
|
||||
|
||||
test('popup default params keep an explicitly cleared item template instead of falling back to parent template', async () => {
|
||||
const flow: any = (KanbanCardItemModel as any).globalFlowRegistry.getFlow('cardSettings');
|
||||
|
||||
await expect(
|
||||
flow.steps.popup.defaultParams({
|
||||
model: {
|
||||
props: { popupTemplateUid: undefined },
|
||||
parent: { props: { cardPopupTemplateUid: 'legacy-template' } },
|
||||
},
|
||||
} as any),
|
||||
).resolves.toMatchObject({
|
||||
popupTemplateUid: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('popup handler clears a stale card popup target uid when removing the popup template', async () => {
|
||||
const flow: any = (KanbanCardItemModel as any).globalFlowRegistry.getFlow('cardSettings');
|
||||
const syncCardViewAction = vi.fn();
|
||||
const ensureCardViewAction = vi.fn().mockResolvedValue({ uid: 'card-view-action' });
|
||||
const masterBlock = {
|
||||
subModels: {
|
||||
cardViewAction: { uid: 'card-view-action' },
|
||||
},
|
||||
ensureCardViewAction,
|
||||
syncCardViewAction,
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
const masterItem = {
|
||||
props: {
|
||||
popupTemplateUid: 'tpl-card',
|
||||
popupTargetUid: 'popup-card-1',
|
||||
},
|
||||
parent: masterBlock,
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
|
||||
await flow.steps.popup.handler(
|
||||
{
|
||||
model: masterItem,
|
||||
} as any,
|
||||
{
|
||||
mode: 'dialog',
|
||||
size: 'large',
|
||||
uid: 'popup-card-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(masterItem.setProps).toHaveBeenCalledWith({
|
||||
openMode: 'dialog',
|
||||
popupSize: 'large',
|
||||
popupTemplateUid: undefined,
|
||||
pageModelClass: undefined,
|
||||
popupTargetUid: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('popup beforeParamsSave clears stale card popup target uid on the actual settings save path', async () => {
|
||||
const flow: any = (KanbanCardItemModel as any).globalFlowRegistry.getFlow('cardSettings');
|
||||
const syncCardViewAction = vi.fn();
|
||||
const ensureCardViewAction = vi.fn().mockResolvedValue({ uid: 'card-view-action' });
|
||||
const masterBlock = {
|
||||
subModels: {
|
||||
cardViewAction: { uid: 'card-view-action' },
|
||||
},
|
||||
ensureCardViewAction,
|
||||
syncCardViewAction,
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
const masterItem: any = {
|
||||
props: {
|
||||
popupTemplateUid: 'tpl-card',
|
||||
popupTargetUid: 'popup-card-1',
|
||||
},
|
||||
stepParams: {
|
||||
cardSettings: {
|
||||
popup: {
|
||||
popupTemplateUid: 'tpl-card',
|
||||
uid: 'popup-card-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
emitter: { emit: vi.fn() },
|
||||
parent: masterBlock,
|
||||
setProps: vi.fn(function (this: any, nextProps) {
|
||||
Object.assign(this.props, nextProps);
|
||||
}),
|
||||
getAction: () => ({ beforeParamsSave: vi.fn().mockResolvedValue(undefined) }),
|
||||
};
|
||||
|
||||
await flow.steps.popup.beforeParamsSave(
|
||||
{
|
||||
model: masterItem,
|
||||
} as any,
|
||||
{
|
||||
mode: 'dialog',
|
||||
size: 'large',
|
||||
uid: 'popup-card-1',
|
||||
},
|
||||
{
|
||||
popupTemplateUid: 'tpl-card',
|
||||
uid: 'popup-card-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(masterItem.props.popupTargetUid).toBeUndefined();
|
||||
expect(masterItem.stepParams.cardSettings.popup.uid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('layout changes persist from card forks and propagate to details items', () => {
|
||||
const flow: any = (KanbanCardItemModel as any).globalFlowRegistry.getFlow('cardSettings');
|
||||
const detailsItem = {
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
const grid = {
|
||||
findSubModel: vi.fn((_key, callback) => callback(detailsItem)),
|
||||
};
|
||||
const masterItem = {
|
||||
subModels: { grid },
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
const forkItem = {
|
||||
isFork: true,
|
||||
master: masterItem,
|
||||
subModels: { grid },
|
||||
setProps: vi.fn(),
|
||||
};
|
||||
|
||||
flow.steps.layout.handler({ model: forkItem } as any, {
|
||||
layout: 'vertical',
|
||||
labelAlign: 'left',
|
||||
labelWidth: 120,
|
||||
labelWrap: false,
|
||||
colon: false,
|
||||
});
|
||||
|
||||
expect(forkItem.setProps).toHaveBeenCalledWith({
|
||||
layout: 'vertical',
|
||||
labelAlign: 'left',
|
||||
labelWidth: null,
|
||||
labelWrap: false,
|
||||
colon: false,
|
||||
});
|
||||
expect(masterItem.setProps).toHaveBeenCalledWith({
|
||||
layout: 'vertical',
|
||||
labelAlign: 'left',
|
||||
labelWidth: null,
|
||||
labelWrap: false,
|
||||
colon: false,
|
||||
});
|
||||
expect(detailsItem.setProps).toHaveBeenCalledWith({
|
||||
layout: 'vertical',
|
||||
labelAlign: 'left',
|
||||
labelWidth: '100%',
|
||||
labelWrap: true,
|
||||
colon: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('scopes the details grid fork to the card instance to avoid column data leakage', () => {
|
||||
const defineProperty = vi.fn();
|
||||
const createFork = vi.fn(() => ({
|
||||
context: {
|
||||
collection: undefined,
|
||||
t: () => 'Current record',
|
||||
defineProperty,
|
||||
},
|
||||
}));
|
||||
|
||||
KanbanCardItemModel.prototype.render.call({
|
||||
uid: 'kanban-card-column-a-1',
|
||||
forkId: 'fork-card-a-1',
|
||||
context: {
|
||||
index: 0,
|
||||
record: { id: 1, title: 'Task 1' },
|
||||
onCardClick: undefined,
|
||||
},
|
||||
props: {},
|
||||
subModels: {
|
||||
grid: {
|
||||
createFork,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createFork).toHaveBeenCalledWith({}, 'grid-fork-card-a-1');
|
||||
const fieldKeyRegistration = defineProperty.mock.calls.find(([key]) => key === 'fieldKey');
|
||||
expect(fieldKeyRegistration?.[1]?.get()).toBe('fork-card-a-1');
|
||||
const fieldIndexRegistration = defineProperty.mock.calls.find(([key]) => key === 'fieldIndex');
|
||||
expect(fieldIndexRegistration?.[1]?.get()).toEqual(['fork-card-a-1:0']);
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { FlowEngine } from '@nocobase/flow-engine';
|
||||
import { KanbanCollectionActionGroupModel } from '../models/KanbanCollectionActionGroupModel';
|
||||
|
||||
describe('KanbanCollectionActionGroupModel', () => {
|
||||
test('uses imported AIEmployeeActionModel fallback when the model is not registered in the flow engine', async () => {
|
||||
const engine = new FlowEngine();
|
||||
engine.registerModels({ KanbanCollectionActionGroupModel });
|
||||
|
||||
const ds = engine.dataSourceManager.getDataSource('main');
|
||||
if (!ds) {
|
||||
throw new Error('main data source not found');
|
||||
}
|
||||
ds.addCollection({
|
||||
name: 'posts',
|
||||
filterTargetKey: 'id',
|
||||
availableActions: ['create'],
|
||||
fields: [{ name: 'id', type: 'integer', interface: 'number' }],
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
engine,
|
||||
dataSourceManager: engine.dataSourceManager,
|
||||
model: { uid: 'kanban-1' },
|
||||
aiConfigRepository: {
|
||||
getAIEmployees: vi.fn().mockResolvedValue([{ username: 'alice', nickname: 'Alice' }]),
|
||||
},
|
||||
collection: ds.getCollection('posts'),
|
||||
} as any;
|
||||
|
||||
const items = await KanbanCollectionActionGroupModel.defineChildren(ctx);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
key: 'AIEmployeeActionModel',
|
||||
useModel: 'AIEmployeeActionModel',
|
||||
sort: 8000,
|
||||
});
|
||||
expect(typeof items[0].children).toBe('function');
|
||||
});
|
||||
});
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { upsertKanbanCollectionFieldOptions } from '../models/components/KanbanCreateSortFieldSelect';
|
||||
|
||||
describe('KanbanCreateSortFieldSelect', () => {
|
||||
test('upserts newly created sort fields into collection metadata', () => {
|
||||
const nextFields = upsertKanbanCollectionFieldOptions(
|
||||
[
|
||||
{ name: 'title', interface: 'input' },
|
||||
{ name: 'status', interface: 'select' },
|
||||
],
|
||||
{ name: 'status_sort', interface: 'sort', scopeKey: 'status' },
|
||||
);
|
||||
|
||||
expect(nextFields).toEqual([
|
||||
{ name: 'title', interface: 'input' },
|
||||
{ name: 'status', interface: 'select' },
|
||||
{ name: 'status_sort', interface: 'sort', scopeKey: 'status' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('updates existing field metadata when an integer field is converted to sort', () => {
|
||||
const nextFields = upsertKanbanCollectionFieldOptions(
|
||||
[
|
||||
{ name: 'priority', interface: 'integer', type: 'integer' },
|
||||
{ name: 'status', interface: 'select' },
|
||||
],
|
||||
{ name: 'priority', interface: 'sort', type: 'sort', scopeKey: 'status' },
|
||||
);
|
||||
|
||||
expect(nextFields).toEqual([
|
||||
{ name: 'priority', interface: 'sort', type: 'sort', scopeKey: 'status' },
|
||||
{ name: 'status', interface: 'select' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { KanbanGroupingSelector } from '../models/components/KanbanGroupingSelector';
|
||||
|
||||
const useFormMock = vi.fn(() => undefined);
|
||||
|
||||
vi.mock('@formily/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@formily/react')>();
|
||||
return {
|
||||
...actual,
|
||||
observer: (component: any) => component,
|
||||
useForm: () => useFormMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/client', () => ({
|
||||
useCollectionManager_deprecated: () => ({
|
||||
getCollection: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('KanbanGroupingSelector', () => {
|
||||
it('renders an empty state before a grouping field is selected', () => {
|
||||
render(<KanbanGroupingSelector value={[]} collection={{ getField: () => undefined }} />);
|
||||
|
||||
expect(screen.getByText('Select a grouping field first')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not emit an initial grouping field when nothing is selected yet', async () => {
|
||||
const ownerField = {
|
||||
name: 'owner',
|
||||
interface: 'm2o',
|
||||
};
|
||||
const collection = {
|
||||
getFields: () => [ownerField],
|
||||
getField: (name: string) => (name === 'owner' ? ownerField : undefined),
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
|
||||
const Wrapper = () => {
|
||||
const [value, setValue] = React.useState<any>();
|
||||
return (
|
||||
<KanbanGroupingSelector
|
||||
collection={collection}
|
||||
value={value}
|
||||
onChange={(nextValue) => {
|
||||
onChange(nextValue);
|
||||
setValue(nextValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders selected group values', () => {
|
||||
const statusField = {
|
||||
name: 'status',
|
||||
interface: 'select',
|
||||
};
|
||||
const collection = {
|
||||
getFields: () => [statusField],
|
||||
getField: (name: string) => (name === 'status' ? statusField : undefined),
|
||||
};
|
||||
|
||||
render(
|
||||
<KanbanGroupingSelector
|
||||
collection={collection}
|
||||
value={{
|
||||
groupField: 'status',
|
||||
groupOptions: [{ value: 'todo', label: 'Todo', color: 'red', enabled: true }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Todo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
getKanbanDesignSettingsHost,
|
||||
isKanbanDesignSettingsHost,
|
||||
shouldHideUnknownColumn,
|
||||
shouldMountUnknownColumn,
|
||||
} from '../models/components/kanban-block/shared';
|
||||
|
||||
describe('kanban design settings host', () => {
|
||||
test('picks the first card from the first visible non-empty column in design mode', () => {
|
||||
const host = getKanbanDesignSettingsHost({
|
||||
enabled: true,
|
||||
columns: [
|
||||
{ key: 'todo', value: 'todo', label: 'Todo', color: 'blue' },
|
||||
{ key: 'done', value: 'done', label: 'Done', color: 'green' },
|
||||
],
|
||||
itemsByColumn: {
|
||||
todo: [{ __kanbanRecordKey: 'task-1' }, { __kanbanRecordKey: 'task-2' }],
|
||||
done: [{ __kanbanRecordKey: 'task-3' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(host).toEqual({
|
||||
columnKey: 'todo',
|
||||
recordKey: 'task-1',
|
||||
index: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns no host outside design mode', () => {
|
||||
const host = getKanbanDesignSettingsHost({
|
||||
enabled: false,
|
||||
columns: [{ key: 'todo', value: 'todo', label: 'Todo', color: 'blue' }],
|
||||
itemsByColumn: {
|
||||
todo: [{ __kanbanRecordKey: 'task-1' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(host).toBeNull();
|
||||
});
|
||||
|
||||
test('matches by record key and falls back to index when a runtime key is absent', () => {
|
||||
expect(
|
||||
isKanbanDesignSettingsHost({
|
||||
host: { columnKey: 'todo', recordKey: 'task-1', index: 0 },
|
||||
columnKey: 'todo',
|
||||
record: { __kanbanRecordKey: 'task-1' },
|
||||
index: 3,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isKanbanDesignSettingsHost({
|
||||
host: { columnKey: 'todo', index: 0 },
|
||||
columnKey: 'todo',
|
||||
record: { title: 'Untitled task' },
|
||||
index: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isKanbanDesignSettingsHost({
|
||||
host: { columnKey: 'todo', recordKey: 'task-1', index: 0 },
|
||||
columnKey: 'done',
|
||||
record: { __kanbanRecordKey: 'task-1' },
|
||||
index: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps unknown column mounted but hidden while an empty refresh is still in flight', () => {
|
||||
const options = {
|
||||
state: {
|
||||
items: [],
|
||||
page: 1,
|
||||
hasNext: false,
|
||||
loading: false,
|
||||
loadedRefreshToken: 1,
|
||||
},
|
||||
refreshMeta: {
|
||||
token: 2,
|
||||
reason: 'global' as const,
|
||||
},
|
||||
displayItems: [],
|
||||
};
|
||||
|
||||
expect(shouldMountUnknownColumn(options)).toBe(true);
|
||||
expect(shouldHideUnknownColumn(options)).toBe(true);
|
||||
});
|
||||
|
||||
test('shows unknown column when it already has display items during refresh', () => {
|
||||
const options = {
|
||||
state: {
|
||||
items: [{ __kanbanRecordKey: 'task-1' }],
|
||||
page: 1,
|
||||
hasNext: false,
|
||||
loading: true,
|
||||
loadedRefreshToken: 1,
|
||||
},
|
||||
refreshMeta: {
|
||||
token: 2,
|
||||
reason: 'drag' as const,
|
||||
},
|
||||
displayItems: [{ __kanbanRecordKey: 'task-1' }],
|
||||
};
|
||||
|
||||
expect(shouldMountUnknownColumn(options)).toBe(true);
|
||||
expect(shouldHideUnknownColumn(options)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildKanbanBoardDisplayItems,
|
||||
areKanbanGroupOptionsEqual,
|
||||
buildKanbanColumns,
|
||||
createKanbanColumnFilter,
|
||||
getKanbanRecordKey,
|
||||
getKanbanCollectionFilterTargetKey,
|
||||
getKanbanCrossColumnMoveParams,
|
||||
getKanbanDefaultSortFieldName,
|
||||
getKanbanGroupFieldCandidates,
|
||||
getKanbanGroupFieldSortScopeKeys,
|
||||
getKanbanPreferredSortScopeKey,
|
||||
getKanbanPreviewInsertIndex,
|
||||
getRecordKanbanColumnKey,
|
||||
getRecordGroupValues,
|
||||
getKanbanMoveParamsFromPreviewOrder,
|
||||
getKanbanSameColumnColumnDropMoveParams,
|
||||
isSameKanbanGroupingValue,
|
||||
mergeKanbanFilters,
|
||||
normalizeKanbanGroupOptions,
|
||||
orderKanbanGroupOptionsBySelection,
|
||||
reorderKanbanGroupOptions,
|
||||
shouldMigrateKanbanSortFieldScope,
|
||||
shouldInsertAfterSameColumnCardMove,
|
||||
} from '../models/utils';
|
||||
|
||||
describe('kanban model utils', () => {
|
||||
test('normalizeKanbanGroupOptions merges saved options with source options', () => {
|
||||
const options = normalizeKanbanGroupOptions(
|
||||
[
|
||||
{ value: 'todo', label: 'Todo' },
|
||||
{ value: 'done', label: 'Done', color: 'green' },
|
||||
],
|
||||
[{ value: 'todo', label: 'Backlog', color: 'blue' }],
|
||||
);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ value: 'todo', label: 'Backlog', color: 'blue', isUnknown: undefined },
|
||||
{ value: 'done', label: 'Done', color: 'green', isUnknown: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizeKanbanGroupOptions preserves saved order and appends new source options', () => {
|
||||
const options = normalizeKanbanGroupOptions(
|
||||
[
|
||||
{ value: 'todo', label: 'Todo', color: 'blue' },
|
||||
{ value: 'doing', label: 'Doing', color: 'orange' },
|
||||
{ value: 'done', label: 'Done', color: 'green' },
|
||||
],
|
||||
[
|
||||
{ value: 'done', label: 'Done', color: 'green', enabled: true },
|
||||
{ value: 'todo', label: 'Todo', color: 'blue', enabled: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(options.map((item) => item.value)).toEqual(['done', 'todo', 'doing']);
|
||||
});
|
||||
|
||||
test('normalizeKanbanGroupOptions keeps colors empty when source enum colors are missing', () => {
|
||||
const options = normalizeKanbanGroupOptions([
|
||||
{ value: 'todo', label: 'Todo', color: 'default' },
|
||||
{ value: 'doing', label: 'Doing', color: 'default' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
]);
|
||||
|
||||
expect(options.map((item) => item.color)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
test('normalizeKanbanGroupOptions falls back to enum title when label is absent', () => {
|
||||
const options = normalizeKanbanGroupOptions([
|
||||
{ value: 'todo', title: 'To do' },
|
||||
{ value: 'done', name: 'Done name' },
|
||||
] as any);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ value: 'todo', label: 'To do', color: undefined, isUnknown: undefined },
|
||||
{ value: 'done', label: 'Done name', color: undefined, isUnknown: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizeKanbanGroupOptions can skip default and saved colors for relation options', () => {
|
||||
const options = normalizeKanbanGroupOptions(
|
||||
[{ value: 'u1', label: 'User 1' }],
|
||||
[{ value: 'u1', label: 'User 1', color: 'blue' }],
|
||||
{
|
||||
useDefaultColors: false,
|
||||
preserveSavedColors: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(options).toEqual([{ value: 'u1', label: 'User 1', color: undefined, isUnknown: undefined }]);
|
||||
});
|
||||
|
||||
test('orderKanbanGroupOptionsBySelection keeps enabled values in selection order', () => {
|
||||
expect(
|
||||
orderKanbanGroupOptionsBySelection(
|
||||
[
|
||||
{ value: 'todo', label: 'Todo', enabled: true },
|
||||
{ value: 'doing', label: 'Doing', enabled: false },
|
||||
{ value: 'done', label: 'Done', enabled: true },
|
||||
],
|
||||
['done', 'todo'],
|
||||
),
|
||||
).toEqual([
|
||||
{ value: 'done', label: 'Done', enabled: true },
|
||||
{ value: 'todo', label: 'Todo', enabled: true },
|
||||
{ value: 'doing', label: 'Doing', enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getKanbanGroupFieldCandidates supports plain collection objects', () => {
|
||||
expect(
|
||||
getKanbanGroupFieldCandidates({
|
||||
fields: [
|
||||
{ name: 'status', interface: 'select', uiSchema: { title: 'Status' } },
|
||||
{ name: 'priority', interface: 'radioGroup', uiSchema: { title: 'Priority' } },
|
||||
{ name: 'owner', interface: 'm2o', title: 'Owner' },
|
||||
{ name: 'watchers', interface: 'm2m', title: 'Watchers' },
|
||||
{ name: 'title', interface: 'input' },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ label: 'Status', value: 'status' },
|
||||
{ label: 'Owner', value: 'owner' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getKanbanDefaultSortFieldName uses the grouping field name', () => {
|
||||
expect(getKanbanDefaultSortFieldName('status')).toBe('status_sort');
|
||||
expect(getKanbanDefaultSortFieldName()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('association grouping fields prefer the raw foreign key as sort scope', () => {
|
||||
const groupField = { name: 'owner', interface: 'm2o', foreignKey: 'owner_id' };
|
||||
|
||||
expect(getKanbanGroupFieldSortScopeKeys(groupField)).toEqual(['owner_id', 'owner']);
|
||||
expect(getKanbanPreferredSortScopeKey(groupField)).toBe('owner_id');
|
||||
});
|
||||
|
||||
test('legacy association sort fields are marked for scope migration', () => {
|
||||
const groupField = { name: 'owner', interface: 'm2o', foreignKey: 'owner_id' };
|
||||
|
||||
expect(
|
||||
shouldMigrateKanbanSortFieldScope({
|
||||
groupField,
|
||||
sortField: { name: 'owner_sort', interface: 'sort', scopeKey: 'owner' },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldMigrateKanbanSortFieldScope({
|
||||
groupField,
|
||||
sortField: { name: 'owner_sort', interface: 'sort', scopeKey: 'owner_id' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('getKanbanCollectionFilterTargetKey supports plain collection metadata', () => {
|
||||
expect(getKanbanCollectionFilterTargetKey({ filterTargetKey: ['slug'] })).toBe('slug');
|
||||
expect(
|
||||
getKanbanCollectionFilterTargetKey({
|
||||
fields: [{ name: 'pk', primaryKey: true }],
|
||||
}),
|
||||
).toBe('pk');
|
||||
});
|
||||
|
||||
test('getRecordGroupValues resolves scalar and association values', () => {
|
||||
expect(getRecordGroupValues({ status: 'todo' }, { name: 'status', interface: 'select' })).toEqual(['todo']);
|
||||
|
||||
expect(
|
||||
getRecordGroupValues(
|
||||
{
|
||||
users: [{ id: 1 }, { id: 2 }],
|
||||
},
|
||||
{ name: 'users', interface: 'm2m' },
|
||||
),
|
||||
).toEqual(['1', '2']);
|
||||
|
||||
expect(
|
||||
getRecordGroupValues(
|
||||
{
|
||||
owner: { rows: [{ id: 3 }] },
|
||||
},
|
||||
{ name: 'owner', interface: 'm2o' },
|
||||
),
|
||||
).toEqual(['3']);
|
||||
|
||||
expect(
|
||||
getRecordGroupValues(
|
||||
{
|
||||
owner: { slug: 'u-1', nickname: 'User 1' },
|
||||
},
|
||||
{
|
||||
name: 'owner',
|
||||
interface: 'm2o',
|
||||
targetKey: 'slug',
|
||||
targetCollection: { filterTargetKey: 'slug' },
|
||||
},
|
||||
),
|
||||
).toEqual(['u-1']);
|
||||
});
|
||||
|
||||
test('buildKanbanColumns preserves configured order and routes unmatched records to Unknown', () => {
|
||||
const columns = buildKanbanColumns({
|
||||
records: [
|
||||
{ id: 1, status: 'done' },
|
||||
{ id: 2, status: 'todo' },
|
||||
{ id: 3, status: 'other' },
|
||||
],
|
||||
primaryKey: 'id',
|
||||
groupField: { name: 'status', interface: 'select' },
|
||||
groupOptions: [
|
||||
{ value: 'todo', label: 'Todo', color: 'blue', enabled: true },
|
||||
{ value: 'done', label: 'Done', color: 'green', enabled: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(columns.map((column) => column.key)).toEqual(['__unknown__', 'todo', 'done']);
|
||||
expect(columns[0].records.map((record) => record.id)).toEqual([3]);
|
||||
expect(columns[1].records.map((record) => record.id)).toEqual([2]);
|
||||
expect(columns[2].records.map((record) => record.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
test('getRecordKanbanColumnKey resolves one canonical column for multi-value records', () => {
|
||||
expect(
|
||||
getRecordKanbanColumnKey({
|
||||
record: {
|
||||
watchers: [{ id: 2 }, { id: 1 }],
|
||||
},
|
||||
groupField: { name: 'watchers', interface: 'm2m' },
|
||||
groupOptions: [
|
||||
{ value: '1', enabled: true },
|
||||
{ value: '2', enabled: true },
|
||||
],
|
||||
}),
|
||||
).toBe('1');
|
||||
|
||||
expect(
|
||||
getRecordKanbanColumnKey({
|
||||
record: { status: null },
|
||||
groupField: { name: 'status', interface: 'select' },
|
||||
groupOptions: [{ value: 'todo', enabled: true }],
|
||||
}),
|
||||
).toBe('__unknown__');
|
||||
|
||||
expect(
|
||||
getRecordKanbanColumnKey({
|
||||
record: {
|
||||
owner: { slug: 'u-1', nickname: 'User 1' },
|
||||
},
|
||||
groupField: {
|
||||
name: 'owner',
|
||||
interface: 'm2o',
|
||||
targetKey: 'slug',
|
||||
targetCollection: { filterTargetKey: 'slug' },
|
||||
},
|
||||
groupOptions: [{ value: 'u-1', enabled: true }],
|
||||
}),
|
||||
).toBe('u-1');
|
||||
});
|
||||
|
||||
test('getKanbanRecordKey respects collection filterTargetKey when record.id is absent', () => {
|
||||
expect(
|
||||
getKanbanRecordKey(
|
||||
{ slug: 'post-1', title: 'Hello' },
|
||||
{
|
||||
filterTargetKey: 'slug',
|
||||
},
|
||||
),
|
||||
).toBe('post-1');
|
||||
|
||||
expect(
|
||||
getKanbanRecordKey(
|
||||
{ orgId: 'o1', userId: 'u1' },
|
||||
{
|
||||
filterTargetKey: ['orgId', 'userId'],
|
||||
},
|
||||
),
|
||||
).toBe('orgId:o1|userId:u1');
|
||||
});
|
||||
|
||||
test('getKanbanSameColumnColumnDropMoveParams persists same-column column drops after the last remaining card', () => {
|
||||
expect(
|
||||
getKanbanSameColumnColumnDropMoveParams({
|
||||
items: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
sourceRecord: { id: 1 },
|
||||
collection: { filterTargetKey: 'id' },
|
||||
}),
|
||||
).toEqual({
|
||||
targetId: 3,
|
||||
method: 'insertAfter',
|
||||
});
|
||||
});
|
||||
|
||||
test('getKanbanSameColumnColumnDropMoveParams returns undefined when no anchor card remains', () => {
|
||||
expect(
|
||||
getKanbanSameColumnColumnDropMoveParams({
|
||||
items: [{ id: 1 }],
|
||||
sourceRecord: { id: 1 },
|
||||
collection: { filterTargetKey: 'id' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('shouldInsertAfterSameColumnCardMove matches same-column downward drag semantics', () => {
|
||||
expect(shouldInsertAfterSameColumnCardMove(0, 1)).toBe(true);
|
||||
expect(shouldInsertAfterSameColumnCardMove(1, 3)).toBe(true);
|
||||
expect(shouldInsertAfterSameColumnCardMove(3, 1)).toBe(false);
|
||||
expect(shouldInsertAfterSameColumnCardMove(1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
test('getKanbanPreviewInsertIndex keeps same-column downward preview aligned with persisted order', () => {
|
||||
expect(
|
||||
getKanbanPreviewInsertIndex({
|
||||
sourceColumnKey: 'todo',
|
||||
currentSourceColumnKey: 'todo',
|
||||
targetColumnKey: 'todo',
|
||||
sourceIndex: 0,
|
||||
overIndex: 2,
|
||||
targetIndex: 1,
|
||||
overType: 'card',
|
||||
}),
|
||||
).toBe(2);
|
||||
|
||||
expect(
|
||||
getKanbanPreviewInsertIndex({
|
||||
sourceColumnKey: 'todo',
|
||||
currentSourceColumnKey: 'todo',
|
||||
targetColumnKey: 'todo',
|
||||
sourceIndex: 2,
|
||||
overIndex: 1,
|
||||
targetIndex: 1,
|
||||
overType: 'card',
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test('getKanbanPreviewInsertIndex keeps cross-column preview stable after the card is already in the target column', () => {
|
||||
expect(
|
||||
getKanbanPreviewInsertIndex({
|
||||
sourceColumnKey: 'todo',
|
||||
currentSourceColumnKey: 'done',
|
||||
targetColumnKey: 'done',
|
||||
sourceIndex: 1,
|
||||
overIndex: 2,
|
||||
targetIndex: 1,
|
||||
overType: 'card',
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test('getKanbanMoveParamsFromPreviewOrder derives move anchors from the final preview order', () => {
|
||||
expect(
|
||||
getKanbanMoveParamsFromPreviewOrder({
|
||||
items: [{ id: 2 }, { id: 1 }, { id: 3 }],
|
||||
activeRecord: { id: 1 },
|
||||
collection: { filterTargetKey: 'id' },
|
||||
}),
|
||||
).toEqual({ targetId: 2, method: 'insertAfter' });
|
||||
|
||||
expect(
|
||||
getKanbanMoveParamsFromPreviewOrder({
|
||||
items: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
activeRecord: { id: 1 },
|
||||
collection: { filterTargetKey: 'id' },
|
||||
}),
|
||||
).toEqual({ targetId: 2 });
|
||||
|
||||
expect(
|
||||
getKanbanMoveParamsFromPreviewOrder({
|
||||
items: [{ id: 1 }],
|
||||
activeRecord: { id: 1 },
|
||||
collection: { filterTargetKey: 'id' },
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('getKanbanCrossColumnMoveParams uses target card anchors instead of mixing targetScope', () => {
|
||||
expect(
|
||||
getKanbanCrossColumnMoveParams({
|
||||
overType: 'card',
|
||||
overRecord: { id: 2 },
|
||||
targetColumnKey: 'done',
|
||||
groupFieldName: 'status',
|
||||
collection: { filterTargetKey: 'id' },
|
||||
}),
|
||||
).toEqual({ targetId: 2 });
|
||||
|
||||
expect(
|
||||
getKanbanCrossColumnMoveParams({
|
||||
overType: 'card',
|
||||
overRecord: { id: 2 },
|
||||
targetColumnKey: 'done',
|
||||
groupFieldName: 'status',
|
||||
collection: { filterTargetKey: 'id' },
|
||||
insertAfter: true,
|
||||
}),
|
||||
).toEqual({ targetId: 2, method: 'insertAfter' });
|
||||
});
|
||||
|
||||
test('getKanbanCrossColumnMoveParams falls back to targetScope for empty-column drops', () => {
|
||||
expect(
|
||||
getKanbanCrossColumnMoveParams({
|
||||
overType: 'column',
|
||||
targetColumnKey: 'done',
|
||||
groupFieldName: 'status',
|
||||
}),
|
||||
).toEqual({
|
||||
targetScope: {
|
||||
status: 'done',
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getKanbanCrossColumnMoveParams({
|
||||
overType: 'column',
|
||||
targetColumnKey: '__unknown__',
|
||||
groupFieldScopeKey: 'status',
|
||||
}),
|
||||
).toEqual({
|
||||
targetScope: {
|
||||
status: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('buildKanbanBoardDisplayItems keeps each record in one canonical column across column caches', () => {
|
||||
const grouped = buildKanbanBoardDisplayItems({
|
||||
columnItems: [
|
||||
{
|
||||
columnKey: 'todo',
|
||||
items: [
|
||||
{ slug: 'post-1', status: 'todo' },
|
||||
{ slug: 'post-2', status: 'done' },
|
||||
],
|
||||
},
|
||||
{
|
||||
columnKey: 'done',
|
||||
items: [
|
||||
{ slug: 'post-1', status: 'todo' },
|
||||
{ slug: 'post-2', status: 'done' },
|
||||
],
|
||||
},
|
||||
],
|
||||
collection: { filterTargetKey: 'slug' },
|
||||
groupField: { name: 'status', interface: 'select' },
|
||||
groupOptions: [
|
||||
{ value: 'todo', enabled: true },
|
||||
{ value: 'done', enabled: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect((grouped.todo || []).map((item) => item.slug)).toEqual(['post-1']);
|
||||
expect((grouped.done || []).map((item) => item.slug)).toEqual(['post-2']);
|
||||
});
|
||||
|
||||
test('buildKanbanBoardDisplayItems prefers optimistic runtime column overrides', () => {
|
||||
const grouped = buildKanbanBoardDisplayItems({
|
||||
columnItems: [
|
||||
{
|
||||
columnKey: 'todo',
|
||||
items: [{ slug: 'post-1', status: 'todo' }],
|
||||
},
|
||||
{
|
||||
columnKey: 'done',
|
||||
items: [{ slug: 'post-1', status: 'todo', __kanbanColumnKey: 'done' }],
|
||||
},
|
||||
],
|
||||
collection: { filterTargetKey: 'slug' },
|
||||
groupField: { name: 'status', interface: 'select' },
|
||||
groupOptions: [
|
||||
{ value: 'todo', enabled: true },
|
||||
{ value: 'done', enabled: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect((grouped.todo || []).map((item) => item.slug)).toEqual([]);
|
||||
expect((grouped.done || []).map((item) => item.slug)).toEqual(['post-1']);
|
||||
});
|
||||
|
||||
test('mergeKanbanFilters composes base and column filters', () => {
|
||||
expect(mergeKanbanFilters({ status: 'open' }, { type: 'task' })).toEqual({
|
||||
$and: [{ status: 'open' }, { type: 'task' }],
|
||||
});
|
||||
|
||||
expect(mergeKanbanFilters(undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('createKanbanColumnFilter only matches null values for Unknown column', () => {
|
||||
expect(
|
||||
createKanbanColumnFilter({
|
||||
fieldName: 'status',
|
||||
enabledValues: ['todo', 'done'],
|
||||
isUnknown: true,
|
||||
}),
|
||||
).toEqual({ status: null });
|
||||
|
||||
expect(
|
||||
createKanbanColumnFilter({
|
||||
fieldName: 'status',
|
||||
columnValue: 'todo',
|
||||
}),
|
||||
).toEqual({ status: 'todo' });
|
||||
});
|
||||
|
||||
test('createKanbanColumnFilter targets association targetKey for many-to-one grouping fields', () => {
|
||||
expect(
|
||||
createKanbanColumnFilter({
|
||||
field: {
|
||||
name: 'owner',
|
||||
interface: 'm2o',
|
||||
targetKey: 'slug',
|
||||
},
|
||||
columnValue: 'alice',
|
||||
}),
|
||||
).toEqual({ 'owner.slug': 'alice' });
|
||||
|
||||
expect(
|
||||
createKanbanColumnFilter({
|
||||
field: {
|
||||
name: 'owner',
|
||||
interface: 'm2o',
|
||||
targetCollection: {
|
||||
filterTargetKey: ['slug'],
|
||||
},
|
||||
},
|
||||
enabledValues: ['alice', 'bob'],
|
||||
isUnknown: true,
|
||||
}),
|
||||
).toEqual({ 'owner.slug': null });
|
||||
});
|
||||
|
||||
test('isSameKanbanGroupingValue only matches when grouping payload is unchanged', () => {
|
||||
expect(
|
||||
isSameKanbanGroupingValue(
|
||||
{
|
||||
groupField: 'status',
|
||||
groupOptions: [{ value: 'todo', label: 'Todo', color: 'blue', enabled: true } as any],
|
||||
},
|
||||
{
|
||||
groupField: 'status',
|
||||
groupOptions: [{ value: 'todo', label: 'Todo', color: 'blue', enabled: true } as any],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
areKanbanGroupOptionsEqual(
|
||||
[{ value: 'todo', label: 'Todo', color: 'blue', enabled: true } as any],
|
||||
[{ value: 'todo', label: 'Todo', color: 'green', enabled: true } as any],
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('reorderKanbanGroupOptions reorders values by dragged ids', () => {
|
||||
expect(
|
||||
reorderKanbanGroupOptions(
|
||||
[
|
||||
{ value: 'todo', label: 'Todo', color: 'default', enabled: true },
|
||||
{ value: 'doing', label: 'Doing', color: 'red', enabled: true },
|
||||
{ value: 'done', label: 'Done', color: 'green', enabled: true },
|
||||
],
|
||||
'done',
|
||||
'todo',
|
||||
).map((item) => item.value),
|
||||
).toEqual(['done', 'todo', 'doing']);
|
||||
});
|
||||
});
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import PluginKanbanClient from '..';
|
||||
import {
|
||||
KanbanBlockModel,
|
||||
KanbanCardItemModel,
|
||||
KanbanCollectionActionGroupModel,
|
||||
KanbanCardViewActionModel,
|
||||
KanbanQuickCreateActionModel,
|
||||
KanbanGroupOptionsTable,
|
||||
KanbanGroupingSelector,
|
||||
} from '../models';
|
||||
|
||||
describe('plugin-kanban v2 registration', () => {
|
||||
test('load registers v2 models and grouping components', async () => {
|
||||
const pageAddBlock = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
const schemaInitializerManager = {
|
||||
add: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
get: vi.fn(() => pageAddBlock),
|
||||
};
|
||||
const schemaSettingsManager = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
const flowEngine = {
|
||||
registerModels: vi.fn(),
|
||||
flowSettings: {
|
||||
registerComponents: vi.fn(),
|
||||
},
|
||||
};
|
||||
const app = {
|
||||
use: vi.fn(),
|
||||
addComponents: vi.fn(),
|
||||
flowEngine,
|
||||
schemaInitializerManager,
|
||||
schemaSettingsManager,
|
||||
};
|
||||
|
||||
await PluginKanbanClient.prototype.load.call({ app, flowEngine });
|
||||
|
||||
expect(app.addComponents).toHaveBeenCalledWith({ KanbanGroupingSelector, KanbanGroupOptionsTable });
|
||||
expect(flowEngine.flowSettings.registerComponents).toHaveBeenCalledWith({
|
||||
KanbanGroupingSelector,
|
||||
KanbanGroupOptionsTable,
|
||||
});
|
||||
expect(flowEngine.registerModels).toHaveBeenCalledWith({
|
||||
KanbanBlockModel,
|
||||
KanbanCardItemModel,
|
||||
KanbanCollectionActionGroupModel,
|
||||
KanbanCardViewActionModel,
|
||||
KanbanQuickCreateActionModel,
|
||||
});
|
||||
expect(schemaInitializerManager.get).toHaveBeenCalledWith('page:addBlock');
|
||||
expect(pageAddBlock.add).toHaveBeenCalledWith(
|
||||
'dataBlocks.kanban',
|
||||
expect.objectContaining({
|
||||
Component: 'KanbanBlockInitializer',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,15 @@ import {
|
||||
useCreateKanbanBlock,
|
||||
} from './KanbanBlockInitializer';
|
||||
import { KanbanBlockProvider, useKanbanBlockProps } from './KanbanBlockProvider';
|
||||
import {
|
||||
KanbanBlockModel,
|
||||
KanbanCardItemModel,
|
||||
KanbanCollectionActionGroupModel,
|
||||
KanbanQuickCreateActionModel,
|
||||
KanbanCardViewActionModel,
|
||||
KanbanGroupOptionsTable,
|
||||
KanbanGroupingSelector,
|
||||
} from './models';
|
||||
|
||||
Kanban.Card = KanbanCard;
|
||||
Kanban.CardAdder = Action;
|
||||
@@ -84,11 +93,20 @@ class PluginKanbanClient extends Plugin {
|
||||
|
||||
async load() {
|
||||
this.app.use(KanbanPluginProvider);
|
||||
this.app.addComponents({ KanbanGroupingSelector, KanbanGroupOptionsTable });
|
||||
this.flowEngine.flowSettings.registerComponents({ KanbanGroupingSelector, KanbanGroupOptionsTable });
|
||||
this.app.schemaInitializerManager.add(kanbanCardInitializers_deprecated);
|
||||
this.app.schemaInitializerManager.add(kanbanCardInitializers);
|
||||
this.app.schemaInitializerManager.add(kanbanActionInitializers_deprecated);
|
||||
this.app.schemaInitializerManager.add(kanbanActionInitializers);
|
||||
this.app.schemaSettingsManager.add(kanbanSettings);
|
||||
this.flowEngine.registerModels({
|
||||
KanbanBlockModel,
|
||||
KanbanCardItemModel,
|
||||
KanbanCollectionActionGroupModel,
|
||||
KanbanQuickCreateActionModel,
|
||||
KanbanCardViewActionModel,
|
||||
});
|
||||
|
||||
const blockInitializers = this.app.schemaInitializerManager.get('page:addBlock');
|
||||
blockInitializers?.add('dataBlocks.kanban', {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { DetailsGridModel, FormComponent } from '@nocobase/client';
|
||||
import {
|
||||
createRecordMetaFactory,
|
||||
FlowModel,
|
||||
FlowModelRenderer,
|
||||
PropertyMetaFactory,
|
||||
tExpr,
|
||||
} from '@nocobase/flow-engine';
|
||||
import { Card } from 'antd';
|
||||
import React from 'react';
|
||||
import {
|
||||
normalizeKanbanPopupTargetUid,
|
||||
normalizeKanbanPopupTemplateUid,
|
||||
resolveKanbanOpenViewDefaultParams,
|
||||
} from './popupSettings';
|
||||
import { getKanbanRecordKey, normalizeKanbanCardOpenMode, normalizeKanbanPopupSize } from './utils';
|
||||
|
||||
type KanbanCardItemStructure = {
|
||||
subModels: {
|
||||
grid: DetailsGridModel;
|
||||
};
|
||||
};
|
||||
|
||||
const getKanbanCardPersistentModel = (model: any) => {
|
||||
return model?.isFork ? model?.master || model : model;
|
||||
};
|
||||
|
||||
const getKanbanCardPersistentParentModel = (model: any) => {
|
||||
const parentModel = model?.parent;
|
||||
return parentModel?.isFork ? parentModel?.master || parentModel : parentModel;
|
||||
};
|
||||
|
||||
const syncKanbanCardModelProps = (model: any, props: Record<string, any>) => {
|
||||
model?.setProps?.(props);
|
||||
|
||||
const persistentModel = getKanbanCardPersistentModel(model);
|
||||
if (persistentModel && persistentModel !== model) {
|
||||
persistentModel.setProps?.(props);
|
||||
}
|
||||
|
||||
return persistentModel;
|
||||
};
|
||||
|
||||
const hasOwnModelProp = (props: Record<string, any>, key: string) => Object.prototype.hasOwnProperty.call(props, key);
|
||||
|
||||
const getKanbanCardPopupProp = (model: any, key: string, parentKey: string) => {
|
||||
if (hasOwnModelProp(model?.props || {}, key)) {
|
||||
return model?.props?.[key];
|
||||
}
|
||||
|
||||
return model?.parent?.props?.[parentKey];
|
||||
};
|
||||
|
||||
const replaceModelStepParams = (model: any, flowKey: string, stepKey: string, params: Record<string, any>) => {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.stepParams = model.stepParams || {};
|
||||
model.stepParams[flowKey] = model.stepParams[flowKey] || {};
|
||||
model.stepParams[flowKey][stepKey] = { ...params };
|
||||
model.emitter?.emit?.('onStepParamsChanged');
|
||||
};
|
||||
|
||||
const applyKanbanCardPopupSettings = async (model: any, params: Record<string, any>) => {
|
||||
const parentModel = getKanbanCardPersistentParentModel(model);
|
||||
const nextPopupTemplateUid = normalizeKanbanPopupTemplateUid(params.popupTemplateUid);
|
||||
const nextPopupTargetUid = normalizeKanbanPopupTargetUid(params.uid);
|
||||
const currentPopupTemplateUid = normalizeKanbanPopupTemplateUid(
|
||||
getKanbanCardPopupProp(model, 'popupTemplateUid', 'cardPopupTemplateUid'),
|
||||
);
|
||||
const currentPopupTargetUid = normalizeKanbanPopupTargetUid(
|
||||
getKanbanCardPopupProp(model, 'popupTargetUid', 'cardPopupTargetUid'),
|
||||
);
|
||||
const resolvedPopupTargetUid =
|
||||
!nextPopupTemplateUid && currentPopupTemplateUid && nextPopupTargetUid === currentPopupTargetUid
|
||||
? undefined
|
||||
: nextPopupTargetUid;
|
||||
const normalizedParams = {
|
||||
...params,
|
||||
mode: normalizeKanbanCardOpenMode(params.mode),
|
||||
size: normalizeKanbanPopupSize(params.size),
|
||||
popupTemplateUid: nextPopupTemplateUid,
|
||||
pageModelClass: params.pageModelClass || undefined,
|
||||
uid: resolvedPopupTargetUid,
|
||||
};
|
||||
const persistentModel = syncKanbanCardModelProps(model, {
|
||||
openMode: normalizedParams.mode,
|
||||
popupSize: normalizedParams.size,
|
||||
popupTemplateUid: normalizedParams.popupTemplateUid,
|
||||
pageModelClass: normalizedParams.pageModelClass,
|
||||
popupTargetUid: normalizedParams.uid,
|
||||
});
|
||||
|
||||
replaceModelStepParams(model, 'cardSettings', 'popup', normalizedParams);
|
||||
if (persistentModel && persistentModel !== model) {
|
||||
replaceModelStepParams(persistentModel, 'cardSettings', 'popup', normalizedParams);
|
||||
}
|
||||
|
||||
parentModel?.setProps?.({
|
||||
cardPopupPageModelClass: normalizedParams.pageModelClass,
|
||||
});
|
||||
const action = await parentModel?.ensureCardViewAction?.();
|
||||
await parentModel?.syncCardViewAction?.(action || parentModel?.subModels?.cardViewAction);
|
||||
};
|
||||
|
||||
export class KanbanCardItemModel extends FlowModel<KanbanCardItemStructure> {
|
||||
render() {
|
||||
const index = this.context.index;
|
||||
const record = this.context.record;
|
||||
const onCardClick = this.context.onCardClick as (() => void) | undefined;
|
||||
const recordKey = getKanbanRecordKey(record, this.context.collection) || String(index);
|
||||
const cardScopeKey = String((this as any).forkId || this.uid || recordKey);
|
||||
// Keep the card fork scoped like a row-level field host so linkage/design settings
|
||||
// resolve against only this card after refreshes.
|
||||
const fieldIndex = [`${cardScopeKey}:0`];
|
||||
const grid = this.subModels.grid.createFork({}, `grid-${cardScopeKey}`) as any;
|
||||
const themeToken = this.context?.themeToken;
|
||||
|
||||
grid.gridContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
const recordMeta: PropertyMetaFactory = createRecordMetaFactory(
|
||||
() => grid.context.collection,
|
||||
grid.context.t('Current record'),
|
||||
(ctx) => {
|
||||
const collection = ctx.collection;
|
||||
const currentRecord = ctx.record;
|
||||
const collectionName = collection?.name;
|
||||
const dataSourceKey = collection?.dataSourceKey;
|
||||
const filterByTk = collection?.getFilterByTK?.(currentRecord);
|
||||
|
||||
if (!collectionName || filterByTk === undefined || filterByTk === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { collection: collectionName, dataSourceKey, filterByTk };
|
||||
},
|
||||
);
|
||||
|
||||
grid.context.defineProperty('fieldIndex', {
|
||||
get: () => fieldIndex,
|
||||
cache: false,
|
||||
});
|
||||
grid.context.defineProperty('collection', {
|
||||
get: () => this.context.collection,
|
||||
cache: false,
|
||||
});
|
||||
grid.context.defineProperty('record', {
|
||||
get: () => record,
|
||||
cache: false,
|
||||
resolveOnServer: true,
|
||||
meta: recordMeta,
|
||||
});
|
||||
grid.context.defineProperty('fieldKey', {
|
||||
get: () => cardScopeKey,
|
||||
cache: false,
|
||||
});
|
||||
|
||||
const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props;
|
||||
const clickEnabled = this.props.enableCardClick !== false;
|
||||
const clickable = clickEnabled && !!onCardClick;
|
||||
const handleCardClick = clickable
|
||||
? (event: React.MouseEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const interactiveAncestor = target?.closest(
|
||||
'a, button, input, textarea, select, option, [role="button"], [contenteditable="true"]',
|
||||
);
|
||||
if (event.defaultPrevented || (interactiveAncestor && interactiveAncestor !== event.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCardClick?.();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const formLabelWidth = layout === 'horizontal' ? labelWidth : null;
|
||||
return (
|
||||
<Card
|
||||
hoverable={clickable}
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={handleCardClick}
|
||||
className={css`
|
||||
height: 100%;
|
||||
cursor: ${clickable ? 'pointer' : 'default'};
|
||||
|
||||
.ant-card-body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding-left: ${themeToken ? `${themeToken.marginMD}px` : '14px'};
|
||||
padding-right: ${themeToken ? `${themeToken.marginMD}px` : '14px'};
|
||||
padding-top: ${themeToken ? `${themeToken.marginSM}px` : '14px'};
|
||||
padding-bottom: ${themeToken ? `${themeToken.marginSM}px` : '14px'};
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<FormComponent model={this} layoutProps={{ colon, labelAlign, labelWidth: formLabelWidth, labelWrap, layout }}>
|
||||
<FlowModelRenderer
|
||||
key={cardScopeKey}
|
||||
model={grid}
|
||||
showFlowSettings={false}
|
||||
inputArgs={{ record, fieldIndex, fieldKey: cardScopeKey }}
|
||||
useCache={false}
|
||||
/>
|
||||
</FormComponent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
KanbanCardItemModel.registerFlow({
|
||||
key: 'cardSettings',
|
||||
sort: 500,
|
||||
title: tExpr('Card settings', { ns: 'kanban' }),
|
||||
steps: {
|
||||
click: {
|
||||
title: tExpr('Enable click-to-open', { ns: 'kanban' }),
|
||||
uiMode: { type: 'switch', key: 'enableCardClick' },
|
||||
defaultParams(ctx) {
|
||||
return {
|
||||
enableCardClick: ctx.model.props.enableCardClick ?? true,
|
||||
};
|
||||
},
|
||||
handler(ctx, params) {
|
||||
syncKanbanCardModelProps(ctx.model, {
|
||||
enableCardClick: params.enableCardClick !== false,
|
||||
});
|
||||
},
|
||||
},
|
||||
popup: {
|
||||
title: tExpr('Popup settings', { ns: 'kanban' }),
|
||||
use: 'openView',
|
||||
async defaultParams(ctx) {
|
||||
const commonParams = await resolveKanbanOpenViewDefaultParams(ctx as any);
|
||||
return {
|
||||
...commonParams,
|
||||
mode: normalizeKanbanCardOpenMode(getKanbanCardPopupProp(ctx.model, 'openMode', 'cardOpenMode')),
|
||||
size: normalizeKanbanPopupSize(getKanbanCardPopupProp(ctx.model, 'popupSize', 'cardPopupSize')),
|
||||
popupTemplateUid: normalizeKanbanPopupTemplateUid(
|
||||
getKanbanCardPopupProp(ctx.model, 'popupTemplateUid', 'cardPopupTemplateUid'),
|
||||
),
|
||||
pageModelClass: getKanbanCardPopupProp(ctx.model, 'pageModelClass', 'cardPopupPageModelClass'),
|
||||
uid: normalizeKanbanPopupTargetUid(getKanbanCardPopupProp(ctx.model, 'popupTargetUid', 'cardPopupTargetUid')),
|
||||
};
|
||||
},
|
||||
async handler(ctx, params) {
|
||||
await applyKanbanCardPopupSettings(ctx.model, params);
|
||||
},
|
||||
async beforeParamsSave(ctx, params, previousParams) {
|
||||
await ctx.model?.getAction?.('openView')?.beforeParamsSave?.(ctx, params, previousParams);
|
||||
await applyKanbanCardPopupSettings(ctx.model, params);
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
use: 'layout',
|
||||
title: tExpr('Layout'),
|
||||
handler(ctx, params) {
|
||||
const persistentModel = syncKanbanCardModelProps(ctx.model, {
|
||||
...params,
|
||||
labelWidth: params.layout === 'vertical' ? null : params.labelWidth,
|
||||
});
|
||||
|
||||
(persistentModel?.subModels?.grid as any)?.findSubModel('items', (itemModel: any) => {
|
||||
itemModel.setProps({
|
||||
...params,
|
||||
labelWidth: params.layout === 'vertical' ? '100%' : params.labelWidth,
|
||||
labelWrap: params.layout === 'vertical' ? true : params.labelWrap,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
KanbanCardItemModel.define({
|
||||
createModelOptions: {
|
||||
use: 'KanbanCardItemModel',
|
||||
subModels: {
|
||||
grid: {
|
||||
use: 'DetailsGridModel',
|
||||
},
|
||||
},
|
||||
},
|
||||
sort: 360,
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { AIEmployeeActionModel } from '@nocobase/plugin-ai/client';
|
||||
import { ActionGroupModel, ActionModel, ActionSceneEnum } from '@nocobase/client';
|
||||
import { buildSubModelItem, escapeT, type FlowModelContext } from '@nocobase/flow-engine';
|
||||
|
||||
const ALLOWED_KANBAN_COLLECTION_ACTIONS = [
|
||||
'AddNewActionModel',
|
||||
'PopupCollectionActionModel',
|
||||
'FilterActionModel',
|
||||
'LinkActionModel',
|
||||
'RefreshActionModel',
|
||||
'CustomRequestActionModel',
|
||||
'AIEmployeeActionModel',
|
||||
'JSItemActionModel',
|
||||
'JSCollectionActionModel',
|
||||
];
|
||||
|
||||
export class KanbanCollectionActionGroupModel extends ActionGroupModel {
|
||||
static async defineChildren(ctx: FlowModelContext) {
|
||||
const items = [];
|
||||
|
||||
for (const modelName of ALLOWED_KANBAN_COLLECTION_ACTIONS) {
|
||||
const ModelClass = (
|
||||
modelName === 'AIEmployeeActionModel' ? AIEmployeeActionModel : ctx.engine.getModelClass(modelName)
|
||||
) as typeof ActionModel | undefined;
|
||||
if (!ModelClass) {
|
||||
continue;
|
||||
}
|
||||
if (!this.isActionModelVisible(ModelClass, ctx)) {
|
||||
continue;
|
||||
}
|
||||
if (!ModelClass._isScene?.(ActionSceneEnum.collection)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = await buildSubModelItem(ModelClass, ctx);
|
||||
if (item) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
KanbanCollectionActionGroupModel.define({
|
||||
label: escapeT('Kanban action', { ns: 'kanban' }),
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { AddNewActionModel, ViewActionModel } from '@nocobase/client';
|
||||
import { tExpr } from '@nocobase/flow-engine';
|
||||
|
||||
export const createKanbanQuickCreateActionOptions = (uid?: string) => {
|
||||
return {
|
||||
uid,
|
||||
use: 'KanbanQuickCreateActionModel',
|
||||
};
|
||||
};
|
||||
|
||||
export const createKanbanCardViewActionOptions = (uid?: string) => {
|
||||
return {
|
||||
uid,
|
||||
use: 'KanbanCardViewActionModel',
|
||||
};
|
||||
};
|
||||
|
||||
export class KanbanQuickCreateActionModel extends AddNewActionModel {
|
||||
defaultPopupTitle = tExpr('Add new', { ns: 'kanban' });
|
||||
}
|
||||
|
||||
KanbanQuickCreateActionModel.define({
|
||||
hide: true,
|
||||
createModelOptions: async () => {
|
||||
return createKanbanQuickCreateActionOptions();
|
||||
},
|
||||
});
|
||||
|
||||
export class KanbanCardViewActionModel extends ViewActionModel {
|
||||
defaultPopupTitle = tExpr('Details');
|
||||
}
|
||||
|
||||
KanbanCardViewActionModel.define({
|
||||
hide: true,
|
||||
createModelOptions: async () => {
|
||||
return createKanbanCardViewActionOptions();
|
||||
},
|
||||
});
|
||||
+619
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { observer } from '@nocobase/flow-engine';
|
||||
import { Alert } from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
import type { KanbanBlockModel } from '../KanbanBlockModel';
|
||||
import {
|
||||
buildKanbanBoardDisplayItems,
|
||||
getKanbanCrossColumnMoveParams,
|
||||
getKanbanRecordKeyValue,
|
||||
getKanbanFieldScopeKey,
|
||||
getKanbanMoveParamsFromPreviewOrder,
|
||||
KANBAN_UNKNOWN_COLUMN_KEY,
|
||||
type KanbanGroupOption,
|
||||
} from '../utils';
|
||||
import { ColumnPanel } from './kanban-block/ColumnPanel';
|
||||
import { CardPlaceholder } from './kanban-block/CardRenderer';
|
||||
import {
|
||||
applyKanbanColumnOverride,
|
||||
areKanbanRecordListsEqual,
|
||||
type ColumnRefreshMeta,
|
||||
createInitialColumnState,
|
||||
getKanbanDesignSettingsHost,
|
||||
getRuntimeRecordKey,
|
||||
moveKanbanRecordByCoordinates,
|
||||
shouldHideUnknownColumn,
|
||||
shouldMountUnknownColumn,
|
||||
type ColumnState,
|
||||
type DropResult,
|
||||
type KanbanRuntimeRecord,
|
||||
type RuntimeColumn,
|
||||
} from './kanban-block/shared';
|
||||
|
||||
const DEFAULT_KANBAN_BLOCK_MIN_HEIGHT = '60vh';
|
||||
|
||||
type DeferredRefreshHandle = { type: 'idle'; id: number } | { type: 'timeout'; id: number } | null;
|
||||
|
||||
const scheduleDeferredRefresh = (callback: () => void): DeferredRefreshHandle => {
|
||||
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
|
||||
return {
|
||||
type: 'idle',
|
||||
id: window.requestIdleCallback(callback, { timeout: 300 }),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
callback();
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'timeout',
|
||||
id: window.setTimeout(callback, 32),
|
||||
};
|
||||
};
|
||||
|
||||
const cancelDeferredRefresh = (handle: DeferredRefreshHandle) => {
|
||||
if (!handle || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handle.type === 'idle' && typeof window.cancelIdleCallback === 'function') {
|
||||
window.cancelIdleCallback(handle.id);
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(handle.id);
|
||||
};
|
||||
|
||||
export const KanbanBlockView = observer(({ model }: { model: KanbanBlockModel }) => {
|
||||
const [groupOptions, setGroupOptions] = useState<KanbanGroupOption[]>(() => model.getConfiguredGroupOptions());
|
||||
const [groupOptionsLoading, setGroupOptionsLoading] = useState(false);
|
||||
const [groupOptionsError, setGroupOptionsError] = useState<string>();
|
||||
const [columnStates, setColumnStates] = useState<Record<string, ColumnState>>({});
|
||||
const [columnRefreshMetaByColumn, setColumnRefreshMetaByColumn] = useState<Record<string, ColumnRefreshMeta>>({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const actionsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const errorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const columnStatesRef = useRef<Record<string, ColumnState>>({});
|
||||
const boardDisplayItemsRef = useRef<Record<string, KanbanRuntimeRecord[]>>({});
|
||||
const suppressGlobalRefreshUntilRef = useRef(0);
|
||||
const dragPersistingRef = useRef(false);
|
||||
const pendingDragRefreshRef = useRef<DeferredRefreshHandle>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [actionsHeight, setActionsHeight] = useState(0);
|
||||
const [errorHeight, setErrorHeight] = useState(0);
|
||||
const groupField = model.getGroupField();
|
||||
const groupFieldName = groupField?.name;
|
||||
const groupFieldScopeKey = getKanbanFieldScopeKey(groupField);
|
||||
const showActionsBar = !!model.context.flowSettingsEnabled || model.hasSubModel('actions');
|
||||
const heightMode = model.decoratorProps?.heightMode;
|
||||
const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight';
|
||||
const dragEnabled = model.canDragSort();
|
||||
const dragInteractionEnabled = dragEnabled;
|
||||
const crossColumnDragEnabled = model.canCrossColumnDrag();
|
||||
|
||||
useEffect(() => {
|
||||
columnStatesRef.current = columnStates;
|
||||
}, [columnStates]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelDeferredRefresh(pendingDragRefreshRef.current);
|
||||
pendingDragRefreshRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const columns = useMemo<RuntimeColumn[]>(() => {
|
||||
const runtimeColumns: RuntimeColumn[] = [
|
||||
{
|
||||
key: KANBAN_UNKNOWN_COLUMN_KEY,
|
||||
value: KANBAN_UNKNOWN_COLUMN_KEY,
|
||||
label: model.translate('Unknown'),
|
||||
color: 'default',
|
||||
isUnknown: true,
|
||||
},
|
||||
...groupOptions.map((item) => ({
|
||||
key: item.value,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
color: item.color || 'default',
|
||||
})),
|
||||
];
|
||||
return runtimeColumns;
|
||||
}, [groupOptions, model]);
|
||||
|
||||
const triggerColumnRefresh = useCallback(
|
||||
(
|
||||
columnKeys: Array<string | undefined>,
|
||||
options: {
|
||||
reason: 'global' | 'drag';
|
||||
activeRecordKey?: string;
|
||||
} = { reason: 'global' },
|
||||
) => {
|
||||
const uniqueColumnKeys = [...new Set(columnKeys.filter(Boolean) as string[])];
|
||||
if (!uniqueColumnKeys.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setColumnRefreshMetaByColumn((prev) => {
|
||||
const next = { ...prev };
|
||||
uniqueColumnKeys.forEach((columnKey) => {
|
||||
const previousToken = next[columnKey]?.token || 0;
|
||||
next[columnKey] = {
|
||||
token: previousToken + 1,
|
||||
reason: options.reason,
|
||||
activeRecordKey: options.activeRecordKey,
|
||||
};
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateMeasuredHeights = () => {
|
||||
setContainerHeight(containerRef.current?.getBoundingClientRect().height || 0);
|
||||
setActionsHeight(actionsContainerRef.current?.offsetHeight || 0);
|
||||
setErrorHeight(errorContainerRef.current?.offsetHeight || 0);
|
||||
};
|
||||
|
||||
updateMeasuredHeights();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => updateMeasuredHeights());
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
if (actionsContainerRef.current) {
|
||||
observer.observe(actionsContainerRef.current);
|
||||
}
|
||||
if (errorContainerRef.current) {
|
||||
observer.observe(errorContainerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [showActionsBar, groupOptionsError]);
|
||||
|
||||
const contentHeight = useMemo(() => {
|
||||
if (!isFixedHeight || !containerHeight) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const gapCount =
|
||||
Number(Boolean(showActionsBar && actionsHeight)) + Number(Boolean(groupOptionsError && errorHeight));
|
||||
return Math.max(containerHeight - actionsHeight - errorHeight - gapCount * 16, 0);
|
||||
}, [actionsHeight, containerHeight, errorHeight, groupOptionsError, isFixedHeight, showActionsBar]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const syncGroupOptions = async () => {
|
||||
if (!groupField) {
|
||||
setGroupOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredGroupOptions: KanbanGroupOption[] = model.getConfiguredGroupOptions();
|
||||
if (!configuredGroupOptions.length) {
|
||||
setGroupOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const inlineOptions = model.getInlineGroupOptions(groupField);
|
||||
if (inlineOptions.length) {
|
||||
setGroupOptions(configuredGroupOptions);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroupOptionsLoading(true);
|
||||
setGroupOptionsError(undefined);
|
||||
try {
|
||||
const relationOptions: KanbanGroupOption[] = await model.loadRelationGroupOptions(groupField);
|
||||
if (!cancelled) {
|
||||
const selectedValueKeys = new Set(configuredGroupOptions.map((item) => item.value));
|
||||
setGroupOptions(relationOptions.filter((item) => selectedValueKeys.has(item.value)));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setGroupOptionsError(error?.message || model.translate('Failed to load group options'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setGroupOptionsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncGroupOptions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
groupField,
|
||||
groupFieldName,
|
||||
model,
|
||||
model.props.groupColorField,
|
||||
model.props.groupOptions,
|
||||
model.props.groupTitleField,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
if (Date.now() < suppressGlobalRefreshUntilRef.current) {
|
||||
suppressGlobalRefreshUntilRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
triggerColumnRefresh(
|
||||
columns.map((column) => column.key),
|
||||
{ reason: 'global' },
|
||||
);
|
||||
};
|
||||
|
||||
model.emitter.on('refresh', handleRefresh);
|
||||
return () => {
|
||||
model.emitter.off('refresh', handleRefresh);
|
||||
};
|
||||
}, [columns, model, triggerColumnRefresh, model.context.flowSettingsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnStates({});
|
||||
setColumnRefreshMetaByColumn({});
|
||||
}, [groupFieldName, groupOptions]);
|
||||
|
||||
const boardDisplayItemsByColumn = useMemo(() => {
|
||||
const nextBoardDisplayItemsByColumn = buildKanbanBoardDisplayItems({
|
||||
columnItems: columns.map((column) => ({
|
||||
columnKey: column.key,
|
||||
items: columnStates[column.key]?.items || [],
|
||||
})),
|
||||
collection: model.collection,
|
||||
groupField,
|
||||
groupOptions,
|
||||
});
|
||||
|
||||
const previousBoardDisplayItemsByColumn = boardDisplayItemsRef.current;
|
||||
const stabilizedBoardDisplayItemsByColumn = Object.fromEntries(
|
||||
columns.map((column) => {
|
||||
const nextItems = (nextBoardDisplayItemsByColumn[column.key] || []) as KanbanRuntimeRecord[];
|
||||
const previousItems = (previousBoardDisplayItemsByColumn[column.key] || []) as KanbanRuntimeRecord[];
|
||||
|
||||
return [
|
||||
column.key,
|
||||
areKanbanRecordListsEqual(previousItems, nextItems, model.collection) ? previousItems : nextItems,
|
||||
];
|
||||
}),
|
||||
) as Record<string, KanbanRuntimeRecord[]>;
|
||||
|
||||
boardDisplayItemsRef.current = stabilizedBoardDisplayItemsByColumn;
|
||||
return stabilizedBoardDisplayItemsByColumn;
|
||||
}, [columnStates, columns, groupField, groupOptions, model.collection]);
|
||||
|
||||
const mountedColumns = useMemo(() => {
|
||||
return columns.filter((column) => {
|
||||
if (!column.isUnknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return shouldMountUnknownColumn({
|
||||
state: columnStates[column.key],
|
||||
refreshMeta: columnRefreshMetaByColumn[column.key],
|
||||
displayItems: boardDisplayItemsByColumn[column.key] || [],
|
||||
});
|
||||
});
|
||||
}, [boardDisplayItemsByColumn, columnRefreshMetaByColumn, columnStates, columns]);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
return mountedColumns.filter((column) => {
|
||||
if (!column.isUnknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !shouldHideUnknownColumn({
|
||||
state: columnStates[column.key],
|
||||
refreshMeta: columnRefreshMetaByColumn[column.key],
|
||||
displayItems: boardDisplayItemsByColumn[column.key] || [],
|
||||
});
|
||||
});
|
||||
}, [boardDisplayItemsByColumn, columnRefreshMetaByColumn, columnStates, mountedColumns]);
|
||||
|
||||
const designSettingsHost = useMemo(() => {
|
||||
return getKanbanDesignSettingsHost({
|
||||
enabled: !!model.context.flowSettingsEnabled,
|
||||
columns: visibleColumns,
|
||||
itemsByColumn: boardDisplayItemsByColumn,
|
||||
collection: model.collection,
|
||||
});
|
||||
}, [boardDisplayItemsByColumn, model.collection, model.context.flowSettingsEnabled, visibleColumns]);
|
||||
|
||||
const applyPreviewToColumnStates = useCallback(
|
||||
(options: {
|
||||
previewItemsByColumn: Record<string, KanbanRuntimeRecord[]>;
|
||||
sourceColumnKey: string;
|
||||
targetColumnKey: string;
|
||||
activeRecordKey: string;
|
||||
}) => {
|
||||
const { previewItemsByColumn, sourceColumnKey, targetColumnKey, activeRecordKey } = options;
|
||||
const affectedColumnKeys = [...new Set([sourceColumnKey, targetColumnKey])];
|
||||
|
||||
setColumnStates((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
affectedColumnKeys.forEach((columnKey) => {
|
||||
const previous = prev[columnKey] || createInitialColumnState(false);
|
||||
const previousItems = previous.items || [];
|
||||
const nextItems = (previewItemsByColumn[columnKey] || []).map((item) => {
|
||||
if (getRuntimeRecordKey(item, model.collection) !== activeRecordKey) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return applyKanbanColumnOverride({
|
||||
record: item,
|
||||
sourceColumnKey,
|
||||
targetColumnKey,
|
||||
});
|
||||
});
|
||||
|
||||
next[columnKey] = {
|
||||
...previous,
|
||||
error: undefined,
|
||||
items: nextItems,
|
||||
count:
|
||||
typeof previous.count === 'number'
|
||||
? Math.max(previous.count + (nextItems.length - previousItems.length), 0)
|
||||
: previous.count,
|
||||
};
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[model.collection],
|
||||
);
|
||||
|
||||
const setMoveError = useCallback((columnKeys: string[], message: string) => {
|
||||
setColumnStates((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
columnKeys.forEach((columnKey) => {
|
||||
next[columnKey] = {
|
||||
...(next[columnKey] || createInitialColumnState(false)),
|
||||
error: message,
|
||||
};
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const shouldRefreshColumnsAfterMove = useCallback((columnKeys: string[]) => {
|
||||
return columnKeys.some((columnKey) => {
|
||||
const state = columnStatesRef.current[columnKey];
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state.hasNext || state.page > 1;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scheduleDragColumnRefresh = useCallback(
|
||||
(
|
||||
columnKeys: string[],
|
||||
options: {
|
||||
reason: 'drag';
|
||||
activeRecordKey?: string;
|
||||
},
|
||||
) => {
|
||||
cancelDeferredRefresh(pendingDragRefreshRef.current);
|
||||
pendingDragRefreshRef.current = scheduleDeferredRefresh(() => {
|
||||
pendingDragRefreshRef.current = null;
|
||||
triggerColumnRefresh(columnKeys, options);
|
||||
});
|
||||
},
|
||||
[triggerColumnRefresh],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (result: DropResult) => {
|
||||
const { source, destination } = result;
|
||||
|
||||
if (dragPersistingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceColumnKey = source.droppableId;
|
||||
const targetColumnKey = destination.droppableId;
|
||||
const sourceIndex = source.index;
|
||||
const targetIndex = destination.index;
|
||||
|
||||
if (!crossColumnDragEnabled && targetColumnKey !== sourceColumnKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceColumnKey === targetColumnKey && sourceIndex === targetIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortField = model.getDragSortFieldName();
|
||||
const sourceItems = boardDisplayItemsByColumn[sourceColumnKey] || [];
|
||||
const sourceRecord = sourceItems[sourceIndex];
|
||||
const sourceId = getKanbanRecordKeyValue(sourceRecord, model.collection);
|
||||
|
||||
if (!sortField || sourceId === undefined || sourceId === null || !sourceRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewItemsByColumn = moveKanbanRecordByCoordinates({
|
||||
itemsByColumn: boardDisplayItemsByColumn,
|
||||
sourceColumnKey,
|
||||
sourceIndex,
|
||||
targetColumnKey,
|
||||
targetIndex,
|
||||
});
|
||||
|
||||
const targetItems = previewItemsByColumn[targetColumnKey] || [];
|
||||
const targetItemsExcludingActive = targetItems.filter(
|
||||
(item) => getRuntimeRecordKey(item, model.collection) !== getRuntimeRecordKey(sourceRecord, model.collection),
|
||||
);
|
||||
|
||||
let moveParams: Record<string, any> = {};
|
||||
if (!targetItemsExcludingActive.length && targetColumnKey !== sourceColumnKey) {
|
||||
moveParams = getKanbanCrossColumnMoveParams({
|
||||
overType: 'column',
|
||||
targetColumnKey,
|
||||
groupFieldName,
|
||||
groupFieldScopeKey,
|
||||
collection: model.collection,
|
||||
});
|
||||
} else {
|
||||
moveParams = getKanbanMoveParamsFromPreviewOrder({
|
||||
items: targetItems,
|
||||
activeRecord: sourceRecord,
|
||||
collection: model.collection,
|
||||
});
|
||||
}
|
||||
|
||||
const previousColumnStates = columnStatesRef.current;
|
||||
const affectedColumnKeys = [...new Set([sourceColumnKey, targetColumnKey])];
|
||||
const activeRecordKey = getRuntimeRecordKey(sourceRecord, model.collection) || String(sourceId);
|
||||
|
||||
applyPreviewToColumnStates({
|
||||
previewItemsByColumn,
|
||||
sourceColumnKey,
|
||||
targetColumnKey,
|
||||
activeRecordKey,
|
||||
});
|
||||
dragPersistingRef.current = true;
|
||||
|
||||
try {
|
||||
await model.resource.runAction('move', {
|
||||
method: 'post',
|
||||
params: {
|
||||
sourceId,
|
||||
sortField,
|
||||
...moveParams,
|
||||
},
|
||||
});
|
||||
suppressGlobalRefreshUntilRef.current = Date.now() + 1500;
|
||||
|
||||
if (shouldRefreshColumnsAfterMove(affectedColumnKeys)) {
|
||||
scheduleDragColumnRefresh(affectedColumnKeys, {
|
||||
reason: 'drag',
|
||||
activeRecordKey,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
setColumnStates(previousColumnStates);
|
||||
setMoveError(affectedColumnKeys, error?.message || model.translate('Failed to save drag sort'));
|
||||
} finally {
|
||||
dragPersistingRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
applyPreviewToColumnStates,
|
||||
boardDisplayItemsByColumn,
|
||||
groupFieldName,
|
||||
groupFieldScopeKey,
|
||||
model,
|
||||
setMoveError,
|
||||
scheduleDragColumnRefresh,
|
||||
shouldRefreshColumnsAfterMove,
|
||||
crossColumnDragEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
if (!groupField) {
|
||||
return <Alert type="warning" showIcon message={model.translate('Grouping field is required')} />;
|
||||
}
|
||||
|
||||
const boardColumns = (
|
||||
<div
|
||||
className={css`
|
||||
${contentHeight ? 'flex: 1 1 0;' : ''}
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: ${contentHeight ? 'hidden' : 'visible'};
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
`}
|
||||
style={{ height: contentHeight }}
|
||||
>
|
||||
{groupOptionsLoading && !groupOptions.length ? <CardPlaceholder /> : null}
|
||||
{mountedColumns.map((column) => {
|
||||
const hidden =
|
||||
column.isUnknown &&
|
||||
shouldHideUnknownColumn({
|
||||
state: columnStates[column.key],
|
||||
refreshMeta: columnRefreshMetaByColumn[column.key],
|
||||
displayItems: boardDisplayItemsByColumn[column.key] || [],
|
||||
});
|
||||
|
||||
return (
|
||||
<ColumnPanel
|
||||
key={column.key}
|
||||
model={model}
|
||||
column={column}
|
||||
state={columnStates[column.key]}
|
||||
displayItems={boardDisplayItemsByColumn[column.key] || []}
|
||||
setState={setColumnStates}
|
||||
refreshMeta={columnRefreshMetaByColumn[column.key]}
|
||||
designSettingsHost={designSettingsHost}
|
||||
fixedHeight={Boolean(contentHeight)}
|
||||
dragEnabled={dragEnabled}
|
||||
dragInteractionEnabled={dragInteractionEnabled}
|
||||
hidden={hidden}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: isFixedHeight ? 0 : DEFAULT_KANBAN_BLOCK_MIN_HEIGHT,
|
||||
gap: 16,
|
||||
height: isFixedHeight ? '100%' : 'auto',
|
||||
}}
|
||||
>
|
||||
{showActionsBar ? <div ref={actionsContainerRef}>{model.renderActions()}</div> : null}
|
||||
{groupOptionsError ? (
|
||||
<div ref={errorContainerRef}>
|
||||
<Alert type="error" showIcon message={groupOptionsError} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dragEnabled ? <DragDropContext onDragEnd={handleDragEnd}>{boardColumns}</DragDropContext> : boardColumns}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
KanbanBlockView.displayName = 'KanbanBlockView';
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { FormLayout } from '@formily/antd-v5';
|
||||
import { SchemaOptionsContext, useField } from '@formily/react';
|
||||
import {
|
||||
APIClientProvider,
|
||||
FormDialog,
|
||||
SchemaComponent,
|
||||
SchemaComponentOptions,
|
||||
Select,
|
||||
useAPIClient,
|
||||
useCompile,
|
||||
useGlobalTheme,
|
||||
} from '@nocobase/client';
|
||||
import { useFlowSettingsContext } from '@nocobase/flow-engine';
|
||||
import { Button, Space } from 'antd';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NAMESPACE } from '../../locale';
|
||||
import { getKanbanDefaultSortFieldName, getKanbanPreferredSortScopeKey } from '../utils';
|
||||
|
||||
type SortFieldOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
scopeKey?: string;
|
||||
uiSchema?: {
|
||||
title?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CollectionFieldMetadata = {
|
||||
name: string;
|
||||
interface?: string;
|
||||
foreignKey?: string;
|
||||
scopeKey?: string;
|
||||
uiSchema?: {
|
||||
title?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GroupFieldOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const upsertKanbanCollectionFieldOptions = (
|
||||
fields: Record<string, any>[] = [],
|
||||
fieldData?: Record<string, any>,
|
||||
) => {
|
||||
if (!fieldData?.name) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
const nextFields = [...fields];
|
||||
const existingIndex = nextFields.findIndex((field) => field?.name === fieldData.name);
|
||||
if (existingIndex >= 0) {
|
||||
nextFields[existingIndex] = {
|
||||
...nextFields[existingIndex],
|
||||
...fieldData,
|
||||
};
|
||||
return nextFields;
|
||||
}
|
||||
|
||||
nextFields.push(fieldData);
|
||||
return nextFields;
|
||||
};
|
||||
|
||||
export const KanbanCreateSortFieldSelect = (props: {
|
||||
sortFields?: SortFieldOption[];
|
||||
collectionFields?: CollectionFieldMetadata[];
|
||||
groupField?: GroupFieldOption;
|
||||
collectionName?: string;
|
||||
dataSource?: string;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const { sortFields = [], collectionFields = [], groupField, collectionName, dataSource, ...others } = props;
|
||||
const formField: any = useField();
|
||||
const compile = useCompile();
|
||||
const api = useAPIClient();
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useGlobalTheme();
|
||||
const schemaOptions = useContext(SchemaOptionsContext);
|
||||
const flowSettingsContext = useFlowSettingsContext<any>();
|
||||
const flowSettings = flowSettingsContext?.model?.context?.engine?.flowSettings;
|
||||
const [sortFieldOptions, setSortFieldOptions] = useState<SortFieldOption[]>(sortFields);
|
||||
const resolvedGroupField = groupField?.value
|
||||
? flowSettingsContext?.model?.collection?.getField?.(groupField.value)
|
||||
: undefined;
|
||||
const groupFieldSortScopeKey = useMemo(
|
||||
() => getKanbanPreferredSortScopeKey(resolvedGroupField) || groupField?.value,
|
||||
[groupField?.value, resolvedGroupField],
|
||||
);
|
||||
const groupFieldScopeOption = useMemo(() => {
|
||||
if (!groupField || !groupFieldSortScopeKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ label: groupField.label, value: groupFieldSortScopeKey }];
|
||||
}, [groupField, groupFieldSortScopeKey]);
|
||||
|
||||
const integerFields = useMemo(() => {
|
||||
return collectionFields.filter((field) => field.interface === 'integer');
|
||||
}, [collectionFields]);
|
||||
|
||||
useEffect(() => {
|
||||
setSortFieldOptions(sortFields);
|
||||
}, [sortFields]);
|
||||
|
||||
const dialogScope = useMemo(() => {
|
||||
return {
|
||||
...(schemaOptions?.scope || {}),
|
||||
...(flowSettings?.scopes || {}),
|
||||
useFlowSettingsContext,
|
||||
};
|
||||
}, [flowSettings?.scopes, schemaOptions?.scope]);
|
||||
|
||||
const dialogComponents = useMemo(() => {
|
||||
return {
|
||||
...(schemaOptions?.components || {}),
|
||||
...(flowSettings?.components || {}),
|
||||
};
|
||||
}, [flowSettings?.components, schemaOptions?.components]);
|
||||
|
||||
const renderScopeKeyDescription = (scopeKey?: string) => {
|
||||
const scopedField = scopeKey ? collectionFields.find((field) => field.name === scopeKey) : undefined;
|
||||
const isGroupFieldScope = Boolean(scopeKey && [groupField?.value, groupFieldSortScopeKey].includes(scopeKey));
|
||||
const scopedFieldSchemaTitle = scopedField?.uiSchema?.title;
|
||||
const scopedFieldTitle = isGroupFieldScope
|
||||
? groupField?.label
|
||||
: scopedFieldSchemaTitle
|
||||
? compile(scopedFieldSchemaTitle)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<span style={{ color: 'rgba(0, 0, 0, 0.25)' }}>
|
||||
(
|
||||
{scopedFieldTitle
|
||||
? t('Grouped sorting based on', { ns: NAMESPACE }) + `「${scopedFieldTitle}」`
|
||||
: t('Global sorting', { ns: NAMESPACE })}
|
||||
)
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const buildCreateSchema = () => ({
|
||||
properties: {
|
||||
'uiSchema.title': {
|
||||
type: 'string',
|
||||
title: t('Field display name'),
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
title: t('Field name'),
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input',
|
||||
'x-validator': 'uid',
|
||||
description: t(
|
||||
'Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.',
|
||||
),
|
||||
},
|
||||
scopeKey: {
|
||||
type: 'string',
|
||||
title: t('Grouped sorting'),
|
||||
default: groupFieldSortScopeKey,
|
||||
'x-disabled': true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: groupFieldScopeOption,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildUpdateSchema = () => ({
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string',
|
||||
title: t('Convert the following integer fields to sorting fields', { ns: NAMESPACE }),
|
||||
required: true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: integerFields.map((field) => ({
|
||||
value: field.name,
|
||||
label: compile(field.uiSchema?.title || field.name),
|
||||
})),
|
||||
},
|
||||
scopeKey: {
|
||||
type: 'string',
|
||||
title: t('Grouped sorting'),
|
||||
default: groupFieldSortScopeKey,
|
||||
'x-disabled': true,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: groupFieldScopeOption,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const openSortFieldDialog = async (schema: any, initialValues: Record<string, any>) => {
|
||||
return FormDialog(
|
||||
t('Create sort field', { ns: NAMESPACE }),
|
||||
() => {
|
||||
return (
|
||||
<APIClientProvider apiClient={api}>
|
||||
<SchemaComponentOptions scope={dialogScope} components={dialogComponents}>
|
||||
<FormLayout layout={'vertical'}>
|
||||
<SchemaComponent schema={schema} />
|
||||
</FormLayout>
|
||||
</SchemaComponentOptions>
|
||||
</APIClientProvider>
|
||||
);
|
||||
},
|
||||
theme,
|
||||
).open({ initialValues });
|
||||
};
|
||||
|
||||
const appendSortField = (fieldData: any) => {
|
||||
const nextOption = {
|
||||
...fieldData,
|
||||
value: fieldData.name,
|
||||
label: compile(fieldData?.uiSchema?.title || fieldData.name),
|
||||
};
|
||||
setSortFieldOptions((previousOptions) => {
|
||||
const currentOptions = previousOptions.length ? previousOptions : sortFields;
|
||||
return upsertKanbanCollectionFieldOptions(
|
||||
currentOptions as Record<string, any>[],
|
||||
nextOption,
|
||||
) as SortFieldOption[];
|
||||
});
|
||||
formField.dataSource = upsertKanbanCollectionFieldOptions(
|
||||
(formField.dataSource || sortFields) as Record<string, any>[],
|
||||
nextOption,
|
||||
);
|
||||
formField.value = fieldData.name;
|
||||
|
||||
const collection = flowSettingsContext?.model?.collection as
|
||||
| { getFields?: () => Record<string, any>[]; setOption?: (key: string, value: any) => void }
|
||||
| undefined;
|
||||
if (collection?.setOption && collection?.getFields) {
|
||||
collection.setOption('fields', upsertKanbanCollectionFieldOptions(collection.getFields(), fieldData));
|
||||
(collection as any).fieldsMap = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSortField = async () => {
|
||||
const values = await openSortFieldDialog(buildCreateSchema(), {
|
||||
name: getKanbanDefaultSortFieldName(groupField?.value),
|
||||
});
|
||||
|
||||
const { data } = await api.resource('collections.fields', collectionName).create({
|
||||
values: {
|
||||
type: 'sort',
|
||||
interface: 'sort',
|
||||
...values,
|
||||
},
|
||||
});
|
||||
|
||||
appendSortField(data.data);
|
||||
};
|
||||
|
||||
const handleUpdateSortField = async () => {
|
||||
const values = await openSortFieldDialog(buildUpdateSchema(), {});
|
||||
|
||||
const { data } = await api.request({
|
||||
url: `dataSourcesCollections/${dataSource}.${collectionName}/fields:update?filterByTk=${values.field}`,
|
||||
method: 'post',
|
||||
data: { type: 'sort', interface: 'sort', ...values },
|
||||
});
|
||||
|
||||
appendSortField(data.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Select
|
||||
options={sortFieldOptions}
|
||||
{...others}
|
||||
disabled={!groupField}
|
||||
optionRender={({ label, data }) => {
|
||||
return (
|
||||
<Space>
|
||||
<span>{label}</span>
|
||||
</Space>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button disabled={!groupField} onClick={dataSource === 'main' ? handleCreateSortField : handleUpdateSortField}>
|
||||
{t('Add new')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
);
|
||||
};
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MenuOutlined } from '@ant-design/icons';
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { observer } from '@formily/react';
|
||||
import { useAPIClient, useCollectionManager_deprecated } from '@nocobase/client';
|
||||
import { MultiRecordResource, useFlowSettingsContext } from '@nocobase/flow-engine';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { Alert, Empty, Input, Select, Space, Spin, Switch, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { KanbanBlockModel } from '../KanbanBlockModel';
|
||||
import {
|
||||
getKanbanCollectionField,
|
||||
getKanbanCollectionFilterTargetKey,
|
||||
getKanbanCollectionTitleField,
|
||||
isSameKanbanGroupingValue,
|
||||
KANBAN_COLOR_OPTIONS,
|
||||
normalizeKanbanGroupOptions,
|
||||
reorderKanbanGroupOptions,
|
||||
type KanbanGroupOption,
|
||||
} from '../utils';
|
||||
|
||||
type GroupOptionsValue = KanbanGroupOption[];
|
||||
|
||||
type RelationOptionRecord = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type SortableRowProps = React.HTMLAttributes<HTMLTableRowElement> & {
|
||||
'data-row-key': string;
|
||||
};
|
||||
|
||||
const SortableRowContext = React.createContext<{
|
||||
attributes?: DraggableAttributes;
|
||||
listeners?: DraggableSyntheticListeners;
|
||||
setActivatorNodeRef?: (node: HTMLElement | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
const SortableRow = (props: SortableRowProps) => {
|
||||
const id = String(props['data-row-key']);
|
||||
const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
...props.style,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative' as const, zIndex: 1 } : null),
|
||||
};
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners, setActivatorNodeRef }}>
|
||||
<tr ref={setNodeRef} {...props} style={style} />
|
||||
</SortableRowContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SortHandle = () => {
|
||||
const dragContext = React.useContext(SortableRowContext);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={dragContext?.setActivatorNodeRef}
|
||||
{...dragContext?.attributes}
|
||||
{...dragContext?.listeners}
|
||||
style={{ cursor: 'grab', display: 'inline-flex', alignItems: 'center' }}
|
||||
>
|
||||
<MenuOutlined />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeRelationGroupOptions = (
|
||||
records: RelationOptionRecord[],
|
||||
targetCollection: any,
|
||||
saved: KanbanGroupOption[] = [],
|
||||
) => {
|
||||
const titleField = getKanbanCollectionTitleField(targetCollection);
|
||||
const filterTargetKey = getKanbanCollectionFilterTargetKey(targetCollection);
|
||||
|
||||
return normalizeKanbanGroupOptions(
|
||||
records.map((item) => ({
|
||||
label: item?.[titleField] || item?.[filterTargetKey],
|
||||
value: item?.[filterTargetKey],
|
||||
})),
|
||||
saved,
|
||||
);
|
||||
};
|
||||
|
||||
const loadRelationGroupOptions = async (
|
||||
model: KanbanBlockModel | undefined,
|
||||
field: any,
|
||||
api: any,
|
||||
dataSourceKey?: string,
|
||||
savedOptions: KanbanGroupOption[] = [],
|
||||
): Promise<KanbanGroupOption[]> => {
|
||||
const targetCollection = field?.targetCollection;
|
||||
if (!targetCollection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (model) {
|
||||
const params = model.getResourceSettingsInitParams();
|
||||
const resource = model.context.createResource(MultiRecordResource);
|
||||
const sortField = getKanbanCollectionTitleField(targetCollection);
|
||||
resource.setDataSourceKey(params.dataSourceKey);
|
||||
resource.setResourceName(targetCollection.name);
|
||||
resource.setPage(1);
|
||||
resource.setPageSize(200);
|
||||
resource.setSort([sortField]);
|
||||
await resource.refresh();
|
||||
|
||||
return normalizeRelationGroupOptions(resource.getData() as RelationOptionRecord[], targetCollection, savedOptions);
|
||||
}
|
||||
|
||||
const response = await api.resource(targetCollection.name).list({
|
||||
paginate: false,
|
||||
sort: [getKanbanCollectionTitleField(targetCollection)],
|
||||
...(dataSourceKey && dataSourceKey !== 'main' ? { dataSourceKey } : {}),
|
||||
});
|
||||
const rows = response?.data?.data || response?.data?.rows || response?.data || [];
|
||||
return normalizeRelationGroupOptions(Array.isArray(rows) ? rows : [], targetCollection, savedOptions);
|
||||
};
|
||||
|
||||
export const KanbanGroupOptionsTable = observer(
|
||||
({
|
||||
value,
|
||||
onChange,
|
||||
groupFieldName,
|
||||
model,
|
||||
collection,
|
||||
dataSourceKey,
|
||||
}: {
|
||||
value?: GroupOptionsValue;
|
||||
onChange?: (value: GroupOptionsValue) => void;
|
||||
groupFieldName?: string;
|
||||
model?: KanbanBlockModel;
|
||||
collection?: any;
|
||||
dataSourceKey?: string;
|
||||
}) => {
|
||||
let settingsContext: any;
|
||||
try {
|
||||
settingsContext = useFlowSettingsContext<KanbanBlockModel>();
|
||||
} catch (error) {
|
||||
settingsContext = undefined;
|
||||
}
|
||||
|
||||
const api = useAPIClient();
|
||||
const resolvedModel = model || settingsContext?.model;
|
||||
const resolvedCollection = resolvedModel?.collection || collection || settingsContext?.collection;
|
||||
const resolvedDataSourceKey =
|
||||
resolvedCollection?.dataSourceKey || dataSourceKey || settingsContext?.dataSource?.key;
|
||||
useCollectionManager_deprecated(resolvedDataSourceKey);
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
const translate = useCallback((key: string) => resolvedModel?.translate?.(key) || key, [resolvedModel]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
const [optionsError, setOptionsError] = useState<string>();
|
||||
const groupOptions = useMemo(() => value || [], [value]);
|
||||
const currentField = useMemo(() => {
|
||||
return getKanbanCollectionField(resolvedCollection, groupFieldName);
|
||||
}, [groupFieldName, resolvedCollection]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const syncOptions = async () => {
|
||||
if (!currentField || !onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionsLoading(true);
|
||||
setOptionsError(undefined);
|
||||
|
||||
try {
|
||||
const nextOptions = await loadRelationGroupOptions(
|
||||
resolvedModel,
|
||||
currentField,
|
||||
api,
|
||||
resolvedDataSourceKey,
|
||||
groupOptions,
|
||||
);
|
||||
const normalizedOptions = nextOptions.length
|
||||
? nextOptions
|
||||
: normalizeKanbanGroupOptions(currentField?.uiSchema?.enum || [], groupOptions);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isSameKanbanGroupingValue(
|
||||
{ groupField: groupFieldName, groupOptions },
|
||||
{ groupField: groupFieldName, groupOptions: normalizedOptions },
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(normalizedOptions);
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setOptionsError(error?.message || translate('Failed to load group options'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setOptionsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!groupFieldName) {
|
||||
if (groupOptions.length) {
|
||||
onChange?.([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void syncOptions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [api, currentField, groupFieldName, groupOptions, onChange, resolvedDataSourceKey, resolvedModel, translate]);
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
return groupOptions.map((item, index) => ({ ...item, key: item.value || String(index) }));
|
||||
}, [groupOptions]);
|
||||
|
||||
const updateOptions = useCallback(
|
||||
(updater: (items: KanbanGroupOption[]) => KanbanGroupOption[]) => {
|
||||
onChange?.(updater(groupOptions));
|
||||
},
|
||||
[groupOptions, onChange],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<KanbanGroupOption & { key: string }>>(
|
||||
() => [
|
||||
{
|
||||
title: translate('Sort'),
|
||||
dataIndex: 'sort',
|
||||
width: 72,
|
||||
render: () => <SortHandle />,
|
||||
},
|
||||
{
|
||||
title: translate('Label'),
|
||||
dataIndex: 'label',
|
||||
render: (_value, record) => (
|
||||
<Input
|
||||
value={record.label}
|
||||
onChange={(event) => {
|
||||
updateOptions((items) =>
|
||||
items.map((item) => (item.value === record.value ? { ...item, label: event.target.value } : item)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: translate('Color'),
|
||||
dataIndex: 'color',
|
||||
width: 180,
|
||||
render: (_value, record) => (
|
||||
<Select
|
||||
value={record.color}
|
||||
style={{ width: '100%' }}
|
||||
options={KANBAN_COLOR_OPTIONS.map((color) => ({
|
||||
label: <Tag color={color}>{color}</Tag>,
|
||||
value: color,
|
||||
}))}
|
||||
onChange={(color) => {
|
||||
updateOptions((items) =>
|
||||
items.map((item) => (item.value === record.value ? { ...item, color } : item)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: translate('Enabled'),
|
||||
dataIndex: 'enabled',
|
||||
width: 120,
|
||||
render: (_value, record) => (
|
||||
<Switch
|
||||
checked={record.enabled !== false}
|
||||
onChange={(enabled) => {
|
||||
updateOptions((items) =>
|
||||
items.map((item) => (item.value === record.value ? { ...item, enabled } : item)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[translate, updateOptions],
|
||||
);
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!active?.id || !over?.id || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = dataSource.findIndex((item) => item.key === active.id);
|
||||
const newIndex = dataSource.findIndex((item) => item.key === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange?.(reorderKanbanGroupOptions(groupOptions, String(active.id), String(over.id)));
|
||||
};
|
||||
|
||||
if (!groupFieldName) {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={translate('Select a grouping field first')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: optionsError ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{optionsError ? <Alert type="error" message={optionsError} showIcon /> : null}
|
||||
<Spin spinning={optionsLoading}>
|
||||
<div style={{ width: '100%' }}>
|
||||
{dataSource.length ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={dataSource.map((item) => item.key)} strategy={verticalListSortingStrategy}>
|
||||
<Table
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="key"
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
components={{
|
||||
body: {
|
||||
row: SortableRow,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={translate('No options')} />
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
KanbanGroupOptionsTable.displayName = 'KanbanGroupOptionsTable';
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { observer, useForm } from '@formily/react';
|
||||
import { useCollectionManager_deprecated } from '@nocobase/client';
|
||||
import { useFlowSettingsContext } from '@nocobase/flow-engine';
|
||||
import { Alert, Empty, Select, Space, Spin } from 'antd';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { KanbanBlockModel } from '../KanbanBlockModel';
|
||||
import {
|
||||
areKanbanGroupOptionsEqual,
|
||||
getKanbanInlineGroupOptions,
|
||||
isAssociationGroupField,
|
||||
normalizeKanbanGroupOptions,
|
||||
type KanbanGroupOption,
|
||||
type KanbanGroupingValue,
|
||||
} from '../utils';
|
||||
|
||||
const getGroupingOptionKey = (value: any) => String(value?.value ?? value ?? '');
|
||||
|
||||
const isGroupingValue = (value: unknown): value is KanbanGroupingValue => {
|
||||
return Boolean(value && !Array.isArray(value) && typeof value === 'object');
|
||||
};
|
||||
|
||||
const getGroupOptions = (value?: KanbanGroupOption[] | KanbanGroupingValue) => {
|
||||
return Array.isArray(value) ? value : value?.groupOptions || [];
|
||||
};
|
||||
|
||||
const buildSelectedGroupOptions = (availableOptions: KanbanGroupOption[], nextValues: any[]) => {
|
||||
const optionMap = new Map(availableOptions.map((option) => [getGroupingOptionKey(option), option] as const));
|
||||
|
||||
return nextValues
|
||||
.map((value) => optionMap.get(getGroupingOptionKey(value)))
|
||||
.filter((option): option is KanbanGroupOption => Boolean(option));
|
||||
};
|
||||
|
||||
export const KanbanGroupingSelector = observer(
|
||||
({
|
||||
value,
|
||||
onChange,
|
||||
model,
|
||||
collection,
|
||||
dataSourceKey,
|
||||
}: {
|
||||
value?: KanbanGroupOption[] | KanbanGroupingValue;
|
||||
onChange?: (value: KanbanGroupOption[]) => void;
|
||||
model?: KanbanBlockModel;
|
||||
collection?: any;
|
||||
dataSourceKey?: string;
|
||||
}) => {
|
||||
const form = useForm();
|
||||
let settingsContext: any;
|
||||
try {
|
||||
settingsContext = useFlowSettingsContext<KanbanBlockModel>();
|
||||
} catch (error) {
|
||||
settingsContext = undefined;
|
||||
}
|
||||
|
||||
const resolvedModel = model || settingsContext?.model;
|
||||
const resolvedCollection = resolvedModel?.collection || collection || settingsContext?.collection;
|
||||
const resolvedDataSourceKey =
|
||||
resolvedCollection?.dataSourceKey || dataSourceKey || settingsContext?.dataSource?.key;
|
||||
useCollectionManager_deprecated(resolvedDataSourceKey);
|
||||
|
||||
const translate = useCallback(
|
||||
(key: string) => resolvedModel?.translate?.(key, { ns: 'kanban' }) || key,
|
||||
[resolvedModel],
|
||||
);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
const [optionsError, setOptionsError] = useState<string>();
|
||||
const grouping = (form?.values?.grouping || (isGroupingValue(value) ? value : {}) || {}) as KanbanGroupingValue;
|
||||
const resolvedFieldName = grouping.groupField;
|
||||
const currentField = useMemo(() => {
|
||||
return resolvedFieldName ? resolvedCollection?.getField?.(resolvedFieldName) : undefined;
|
||||
}, [resolvedCollection, resolvedFieldName]);
|
||||
|
||||
const groupOptions = useMemo(() => getGroupOptions(value), [value]);
|
||||
const [availableOptions, setAvailableOptions] = useState<KanbanGroupOption[]>(groupOptions);
|
||||
|
||||
const emitChange = useCallback(
|
||||
(nextOptions: KanbanGroupOption[]) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form?.values?.grouping || !isGroupingValue(value)) {
|
||||
onChange(nextOptions as any);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...value,
|
||||
groupOptions: nextOptions,
|
||||
} as any);
|
||||
},
|
||||
[form, onChange, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const syncOptions = async () => {
|
||||
if (!currentField || !onChange) {
|
||||
setAvailableOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionsLoading(true);
|
||||
setOptionsError(undefined);
|
||||
|
||||
try {
|
||||
const inlineGroupOptions = resolvedModel?.getInlineGroupOptions(currentField);
|
||||
const sourceOptions = inlineGroupOptions?.length
|
||||
? inlineGroupOptions
|
||||
: getKanbanInlineGroupOptions(currentField, groupOptions);
|
||||
const groupTitleField = isAssociationGroupField(currentField)
|
||||
? grouping.groupTitleField || resolvedModel?.getGroupTitleFieldName(currentField)
|
||||
: undefined;
|
||||
const groupColorField = isAssociationGroupField(currentField)
|
||||
? grouping.groupColorField || resolvedModel?.getGroupColorFieldName(currentField)
|
||||
: undefined;
|
||||
|
||||
const nextOptions = sourceOptions.length
|
||||
? normalizeKanbanGroupOptions(sourceOptions, groupOptions)
|
||||
: resolvedModel
|
||||
? await resolvedModel.loadRelationGroupOptions(currentField, {
|
||||
titleFieldName: groupTitleField,
|
||||
colorFieldName: groupColorField,
|
||||
savedOptions: groupOptions,
|
||||
})
|
||||
: [];
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableOptions(nextOptions);
|
||||
|
||||
if (!groupOptions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedKeys = new Set(groupOptions.map((item) => getGroupingOptionKey(item)));
|
||||
const isSelectedGroupOption = (item: KanbanGroupOption) => selectedKeys.has(getGroupingOptionKey(item));
|
||||
const nextGroupOptions = nextOptions.filter(isSelectedGroupOption);
|
||||
|
||||
if (areKanbanGroupOptionsEqual(groupOptions, nextGroupOptions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitChange(nextGroupOptions);
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setOptionsError(error?.message || translate('Failed to load group options'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setOptionsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncOptions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
currentField,
|
||||
form,
|
||||
groupOptions,
|
||||
grouping.groupColorField,
|
||||
grouping.groupTitleField,
|
||||
onChange,
|
||||
resolvedModel,
|
||||
translate,
|
||||
value,
|
||||
emitChange,
|
||||
]);
|
||||
|
||||
const selectedValues = groupOptions.map((item) => item.value);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{optionsError ? <Alert type="error" message={optionsError} showIcon /> : null}
|
||||
<Spin spinning={optionsLoading}>
|
||||
{currentField ? (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedValues}
|
||||
options={availableOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))}
|
||||
placeholder={translate('Select group values')}
|
||||
onChange={(nextValues) => {
|
||||
emitChange(buildSelectedGroupOptions(availableOptions, nextValues));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={translate('Select a grouping field first')} />
|
||||
)}
|
||||
</Spin>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
KanbanGroupingSelector.displayName = 'KanbanGroupingSelector';
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { FlowModelRenderer } from '@nocobase/flow-engine';
|
||||
import React, { memo, useMemo, useRef } from 'react';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import type { KanbanBlockModel } from '../../KanbanBlockModel';
|
||||
import { getKanbanCardRenderKey, getRuntimeRecordKey, type KanbanRuntimeRecord } from './shared';
|
||||
|
||||
export const CardPlaceholder = () => {
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
margin-bottom: 12px;
|
||||
min-height: 132px;
|
||||
border-radius: 12px;
|
||||
background: var(--ant-colorFillQuaternary);
|
||||
opacity: 0.55;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type LazyCardRendererProps = {
|
||||
model: KanbanBlockModel;
|
||||
record: any;
|
||||
index: number;
|
||||
columnKey: string;
|
||||
enableDesignSettings?: boolean;
|
||||
};
|
||||
|
||||
export const LazyCardRenderer = memo(
|
||||
({ model, record, index, columnKey, enableDesignSettings }: LazyCardRendererProps) => {
|
||||
const { ref, inView } = useInView({
|
||||
threshold: 0,
|
||||
triggerOnce: true,
|
||||
rootMargin: '240px 0px 240px 0px',
|
||||
fallbackInView: true,
|
||||
});
|
||||
const recordRef = useRef(record);
|
||||
const indexRef = useRef(index);
|
||||
const hasRenderedContentRef = useRef(false);
|
||||
const cardRenderKey = getKanbanCardRenderKey({ columnKey, record, index });
|
||||
|
||||
recordRef.current = record;
|
||||
indexRef.current = index;
|
||||
if (inView) {
|
||||
hasRenderedContentRef.current = true;
|
||||
}
|
||||
|
||||
const shouldRenderContent = hasRenderedContentRef.current;
|
||||
|
||||
const itemModel = useMemo(() => {
|
||||
if (!shouldRenderContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextItemModel = model.subModels.item.createFork({}, cardRenderKey);
|
||||
nextItemModel.context.defineProperty('collection', {
|
||||
get: () => model.collection,
|
||||
cache: false,
|
||||
});
|
||||
nextItemModel.context.defineProperty('record', {
|
||||
get: () => recordRef.current,
|
||||
cache: false,
|
||||
});
|
||||
nextItemModel.context.defineProperty('index', {
|
||||
get: () => indexRef.current,
|
||||
cache: false,
|
||||
});
|
||||
nextItemModel.context.defineProperty('onCardClick', {
|
||||
get: () => () => model.openCard(recordRef.current),
|
||||
cache: false,
|
||||
});
|
||||
return nextItemModel;
|
||||
}, [cardRenderKey, model, shouldRenderContent]);
|
||||
|
||||
if (!shouldRenderContent) {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CardPlaceholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!itemModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowModelRenderer
|
||||
model={itemModel}
|
||||
inputArgs={{ record, index, columnKey }}
|
||||
showFlowSettings={
|
||||
enableDesignSettings ? { showBackground: false, showBorder: false, toolbarPosition: 'inside' } : false
|
||||
}
|
||||
useCache={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
areLazyCardRendererPropsEqual,
|
||||
);
|
||||
|
||||
function areLazyCardRendererPropsEqual(prev: LazyCardRendererProps, next: LazyCardRendererProps) {
|
||||
return (
|
||||
prev.model === next.model &&
|
||||
prev.record === next.record &&
|
||||
prev.columnKey === next.columnKey &&
|
||||
prev.enableDesignSettings === next.enableDesignSettings
|
||||
);
|
||||
}
|
||||
|
||||
type DraggableKanbanCardProps = {
|
||||
model: KanbanBlockModel;
|
||||
record: KanbanRuntimeRecord;
|
||||
index: number;
|
||||
columnKey: string;
|
||||
dragEnabled: boolean;
|
||||
dragInteractionEnabled: boolean;
|
||||
enableDesignSettings?: boolean;
|
||||
};
|
||||
|
||||
export const DraggableKanbanCard = memo(
|
||||
({
|
||||
model,
|
||||
record,
|
||||
index,
|
||||
columnKey,
|
||||
dragEnabled,
|
||||
dragInteractionEnabled,
|
||||
enableDesignSettings,
|
||||
}: DraggableKanbanCardProps) => {
|
||||
const recordKey = getRuntimeRecordKey(record, model.collection);
|
||||
const draggableId = String(recordKey || `${columnKey}:${index}`);
|
||||
|
||||
return (
|
||||
<Draggable draggableId={draggableId} index={index} isDragDisabled={!dragInteractionEnabled || !recordKey}>
|
||||
{(provided: any, snapshot: any) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
opacity: snapshot.isDragging ? 0.4 : 1,
|
||||
cursor: dragEnabled && recordKey ? 'grab' : undefined,
|
||||
zIndex: snapshot.isDragging ? 999 : undefined,
|
||||
willChange: snapshot.isDragging ? 'transform' : undefined,
|
||||
}}
|
||||
className={css`
|
||||
margin-bottom: 12px;
|
||||
backface-visibility: hidden;
|
||||
contain: layout paint;
|
||||
`}
|
||||
>
|
||||
<LazyCardRenderer
|
||||
model={model}
|
||||
record={record}
|
||||
index={index}
|
||||
columnKey={columnKey}
|
||||
enableDesignSettings={enableDesignSettings}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
},
|
||||
areDraggableKanbanCardPropsEqual,
|
||||
);
|
||||
|
||||
function areDraggableKanbanCardPropsEqual(prev: DraggableKanbanCardProps, next: DraggableKanbanCardProps) {
|
||||
return (
|
||||
prev.model === next.model &&
|
||||
prev.record === next.record &&
|
||||
prev.index === next.index &&
|
||||
prev.columnKey === next.columnKey &&
|
||||
prev.dragEnabled === next.dragEnabled &&
|
||||
prev.dragInteractionEnabled === next.dragInteractionEnabled &&
|
||||
prev.enableDesignSettings === next.enableDesignSettings
|
||||
);
|
||||
}
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Alert, Badge, Button, Empty, Tooltip } from 'antd';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
import type { KanbanBlockModel } from '../../KanbanBlockModel';
|
||||
import { toKanbanAlphaColor } from '../../utils';
|
||||
import { CardPlaceholder, DraggableKanbanCard, LazyCardRenderer } from './CardRenderer';
|
||||
import {
|
||||
type ColumnRefreshMeta,
|
||||
createColumnResource,
|
||||
createInitialColumnState,
|
||||
dedupeColumnItemsByRecordKey,
|
||||
getRuntimeRecordKey,
|
||||
normalizeKanbanRuntimeRecord,
|
||||
reuseKanbanRecordReferences,
|
||||
type ColumnState,
|
||||
type KanbanDesignSettingsHost,
|
||||
type KanbanRuntimeRecord,
|
||||
type RuntimeColumn,
|
||||
} from './shared';
|
||||
|
||||
const DEFAULT_KANBAN_BLOCK_MIN_HEIGHT = '60vh';
|
||||
|
||||
type ColumnPanelProps = {
|
||||
model: KanbanBlockModel;
|
||||
column: RuntimeColumn;
|
||||
state: ColumnState | undefined;
|
||||
displayItems: KanbanRuntimeRecord[];
|
||||
setState: React.Dispatch<React.SetStateAction<Record<string, ColumnState>>>;
|
||||
refreshMeta?: ColumnRefreshMeta;
|
||||
designSettingsHost?: KanbanDesignSettingsHost | null;
|
||||
fixedHeight: boolean;
|
||||
dragEnabled: boolean;
|
||||
dragInteractionEnabled: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const ColumnPanelComponent = ({
|
||||
model,
|
||||
column,
|
||||
state,
|
||||
displayItems,
|
||||
setState,
|
||||
refreshMeta,
|
||||
designSettingsHost,
|
||||
fixedHeight,
|
||||
dragEnabled,
|
||||
dragInteractionEnabled,
|
||||
hidden,
|
||||
}: ColumnPanelProps) => {
|
||||
const loadingRef = useRef(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const loadedPageCountRef = useRef(1);
|
||||
const [autoLoadArmed, setAutoLoadArmed] = useState(false);
|
||||
const runtimeState = state || createInitialColumnState();
|
||||
const displayCount = runtimeState.count ?? displayItems.length;
|
||||
const styleVariant = model.getStyleVariant();
|
||||
const isColorStyle = styleVariant === 'filled';
|
||||
const hasColumnColor = Boolean(column.color);
|
||||
const panelBackgroundColor = isColorStyle
|
||||
? hasColumnColor
|
||||
? toKanbanAlphaColor(column.color, column.isUnknown ? 0.08 : 0.1)
|
||||
: 'var(--colorBgLayout)'
|
||||
: 'var(--colorBgLayout)';
|
||||
const panelBorderColor = isColorStyle
|
||||
? hasColumnColor
|
||||
? toKanbanAlphaColor(column.color, column.isUnknown ? 0.2 : 0.24)
|
||||
: 'var(--colorBgContainer)'
|
||||
: 'var(--colorBgContainer)';
|
||||
const panelHeaderBackgroundColor = isColorStyle
|
||||
? hasColumnColor
|
||||
? toKanbanAlphaColor(column.color, column.isUnknown ? 0.14 : 0.18)
|
||||
: 'var(--colorBgLayout)'
|
||||
: 'var(--colorBgLayout)';
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (page: number, append = false) => {
|
||||
if (loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedLoadedPages = Math.max(page, 1);
|
||||
const basePageSize = model.getPageSize() || 10;
|
||||
const requestPage = append ? requestedLoadedPages : 1;
|
||||
const requestPageSize = append ? basePageSize : basePageSize * requestedLoadedPages;
|
||||
|
||||
// Save scroll position before reload so we can restore it after
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const savedScrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
loadingRef.current = true;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[column.key]: {
|
||||
...(prev[column.key] || createInitialColumnState()),
|
||||
loading: true,
|
||||
error: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const resource = createColumnResource(model, column, requestPage, requestPageSize);
|
||||
await resource.refresh();
|
||||
const nextItems = (resource.getData() || []).map((item: any) =>
|
||||
normalizeKanbanRuntimeRecord(item, model.collection),
|
||||
);
|
||||
const metaCount = Number(resource.getMeta('count') ?? nextItems.length);
|
||||
const metaPage = Number(resource.getMeta('page') ?? requestPage);
|
||||
const metaPageSize = Number(resource.getMeta('pageSize') ?? requestPageSize);
|
||||
const metaTotalPage = Number(resource.getMeta('totalPage') ?? 0);
|
||||
const metaHasNext = resource.getMeta('hasNext');
|
||||
const resolvedLoadedPages = append ? metaPage : requestedLoadedPages;
|
||||
const loadedItemCount = append ? resolvedLoadedPages * metaPageSize : nextItems.length;
|
||||
const resolvedHasNext =
|
||||
typeof metaHasNext === 'boolean'
|
||||
? metaHasNext
|
||||
: metaTotalPage > 0
|
||||
? loadedItemCount < metaCount
|
||||
: metaCount > loadedItemCount;
|
||||
|
||||
setState((prev) => {
|
||||
const previous = prev[column.key] || createInitialColumnState();
|
||||
const resolvedItems = append
|
||||
? [...previous.items, ...nextItems]
|
||||
: refreshMeta?.reason === 'drag'
|
||||
? reuseKanbanRecordReferences({
|
||||
previousItems: previous.items,
|
||||
nextItems,
|
||||
collection: model.collection,
|
||||
skipRecordKeys: [refreshMeta.activeRecordKey],
|
||||
})
|
||||
: nextItems;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[column.key]: {
|
||||
items: dedupeColumnItemsByRecordKey(resolvedItems, model.collection),
|
||||
page: resolvedLoadedPages,
|
||||
hasNext: resolvedHasNext,
|
||||
count: Number.isNaN(metaCount) ? nextItems.length : metaCount,
|
||||
loading: false,
|
||||
loadedRefreshToken: refreshMeta?.token || 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Restore scroll position after non-append reload
|
||||
if (!append && savedScrollTop > 0 && scrollContainer) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollContainer.scrollTop = savedScrollTop;
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[column.key]: {
|
||||
...(prev[column.key] || createInitialColumnState()),
|
||||
loading: false,
|
||||
error: error?.message || model.translate('Failed to load data'),
|
||||
loadedRefreshToken: refreshMeta?.token || 0,
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
}
|
||||
},
|
||||
[column, model, refreshMeta, setState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadedPageCountRef.current = Math.max(state?.page || 1, 1);
|
||||
}, [state?.page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoLoadArmed || fixedHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleWindowScroll = () => {
|
||||
setAutoLoadArmed(true);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleWindowScroll);
|
||||
};
|
||||
}, [autoLoadArmed, fixedHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage(loadedPageCountRef.current, false);
|
||||
}, [column.key, loadPage, refreshMeta?.token]);
|
||||
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
|
||||
if (!autoLoadArmed) {
|
||||
setAutoLoadArmed(true);
|
||||
}
|
||||
|
||||
if (runtimeState.loading || !runtimeState.hasNext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
if (distanceToBottom < 120) {
|
||||
void loadPage(runtimeState.page + 1, true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const sentinelElement = loadMoreSentinelRef.current;
|
||||
if (
|
||||
!sentinelElement ||
|
||||
!autoLoadArmed ||
|
||||
runtimeState.loading ||
|
||||
!runtimeState.hasNext ||
|
||||
typeof IntersectionObserver === 'undefined'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry?.isIntersecting || loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadPage(runtimeState.page + 1, true);
|
||||
},
|
||||
{
|
||||
root: fixedHeight ? scrollContainerRef.current : null,
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(sentinelElement);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [autoLoadArmed, fixedHeight, loadPage, runtimeState.hasNext, runtimeState.loading, runtimeState.page]);
|
||||
|
||||
const setBodyRef = useCallback((node: HTMLDivElement | null, provided?: any) => {
|
||||
scrollContainerRef.current = node;
|
||||
|
||||
if (!provided?.innerRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof provided.innerRef === 'function') {
|
||||
provided.innerRef(node);
|
||||
return;
|
||||
}
|
||||
|
||||
provided.innerRef.current = node;
|
||||
}, []);
|
||||
|
||||
const renderColumnBody = (options?: { provided?: any; snapshot?: any }) => (
|
||||
<div
|
||||
ref={(node) => setBodyRef(node, options?.provided)}
|
||||
{...(options?.provided?.droppableProps || {})}
|
||||
onScroll={handleScroll}
|
||||
style={
|
||||
dragEnabled && options?.snapshot?.isDraggingOver && hasColumnColor
|
||||
? {
|
||||
background: toKanbanAlphaColor(column.color, column.isUnknown ? 0.12 : 0.14),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={css`
|
||||
flex: 1 1 0;
|
||||
min-height: ${fixedHeight ? '0' : '180px'};
|
||||
overflow-x: hidden;
|
||||
overflow-y: ${fixedHeight ? 'auto' : 'visible'};
|
||||
padding: 12px;
|
||||
scrollbar-gutter: stable;
|
||||
transition: background 0.2s ease;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: ${fixedHeight ? '100%' : '120px'};
|
||||
`}
|
||||
>
|
||||
{runtimeState.loading && !displayItems.length ? (
|
||||
<CardPlaceholder />
|
||||
) : displayItems.length ? (
|
||||
displayItems.map((record, index) => {
|
||||
const recordKey = getRuntimeRecordKey(record, model.collection);
|
||||
const cardRenderKey = recordKey || `${column.key}:${index}`;
|
||||
const enableDesignSettings = Boolean(model.context.flowSettingsEnabled);
|
||||
|
||||
if (!dragEnabled || !recordKey) {
|
||||
return (
|
||||
<div
|
||||
key={`card-wrap:${cardRenderKey}`}
|
||||
className={css`
|
||||
margin-bottom: 12px;
|
||||
`}
|
||||
>
|
||||
<LazyCardRenderer
|
||||
model={model}
|
||||
record={record}
|
||||
index={index}
|
||||
columnKey={column.key}
|
||||
enableDesignSettings={enableDesignSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableKanbanCard
|
||||
key={`card-wrap:${cardRenderKey}`}
|
||||
model={model}
|
||||
record={record}
|
||||
index={index}
|
||||
columnKey={column.key}
|
||||
dragEnabled={dragEnabled}
|
||||
dragInteractionEnabled={dragInteractionEnabled}
|
||||
enableDesignSettings={enableDesignSettings}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={model.translate('No data')} />
|
||||
)}
|
||||
{options?.provided?.placeholder}
|
||||
{runtimeState.hasNext ? <div ref={loadMoreSentinelRef} style={{ height: 1 }} /> : null}
|
||||
</div>
|
||||
{/* {runtimeState.hasNext ? (
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
<Button block loading={runtimeState.loading} onClick={() => void loadPage(runtimeState.page + 1, true)}>
|
||||
{model.translate('Load more')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null} */}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
display: ${hidden ? 'none' : 'flex'};
|
||||
width: ${model.getColumnWidth()}px;
|
||||
flex-direction: column;
|
||||
min-height: ${fixedHeight ? '0' : DEFAULT_KANBAN_BLOCK_MIN_HEIGHT};
|
||||
height: ${fixedHeight ? '100%' : 'auto'};
|
||||
background: ${panelBackgroundColor};
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${panelBorderColor};
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '12px 14px',
|
||||
background: panelHeaderBackgroundColor,
|
||||
borderBottom: `1px solid ${panelBorderColor}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 auto', minWidth: 0 }}>
|
||||
<Badge color={column.color}></Badge>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{`${column.label}(${displayCount})`}
|
||||
</span>
|
||||
</div>
|
||||
{model.getQuickCreateEnabled() ? (
|
||||
<Tooltip title={model.translate('Add new')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void model.openQuickCreate(column);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{runtimeState.error ? <Alert type="error" message={runtimeState.error} showIcon /> : null}
|
||||
{dragEnabled ? (
|
||||
<Droppable droppableId={String(column.key)} isDropDisabled={!dragInteractionEnabled}>
|
||||
{(provided: any, snapshot: any) => renderColumnBody({ provided, snapshot })}
|
||||
</Droppable>
|
||||
) : (
|
||||
renderColumnBody()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnPanel = memo(ColumnPanelComponent, areColumnPanelPropsEqual);
|
||||
ColumnPanel.displayName = 'ColumnPanel';
|
||||
|
||||
function areColumnPanelPropsEqual(prev: ColumnPanelProps, next: ColumnPanelProps) {
|
||||
return (
|
||||
prev.model === next.model &&
|
||||
prev.column === next.column &&
|
||||
prev.state === next.state &&
|
||||
prev.displayItems === next.displayItems &&
|
||||
prev.setState === next.setState &&
|
||||
prev.refreshMeta === next.refreshMeta &&
|
||||
prev.fixedHeight === next.fixedHeight &&
|
||||
prev.dragEnabled === next.dragEnabled &&
|
||||
prev.dragInteractionEnabled === next.dragInteractionEnabled &&
|
||||
prev.hidden === next.hidden &&
|
||||
!isDesignSettingsHostChangeRelevant(prev.designSettingsHost, next.designSettingsHost, prev.column.key)
|
||||
);
|
||||
}
|
||||
|
||||
function isDesignSettingsHostChangeRelevant(
|
||||
prevHost: KanbanDesignSettingsHost | null | undefined,
|
||||
nextHost: KanbanDesignSettingsHost | null | undefined,
|
||||
columnKey: string,
|
||||
) {
|
||||
const prevRelevant = prevHost?.columnKey === columnKey ? prevHost : undefined;
|
||||
const nextRelevant = nextHost?.columnKey === columnKey ? nextHost : undefined;
|
||||
|
||||
return (
|
||||
prevRelevant?.columnKey !== nextRelevant?.columnKey ||
|
||||
prevRelevant?.recordKey !== nextRelevant?.recordKey ||
|
||||
prevRelevant?.index !== nextRelevant?.index
|
||||
);
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MultiRecordResource } from '@nocobase/flow-engine';
|
||||
import type { KanbanBlockModel } from '../../KanbanBlockModel';
|
||||
import {
|
||||
createKanbanColumnFilter,
|
||||
DEFAULT_KANBAN_PAGE_SIZE,
|
||||
getEnabledKanbanGroupOptions,
|
||||
getKanbanRecordKey,
|
||||
getKanbanRecordKeyValue,
|
||||
mergeKanbanFilters,
|
||||
} from '../../utils';
|
||||
|
||||
export type DropResult = {
|
||||
source: { droppableId: string; index: number };
|
||||
destination?: { droppableId: string; index: number } | null;
|
||||
};
|
||||
|
||||
export type ColumnState = {
|
||||
items: any[];
|
||||
page: number;
|
||||
hasNext: boolean;
|
||||
count?: number;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
loadedRefreshToken: number;
|
||||
};
|
||||
|
||||
export type RuntimeColumn = {
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
isUnknown?: boolean;
|
||||
};
|
||||
|
||||
export type KanbanDesignSettingsHost = {
|
||||
columnKey: string;
|
||||
recordKey?: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ColumnRefreshMeta = {
|
||||
token: number;
|
||||
reason: 'global' | 'drag';
|
||||
activeRecordKey?: string;
|
||||
};
|
||||
|
||||
export type KanbanRuntimeRecord = {
|
||||
id?: string | number;
|
||||
__kanbanRecordKey?: string;
|
||||
__kanbanRecordKeyValue?: any;
|
||||
__kanbanColumnKey?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export const createInitialColumnState = (loading = true): ColumnState => ({
|
||||
items: [],
|
||||
page: 1,
|
||||
hasNext: false,
|
||||
loading,
|
||||
loadedRefreshToken: -1,
|
||||
});
|
||||
|
||||
export const getRuntimeRecordKey = (
|
||||
record: KanbanRuntimeRecord,
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any },
|
||||
) => {
|
||||
return record?.__kanbanRecordKey || getKanbanRecordKey(record, collection);
|
||||
};
|
||||
|
||||
export const normalizeKanbanRuntimeRecord = (
|
||||
record: any,
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any },
|
||||
): KanbanRuntimeRecord => {
|
||||
const recordKeyValue = getKanbanRecordKeyValue(record, collection);
|
||||
const recordKey =
|
||||
recordKeyValue === undefined || recordKeyValue === null || recordKeyValue === ''
|
||||
? undefined
|
||||
: String(recordKeyValue);
|
||||
|
||||
if (!recordKey) {
|
||||
return record;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
__kanbanRecordKey: recordKey,
|
||||
__kanbanRecordKeyValue: recordKeyValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const dedupeColumnItemsByRecordKey = (
|
||||
items: KanbanRuntimeRecord[],
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any },
|
||||
) => {
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
return items.filter((item) => {
|
||||
const recordId = getRuntimeRecordKey(item, collection);
|
||||
if (recordId === undefined || recordId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedId = String(recordId);
|
||||
if (seenIds.has(normalizedId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenIds.add(normalizedId);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getKanbanCardRenderKey = (options: { columnKey: string; record: any; index: number }) => {
|
||||
const { columnKey, record, index } = options;
|
||||
const recordId = record?.__kanbanRecordKey ?? record?.id;
|
||||
|
||||
return recordId === undefined || recordId === null ? `${columnKey}-${index}` : String(recordId);
|
||||
};
|
||||
|
||||
export const getKanbanDesignSettingsHost = (options: {
|
||||
enabled: boolean;
|
||||
columns: RuntimeColumn[];
|
||||
itemsByColumn: Record<string, KanbanRuntimeRecord[]>;
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any };
|
||||
}): KanbanDesignSettingsHost | null => {
|
||||
const { enabled, columns, itemsByColumn, collection } = options;
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
const items = itemsByColumn[column.key] || [];
|
||||
if (!items.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const recordKey = getRuntimeRecordKey(items[0], collection);
|
||||
return {
|
||||
columnKey: column.key,
|
||||
recordKey: recordKey == null ? undefined : String(recordKey),
|
||||
index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const shouldMountUnknownColumn = (options: {
|
||||
state?: ColumnState;
|
||||
refreshMeta?: ColumnRefreshMeta;
|
||||
displayItems?: KanbanRuntimeRecord[];
|
||||
}) => {
|
||||
const { state, refreshMeta, displayItems = [] } = options;
|
||||
|
||||
if (!state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const refreshToken = refreshMeta?.token || 0;
|
||||
if (refreshToken > state.loadedRefreshToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.loading || state.error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return displayItems.length > 0;
|
||||
};
|
||||
|
||||
export const shouldHideUnknownColumn = (options: {
|
||||
state?: ColumnState;
|
||||
refreshMeta?: ColumnRefreshMeta;
|
||||
displayItems?: KanbanRuntimeRecord[];
|
||||
}) => {
|
||||
const { state, refreshMeta, displayItems = [] } = options;
|
||||
|
||||
if (!shouldMountUnknownColumn(options)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (displayItems.length > 0 || state?.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const refreshToken = refreshMeta?.token || 0;
|
||||
return refreshToken > state.loadedRefreshToken || state.loading;
|
||||
};
|
||||
|
||||
export const isKanbanDesignSettingsHost = (options: {
|
||||
host: KanbanDesignSettingsHost | null | undefined;
|
||||
columnKey: string;
|
||||
record: KanbanRuntimeRecord;
|
||||
index: number;
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any };
|
||||
}) => {
|
||||
const { host, columnKey, record, index, collection } = options;
|
||||
|
||||
if (!host || host.columnKey !== columnKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (host.recordKey != null) {
|
||||
const recordKey = getRuntimeRecordKey(record, collection);
|
||||
return recordKey != null && String(recordKey) === host.recordKey;
|
||||
}
|
||||
|
||||
return index === host.index;
|
||||
};
|
||||
|
||||
export const removeKanbanColumnOverride = (record: KanbanRuntimeRecord): KanbanRuntimeRecord => {
|
||||
if (!record?.__kanbanColumnKey) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const { __kanbanColumnKey, ...rest } = record;
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const applyKanbanColumnOverride = (options: {
|
||||
record: KanbanRuntimeRecord;
|
||||
sourceColumnKey: string;
|
||||
targetColumnKey: string;
|
||||
}) => {
|
||||
const { record, sourceColumnKey, targetColumnKey } = options;
|
||||
|
||||
if (sourceColumnKey === targetColumnKey) {
|
||||
return removeKanbanColumnOverride(record);
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
__kanbanColumnKey: targetColumnKey,
|
||||
};
|
||||
};
|
||||
|
||||
export const moveKanbanRecordByCoordinates = (options: {
|
||||
itemsByColumn: Record<string, KanbanRuntimeRecord[]>;
|
||||
sourceColumnKey: string;
|
||||
sourceIndex: number;
|
||||
targetColumnKey: string;
|
||||
targetIndex: number;
|
||||
}) => {
|
||||
const { itemsByColumn, sourceColumnKey, sourceIndex, targetColumnKey, targetIndex } = options;
|
||||
const sourceItems = itemsByColumn[sourceColumnKey] || [];
|
||||
|
||||
if (!sourceItems[sourceIndex]) {
|
||||
return itemsByColumn;
|
||||
}
|
||||
|
||||
if (sourceColumnKey === targetColumnKey) {
|
||||
const nextItems = [...sourceItems];
|
||||
const [movedRecord] = nextItems.splice(sourceIndex, 1);
|
||||
nextItems.splice(Math.max(0, Math.min(targetIndex, nextItems.length)), 0, movedRecord);
|
||||
|
||||
return {
|
||||
...itemsByColumn,
|
||||
[sourceColumnKey]: nextItems,
|
||||
} as Record<string, KanbanRuntimeRecord[]>;
|
||||
}
|
||||
|
||||
const nextSourceItems = [...sourceItems];
|
||||
const [movedRecord] = nextSourceItems.splice(sourceIndex, 1);
|
||||
const nextTargetItems = [...(itemsByColumn[targetColumnKey] || [])];
|
||||
nextTargetItems.splice(Math.max(0, Math.min(targetIndex, nextTargetItems.length)), 0, movedRecord);
|
||||
|
||||
return {
|
||||
...itemsByColumn,
|
||||
[sourceColumnKey]: nextSourceItems,
|
||||
[targetColumnKey]: nextTargetItems,
|
||||
} as Record<string, KanbanRuntimeRecord[]>;
|
||||
};
|
||||
|
||||
export const areKanbanRecordListsEqual = (
|
||||
left: KanbanRuntimeRecord[] = [],
|
||||
right: KanbanRuntimeRecord[] = [],
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any },
|
||||
) => {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const nextItem = right[index];
|
||||
return (
|
||||
item === nextItem &&
|
||||
getRuntimeRecordKey(item, collection) === getRuntimeRecordKey(nextItem, collection) &&
|
||||
item?.__kanbanColumnKey === nextItem?.__kanbanColumnKey
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const reuseKanbanRecordReferences = (options: {
|
||||
previousItems?: KanbanRuntimeRecord[];
|
||||
nextItems?: KanbanRuntimeRecord[];
|
||||
collection?: { filterTargetKey?: string | string[]; getFilterByTK?: (record: any) => any };
|
||||
skipRecordKeys?: Array<string | undefined>;
|
||||
}) => {
|
||||
const { previousItems = [], nextItems = [], collection, skipRecordKeys = [] } = options;
|
||||
const previousItemMap = new Map<string, KanbanRuntimeRecord>();
|
||||
const skipKeySet = new Set(skipRecordKeys.filter(Boolean).map(String));
|
||||
|
||||
previousItems.forEach((item) => {
|
||||
const recordKey = getRuntimeRecordKey(item, collection);
|
||||
if (recordKey === undefined || recordKey === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedKey = String(recordKey);
|
||||
if (!previousItemMap.has(normalizedKey)) {
|
||||
previousItemMap.set(normalizedKey, item);
|
||||
}
|
||||
});
|
||||
|
||||
return nextItems.map((item) => {
|
||||
const recordKey = getRuntimeRecordKey(item, collection);
|
||||
if (recordKey === undefined || recordKey === null) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const normalizedKey = String(recordKey);
|
||||
if (skipKeySet.has(normalizedKey)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const previousItem = previousItemMap.get(normalizedKey);
|
||||
if (!previousItem) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (previousItem?.__kanbanColumnKey !== item?.__kanbanColumnKey) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return previousItem;
|
||||
});
|
||||
};
|
||||
|
||||
export const createColumnResource = (
|
||||
model: KanbanBlockModel,
|
||||
column: RuntimeColumn,
|
||||
page: number,
|
||||
pageSize?: number,
|
||||
) => {
|
||||
const params = model.getResourceSettingsInitParams();
|
||||
const baseResource = model.resource;
|
||||
const resource = model.context.createResource(MultiRecordResource);
|
||||
|
||||
resource.setDataSourceKey(params.dataSourceKey);
|
||||
resource.setResourceName(params.associationName || params.collectionName);
|
||||
if (params.sourceId !== undefined && params.sourceId !== null) {
|
||||
resource.setSourceId(params.sourceId as any);
|
||||
}
|
||||
if (baseResource.getFilterByTk() !== undefined && baseResource.getFilterByTk() !== null) {
|
||||
resource.setFilterByTk(baseResource.getFilterByTk());
|
||||
}
|
||||
if (baseResource.getAppends()?.length) {
|
||||
resource.setAppends([...baseResource.getAppends()]);
|
||||
}
|
||||
if (baseResource.getFields()?.length) {
|
||||
resource.setFields([...baseResource.getFields()]);
|
||||
}
|
||||
if (baseResource.getExcept()?.length) {
|
||||
resource.setExcept([...baseResource.getExcept()]);
|
||||
}
|
||||
if (baseResource.getWhitelist()?.length) {
|
||||
resource.setWhitelist([...baseResource.getWhitelist()]);
|
||||
}
|
||||
if (baseResource.getBlacklist()?.length) {
|
||||
resource.setBlacklist([...baseResource.getBlacklist()]);
|
||||
}
|
||||
if (baseResource.getSort()?.length) {
|
||||
resource.setSort([...baseResource.getSort()]);
|
||||
}
|
||||
|
||||
resource.setPageSize(pageSize || model.getPageSize() || DEFAULT_KANBAN_PAGE_SIZE);
|
||||
resource.setPage(page);
|
||||
|
||||
const groupField = model.getGroupField();
|
||||
const enabledValues = getEnabledKanbanGroupOptions(model.getConfiguredGroupOptions()).map((item) => item.value);
|
||||
const mergedFilter = mergeKanbanFilters(
|
||||
baseResource.getFilter(),
|
||||
createKanbanColumnFilter({
|
||||
field: groupField,
|
||||
columnValue: column.value,
|
||||
enabledValues,
|
||||
isUnknown: column.isUnknown,
|
||||
}),
|
||||
);
|
||||
|
||||
if (mergedFilter) {
|
||||
resource.setFilter(mergedFilter);
|
||||
}
|
||||
|
||||
return resource;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './utils';
|
||||
export * from './KanbanBlockModel';
|
||||
export * from './KanbanCardItemModel';
|
||||
export * from './KanbanCollectionActionGroupModel';
|
||||
export * from './actions/KanbanPopupModels';
|
||||
export * from './components/KanbanGroupingSelector';
|
||||
export * from './components/KanbanGroupOptionsTable';
|
||||
export * from './components/KanbanCreateSortFieldSelect';
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { openView } from '@nocobase/client';
|
||||
|
||||
export const normalizeKanbanPopupTargetUid = (value?: string) => {
|
||||
if (!value?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
export const normalizeKanbanPopupTemplateUid = (value?: string) => {
|
||||
return value?.trim() ? value.trim() : undefined;
|
||||
};
|
||||
|
||||
export const resolveKanbanOpenViewDefaultParams = async (ctx: any) => {
|
||||
const commonDefaultParams = openView.defaultParams;
|
||||
const normalizedCtx = {
|
||||
getPropertyMetaTree: ctx.getPropertyMetaTree || (() => []),
|
||||
blockModel: ctx.blockModel || ctx.model?.parent,
|
||||
collection: ctx.collection || ctx.model?.collection || ctx.model?.parent?.collection,
|
||||
resource: ctx.resource || ctx.model?.resource || ctx.model?.parent?.resource,
|
||||
...ctx,
|
||||
};
|
||||
|
||||
return typeof commonDefaultParams === 'function'
|
||||
? (await commonDefaultParams(normalizedCtx)) || {}
|
||||
: commonDefaultParams || {};
|
||||
};
|
||||
@@ -0,0 +1,925 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export const KANBAN_UNKNOWN_COLUMN_KEY = '__unknown__';
|
||||
export const DEFAULT_KANBAN_PAGE_SIZE = 10;
|
||||
export const DEFAULT_KANBAN_COLUMN_WIDTH = 300;
|
||||
|
||||
export const KANBAN_COLOR_OPTIONS = [
|
||||
'default',
|
||||
'red',
|
||||
'volcano',
|
||||
'orange',
|
||||
'gold',
|
||||
'lime',
|
||||
'green',
|
||||
'cyan',
|
||||
'blue',
|
||||
'geekblue',
|
||||
'purple',
|
||||
'magenta',
|
||||
];
|
||||
|
||||
const KANBAN_COLOR_VALUE_MAP: Record<string, string> = {
|
||||
default: '#d9d9d9',
|
||||
red: '#ff4d4f',
|
||||
volcano: '#fa541c',
|
||||
orange: '#fa8c16',
|
||||
gold: '#faad14',
|
||||
lime: '#a0d911',
|
||||
green: '#52c41a',
|
||||
cyan: '#13c2c2',
|
||||
blue: '#1677ff',
|
||||
geekblue: '#2f54eb',
|
||||
purple: '#722ed1',
|
||||
magenta: '#eb2f96',
|
||||
};
|
||||
|
||||
export type KanbanGroupOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
enabled?: boolean;
|
||||
isUnknown?: boolean;
|
||||
};
|
||||
|
||||
export type KanbanGroupingValue = {
|
||||
groupField?: string;
|
||||
groupOptions?: KanbanGroupOption[];
|
||||
groupTitleField?: string;
|
||||
groupColorField?: string;
|
||||
};
|
||||
|
||||
export type KanbanColumnRecord = {
|
||||
id: string | number;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type KanbanColumn = {
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
isUnknown?: boolean;
|
||||
records: KanbanColumnRecord[];
|
||||
};
|
||||
|
||||
type KanbanCollectionIdentity = {
|
||||
filterTargetKey?: string | string[];
|
||||
getFilterByTK?: (record: any) => any;
|
||||
};
|
||||
|
||||
export const KANBAN_GROUP_FIELD_INTERFACES = ['select', 'm2o'] as const;
|
||||
|
||||
const MULTIPLE_GROUP_FIELD_INTERFACES = new Set(['m2m', 'o2m']);
|
||||
const ASSOCIATION_GROUP_FIELD_INTERFACES = new Set(['m2o', 'm2m', 'o2m', 'obo', 'oho']);
|
||||
|
||||
const normalizeFilterTargetKey = (filterTargetKey: any) => {
|
||||
if (typeof filterTargetKey === 'string') {
|
||||
return filterTargetKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(filterTargetKey) && filterTargetKey.length === 1) {
|
||||
return filterTargetKey[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toStringValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getAssociationTargetValue = (
|
||||
value: any,
|
||||
field?: { targetKey?: string; targetCollection?: { filterTargetKey?: string | string[] } },
|
||||
) => {
|
||||
const targetKey = field?.targetKey || normalizeFilterTargetKey(field?.targetCollection?.filterTargetKey);
|
||||
|
||||
if (!targetKey || !value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value?.[targetKey];
|
||||
};
|
||||
|
||||
const extractRelationValue = (
|
||||
value: any,
|
||||
field?: {
|
||||
name?: string;
|
||||
interface?: string;
|
||||
targetKey?: string;
|
||||
targetCollection?: { filterTargetKey?: string | string[] };
|
||||
},
|
||||
): string | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toStringValue(
|
||||
getAssociationTargetValue(value, field) ?? value.id ?? value.value ?? value.filterByTk ?? value.key,
|
||||
);
|
||||
};
|
||||
|
||||
const extractRelationValues = (
|
||||
value: any,
|
||||
field?: {
|
||||
name?: string;
|
||||
interface?: string;
|
||||
targetKey?: string;
|
||||
targetCollection?: { filterTargetKey?: string | string[] };
|
||||
},
|
||||
): string[] => {
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => extractRelationValue(item, field)).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
if (Array.isArray(value?.rows)) {
|
||||
return value.rows.map((item: any) => extractRelationValue(item, field)).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
const relationValue = extractRelationValue(value, field);
|
||||
return relationValue ? [relationValue] : [];
|
||||
};
|
||||
|
||||
const flattenAnd = (filter: Record<string, any> | undefined) => {
|
||||
if (!filter) {
|
||||
return [] as Record<string, any>[];
|
||||
}
|
||||
|
||||
if (Array.isArray(filter.$and)) {
|
||||
return filter.$and.filter(Boolean);
|
||||
}
|
||||
|
||||
return [filter];
|
||||
};
|
||||
|
||||
export const getDefaultKanbanColor = (index: number, fallback?: string) => {
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return KANBAN_COLOR_OPTIONS[index % KANBAN_COLOR_OPTIONS.length];
|
||||
};
|
||||
|
||||
export const resolveKanbanColorValue = (color?: string) => {
|
||||
if (!color) {
|
||||
return KANBAN_COLOR_VALUE_MAP.default;
|
||||
}
|
||||
|
||||
if (KANBAN_COLOR_VALUE_MAP[color]) {
|
||||
return KANBAN_COLOR_VALUE_MAP[color];
|
||||
}
|
||||
|
||||
return color;
|
||||
};
|
||||
|
||||
export const toKanbanAlphaColor = (color?: string, alpha = 1) => {
|
||||
const resolvedColor = resolveKanbanColorValue(color);
|
||||
|
||||
if (resolvedColor.startsWith('#')) {
|
||||
const normalized = resolvedColor.slice(1);
|
||||
const hex =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('')
|
||||
: normalized;
|
||||
|
||||
if (hex.length === 6) {
|
||||
const red = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const green = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
const rgbMatch = resolvedColor.match(/rgba?\(([^)]+)\)/i);
|
||||
if (rgbMatch) {
|
||||
const [red = '217', green = '217', blue = '217'] = rgbMatch[1].split(',').map((item) => item.trim());
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
return resolvedColor;
|
||||
};
|
||||
|
||||
export const normalizeKanbanCardOpenMode = (value?: string) => {
|
||||
if (value === 'modal') {
|
||||
return 'dialog';
|
||||
}
|
||||
|
||||
if (value === 'page') {
|
||||
return 'embed';
|
||||
}
|
||||
|
||||
return value === 'dialog' || value === 'embed' ? value : 'drawer';
|
||||
};
|
||||
|
||||
export const normalizeKanbanPopupSize = (value?: string) => {
|
||||
return value === 'small' || value === 'large' ? value : 'medium';
|
||||
};
|
||||
|
||||
export const isKanbanGroupField = (field: any) => {
|
||||
return !!field?.interface && KANBAN_GROUP_FIELD_INTERFACES.includes(field.interface) && !isMultipleGroupField(field);
|
||||
};
|
||||
|
||||
export const isAssociationGroupField = (field: any) => {
|
||||
return ASSOCIATION_GROUP_FIELD_INTERFACES.has(field?.interface);
|
||||
};
|
||||
|
||||
export const isMultipleGroupField = (field: any) => {
|
||||
return MULTIPLE_GROUP_FIELD_INTERFACES.has(field?.interface);
|
||||
};
|
||||
|
||||
export const getKanbanCollectionFields = (collection: any) => {
|
||||
if (typeof collection?.getFields === 'function') {
|
||||
return collection.getFields();
|
||||
}
|
||||
|
||||
return collection?.fields || collection?.options?.fields || [];
|
||||
};
|
||||
|
||||
export const getKanbanCollectionFieldMetadata = (collection: any) => {
|
||||
return getKanbanCollectionFields(collection).map((field: any) => ({
|
||||
name: field?.name,
|
||||
interface: field?.interface,
|
||||
foreignKey: field?.foreignKey,
|
||||
scopeKey: field?.scopeKey || field?.options?.scopeKey,
|
||||
uiSchema: field?.uiSchema
|
||||
? {
|
||||
title: field.uiSchema.title,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getKanbanFieldScopeKey = (field: any) => {
|
||||
return field?.scopeKey || field?.options?.scopeKey;
|
||||
};
|
||||
|
||||
export const getKanbanGroupFieldSortScopeKeys = (field?: any) => {
|
||||
if (!field?.name) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
if (!isAssociationGroupField(field) || !field?.foreignKey) {
|
||||
return [field.name];
|
||||
}
|
||||
|
||||
return [...new Set([field.foreignKey, field.name].filter(Boolean))];
|
||||
};
|
||||
|
||||
export const getKanbanPreferredSortScopeKey = (field?: any) => {
|
||||
return getKanbanGroupFieldSortScopeKeys(field)[0];
|
||||
};
|
||||
|
||||
export const shouldMigrateKanbanSortFieldScope = (options: { groupField?: any; sortField?: any }) => {
|
||||
const { groupField, sortField } = options;
|
||||
const currentScopeKey = getKanbanFieldScopeKey(sortField);
|
||||
|
||||
if (!currentScopeKey || !groupField?.name || !isAssociationGroupField(groupField) || !groupField?.foreignKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentScopeKey === groupField.name && currentScopeKey !== groupField.foreignKey;
|
||||
};
|
||||
|
||||
export const getKanbanDefaultSortFieldName = (groupFieldName?: string) => {
|
||||
if (!groupFieldName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${groupFieldName}_sort`;
|
||||
};
|
||||
|
||||
export const getKanbanCollectionField = (collection: any, fieldName?: string) => {
|
||||
if (!fieldName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof collection?.getField === 'function') {
|
||||
return collection.getField(fieldName);
|
||||
}
|
||||
|
||||
return getKanbanCollectionFields(collection).find((field: any) => field?.name === fieldName);
|
||||
};
|
||||
|
||||
export const getKanbanCollectionFilterTargetKey = (collection: any) => {
|
||||
if (typeof collection?.getFilterTargetKey === 'function') {
|
||||
return collection.getFilterTargetKey();
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeFilterTargetKey(collection?.filterTargetKey) ||
|
||||
collection?.targetKey ||
|
||||
getKanbanCollectionFields(collection).find((field: any) => field?.primaryKey)?.name ||
|
||||
'id'
|
||||
);
|
||||
};
|
||||
|
||||
export const getKanbanFieldFilterPath = (field?: {
|
||||
name?: string;
|
||||
interface?: string;
|
||||
targetKey?: string;
|
||||
targetCollection?: { filterTargetKey?: string | string[] };
|
||||
}) => {
|
||||
if (!field?.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isAssociationGroupField(field)) {
|
||||
return field.name;
|
||||
}
|
||||
|
||||
const targetKey = field.targetKey || normalizeFilterTargetKey(field.targetCollection?.filterTargetKey) || 'id';
|
||||
|
||||
return `${field.name}.${targetKey}`;
|
||||
};
|
||||
|
||||
export const getKanbanCollectionTitleField = (collection: any) => {
|
||||
return (
|
||||
collection?.titleField || collection?.titleCollectionField?.name || getKanbanCollectionFilterTargetKey(collection)
|
||||
);
|
||||
};
|
||||
|
||||
export const getKanbanCollectionSelectableFields = (collection: any) => {
|
||||
return getKanbanCollectionFields(collection).filter((field: any) => {
|
||||
return field?.name && !isMultipleGroupField(field);
|
||||
});
|
||||
};
|
||||
|
||||
export const getKanbanCollectionFieldOptions = (collection: any) => {
|
||||
return getKanbanCollectionSelectableFields(collection).map((field: any) => ({
|
||||
label: field.title || field.uiSchema?.title || field.name,
|
||||
value: field.name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getKanbanFieldEnumColor = (field: any, value: any) => {
|
||||
const normalizedValue = toStringValue(extractRelationValue(value, field) ?? value);
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const enumOptions = Array.isArray(field?.uiSchema?.enum) ? field.uiSchema.enum : [];
|
||||
const matchedOption = enumOptions.find((item: any) => {
|
||||
return String(item?.value ?? item?.name ?? item?.id ?? '') === normalizedValue;
|
||||
});
|
||||
|
||||
return matchedOption?.color;
|
||||
};
|
||||
|
||||
export const resolveKanbanFieldColorValue = (field: any, value: any) => {
|
||||
const enumColor = getKanbanFieldEnumColor(field, value);
|
||||
if (enumColor) {
|
||||
return enumColor;
|
||||
}
|
||||
|
||||
const normalizedValue = toStringValue(extractRelationValue(value, field) ?? value);
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
KANBAN_COLOR_OPTIONS.includes(normalizedValue) ||
|
||||
normalizedValue.startsWith('#') ||
|
||||
/^rgba?\(/i.test(normalizedValue)
|
||||
) {
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getKanbanGroupFieldCandidates = (collection: any) => {
|
||||
return getKanbanCollectionFields(collection)
|
||||
.filter((field: any) => isKanbanGroupField(field))
|
||||
.map((field: any) => ({
|
||||
label: field.title || field.uiSchema?.title || field.name,
|
||||
value: field.name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getKanbanInlineGroupOptions = (field?: any, savedOptions: Array<Partial<KanbanGroupOption>> = []) => {
|
||||
if (!field) {
|
||||
return [] as KanbanGroupOption[];
|
||||
}
|
||||
|
||||
const enumOptions = field?.uiSchema?.enum;
|
||||
if (!Array.isArray(enumOptions)) {
|
||||
return [] as KanbanGroupOption[];
|
||||
}
|
||||
|
||||
return normalizeKanbanGroupOptions(
|
||||
enumOptions.map((item: any) => ({
|
||||
value: item.value,
|
||||
label: item.label ?? item.title ?? item.uiSchema?.title ?? item.name,
|
||||
color: item.color,
|
||||
})),
|
||||
savedOptions,
|
||||
);
|
||||
};
|
||||
|
||||
const getKanbanGroupOptionLabel = (item: Partial<KanbanGroupOption> & Record<string, any>) => {
|
||||
const resolvedLabel = item?.label ?? item?.title ?? item?.uiSchema?.title ?? item?.name;
|
||||
|
||||
if (typeof resolvedLabel === 'string' || typeof resolvedLabel === 'number' || typeof resolvedLabel === 'boolean') {
|
||||
return String(resolvedLabel);
|
||||
}
|
||||
|
||||
return String(item?.value ?? '');
|
||||
};
|
||||
|
||||
export const areKanbanGroupOptionsEqual = (
|
||||
left: Array<Partial<KanbanGroupOption>> = [],
|
||||
right: Array<Partial<KanbanGroupOption>> = [],
|
||||
) => {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const next = right[index];
|
||||
return (
|
||||
String(item?.value ?? '') === String(next?.value ?? '') &&
|
||||
String(item?.label ?? '') === String(next?.label ?? '') &&
|
||||
String(item?.color ?? '') === String(next?.color ?? '') &&
|
||||
Boolean(item?.isUnknown) === Boolean(next?.isUnknown)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const isSameKanbanGroupingValue = (
|
||||
left?: { groupField?: string; groupOptions?: KanbanGroupOption[] },
|
||||
right?: { groupField?: string; groupOptions?: KanbanGroupOption[] },
|
||||
) => {
|
||||
return (
|
||||
String(left?.groupField ?? '') === String(right?.groupField ?? '') &&
|
||||
areKanbanGroupOptionsEqual(left?.groupOptions || [], right?.groupOptions || [])
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeKanbanGroupOptions = (
|
||||
sourceOptions: Array<Partial<KanbanGroupOption>>,
|
||||
savedOptions: Array<Partial<KanbanGroupOption>> = [],
|
||||
options: {
|
||||
useDefaultColors?: boolean;
|
||||
preserveSavedColors?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const { useDefaultColors = false, preserveSavedColors = true } = options;
|
||||
const savedMap = new Map(savedOptions.map((item) => [String(item.value), item]));
|
||||
const sourceMap = new Map(sourceOptions.map((item) => [String(item.value ?? ''), item]));
|
||||
const mergedOptions = [
|
||||
...savedOptions.map((item) => sourceMap.get(String(item.value ?? ''))).filter(Boolean),
|
||||
...sourceOptions.filter((item) => !savedMap.has(String(item.value ?? ''))),
|
||||
].filter((item): item is Partial<KanbanGroupOption> => Boolean(item));
|
||||
|
||||
return mergedOptions.map((item) => {
|
||||
const value = String(item.value ?? '');
|
||||
const saved = savedMap.get(value);
|
||||
const sourceColor = typeof item.color === 'string' && item.color !== 'default' ? item.color : undefined;
|
||||
const savedColor =
|
||||
preserveSavedColors && typeof saved?.color === 'string' && saved.color !== 'default' ? saved.color : undefined;
|
||||
const resolvedColor = sourceColor || savedColor || (useDefaultColors ? getDefaultKanbanColor(0) : undefined);
|
||||
|
||||
return {
|
||||
value,
|
||||
label: saved?.label ? String(saved.label) : getKanbanGroupOptionLabel(item),
|
||||
color: resolvedColor,
|
||||
isUnknown: item.isUnknown ?? saved?.isUnknown,
|
||||
} satisfies KanbanGroupOption;
|
||||
});
|
||||
};
|
||||
|
||||
export const reorderKanbanGroupOptions = (
|
||||
options: KanbanGroupOption[] = [],
|
||||
activeValue?: string,
|
||||
overValue?: string,
|
||||
) => {
|
||||
if (!activeValue || !overValue || activeValue === overValue) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const oldIndex = options.findIndex((item) => item.value === activeValue);
|
||||
const newIndex = options.findIndex((item) => item.value === overValue);
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const next = [...options];
|
||||
const [moved] = next.splice(oldIndex, 1);
|
||||
next.splice(newIndex, 0, moved);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const orderKanbanGroupOptionsBySelection = (
|
||||
options: KanbanGroupOption[] = [],
|
||||
selectedValues: Array<string | number> = [],
|
||||
) => {
|
||||
const selectedValueKeys = selectedValues.map((value) => String(value));
|
||||
const selectedValueKeySet = new Set(selectedValueKeys);
|
||||
const optionMap = new Map(options.map((item) => [String(item.value), item]));
|
||||
|
||||
const orderedSelected = selectedValueKeys
|
||||
.map((value) => optionMap.get(value))
|
||||
.filter((item): item is KanbanGroupOption => Boolean(item))
|
||||
.map((item) => ({ ...item }));
|
||||
|
||||
const remaining = options.filter((item) => !selectedValueKeySet.has(String(item.value))).map((item) => ({ ...item }));
|
||||
|
||||
return [...orderedSelected, ...remaining];
|
||||
};
|
||||
|
||||
export const getEnabledKanbanGroupOptions = (options: KanbanGroupOption[] = []) => {
|
||||
return options.filter((item) => !item.isUnknown);
|
||||
};
|
||||
|
||||
export const getUnknownKanbanColumn = () => {
|
||||
return {
|
||||
value: KANBAN_UNKNOWN_COLUMN_KEY,
|
||||
key: KANBAN_UNKNOWN_COLUMN_KEY,
|
||||
label: 'Unknown',
|
||||
color: 'default',
|
||||
isUnknown: true,
|
||||
records: [] as KanbanColumnRecord[],
|
||||
} satisfies KanbanColumn & KanbanGroupOption;
|
||||
};
|
||||
|
||||
export const getRecordGroupValues = (record: any, field: { name: string; interface?: string }) => {
|
||||
const fieldValue = record?.[field?.name];
|
||||
|
||||
if (isAssociationGroupField(field)) {
|
||||
return isMultipleGroupField(field)
|
||||
? extractRelationValues(fieldValue, field)
|
||||
: extractRelationValues(fieldValue, field).slice(0, 1);
|
||||
}
|
||||
|
||||
const scalarValue = toStringValue(fieldValue);
|
||||
return scalarValue ? [scalarValue] : [];
|
||||
};
|
||||
|
||||
export const getRecordKanbanColumnKey = (options: {
|
||||
record: any;
|
||||
groupField?: { name: string; interface?: string };
|
||||
groupOptions?: Array<Pick<KanbanGroupOption, 'value'>>;
|
||||
}) => {
|
||||
const { record, groupField, groupOptions = [] } = options;
|
||||
|
||||
if (!groupField) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const recordValues = new Set(getRecordGroupValues(record, groupField).map((value) => String(value)));
|
||||
const matchedOption = groupOptions.find((item) => recordValues.has(String(item.value)));
|
||||
|
||||
return matchedOption ? String(matchedOption.value) : KANBAN_UNKNOWN_COLUMN_KEY;
|
||||
};
|
||||
|
||||
export const getKanbanRecordKeyValue = (record: any, collection?: KanbanCollectionIdentity) => {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const filterByTk = collection?.getFilterByTK?.(record);
|
||||
if (filterByTk !== undefined && filterByTk !== null && filterByTk !== '') {
|
||||
return filterByTk;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid getFilterByTK fallthroughs and continue with field-based lookup
|
||||
}
|
||||
|
||||
const filterTargetKey = collection?.filterTargetKey;
|
||||
if (typeof filterTargetKey === 'string') {
|
||||
return record?.[filterTargetKey];
|
||||
}
|
||||
|
||||
if (Array.isArray(filterTargetKey) && filterTargetKey.length === 1) {
|
||||
return record?.[filterTargetKey[0]];
|
||||
}
|
||||
|
||||
if (Array.isArray(filterTargetKey) && filterTargetKey.length > 1) {
|
||||
return Object.fromEntries(filterTargetKey.map((key) => [key, record?.[key]]));
|
||||
}
|
||||
|
||||
return record?.id;
|
||||
};
|
||||
|
||||
export const stringifyKanbanRecordKey = (value: any): string | undefined => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item ?? '')).join('-');
|
||||
}
|
||||
|
||||
const keys = Object.keys(value).sort();
|
||||
return keys.map((key) => `${key}:${String(value[key] ?? '')}`).join('|');
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const getKanbanRecordKey = (record: any, collection?: KanbanCollectionIdentity) => {
|
||||
return stringifyKanbanRecordKey(getKanbanRecordKeyValue(record, collection));
|
||||
};
|
||||
|
||||
export const getKanbanSameColumnColumnDropMoveParams = (options: {
|
||||
items: any[];
|
||||
sourceRecord: any;
|
||||
collection?: KanbanCollectionIdentity;
|
||||
}) => {
|
||||
const { items, sourceRecord, collection } = options;
|
||||
const sourceRecordKey = getKanbanRecordKey(sourceRecord, collection);
|
||||
const remainingItems = items.filter((item) => getKanbanRecordKey(item, collection) !== sourceRecordKey);
|
||||
const targetRecord = remainingItems[remainingItems.length - 1];
|
||||
const targetId = getKanbanRecordKeyValue(targetRecord, collection);
|
||||
|
||||
if (targetId === undefined || targetId === null || targetId === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
targetId,
|
||||
method: 'insertAfter' as const,
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldInsertAfterSameColumnCardMove = (sourceIndex?: number, targetIndex?: number) => {
|
||||
return typeof sourceIndex === 'number' && typeof targetIndex === 'number' && targetIndex > sourceIndex;
|
||||
};
|
||||
|
||||
export const getKanbanPreviewInsertIndex = (options: {
|
||||
sourceColumnKey: string;
|
||||
currentSourceColumnKey: string;
|
||||
targetColumnKey: string;
|
||||
sourceIndex: number;
|
||||
overIndex: number;
|
||||
targetIndex: number;
|
||||
overType?: string;
|
||||
}) => {
|
||||
const { sourceColumnKey, currentSourceColumnKey, targetColumnKey, sourceIndex, overIndex, targetIndex, overType } =
|
||||
options;
|
||||
|
||||
if (overType !== 'card') {
|
||||
return targetIndex;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceColumnKey === targetColumnKey &&
|
||||
currentSourceColumnKey === targetColumnKey &&
|
||||
shouldInsertAfterSameColumnCardMove(sourceIndex, overIndex)
|
||||
) {
|
||||
return targetIndex + 1;
|
||||
}
|
||||
|
||||
return targetIndex;
|
||||
};
|
||||
|
||||
export const getKanbanMoveParamsFromPreviewOrder = (options: {
|
||||
items: any[];
|
||||
activeRecord: any;
|
||||
collection?: KanbanCollectionIdentity;
|
||||
}) => {
|
||||
const { items, activeRecord, collection } = options;
|
||||
const activeRecordKey = getKanbanRecordKey(activeRecord, collection);
|
||||
|
||||
if (!activeRecordKey) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activeIndex = items.findIndex((item) => getKanbanRecordKey(item, collection) === activeRecordKey);
|
||||
if (activeIndex < 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (activeIndex === 0) {
|
||||
const nextRecord = items[1];
|
||||
const targetId = getKanbanRecordKeyValue(nextRecord, collection);
|
||||
|
||||
return targetId === undefined ? {} : { targetId };
|
||||
}
|
||||
|
||||
const previousRecord = items[activeIndex - 1];
|
||||
const targetId = getKanbanRecordKeyValue(previousRecord, collection);
|
||||
|
||||
return targetId === undefined ? {} : { targetId, method: 'insertAfter' as const };
|
||||
};
|
||||
|
||||
export const getKanbanCrossColumnMoveParams = (options: {
|
||||
overType?: string;
|
||||
overRecord?: any;
|
||||
targetColumnKey: string;
|
||||
groupFieldName?: string;
|
||||
groupFieldScopeKey?: string;
|
||||
collection?: KanbanCollectionIdentity;
|
||||
insertAfter?: boolean;
|
||||
}) => {
|
||||
const { overType, overRecord, targetColumnKey, groupFieldName, groupFieldScopeKey, collection, insertAfter } =
|
||||
options;
|
||||
|
||||
if (overType === 'card' && overRecord) {
|
||||
const targetId = getKanbanRecordKeyValue(overRecord, collection);
|
||||
|
||||
if (targetId !== undefined && targetId !== null && targetId !== '') {
|
||||
return insertAfter ? { targetId, method: 'insertAfter' as const } : { targetId };
|
||||
}
|
||||
}
|
||||
|
||||
const scopeFieldKey = groupFieldScopeKey || groupFieldName;
|
||||
if (!scopeFieldKey) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
targetScope: {
|
||||
[scopeFieldKey]: targetColumnKey === KANBAN_UNKNOWN_COLUMN_KEY ? null : targetColumnKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const buildKanbanBoardDisplayItems = (options: {
|
||||
columnItems: Array<{ columnKey: string; items: any[] }>;
|
||||
collection?: KanbanCollectionIdentity;
|
||||
groupField?: { name: string; interface?: string };
|
||||
groupOptions?: Array<Pick<KanbanGroupOption, 'value'>>;
|
||||
}) => {
|
||||
const { columnItems, collection, groupField, groupOptions = [] } = options;
|
||||
const displayItemsByColumn: Record<string, any[]> = {};
|
||||
const recordsById = new Map<
|
||||
string,
|
||||
Array<{
|
||||
record: any;
|
||||
sourceColumnKey: string;
|
||||
effectiveColumnKey: string;
|
||||
explicitColumnOverride: boolean;
|
||||
}>
|
||||
>();
|
||||
|
||||
columnItems.forEach(({ columnKey, items }) => {
|
||||
displayItemsByColumn[columnKey] = displayItemsByColumn[columnKey] || [];
|
||||
|
||||
items.forEach((record) => {
|
||||
const effectiveColumnKey =
|
||||
record?.__kanbanColumnKey ||
|
||||
getRecordKanbanColumnKey({
|
||||
record,
|
||||
groupField,
|
||||
groupOptions,
|
||||
}) ||
|
||||
KANBAN_UNKNOWN_COLUMN_KEY;
|
||||
const recordId = getKanbanRecordKey(record, collection);
|
||||
|
||||
if (recordId === undefined || recordId === null) {
|
||||
displayItemsByColumn[effectiveColumnKey] = displayItemsByColumn[effectiveColumnKey] || [];
|
||||
displayItemsByColumn[effectiveColumnKey].push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedId = String(recordId);
|
||||
const candidates = recordsById.get(normalizedId) || [];
|
||||
candidates.push({
|
||||
record,
|
||||
sourceColumnKey: columnKey,
|
||||
effectiveColumnKey,
|
||||
explicitColumnOverride: Boolean(record?.__kanbanColumnKey),
|
||||
});
|
||||
recordsById.set(normalizedId, candidates);
|
||||
});
|
||||
});
|
||||
|
||||
recordsById.forEach((candidates) => {
|
||||
const selectedCandidate =
|
||||
candidates.find((candidate) => candidate.explicitColumnOverride) ||
|
||||
candidates.find((candidate) => candidate.sourceColumnKey === candidate.effectiveColumnKey) ||
|
||||
candidates[0];
|
||||
|
||||
if (!selectedCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { effectiveColumnKey, record } = selectedCandidate;
|
||||
displayItemsByColumn[effectiveColumnKey] = displayItemsByColumn[effectiveColumnKey] || [];
|
||||
displayItemsByColumn[effectiveColumnKey].push(record);
|
||||
});
|
||||
|
||||
return displayItemsByColumn;
|
||||
};
|
||||
|
||||
export const buildKanbanColumns = (options: {
|
||||
records: any[];
|
||||
primaryKey: string;
|
||||
groupField: { name: string; interface?: string };
|
||||
groupOptions: KanbanGroupOption[];
|
||||
}) => {
|
||||
const { records, primaryKey, groupField, groupOptions } = options;
|
||||
const columnMap = new Map(
|
||||
groupOptions.map((item) => [
|
||||
item.value,
|
||||
{
|
||||
key: item.value,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
color: item.color,
|
||||
records: [] as KanbanColumnRecord[],
|
||||
} satisfies KanbanColumn,
|
||||
]),
|
||||
);
|
||||
const unknownColumn = getUnknownKanbanColumn();
|
||||
|
||||
records.forEach((record) => {
|
||||
const recordWithId = { ...record, id: record?.[primaryKey] ?? record?.id };
|
||||
const values = getRecordGroupValues(record, groupField);
|
||||
let matched = false;
|
||||
|
||||
values.forEach((value) => {
|
||||
const column = columnMap.get(String(value));
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
matched = true;
|
||||
column.records.push(recordWithId);
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
unknownColumn.records.push(recordWithId);
|
||||
}
|
||||
});
|
||||
|
||||
const orderedColumns: KanbanColumn[] = [];
|
||||
groupOptions.forEach((item) => {
|
||||
const column = columnMap.get(item.value);
|
||||
if (column) {
|
||||
orderedColumns.push(column);
|
||||
}
|
||||
});
|
||||
if (unknownColumn.records.length) {
|
||||
orderedColumns.unshift(unknownColumn);
|
||||
}
|
||||
|
||||
return orderedColumns;
|
||||
};
|
||||
|
||||
export const mergeKanbanFilters = (baseFilter?: Record<string, any>, nextFilter?: Record<string, any>) => {
|
||||
const filters = [...flattenAnd(baseFilter), ...flattenAnd(nextFilter)].filter(Boolean);
|
||||
if (!filters.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return filters.length === 1 ? filters[0] : { $and: filters };
|
||||
};
|
||||
|
||||
export const createKanbanColumnFilter = (options: {
|
||||
field?: {
|
||||
name?: string;
|
||||
interface?: string;
|
||||
targetKey?: string;
|
||||
targetCollection?: { filterTargetKey?: string | string[] };
|
||||
};
|
||||
fieldName?: string;
|
||||
columnValue?: string;
|
||||
enabledValues?: string[];
|
||||
isUnknown?: boolean;
|
||||
}) => {
|
||||
const { field, fieldName, columnValue, enabledValues = [], isUnknown } = options;
|
||||
const resolvedFieldName = getKanbanFieldFilterPath(field) || fieldName;
|
||||
|
||||
if (!resolvedFieldName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isUnknown) {
|
||||
return { [resolvedFieldName]: null };
|
||||
}
|
||||
|
||||
if (!columnValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { [resolvedFieldName]: columnValue };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
declare module 'react-beautiful-dnd' {
|
||||
export const DragDropContext: any;
|
||||
export const Draggable: any;
|
||||
export const Droppable: any;
|
||||
}
|
||||
@@ -1,10 +1,62 @@
|
||||
{
|
||||
"Actions": "Actions",
|
||||
"Add new": "Add new",
|
||||
"At least one option is required": "At least one option is required",
|
||||
"Card settings": "Card settings",
|
||||
"Classic style": "Classic",
|
||||
"Color": "Color",
|
||||
"Color field": "Color field",
|
||||
"Filled style": "Filled",
|
||||
"Column width": "Column width",
|
||||
"Convert the following integer fields to sorting fields": "Convert the following integer fields to sorting fields",
|
||||
"Create sort field": "Create sort field",
|
||||
"Default": "Default",
|
||||
"Default sorting": "Default sorting",
|
||||
"Dialog": "Dialog",
|
||||
"Drag and drop sorting field": "Drag and drop sorting field",
|
||||
"Drag settings": "Drag settings",
|
||||
"Drawer": "Drawer",
|
||||
"Enable click-to-open": "Enable click-to-open",
|
||||
"Enable drag and drop sorting": "Enable drag and drop sorting",
|
||||
"Enable dragging": "Enable dragging",
|
||||
"Enabled": "Enabled",
|
||||
"Failed to load data": "Failed to load data",
|
||||
"Failed to load group options": "Failed to load group options",
|
||||
"Failed to save drag sort": "Failed to save drag sort",
|
||||
"Global sorting": "Global sorting",
|
||||
"Grouped sorting based on": "Grouped sorting based on",
|
||||
"Grouping field": "Grouping field",
|
||||
"Grouping field is required": "Grouping field is required",
|
||||
"Grouping settings": "Grouping settings",
|
||||
"Kanban": "Kanban",
|
||||
"Kanban action": "Kanban action",
|
||||
"Kanban settings": "Kanban settings",
|
||||
"Label": "Label",
|
||||
"Large": "Large",
|
||||
"Load more": "Load more",
|
||||
"Medium": "Medium",
|
||||
"Middle": "Middle",
|
||||
"No data": "No data",
|
||||
"No options": "No options",
|
||||
"Open mode": "Open mode",
|
||||
"Options": "Options",
|
||||
"Page": "Page",
|
||||
"Page size": "Page size",
|
||||
"Popup settings": "Popup settings",
|
||||
"Popup size": "Popup size",
|
||||
"Quick create": "Quick create",
|
||||
"Select a grouping field first": "Select a grouping field first",
|
||||
"Select group values": "Select group values",
|
||||
"The order of the selected values determines the kanban column order": "The order of the selected values determines the kanban column order",
|
||||
"Set column width": "Set column width",
|
||||
"Single select and many-to-one fields can be used as the grouping field": "Single select and many-to-one fields can be used as the grouping field",
|
||||
"Small": "Small",
|
||||
"Sort": "Sort",
|
||||
"Sorting field": "Sorting field",
|
||||
"Used for sorting kanban cards, only sorting fields corresponding to grouping fields can be selected": "Used for sorting kanban cards, only sorting fields corresponding to grouping fields can be selected"
|
||||
"Sorting settings": "Sorting settings",
|
||||
"Style": "Style",
|
||||
"Title field": "Title field",
|
||||
"Unknown": "Unknown",
|
||||
"Used for sorting kanban cards, only sorting fields corresponding to grouping fields can be selected": "Used for sorting kanban cards, only sorting fields corresponding to grouping fields can be selected",
|
||||
"View record": "View record"
|
||||
}
|
||||
@@ -1,10 +1,62 @@
|
||||
{
|
||||
"Actions": "操作",
|
||||
"Add new": "新增",
|
||||
"At least one option is required": "至少需要一个选项",
|
||||
"Card settings": "卡片设置",
|
||||
"Classic style": "经典",
|
||||
"Filled style": "填充背景色",
|
||||
"Color": "颜色",
|
||||
"Color field": "颜色字段",
|
||||
"Column width": "列宽",
|
||||
"Convert the following integer fields to sorting fields": "将以下整数字段转为排序字段",
|
||||
"Create sort field": "创建排序字段",
|
||||
"Default": "默认",
|
||||
"Default sorting": "默认排序",
|
||||
"Dialog": "对话框",
|
||||
"Drag and drop sorting field": "拖拽排序字段",
|
||||
"Drag settings": "拖拽设置",
|
||||
"Drawer": "抽屉",
|
||||
"Enable click-to-open": "启用点击打开",
|
||||
"Enable drag and drop sorting": "启用拖拽排序",
|
||||
"Enable dragging": "启用拖拽",
|
||||
"Enabled": "启用",
|
||||
"Failed to load data": "加载数据失败",
|
||||
"Failed to load group options": "加载分组选项失败",
|
||||
"Failed to save drag sort": "保存拖拽排序失败",
|
||||
"Global sorting": "全局排序",
|
||||
"Grouped sorting based on": "基于分组字段",
|
||||
"Grouping field": "分组字段",
|
||||
"Grouping field is required": "分组字段为必填项",
|
||||
"Grouping settings": "分组设置",
|
||||
"Kanban": "看板",
|
||||
"Kanban action": "看板操作",
|
||||
"Kanban settings": "看板设置",
|
||||
"Label": "标签",
|
||||
"Large": "大",
|
||||
"Load more": "加载更多",
|
||||
"Medium": "中",
|
||||
"Middle": "中",
|
||||
"No data": "暂无数据",
|
||||
"No options": "暂无选项",
|
||||
"Open mode": "打开方式",
|
||||
"Options": "选项",
|
||||
"Page": "页面",
|
||||
"Page size": "每页条数",
|
||||
"Popup settings": "弹窗设置",
|
||||
"Popup size": "弹窗尺寸",
|
||||
"Quick create": "快捷新增",
|
||||
"Select a grouping field first": "请先选择分组字段",
|
||||
"Select group values": "选择分组值",
|
||||
"The order of the selected values determines the kanban column order": "看板的列顺序基于值选择的顺序确定",
|
||||
"Set column width": "设置列宽",
|
||||
"Single select and many-to-one fields can be used as the grouping field": "单选和多对一字段可以用作分组字段",
|
||||
"Small": "小",
|
||||
"Sort": "排序",
|
||||
"Sorting field": "排序字段",
|
||||
"Used for sorting kanban cards, only sorting fields corresponding to grouping fields can be selected": "用于看板卡片的排序,只能选择与分组字段相对应的排序字段"
|
||||
"Sorting settings": "排序设置",
|
||||
"Style": "样式",
|
||||
"Title field": "标题字段",
|
||||
"Unknown": "未知",
|
||||
"Used for sorting kanban cards, only sorting fields corresponding to grouping fields can be selected": "用于看板卡片的排序,只能选择与分组字段相对应的排序字段",
|
||||
"View record": "查看记录"
|
||||
}
|
||||
Reference in New Issue
Block a user