feat(plugin-custom-request): support better custom-request (#2536)

* feat(plugin-custom-request): support better custom-request

* feat: re implement custom request

* feat: complete configuration

* feat: support request in server

* feat: improve usage

* feat: support request

* feat: remove customRequests when action is deleted

* feat: avoid override

* fix: the data is not latest

* feat: should autoSize in url

* feat: support title and acl

* fix: i18n

* feat: support display title

* feat: i18n supports

* refactor: acl check

* feat: better method

* fix: style

* fix: remove unused code

* fix: acl

* docs: update

* fix: upgrade

* feat: hidden skip validator option and display should display data

* feat: support root access all custom-request

* chore: update deps

* fix: limit height of modal body, Close T-1682

* fix: parse incorrect, Close T-1686

* fix: i18n, Close T-1684

* fix: linkage rules incorrect, Close T-1719

* fix: missing pass appends, Close T-1720

* fix: should update initialValues when it changed

* feat: update better message

* fix: permission incorrect

* test: setup

* refactor: fix locale

* chore: update deps

* feat: move to @nocobase

* fix: dir

* fix: ui.*

* feat: add listByCurrentRole action, add decorator instead of acl

* fix: remove title field

* feat: compatible pervious version

* feat: support passing x-* headers to custom-request

* feat: support list all keys

* fix: support baseURL, close T-2090

* fix: remove unused code

* fix: sqlite, Close T-2092

* fix: close T-2089

* feat: alert error when request settings doesn't configure, Close T-2101

* feat: improve message

* chore: fix eslint error

* fix: fix version of plugin-custom-request

* fix: build error

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: Rain <958414905@qq.com>
This commit is contained in:
Dunqing 2023-10-13 02:16:50 -05:00 committed by GitHub
parent 04db43985c
commit bf099a4364
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1061 additions and 195 deletions

View File

@ -4,10 +4,7 @@ export const useStyles = createStyles(({ css }) => {
return css`
.ant-table-cell {
> .ant-space-horizontal {
.ant-space-item:empty:not(:last-child) + .ant-space-item-split {
display: none;
}
.ant-space-item-split:has(+ .ant-space-item:last-child:empty) {
.ant-space-item-split:has(+ .ant-space-item:empty) {
display: none;
}
}

View File

@ -66,7 +66,7 @@ const filterValue = (value) => {
return obj;
};
function getFormValues({
export function getFormValues({
filterByTk,
field,
form,

View File

@ -7,9 +7,8 @@ import { CollectionManagerContext } from '../context';
import { CollectionFieldOptions, CollectionOptions } from '../types';
export const useCollectionManager = () => {
const { refreshCM, updateCollection, service, interfaces, collections, templates } = useContext(
CollectionManagerContext,
);
const { refreshCM, updateCollection, service, interfaces, collections, templates } =
useContext(CollectionManagerContext);
const compile = useCompile();
const getInheritedFields = (name) => {
const inheritKeys = getInheritCollections(name);

View File

@ -2,7 +2,7 @@ import { ArrayTable } from '@formily/antd-v5';
import { onFieldValueChange, onFieldInputValueChange } from '@formily/core';
import { connect, ISchema, mapProps, useField, useFieldSchema, useForm, useFormEffects } from '@formily/react';
import { isValid, uid } from '@formily/shared';
import { Alert, Tree as AntdTree } from 'antd';
import { Alert, Tree as AntdTree, ModalProps } from 'antd';
import { cloneDeep } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -68,12 +68,12 @@ const MenuGroup = (props) => {
);
};
function ButtonEditor() {
function ButtonEditor(props) {
const field = useField();
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
const { t } = useTranslation();
const isLink = fieldSchema['x-component'] === 'Action.Link';
const isLink = props?.isLink || fieldSchema['x-component'] === 'Action.Link';
return (
<SchemaSettings.ModalItem
@ -689,7 +689,11 @@ function AfterSuccess() {
);
}
function RemoveButton() {
function RemoveButton(
props: {
onConfirmOk?: ModalProps['onOk'];
} = {},
) {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const isDeletable = fieldSchema?.parent['x-component'] === 'CollectionField';
@ -704,6 +708,7 @@ function RemoveButton() {
}}
confirm={{
title: t('Delete action'),
onOk: props.onConfirmOk,
}}
/>
</>
@ -890,7 +895,7 @@ function WorkflowConfig() {
}
export const ActionDesigner = (props) => {
const { modalTip, linkageAction, ...restProps } = props;
const { modalTip, linkageAction, removeButtonProps, buttonEditorProps, linkageRulesProps, ...restProps } = props;
const fieldSchema = useFieldSchema();
const { name } = useCollection();
const { getChildrenCollections } = useCollectionManager();
@ -907,10 +912,10 @@ export const ActionDesigner = (props) => {
return (
<GeneralSchemaDesigner {...restProps} disableInitializer draggable={isDraggable}>
<MenuGroup>
<ButtonEditor />
<ButtonEditor {...buttonEditorProps} />
{fieldSchema['x-action'] === 'submit' &&
fieldSchema.parent?.['x-initializer'] === 'CreateFormActionInitializers' && <SaveMode />}
{isLinkageAction && <SchemaSettings.LinkageRules collectionName={name} />}
{isLinkageAction && <SchemaSettings.LinkageRules {...linkageRulesProps} collectionName={name} />}
{isDuplicateAction && <DuplicationMode />}
<OpenModeSchemaItems openMode={isPopupAction} openSize={isPopupAction} />
{isUpdateModePopupAction && <UpdateMode />}
@ -919,10 +924,10 @@ export const ActionDesigner = (props) => {
{isValid(fieldSchema?.['x-action-settings']?.skipValidator) && <SkipValidation />}
{isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && <AfterSuccess />}
{isValid(fieldSchema?.['x-action-settings']?.triggerWorkflows) && <WorkflowConfig />}
{restProps.children}
{isChildCollectionAction && <SchemaSettings.EnableChildCollections collectionName={name} />}
{<RemoveButton />}
{<RemoveButton {...removeButtonProps} />}
</MenuGroup>
</GeneralSchemaDesigner>
);

View File

@ -23,6 +23,7 @@ import { ErrorFallback } from '../error-fallback';
import FixedBlock from './FixedBlock';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
import { useStyles } from './style';
import { useStyles as useAClStyles } from '../../../acl/style';
export const Page = (props) => {
const { children, ...others } = props;
@ -58,6 +59,7 @@ export const Page = (props) => {
);
const [height, setHeight] = useState(0);
const { wrapSSR, hashId, componentCls } = useStyles();
const aclStyles = useAClStyles();
const handleErrors = (error) => {
console.error(error);
@ -66,7 +68,7 @@ export const Page = (props) => {
const pageHeaderTitle = hidePageTitle ? undefined : fieldSchema.title || compile(title);
return wrapSSR(
<FilterBlockProvider>
<div className={`${componentCls} ${hashId}`}>
<div className={`${componentCls} ${hashId} ${aclStyles.styles}`}>
<PageDesigner title={fieldSchema.title || title} />
<div
ref={(ref) => {

View File

@ -131,7 +131,6 @@ function getTypedConstantOption(type: string, types: true | string[], fieldNames
export function Input(props) {
const {
value = '',
scope,
onChange,
children,
button,
@ -141,6 +140,7 @@ export function Input(props) {
changeOnSelect,
fieldNames,
} = props;
const scope = typeof props.scope === 'function' ? props.scope() : props.scope;
const { wrapSSR, hashId, componentCls, rootPrefixCls } = useStyles();
// 添加 antd input 样式,防止样式缺失

View File

@ -18,7 +18,8 @@ function setNativeInputValue(input, value) {
export function RawTextArea(props): JSX.Element {
const inputRef = useRef<any>(null);
const { scope, changeOnSelect, component: Component = Input.TextArea, ...others } = props;
const { changeOnSelect, component: Component = Input.TextArea, ...others } = props;
const scope = typeof props.scope === 'function' ? props.scope() : props.scope;
const [options, setOptions] = useState(scope ? cloneDeep(scope) : []);
function onInsert(selected) {
@ -56,7 +57,13 @@ export function RawTextArea(props): JSX.Element {
}
`}
>
<VariableSelect options={options} setOptions={setOptions} onInsert={onInsert} changeOnSelect={changeOnSelect} />
<VariableSelect
fieldNames={props.fieldNames}
options={options}
setOptions={setOptions}
onInsert={onInsert}
changeOnSelect={changeOnSelect}
/>
</Button.Group>
</div>
);

View File

@ -428,7 +428,9 @@ async function preloadOptions(scope, value) {
}
TextArea.ReadPretty = function ReadPretty(props): JSX.Element {
const { value, scope } = props;
const { value } = props;
const scope = typeof props.scope === 'function' ? props.scope() : props.scope;
const [options, setOptions] = useState([]);
const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [options]);

View File

@ -5,7 +5,13 @@ import { useTranslation } from 'react-i18next';
import { useToken } from '../__builtins__';
import useStyles from './VariableSelect.style';
export function VariableSelect({ options, setOptions, onInsert, changeOnSelect = false }): JSX.Element {
export function VariableSelect({
options,
setOptions,
onInsert,
changeOnSelect = false,
fieldNames = {},
}): JSX.Element {
const { t } = useTranslation();
const [selectedVar, setSelectedVar] = useState<string[]>([]);
const { wrapSSR, componentCls, hashId } = useStyles();
@ -26,6 +32,7 @@ export function VariableSelect({ options, setOptions, onInsert, changeOnSelect =
placeholder={t('Select a variable')}
value={[]}
options={options}
fieldNames={fieldNames}
loadData={loadData}
onChange={(keyPaths = [], selectedOptions = []) => {
setSelectedVar(keyPaths as string[]);

View File

@ -153,25 +153,7 @@ export const CalendarFormActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
component: 'CustomRequestInitializer',
visible: function useVisible() {
const collection = useCollection();
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';

View File

@ -134,25 +134,7 @@ export const FormActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
component: 'CustomRequestInitializer',
},
],
},
@ -268,25 +250,7 @@ export const CreateFormActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
component: 'CustomRequestInitializer',
},
],
},
@ -401,25 +365,7 @@ export const UpdateFormActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Submitted successfully")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
component: 'CustomRequestInitializer',
},
],
},
@ -532,25 +478,7 @@ export const BulkEditFormActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
component: 'CustomRequestInitializer',
},
],
},

