feat: approval, resubmit approval (#1270)

Reviewed-on: daoyoucloud/tachybase#1270
Reviewed-by: sealday <zhanglin@daoyoucloud.com>
Co-authored-by: bai.zixv <bai.zixv@foxmail.com>
Co-committed-by: bai.zixv <bai.zixv@foxmail.com>
This commit is contained in:
bai.zixv 2024-07-04 14:04:26 +08:00 committed by sealday
parent 81a622e77d
commit 86cc441465
25 changed files with 284 additions and 28 deletions

View File

@ -6,7 +6,7 @@ import { uid } from '@tachybase/utils/client';
import { Spin } from 'antd';
import { DetailsBlockProvider, SimpleDesigner } from '../../../../../components';
import { FormBlockProvider } from '../../../common/Pd.FormBlock';
import { FormBlockProvider } from '../../../common/FormBlock.provider';
import { flatSchemaArray } from '../../../constants';
import { ContextApproverConfig } from '../ApproverConfig.context';
import { ActionBarProvider } from './Pd.ActionBarProvider';

View File

@ -11,7 +11,7 @@ import { useForm } from '@tachybase/schema';
import { uid } from '@tachybase/utils/client';
import { useFlowContext } from '../../../../../FlowContext';
import { CollectionApprovalTodos } from '../../../common/Cn.ApprovalTodos';
import { CollectionApprovalTodos } from '../../../common/ApprovalTodos.collection';
import { NAMESPACE } from '../../../locale';
import { SchemaAddBlock } from '../../trigger/launcher-interface/SchemaAddBlock.component';
import { ApproverBlock } from './ApproverBlock.view';

View File

@ -1,4 +1,3 @@
export const ApprovalActionProvider = (props) => {
console.log('%c Line:2 🍰 props', 'font-size:18px;color:#4fff4B;background:#7f2b82', props);
return props.children;
};

View File

@ -1,6 +1,8 @@
import React from 'react';
import { ActionInitializer, useSchemaInitializerItem } from '@tachybase/client';
import { NAMESPACE } from '../../../locale';
// 区块-配置操作
export const LauncherActionConfigComponent = () => {
const itemConfig = useSchemaInitializerItem();
@ -29,3 +31,29 @@ export const LauncherActionConfigComponent = () => {
/>
);
};
// setup-action-resubmit
export const LauncherActionConfigReSubmit = () => {
const itemConfig = useSchemaInitializerItem();
const { action, actionProps = {}, ...restItemConfig } = itemConfig;
return (
<ActionInitializer
{...restItemConfig}
schema={{
type: 'void',
title: restItemConfig.title,
'x-decorator': 'ProviderActionResubmit',
'x-component': 'Action',
'x-component-props': {
confirm: {
title: `{{t('resubmit', { ns: "${NAMESPACE}" })}}`,
content: `{{t('Are you sure you want to resubmit it?', { ns: "${NAMESPACE}" })}}`,
},
useAction: '{{ useActionResubmit }}',
},
'x-designer': 'Action.Designer',
'x-action': `reSubmit`,
}}
/>
);
};

View File

@ -2,11 +2,11 @@ import { SchemaInitializer } from '@tachybase/client';
import { APPROVAL_STATUS } from '../../../constants';
import { NAMESPACE } from '../../../locale';
import { LauncherActionConfigComponent } from './LauncherActionConfig.component';
import { LauncherActionConfigComponent, LauncherActionConfigReSubmit } from './LauncherActionConfig.component';
// 区块-配置操作
export const LauncherActionConfigInitializer = new SchemaInitializer({
name: 'ApprovalApplyAddActionButton',
name: 'LauncherActionConfigInitializer',
title: '{{t("Configure actions")}}',
items: [
{
@ -25,5 +25,12 @@ export const LauncherActionConfigInitializer = new SchemaInitializer({
Component: LauncherActionConfigComponent,
action: APPROVAL_STATUS.DRAFT,
},
{
name: 'reSubmit',
type: 'item',
title: `{{t("resubmit", { ns: "${NAMESPACE}" })}}`,
action: APPROVAL_STATUS.RESUBMIT,
Component: LauncherActionConfigReSubmit,
},
],
});

View File

@ -27,15 +27,21 @@ export const LauncherAddBlockButtonComponent = () => {
['x-acl-action']: deleteB,
...formSchema
} = createFormBlockSchema({
actionInitializers: 'ApprovalApplyAddActionButton',
actionInitializers: 'LauncherActionConfigInitializer',
actions: {
submit: {
type: 'void',
title: '{{t("Submit")}}',
'x-decorator': 'ApplyActionStatusProvider',
'x-decorator-props': { value: APPROVAL_STATUS.SUBMITTED },
'x-decorator-props': {
value: APPROVAL_STATUS.SUBMITTED,
},
'x-component': 'Action',
'x-component-props': { type: 'primary', htmlType: 'submit', useAction: '{{ useSubmit }}' },
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useAction: '{{ useSubmit }}',
},
'x-designer': 'Action.Designer',
'x-designer-props': {},
'x-action': `${APPROVAL_STATUS.SUBMITTED}`,

View File

@ -53,11 +53,13 @@ export const SchemaAddBlock = ({ value, onChange }) => {
ActionBarProvider,
ApplyActionStatusProvider,
WithdrawActionProvider,
ProviderActionResubmit,
}}
scope={{
useSubmit,
useWithdrawAction,
useFormBlockProps,
useActionResubmit,
}}
// @ts-ignore ugly
schema={data}
@ -80,6 +82,13 @@ function ApplyActionStatusProvider(props) {
function WithdrawActionProvider(props) {
return null;
}
function useActionResubmit() {
return { run() {} };
}
function ProviderActionResubmit(props) {
return props.children;
}
// TODO:
const E = (f, o, p) =>
// eslint-disable-next-line promise/param-names

View File

@ -8,6 +8,7 @@ export const APPROVAL_STATUS = {
SUBMITTED: 2,
PROCESSING: 3,
APPROVED: 4,
RESUBMIT: 5,
REJECTED: -1,
};
@ -37,17 +38,43 @@ export const approvalStatusConfigObj = approvalStatusOptions.reduce(
{},
);
export const ApprovalStatusEnums = [
{ value: APPROVAL_STATUS.DRAFT, label: `{{t("Draft", { ns: "${NAMESPACE}" })}}`, editable: true },
{
value: APPROVAL_STATUS.DRAFT,
label: `{{t("Draft", { ns: "${NAMESPACE}" })}}`,
editable: true,
},
{
value: APPROVAL_STATUS.RETURNED,
label: `{{t("Returned", { ns: "${NAMESPACE}" })}}`,
color: 'purple',
editable: true,
},
{ value: APPROVAL_STATUS.SUBMITTED, label: `{{t("Submitted", { ns: "${NAMESPACE}" })}}`, color: 'cyan' },
{ value: APPROVAL_STATUS.PROCESSING, label: `{{t("Processing", { ns: "${NAMESPACE}" })}}`, color: 'gold' },
{ value: APPROVAL_STATUS.APPROVED, label: `{{t("Approved", { ns: "${NAMESPACE}" })}}`, color: 'green' },
{ value: APPROVAL_STATUS.REJECTED, label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`, color: 'red' },
{
value: APPROVAL_STATUS.SUBMITTED,
label: `{{t("Submitted", { ns: "${NAMESPACE}" })}}`,
color: 'cyan',
},
{
value: APPROVAL_STATUS.PROCESSING,
label: `{{t("Processing", { ns: "${NAMESPACE}" })}}`,
color: 'gold',
},
{
value: APPROVAL_STATUS.APPROVED,
label: `{{t("Approved", { ns: "${NAMESPACE}" })}}`,
color: 'green',
},
{
value: APPROVAL_STATUS.RESUBMIT,
label: `{{t("reSubmit", { ns: "${NAMESPACE}" })}}`,
color: 'blue',
editable: true,
},
{
value: APPROVAL_STATUS.REJECTED,
label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`,
color: 'red',
},
];
export const ApprovalStatusEnumDict = ApprovalStatusEnums.reduce((e, t) => Object.assign(e, { [t.value]: t }), {});
export const JobStatusEnums = {

View File

@ -1,7 +1,7 @@
import React from 'react';
import { ExtendCollectionsProvider, TableBlockProvider, useRecord } from '@tachybase/client';
import { CollectionApprovalTodos } from '../../common/Cn.ApprovalTodos';
import { CollectionApprovalTodos } from '../../common/ApprovalTodos.collection';
import { CollectionApprovals } from '../approval-common/Approvals.collection';
import { CollectionFlowNodes } from '../approval-common/FlowNodes.collection';
import { CollectionWorkflows } from '../approval-common/Workflows.collection';

View File

@ -0,0 +1,21 @@
import { useCurrentUserContext } from '@tachybase/client';
import { useFlowContext } from '../../../../../FlowContext';
import { APPROVAL_STATUS } from '../../../constants';
import { useApproval } from '../../approval-common/ApprovalData.provider';
export function ProviderActionResubmit(props) {
const { data } = useCurrentUserContext();
const { status, createdById } = useApproval();
const { workflow } = useFlowContext();
const isSameId = data.data.id === createdById;
// const isEnabledWithdraw = workflow.enabled && workflow.config.withdrawable;
// const isNotDraft = status !== APPROVAL_STATUS.DRAFT;
if (isSameId) {
return props.children;
}
return null;
}

View File

@ -11,7 +11,12 @@ export function ActionBarProvider(props) {
const isSameId = data.data.id === createdById;
const isSameExcutionId = latestExecutionId === approvalExecution.id;
const isExcutionDid = [APPROVAL_STATUS.DRAFT, APPROVAL_STATUS.RETURNED, APPROVAL_STATUS.SUBMITTED].includes(status);
const isExcutionDid = [
APPROVAL_STATUS.DRAFT,
APPROVAL_STATUS.RETURNED,
APPROVAL_STATUS.SUBMITTED,
APPROVAL_STATUS.RESUBMIT,
].includes(status);
if (!isSameId || !isSameExcutionId || !isExcutionDid) {
return null;

View File

@ -18,7 +18,7 @@ export function ApplyActionStatusProvider(props) {
const { data } = useCurrentUserContext();
const isSameId = data.data.id === createdById;
const isEnbled = workflow.enabled;
const isStatusDid = [APPROVAL_STATUS.DRAFT, APPROVAL_STATUS.RETURNED].includes(status);
const isStatusDid = [APPROVAL_STATUS.RESUBMIT, APPROVAL_STATUS.DRAFT, APPROVAL_STATUS.RETURNED].includes(status);
if (isSameId && isEnbled && isStatusDid) {
return <ContextApprovalStatus.Provider value={value}>{children}</ContextApprovalStatus.Provider>;

View File

@ -12,13 +12,16 @@ import { useForm } from '@tachybase/schema';
import { Result, Spin } from 'antd';
import { DetailsBlockProvider } from '../../../../../components';
import { FormBlockProvider } from '../../../common/Pd.FormBlock';
import { FormBlockProvider } from '../../../common/FormBlock.provider';
import { APPROVAL_STATUS } from '../../../constants';
import { NAMESPACE } from '../../../locale';
import { ApprovalContext } from '../../approval-common/ApprovalData.provider';
import { ContextWithActionEnabled } from '../../approval-common/WithActionEnabled.provider';
import { ContextApprovalExecution } from '../common/ApprovalExecution.provider';
import { FlowContextProvider } from '../common/FlowContext.provider';
import { SchemaComponentContextProvider } from '../common/SchemaComponent.provider';
import { ProviderActionResubmit } from './ActionResubmit.provider';
import { useActionResubmit } from './hooks/useActionResubmit';
import { useDestroyAction } from './hooks/useDestroyAction';
import { useFormBlockProps } from './hooks/useFormBlockProps';
import { useSubmit } from './hooks/useSubmit';
@ -59,6 +62,8 @@ export const ViewActionLaunchContent = () => {
// @ts-ignore
const approvalData = data?.data;
const needHideProcess = actionEnabled || approvalData?.status !== APPROVAL_STATUS.RESUBMIT;
if (loading) {
return <Spin />;
}
@ -90,6 +95,7 @@ export const ViewActionLaunchContent = () => {
ApplyActionStatusProvider,
WithdrawActionProvider,
DetailsBlockProvider,
ProviderActionResubmit,
}}
scope={{
useForm,
@ -98,6 +104,7 @@ export const ViewActionLaunchContent = () => {
useDetailsBlockProps: useFormBlockContext,
useWithdrawAction,
useDestroyAction,
useActionResubmit,
}}
schema={{
name: `view-${approval == null ? void 0 : approval.id}`,
@ -116,7 +123,9 @@ export const ViewActionLaunchContent = () => {
detail: {
type: 'void',
'x-decorator': 'SchemaComponentContextProvider',
'x-decorator-props': { designable: false },
'x-decorator-props': {
designable: true,
},
'x-component': 'RemoteSchemaComponent',
'x-component-props': {
uid: workflow?.config.applyForm,
@ -126,7 +135,7 @@ export const ViewActionLaunchContent = () => {
},
},
},
actionEnabled
needHideProcess
? {}
: {
process: {

View File

@ -0,0 +1,59 @@
import { useAPIClient } from '@tachybase/client';
import { useFlowContext } from '@tachybase/plugin-workflow/client';
import { useField } from '@tachybase/schema';
import _ from 'lodash';
import { APPROVAL_STATUS } from '../../../../constants';
import { useApproval } from '../../../approval-common/ApprovalData.provider';
import { useHandleRefresh } from '../../common/useHandleRefresh';
// 重新发起
export function useActionResubmit() {
const { refreshTable } = useHandleRefresh();
const field = useField();
const approval = useApproval();
const api = useAPIClient();
const { workflow } = useFlowContext();
return {
async run() {
try {
field.data = field.data ?? {};
field.data.loading = true;
const appendsConfigs = [...workflow.config.appends];
const summaryConfigs = [...workflow.config.summary];
const appends = _.intersection(appendsConfigs, summaryConfigs);
const { data: approvalData } = await api.resource('approvals').get({
filterByTk: approval.id,
});
const { collectionName, data, workflowId } = approvalData.data;
const newData = _.omit(data, ['id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy']);
await api.resource('approvals').reSubmit({
values: {
collectionName: collectionName,
data: newData,
status: APPROVAL_STATUS.RESUBMIT,
workflowId: workflowId,
collectionAppends: appends,
},
});
field.data.loading = false;
refreshTable();
} catch (v) {
if (field.data) {
field.data.loading = false;
}
}
},
};
}

View File

@ -13,7 +13,7 @@ import _ from 'lodash';
import { DetailsBlockProvider } from '../../../../../components';
import { ExecutionContextProvider } from '../../../../../ExecutionContextProvider';
import { FormBlockProvider } from '../../../common/Pd.FormBlock';
import { FormBlockProvider } from '../../../common/FormBlock.provider';
import { NAMESPACE, useTranslation } from '../../../locale';
import { ApprovalContext } from '../../approval-common/ApprovalData.provider';
import { ContextWithActionEnabled } from '../../approval-common/WithActionEnabled.provider';

View File

@ -1,5 +1,6 @@
import React from 'react';
import { useCollectionManager, useCollectionRecordData, useCompile } from '@tachybase/client';
import { dayjs } from '@tachybase/utils/client';
import useStyles from './style';
@ -13,12 +14,19 @@ export const ApprovalsSummary = (props) => {
const { collectionName } = record;
const results = Object.entries(value).map(([key, objValue]) => {
const field = cm.getCollectionField(`${collectionName}.${key}`);
const realValue = Object.prototype.toString.call(objValue) === '[object Object]' ? objValue?.['name'] : objValue;
// 如果是UTC时间字符串, 则转换为本地时区时间
if (isUTCString(realValue)) {
return {
label: compile(field?.uiSchema?.title || key),
value: Object.prototype.toString.call(objValue) === '[object Object]' ? objValue?.['name'] : objValue,
value: convertUTCToLocal(realValue),
};
}
return {
label: compile(field?.uiSchema?.title || key),
value: realValue,
};
});
// 展示结果要展示一个数组对象, 是 label 和 value 的形式
// label 放中文, value 放值
return (
@ -32,3 +40,19 @@ export const ApprovalsSummary = (props) => {
</div>
);
};
// TODO: 将下边的函数转移至 core 库中
// 定义正则表达式, 检测形如 2024-07-04T04:46:27.166Z 的UTC时间字符串
const utcRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
// 测试函数
function isUTCString(str = '') {
return utcRegex.test(str);
}
// 将UTC时间字符串转换为本地时区时间
function convertUTCToLocal(utcString) {
// 使用dayjs解析UTC时间并转换为本地时区时间
const localDate = dayjs.utc(utcString).local().format('YYYY-MM-DD HH:mm:ss');
return localDate;
}

View File

@ -8,7 +8,7 @@ import {
useRecord,
} from '@tachybase/client';
import { CollectionApprovalTodos } from '../../common/Cn.ApprovalTodos';
import { CollectionApprovalTodos } from '../../common/ApprovalTodos.collection';
import { CollectionApprovals } from '../approval-common/Approvals.collection';
import { CollectionFlowNodes } from '../approval-common/FlowNodes.collection';
import { CollectionWorkflows } from '../approval-common/Workflows.collection';

View File

@ -275,5 +275,6 @@
"Workflow": "Workflow",
"\"Content-Type\" only support \"application/json\", and no need to specify": "\"Content-Type\" only support \"application/json\", and no need to specify",
"concat": "concat",
"ms": "ms"
"ms": "ms",
"reSubmit": "reSubmit"
}

View File

@ -355,5 +355,6 @@
"Workflow": "工作流",
"\"Content-Type\" only support \"application/json\", and no need to specify": "\"Content-Type\" 请求头仅支持 \"application/json\",无需填写",
"concat": "连接",
"ms": "毫秒"
"ms": "毫秒",
"reSubmit": "重新提交"
}

View File

@ -38,7 +38,9 @@ export default class ApprovalTrigger extends Trigger {
},
transaction,
});
if (!workflow || !approval.changed('status') || approval.status !== APPROVAL_STATUS.SUBMITTED) {
const isChangedStatus = approval.changed('status');
const isAllowStatusList = [APPROVAL_STATUS.SUBMITTED, APPROVAL_STATUS.RESUBMIT].includes(approval.status);
if (!workflow || !isChangedStatus || !isAllowStatusList) {
return;
}
const [dataSourceName, collectionName] = parseCollectionName(approval.collectionName);

View File

@ -2,6 +2,7 @@ import actions, { utils } from '@tachybase/actions';
import { parseCollectionName } from '@tachybase/data-source-manager';
import { EXECUTION_STATUS, JOB_STATUS, PluginWorkflow } from '../..';
import { appends } from '../../../client/schemas/collection';
import { APPROVAL_ACTION_STATUS, APPROVAL_STATUS } from './constants';
import { getSummary } from './tools';
import { getAssociationName, jsonParse } from './utils';
@ -84,7 +85,7 @@ const approvals = {
/** 以下为,处理子表单子表格等关联字段的更新逻辑 */
const schemaOfForm = await context.db.getRepository('uiSchemas').getJsonSchema(schemaFormId);
const itemsOfSchema = await jsonParse(`**["x-component-props".mode]`, schemaOfForm);
const fieldAssociationName = itemsOfSchema
const fieldAssociationName = (itemsOfSchema || [])
.filter((item) => {
return ['Nester', 'SubTable', 'PopoverNester'].includes(item?.['x-component-props']?.mode);
})
@ -113,6 +114,61 @@ const approvals = {
});
return actions.update(context, next);
},
// NOTE: 和 create 逻辑雷同, 但是 因为原本的 create 并非纯操作, 因此拷贝一份以便方便改动
async reSubmit(context, next) {
const { status, collectionName, data, workflowId, collectionAppends } = context.action.params.values ?? {};
const [dataSourceName, cName] = parseCollectionName(collectionName);
const dataSource = context.app.dataSourceManager.dataSources.get(dataSourceName);
if (!dataSource) {
return context.throw(400, `Data source "${dataSourceName}" not found`);
}
const collection = dataSource.collectionManager.getCollection(cName);
if (!collection) {
return context.throw(400, `Collection "${cName}" not found`);
}
const workflow = await context.db.getRepository('workflows').findOne({
filterByTk: workflowId,
});
if (!workflow?.enabled) {
return context.throw(400, 'Current workflow not found or disabled, please refresh and try again');
}
const { repository, model } = collection;
const values = await repository.create({
values: {
...data,
createdBy: context.state.currentUser.id,
updatedBy: context.state.currentUser.id,
},
context,
});
const instance = values.get();
// NOTE: 这里需要取出关联字段, 用于摘要的构造;
// 有点奇怪的方式, 但是没想到更好的处理方式, 也许应该改造摘要的构造方式, 存储关联字段的id, 在前端请求具体数据.
const valuesWithAppends = await repository.findOne({
filterByTk: values.id,
appends: [...collectionAppends],
});
const summary = getSummary({
summaryConfig: workflow.config.summary,
data: valuesWithAppends,
});
Object.keys(model.associations).forEach((key) => {
delete instance[key];
});
context.action.mergeParams({
values: {
collectionName,
data: instance,
dataKey: values[collection.filterTargetKey],
workflowKey: workflow.key,
applicantRoleName: context.state.currentRole,
summary,
},
});
return actions.create(context, next);
},
async destroy(context, next) {
const {
filterByTk,

View File

@ -4,6 +4,7 @@ export const APPROVAL_STATUS = {
SUBMITTED: 2,
PROCESSING: 3,
APPROVED: 4,
RESUBMIT: 5,
REJECTED: -1,
};
export const APPROVAL_ACTION_STATUS = {

View File

@ -10,9 +10,10 @@ export function getSummary(params: ParamsType): object {
const result = summaryConfig.reduce((summary, key) => {
const value = _.get(data, key);
const realValue = Object.prototype.toString.call(value) === '[object Object]' ? value?.['name'] : value;
return {
...summary,
[key]: value,
[key]: realValue,
};
}, {});