View File

@ -279,23 +279,9 @@ export const GridCardItemActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
component: 'CustomRequestInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action.Link',
'x-action': 'customize:table:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
visible: function useVisible() {
const collection = useCollection();

View File

@ -283,23 +283,9 @@ export const ListItemActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
component: 'CustomRequestInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action.Link',
'x-action': 'customize:table:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
visible: function useVisible() {
const collection = useCollection();

View File

@ -150,25 +150,7 @@ export const ReadPrettyFormActionInitializers = {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action',
'x-action': 'customize:form:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
skipValidator: false,
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
component: 'CustomRequestInitializer',
visible: useVisibleCollection,
},
],

View File

@ -231,23 +231,9 @@ export const TableActionColumnInitializers = (props: any) => {
{
type: 'item',
title: '{{t("Custom request")}}',
component: 'CustomizeActionInitializer',
component: 'CustomRequestInitializer',
schema: {
title: '{{ t("Custom request") }}',
'x-component': 'Action.Link',
'x-action': 'customize:table:request',
'x-designer': 'Action.Designer',
'x-action-settings': {
requestSettings: {},
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
'x-component-props': {
useProps: '{{ useCustomizeRequestActionProps }}',
},
},
visible: () => {
return (collection.template !== 'view' || collection?.writableView) && collection.template !== 'sql';

View File

@ -25,6 +25,7 @@ import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
// @ts-ignore
useTransition as useReactTransition,
@ -559,7 +560,7 @@ SchemaSettings.Remove = function Remove(props: any) {
title: t('Delete block'),
content: t('Are you sure you want to delete it?'),
...confirm,
onOk() {
async onOk() {
const options = {
removeParentsIfNoChildren,
breakRemoveOn,
@ -569,10 +570,11 @@ SchemaSettings.Remove = function Remove(props: any) {
fieldSchema['required'] = false;
}
if (template && ctx?.dn) {
ctx?.dn.remove(null, options);
await ctx?.dn.remove(null, options);
} else {
dn.remove(null, options);
await dn.remove(null, options);
}
await confirm?.onOk?.();
delete form.values[fieldSchema.name];
removeActiveFieldName?.(fieldSchema.name as string);
if (field?.setInitialValue && field?.reset) {
@ -834,7 +836,7 @@ SchemaSettings.PopupItem = function PopupItem(props) {
};
SchemaSettings.ActionModalItem = React.memo((props: any) => {
const { title, onSubmit, initialValues, initialSchema, schema, modalTip, components, ...others } = props;
const { title, onSubmit, initialValues, initialSchema, schema, modalTip, components, scope, ...others } = props;
const [visible, setVisible] = useState(false);
const [schemaUid, setSchemaUid] = useState<string>(props.uid);
const { t } = useTranslation();
@ -854,9 +856,14 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => {
[initialValues],
);
useEffect(() => {
form.setInitialValues(cloneDeep(initialValues));
}, [JSON.stringify(initialValues || {})]);
const cancelHandler = useCallback(() => {
setVisible(false);
}, []);
form.reset();
}, [form]);
const submitHandler = useCallback(async () => {
await form.submit();
@ -871,6 +878,10 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => {
await api.resource('uiSchemas').insert({ values: initialSchema });
setSchemaUid(initialSchema['x-uid']);
}
if (typeof others?.beforeOpen === 'function') {
others?.beforeOpen?.();
}
ctx.setVisible(false);
setVisible(true);
}, [api, ctx, dn, fieldSchema, initialSchema, schemaUid]);
@ -884,6 +895,10 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => {
<Modal
width={'50%'}
title={compile(title)}
bodyStyle={{
maxHeight: '70vh',
overflowY: 'scroll',
}}
{...others}
destroyOnClose
open={visible}
@ -902,8 +917,10 @@ SchemaSettings.ActionModalItem = React.memo((props: any) => {
<FormLayout layout={'vertical'}>
{modalTip && <Alert message={modalTip} />}
{modalTip && <br />}
{visible && schemaUid && <RemoteSchemaComponent noForm components={components} uid={schemaUid} />}
{visible && schema && <SchemaComponent components={components} schema={schema} />}
{visible && schemaUid && (
<RemoteSchemaComponent noForm components={components} scope={scope} uid={schemaUid} />
)}
{visible && schema && <SchemaComponent components={components} scope={scope} schema={schema} />}
</FormLayout>
</FormProvider>
</FormActiveFieldsProvider>
@ -1137,8 +1154,7 @@ SchemaSettings.LinkageRules = function LinkageRules(props) {
const variables = useVariables();
const localVariables = useLocalVariables();
const record = useRecord();
const type = ['Action', 'Action.Link'].includes(fieldSchema['x-component']) ? 'button' : 'field';
const type = props?.type || ['Action', 'Action.Link'].includes(fieldSchema['x-component']) ? 'button' : 'field';
const gridSchema = findGridSchema(fieldSchema) || fieldSchema;
const schema = useMemo<ISchema>(
() => ({

View File

@ -108,7 +108,7 @@ export interface CollectionContext {
export class Collection<
TModelAttributes extends {} = any,
TCreationAttributes extends {} = TModelAttributes
TCreationAttributes extends {} = TModelAttributes,
> extends EventEmitter {
options: CollectionOptions;
context: CollectionContext;

View File

@ -3,7 +3,7 @@
//
// Created by Curran Kelleher and Chrostophe Serafin.
// Contributions from Paul Brewer and Javier Blanco Martinez.
import objectPath from 'object-path';
import { get } from 'lodash';
import dedupe from 'dedupe';
// An enhanced version of `typeof` that handles arrays and dates as well.
@ -91,7 +91,7 @@ const parseString = (() => {
context = context || {};
return matches.reduce((result, match, i) => {
const parameter = parameters[i];
let value = objectPath.get(context, parameter.key);
let value = get(context, parameter.key);
if (typeof value === 'undefined') {
value = parameter.defaultValue;

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,22 @@
{
"name": "@nocobase/plugin-custom-request",
"version": "0.14.0-alpha.7",
"main": "dist/server/index.js",
"displayName": "Custom request",
"displayName.zh-CN": "自定义请求",
"description": "Custom request action",
"description.zh-CN": "自定义请求操作",
"devDependencies": {
"@formily/react": "2.x",
"@formily/shared": "2.x",
"react-i18next": "^11.15.1",
"react-router-dom": "6.x",
"antd": "5.x",
"lodash": "4.x"
},
"peerDependencies": {
"@nocobase/client": "0.x",
"@nocobase/server": "0.x",
"@nocobase/test": "0.x"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,41 @@
import { Action, useAPIClient, useRequest } from '@nocobase/client';
import React from 'react';
import { CustomRequestActionDesigner } from './CustomRequestActionDesigner';
import { useFieldSchema } from '@formily/react';
import { listByCurrentRoleUrl } from '../constants';
import { useCustomizeRequestActionProps } from '../hooks';
export const CustomRequestActionACLDecorator = (props) => {
const apiClient = useAPIClient();
const isRoot = apiClient.auth.role === 'root';
const fieldSchema = useFieldSchema();
const { data } = useRequest<{ data: string[] }>(
{
url: listByCurrentRoleUrl,
},
{
manual: isRoot,
cacheKey: listByCurrentRoleUrl,
},
);
if (!isRoot && !data?.data?.includes(fieldSchema?.['x-uid'])) {
return null;
}
return props.children;
};
const components = {
'customize:table:request': Action.Link,
};
export const CustomRequestAction = (props) => {
const fieldSchema = useFieldSchema();
const xAction = fieldSchema['x-action'];
const Component = components[xAction] || Action;
return <Component {...props} useProps={useCustomizeRequestActionProps}></Component>;
};
CustomRequestAction.Designer = CustomRequestActionDesigner;
CustomRequestAction.Decorator = CustomRequestActionACLDecorator;

View File

@ -0,0 +1,122 @@
import { useFieldSchema } from '@formily/react';
import { ArrayItems } from '@formily/antd-v5';
import { Action, SchemaSettings, useCollection, useCurrentRoles, useRequest } from '@nocobase/client';
import React, { useEffect } from 'react';
import { CustomRequestACLSchema, CustomRequestConfigurationFieldsSchema } from '../schemas';
import { useCustomRequestVariableOptions, useGetCustomRequest } from '../hooks';
import { App } from 'antd';
import { useTranslation } from '../locale';
import { listByCurrentRoleUrl } from '../constants';
import { useCustomRequestsResource } from '../hooks/useCustomRequestsResource';
function CustomRequestSettingsItem() {
const { t } = useTranslation();
const { name } = useCollection();
const fieldSchema = useFieldSchema();
const customRequestsResource = useCustomRequestsResource();
const { message } = App.useApp();
const { data, refresh } = useGetCustomRequest();
return (
<>
<SchemaSettings.ActionModalItem
title={t('Request settings')}
components={{
ArrayItems,
}}
beforeOpen={() => !data && refresh()}
scope={{ useCustomRequestVariableOptions }}
schema={CustomRequestConfigurationFieldsSchema}
initialValues={{
...data?.data?.options,
}}
onSubmit={async (config) => {
const { ...requestSettings } = config;
await customRequestsResource.updateOrCreate({
values: {
key: fieldSchema['x-uid'],
options: {
...requestSettings,
collectionName: name,
},
},
filterKeys: ['key'],
});
refresh();
return message.success(t('Saved successfully'));
}}
/>
</>
);
}
function CustomRequestACL() {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const customRequestsResource = useCustomRequestsResource();
const { message } = App.useApp();
const { data, refresh } = useGetCustomRequest();
const { refresh: refreshRoleCustomKeys } = useRequest<{ data: string[] }>(
{
url: listByCurrentRoleUrl,
},
{
manual: true,
cacheKey: listByCurrentRoleUrl,
},
);
const currentRoles = useCurrentRoles();
return (
<>
<SchemaSettings.ActionModalItem
title={t('Access Control')}
schema={CustomRequestACLSchema}
scope={{ currentRoles }}
initialValues={{
roles: data?.data?.roles,
}}
beforeOpen={() => !data && refresh()}
onSubmit={async ({ roles }) => {
await customRequestsResource.updateOrCreate({
values: {
key: fieldSchema['x-uid'],
roles,
},
filterKeys: ['key'],
});
refresh();
refreshRoleCustomKeys();
return message.success(t('Saved successfully'));
}}
/>
</>
);
}
export const CustomRequestActionDesigner: React.FC = () => {
const customRequestsResource = useCustomRequestsResource();
const fieldSchema = useFieldSchema();
return (
<Action.Designer
linkageAction
buttonEditorProps={{
isLink: fieldSchema['x-action'] === 'customize:table:request',
}}
linkageRulesProps={{
type: 'button',
}}
removeButtonProps={{
onConfirmOk() {
return customRequestsResource.destroy({
filterByTk: fieldSchema['x-uid'],
});
},
}}
>
<CustomRequestSettingsItem />
<CustomRequestACL />
</Action.Designer>
);
};

View File

@ -0,0 +1 @@
export * from './CustomRequestAction';

View File

@ -0,0 +1 @@
export const listByCurrentRoleUrl = `customRequests:listByCurrentRole`;

View File

@ -0,0 +1,3 @@
export * from './useCustomizeRequestActionProps';
export * from './useGetCustomRequest';
export * from './useCustomRequestVariableOptions';

View File

@ -0,0 +1,34 @@
import { useCollection, useCollectionFilterOptions, useCompile } from '@nocobase/client';
import { useTranslation } from '../locale';
import { useMemo } from 'react';
export const useCustomRequestVariableOptions = () => {
const collection = useCollection();
const { t } = useTranslation();
const fieldsOptions = useCollectionFilterOptions(collection);
const userFieldOptions = useCollectionFilterOptions('users');
const compile = useCompile();
const [fields, userFields] = useMemo(() => {
return [compile(fieldsOptions), compile(userFieldOptions)];
}, [fieldsOptions, userFieldOptions]);
return useMemo(() => {
return [
{
name: 'currentRecord',
title: t('Current record'),
children: [...fields],
},
{
name: 'currentUser',
title: t('Current user'),
children: userFields,
},
{
name: 'currentTime',
title: t('Current time'),
children: null,
},
];
}, [fields, userFields]);
};

View File

@ -0,0 +1,6 @@
import { useAPIClient } from '@nocobase/client';
export const useCustomRequestsResource = () => {
const apiClient = useAPIClient();
return apiClient.resource('customRequests');
};

View File

@ -0,0 +1,112 @@
import { useField, useFieldSchema, useForm } from '@formily/react';
import {
TableFieldResource,
getFormValues,
useAPIClient,
useActionContext,
useBlockRequestContext,
useCollection,
useCompile,
useFilterByTk,
useFormActiveFields,
useRecord,
} from '@nocobase/client';
import { App } from 'antd';
import { isURL } from '@nocobase/utils/client';
import { useNavigate } from 'react-router-dom';
import { useGetCustomRequest } from './useGetCustomRequest';
import { useTranslation } from '../locale';
export const useCustomizeRequestActionProps = () => {
const apiClient = useAPIClient();
const navigate = useNavigate();
const filterByTk = useFilterByTk();
const actionSchema = useFieldSchema();
const compile = useCompile();
const form = useForm();
const { fields, getField, getPrimaryKey } = useCollection();
const { field, resource, __parent, service } = useBlockRequestContext();
const { getActiveFieldsName } = useFormActiveFields() || {};
const record = useRecord();
const fieldSchema = useFieldSchema();
const { data, runAsync } = useGetCustomRequest();
const actionField = useField();
const { setVisible } = useActionContext();
const { modal, message } = App.useApp();
const { t } = useTranslation();
return {
async onClick() {
const { skipValidator, onSuccess } = actionSchema?.['x-action-settings'] ?? {};
const options = data ? data?.data?.options : (await runAsync())?.data?.options;
if (!options?.['url']) {
return message.error(t('Please configure the request settings first'));
}
const xAction = actionSchema?.['x-action'];
if (skipValidator !== true && xAction === 'customize:form:request') {
await form.submit();
}
const requestConfig = {};
const methods = ['POST', 'PUT', 'PATCH'];
if (xAction === 'customize:form:request' && methods.includes(options['method'])) {
const fieldNames = fields.map((field) => field.name);
const values = getFormValues({
filterByTk,
field,
form,
fieldNames,
getField,
resource,
actionFields: getActiveFieldsName?.('form') || [],
});
requestConfig['data'] = values;
}
actionField.data ??= {};
actionField.data.loading = true;
try {
await apiClient.request({
url: `/customRequests:send/${fieldSchema['x-uid']}`,
method: 'POST',
data: {
requestConfig,
currentRecord: {
id: record[getPrimaryKey()],
appends: service.params[0].appends,
},
},
});
actionField.data.loading = false;
if (!(resource instanceof TableFieldResource)) {
__parent?.service?.refresh?.();
}
service?.refresh?.();
if (xAction === 'customize:form:request') {
setVisible?.(false);
}
if (!onSuccess?.successMessage) {
return;
}
if (onSuccess?.manualClose) {
modal.success({
title: compile(onSuccess?.successMessage),
onOk: async () => {
if (onSuccess?.redirecting && onSuccess?.redirectTo) {
if (isURL(onSuccess.redirectTo)) {
window.location.href = onSuccess.redirectTo;
} else {
navigate(onSuccess.redirectTo);
}
}
},
});
} else {
return message.success(compile(onSuccess?.successMessage));
}
} finally {
actionField.data.loading = false;
}
},
};
};

View File

@ -0,0 +1,19 @@
import { useFieldSchema } from '@formily/react';
import { useRequest } from '@nocobase/client';
export const useGetCustomRequest = () => {
const fieldSchema = useFieldSchema();
const url = `customRequests:get/${fieldSchema['x-uid']}`;
return useRequest<{ data: { options: any; title: string; roles: any[] } }>(
{
url,
params: {
appends: ['roles'],
},
},
{
manual: true,
cacheKey: url,
},
);
};

View File

@ -0,0 +1,38 @@
import { CollectionManagerProvider, Plugin, SchemaComponentOptions } from '@nocobase/client';
import React from 'react';
import { CustomRequestConfigurationFieldsSchema } from './schemas';
import { CustomRequestAction } from './components';
import { CustomRequestInitializer } from './initializer';
const CustomRequestProvider: React.FC = (props) => {
return (
<SchemaComponentOptions
scope={{
CustomRequestConfigurationFieldsSchema,
}}
components={{ CustomRequestAction, CustomRequestInitializer }}
>
<CollectionManagerProvider>{props.children}</CollectionManagerProvider>
</SchemaComponentOptions>
);
};
export class CustomRequestPlugin extends Plugin {
async afterAdd() {
// await this.app.pm.add()
}
async beforeLoad() {}
// You can get and modify the app instance here
async load() {
this.app.use(CustomRequestProvider);
// this.app.addComponents({})
// this.app.addScopes({})
// this.app.addProvider()
// this.app.addProviders()
// this.app.router.add()
}
}
export default CustomRequestPlugin;

View File

@ -0,0 +1,40 @@
import { uid } from '@formily/shared';
import { BlockInitializer } from '@nocobase/client';
import React from 'react';
import { useCustomRequestsResource } from '../hooks/useCustomRequestsResource';
export const CustomRequestInitializer: React.FC<any> = (props) => {
const customRequestsResource = useCustomRequestsResource();
const schema = {
title: '{{ t("Custom request") }}',
'x-component': 'CustomRequestAction',
'x-action': 'customize:form:request',
'x-designer': 'CustomRequestAction.Designer',
'x-decorator': 'CustomRequestAction.Decorator',
'x-uid': uid(),
'x-action-settings': {
onSuccess: {
manualClose: false,
redirecting: false,
successMessage: '{{t("Request success")}}',
},
},
};
return (
<BlockInitializer
{...props}
insert={async (s) => {
await customRequestsResource.updateOrCreate({
values: {
key: s['x-uid'],
},
filterKeys: ['key'],
});
await props?.insert(s);
}}
schema={schema}
/>
);
};

View File

@ -0,0 +1 @@
export * from './CustomRequestInitializer';

View File

@ -0,0 +1,18 @@
import { i18n } from '@nocobase/client';
import { useTranslation as useT } from 'react-i18next';
export const NAMESPACE = 'custom-request';
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });
}
export function generateNTemplate(key: string) {
return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`;
}
export function useTranslation() {
return useT(NAMESPACE, {
nsMode: 'fallback',
});
}

View File

@ -0,0 +1,25 @@
import { generateNTemplate } from '../locale';
export const CustomRequestACLSchema = {
type: 'object',
properties: {
roles: {
type: 'array',
title: generateNTemplate('Roles'),
'x-decorator': 'FormItem',
'x-decorator-props': {
tooltip: generateNTemplate('If not set, all roles can see this action'),
},
'x-component': 'Select',
'x-component-props': {
multiple: true,
fieldNames: {
label: 'title',
value: 'name',
},
objectValue: true,
options: '{{ currentRoles }}',
},
},
},
};

View File

@ -0,0 +1,182 @@
import { generateNTemplate } from '../locale';
export const CustomRequestConfigurationFieldsSchema = {
type: 'object',
properties: {
method: {
type: 'string',
required: true,
title: generateNTemplate('HTTP method'),
'x-decorator-props': {
tooltip: generateNTemplate(
'When the HTTP method is Post, Put or Patch, and this custom request inside the form, the request body will be automatically filled in with the form data',
),
},
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
showSearch: false,
allowClear: false,
className: 'auto-width',
},
enum: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
],
default: 'POST',
},
url: {
type: 'string',
required: true,
title: generateNTemplate('URL'),
'x-decorator': 'FormItem',
'x-component': 'Variable.RawTextArea',
'x-component-props': {
scope: '{{useCustomRequestVariableOptions}}',
autoSize: true,
fieldNames: {
value: 'name',
label: 'title',
},
placeholder: 'https://www.nocobase.com',
},
},
headers: {
type: 'array',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: generateNTemplate('Headers'),
description: generateNTemplate('"Content-Type" only support "application/json", and no need to specify'),
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: generateNTemplate('Name'),
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Variable.Input',
'x-component-props': {
scope: '{{useCustomRequestVariableOptions}}',
fieldNames: {
value: 'name',
label: 'title',
},
useTypedConstant: true,
},
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: generateNTemplate('Add request header'),
'x-component': 'ArrayItems.Addition',
},
},
},
params: {
type: 'array',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: generateNTemplate('Parameters'),
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: generateNTemplate('Name'),
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Variable.Input',
'x-component-props': {
scope: '{{useCustomRequestVariableOptions}}',
fieldNames: {
value: 'name',
label: 'title',
},
useTypedConstant: true,
},
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: generateNTemplate('Add parameter'),
'x-component': 'ArrayItems.Addition',
},
},
},
data: {
type: 'string',
title: generateNTemplate('Body'),
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component': 'Variable.JSON',
'x-component-props': {
scope: '{{useCustomRequestVariableOptions}}',
fieldNames: {
value: 'name',
label: 'title',
},
changeOnSelect: true,
autoSize: {
minRows: 10,
},
placeholder: generateNTemplate('Input request data'),
},
description: generateNTemplate('Only support standard JSON data'),
},
timeout: {
type: 'number',
title: generateNTemplate('Timeout config'),
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component': 'InputNumber',
'x-component-props': {
addonAfter: generateNTemplate('ms'),
min: 1,
step: 1000,
defaultValue: 5000,
},
},
},
};

View File

@ -0,0 +1,2 @@
export * from './CustomRequestConfigurationFields';
export * from './CustomRequestACL';

View File

@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,26 @@
export default {
'Access Control': '权限控制',
'Custom Request': '自定义请求',
'Request settings': '请求设置',
'Roles': '角色',
'If not set, all roles can see this action': '如果不设置,所有角色都可以看到这个自定义请求',
"Title": "标题",
'HTTP method': 'HTTP 方法',
'URL': 'URL',
'Headers': '请求头',
'Parameters': '参数',
'Add request header': '添加请求头',
'Add parameter': '添加参数',
'Enter description info': '输入描述信息',
Body: '请求体',
'Use variable': '使用变量',
Format: '格式化',
Insert: '插入',
'Timeout config': '超时设置',
ms: '毫秒',
'Input request data': '输入请求数据',
'Only support standard JSON data': '仅支持标准 JSON 数据',
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" 请求头仅支持 "application/json",无需填写',
'When the HTTP method is Post, Put or Patch, and this custom request inside the form, the request body will be automatically filled in with the form data': '当请求方法为 Post、Put 或 Patch 时,且此自定义请求在表单内,请求体将自动填充表单数据',
'Please configure the request settings first': '请先配置请求设置',
}

View File

@ -0,0 +1,58 @@
import { Context } from '@nocobase/actions';
import Database, { Repository } from '@nocobase/database';
import { MockServer, mockServer, supertest } from '@nocobase/test';
describe('actions', () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent: ReturnType<MockServer['agent']>;
let resource: ReturnType<ReturnType<MockServer['agent']>['resource']>;
beforeEach(async () => {
app = mockServer({
registerActions: true,
acl: true,
plugins: ['users', 'auth', 'acl', 'custom-request'],
});
await app.loadAndInstall({ clean: true });
db = app.db;
repo = db.getRepository('customRequests');
agent = app.agent();
resource = agent.resource('customRequests');
});
describe('send', () => {
let params;
beforeEach(async () => {
app.resource({
name: 'custom-request-test',
actions: {
test(ctx: Context) {
params = ctx.action.params;
console.log('🚀 ~ file: actions.test.ts:34 ~ test ~ params:', params);
return 'test ok';
},
},
});
});
beforeEach(async () => {
await repo.create({
values: {
key: 'test',
options: {
url: 'http://localhost:13000/api/custom-request-test:test',
method: 'GET',
},
},
});
});
test('basic', async () => {
const res = await resource.send({
filterByTk: 'test',
});
console.log(res.status);
});
});
});

View File

@ -0,0 +1,23 @@
import { Context } from '@nocobase/actions';
export async function listByCurrentRole(ctx: Context) {
const repo = ctx.db.getRepository('customRequests');
const data = await repo.find({
appends: ['roles'],
});
const crRepo = ctx.db.getRepository('customRequestsRoles');
ctx.body = data
.filter((item) => {
return !item.roles.length;
})
.map((item) => item.key)
.concat(
(
await crRepo.find({
filter: {
roleName: ctx.state.currentRole,
},
})
).map((item) => item.customRequestKey),
);
}

View File

@ -0,0 +1,115 @@
import { Context, Next } from '@nocobase/actions';
import actions from '@nocobase/actions';
import { parse } from '@nocobase/utils';
import axios from 'axios';
const getHeaders = (headers: Record<string, any>) => {
return Object.keys(headers).reduce((hds, key) => {
if (key.toLocaleLowerCase().startsWith('x-')) {
hds[key] = headers[key];
}
return hds;
}, {});
};
const arrayToObject = (arr: { name: string; value: string }[]) => {
return arr.reduce((acc, cur) => {
acc[cur.name] = cur.value;
return acc;
}, {});
};
const omitNullAndUndefined = (obj: any) => {
return Object.keys(obj).reduce((acc, cur) => {
if (obj[cur] !== null && typeof obj[cur] !== 'undefined') {
acc[cur] = obj[cur];
}
return acc;
}, {});
};
export async function send(ctx: Context, next: Next) {
const { filterByTk, resourceName, values = {} } = ctx.action.params;
const {
currentRecord: { id: currentRecordId, appends: currentRecordAppends } = {
id: 0,
appends: [],
},
requestConfig: requestConfigFirst = {},
} = values;
// root role has all permissions
if (ctx.state.currentRole !== 'root') {
const crRepo = ctx.db.getRepository('customRequestsRoles');
const hasRoles = await crRepo.find({
filter: {
customRequestKey: filterByTk,
},
});
if (hasRoles.length) {
if (!hasRoles.find((item) => item.roleName === ctx.state.currentRole)) {
return ctx.throw(403, 'custom request no permission');
}
}
}
const repo = ctx.db.getRepository(resourceName);
const requestConfig = await repo.findOne({
filter: {
key: filterByTk,
},
});
if (!requestConfig) {
ctx.throw(404, 'request config not found');
}
ctx.withoutDataWrapping = true;
const { collectionName, url, headers = {}, params = {}, data = {}, ...options } = requestConfig.options;
let currentRecord = {};
if (collectionName && typeof currentRecordId !== 'undefined') {
const recordRepo = ctx.db.getRepository(collectionName);
currentRecord = await recordRepo.findOne({
filterByTk: currentRecordId,
appends: currentRecordAppends,
});
}
const variables = {
currentRecord,
currentUser: ctx.auth.user,
currentTime: new Date().toISOString(),
};
try {
ctx.body = await axios({
baseURL: ctx.origin,
...options,
url: parse(url)(variables),
headers: {
Authorization: 'Bearer ' + ctx.getBearerToken(),
...getHeaders(ctx.headers),
...omitNullAndUndefined(parse(arrayToObject(headers))(variables)),
},
params: parse(arrayToObject(params))(variables),
data: parse({
...data,
...requestConfigFirst?.data,
})(variables),
}).then((res) => {
return res.data;
});
} catch (err: any) {
if (axios.isAxiosError(err)) {
ctx.status = err.response?.status || 500;
ctx.body = err.response?.data || { message: err.message };
} else {
ctx.throw(500, err?.message);
}
}
return next();
}

View File

@ -0,0 +1,30 @@
import { CollectionOptions } from '@nocobase/client';
export default {
namespace: 'custom-requests',
duplicator: 'optional',
name: 'customRequests',
title: '{{t("Custom request")}}',
fields: [
{
type: 'uid',
name: 'key',
primaryKey: true,
},
{
type: 'belongsToMany',
name: 'roles',
onDelete: 'CASCADE',
through: 'customRequestsRoles',
target: 'roles',
foreignKey: 'customRequestKey',
otherKey: 'roleName',
sourceKey: 'key',
targetKey: 'name',
},
{
type: 'json',
name: 'options', // 配置的请求参数都放这里
},
],
} as CollectionOptions;

View File

@ -0,0 +1,8 @@
import { CollectionOptions } from '@nocobase/client';
export default {
namespace: 'custom-requests',
duplicator: 'optional',
name: 'customRequestsRoles',
title: '{{t("Custom request")}}',
} as CollectionOptions;

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,41 @@
import { InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { send } from './actions/send';
import { listByCurrentRole } from './actions/listByCurrentRole';
export class CustomRequestPlugin extends Plugin {
afterAdd() {}
beforeLoad() {}
async load() {
await this.db.import({
directory: resolve(__dirname, './collections'),
});
this.app.resource({
name: 'customRequests',
actions: {
send,
listByCurrentRole,
},
});
this.app.acl.registerSnippet({
name: `ui.${this.name}`,
actions: ['customRequests:*'],
});
this.app.acl.allow('customRequests', ['send', 'listByCurrentRole'], 'loggedIn');
}
async install(options?: InstallOptions) {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default CustomRequestPlugin;

View File

@ -44,6 +44,7 @@
"@nocobase/plugin-users": "0.14.0-alpha.7",
"@nocobase/plugin-verification": "0.14.0-alpha.7",
"@nocobase/plugin-workflow": "0.14.0-alpha.7",
"@nocobase/plugin-custom-request": "0.14.0-alpha.7",
"@nocobase/server": "0.14.0-alpha.7",
"cronstrue": "^2.11.0"
},

View File

@ -27,6 +27,7 @@ export class PresetNocoBase extends Plugin {
'data-visualization',
'auth',
'sms-auth',
'custom-request',
];
localPlugins = [

View File

@ -15818,7 +15818,7 @@ lodash.union@^4.6.0:
version "4.6.0"
resolved "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
lodash@4.17.21, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0:
lodash@4.17.21, lodash@4.x, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0:
version "4.17.21"
resolved "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"