@nocobase/client-v2 2.1.0-beta.33 → 2.1.0-beta.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/APIClient.d.ts +16 -0
- package/es/Application.d.ts +2 -1
- package/es/authRedirect.d.ts +9 -16
- package/es/components/form/EnvVariableInput.d.ts +8 -6
- package/es/components/form/VariableInput.d.ts +73 -0
- package/es/components/form/index.d.ts +1 -0
- package/es/components/form/table/RowOverlayPreview.d.ts +27 -0
- package/es/components/form/table/SelectionCell.d.ts +36 -0
- package/es/components/form/table/Table.d.ts +82 -0
- package/es/components/form/table/constants.d.ts +15 -0
- package/es/components/form/table/dnd/SortableRow.d.ts +40 -0
- package/es/components/form/table/dnd/index.d.ts +9 -0
- package/es/components/form/table/index.d.ts +9 -0
- package/es/components/form/table/styles.d.ts +41 -0
- package/es/components/form/table/utils.d.ts +44 -0
- package/es/components/index.d.ts +2 -0
- package/es/flow/components/TextAreaWithContextSelector.d.ts +15 -0
- package/es/flow/models/blocks/table/dragSort/dragSortComponents.d.ts +1 -6
- package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +5 -1
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.d.ts +1 -0
- package/es/index.mjs +145 -78
- package/es/theme/globalStyles.d.ts +9 -0
- package/es/theme/index.d.ts +1 -0
- package/lib/index.js +161 -94
- package/package.json +8 -6
- package/src/APIClient.ts +68 -0
- package/src/Application.tsx +6 -2
- package/src/__tests__/authRedirect.test.ts +170 -64
- package/src/__tests__/globalDeps.test.ts +2 -0
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +6 -6
- package/src/authRedirect.ts +23 -84
- package/src/components/form/EnvVariableInput.tsx +11 -46
- package/src/components/form/VariableInput.tsx +177 -0
- package/src/components/form/__tests__/EnvVariableInput.test.tsx +175 -0
- package/src/components/form/index.tsx +1 -0
- package/src/components/form/table/RowOverlayPreview.tsx +51 -0
- package/src/components/form/table/SelectionCell.tsx +72 -0
- package/src/components/form/table/Table.tsx +279 -0
- package/src/components/form/table/__tests__/Table.pagination.test.tsx +80 -0
- package/src/components/form/table/constants.ts +16 -0
- package/src/components/form/table/dnd/SortableRow.tsx +106 -0
- package/src/components/form/table/dnd/index.ts +10 -0
- package/src/components/form/table/index.tsx +13 -0
- package/src/components/form/table/styles.ts +110 -0
- package/src/components/form/table/utils.ts +75 -0
- package/src/components/index.ts +2 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +2 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +111 -0
- package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -1
- package/src/flow/components/TextAreaWithContextSelector.tsx +30 -6
- package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +81 -0
- package/src/flow/components/code-editor/hooks/useCodeRunner.ts +34 -2
- package/src/flow/models/blocks/table/dragSort/dragSortComponents.tsx +1 -81
- package/src/flow/models/fields/JSEditableFieldModel.tsx +107 -7
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +97 -0
- package/src/index.ts +1 -0
- package/src/nocobase-buildin-plugin/index.tsx +4 -4
- package/src/theme/globalStyles.ts +21 -0
- package/src/theme/index.tsx +1 -0
- package/src/utils/globalDeps.ts +5 -1
|
@@ -7,19 +7,14 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
useFlowContext,
|
|
12
|
-
VariableHybridInput,
|
|
13
|
-
type MetaTreeNode,
|
|
14
|
-
type VariableHybridInputConverters,
|
|
15
|
-
} from '@nocobase/flow-engine';
|
|
16
|
-
import { useRequest } from 'ahooks';
|
|
10
|
+
import type { MetaTreeNode, VariableHybridInputConverters } from '@nocobase/flow-engine';
|
|
17
11
|
import { Input } from 'antd';
|
|
18
12
|
import React, { useMemo } from 'react';
|
|
13
|
+
import { VariableInput } from './VariableInput';
|
|
19
14
|
|
|
20
15
|
const ENV_EXPR_REGEXP = /\{\{\s*(\$env\.[^{}]+?)\s*\}\}/g;
|
|
21
16
|
const ENV_SINGLE_EXPR_REGEXP = /^\{\{\s*(\$env\.[^{}]+?)\s*\}\}$/;
|
|
22
|
-
const
|
|
17
|
+
const ENV_NAMESPACES = ['$env'];
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
20
|
* Convert a stored value like `"{{ $env.foo.bar }}"` back into the
|
|
@@ -43,37 +38,6 @@ export function formatEnvPath(meta?: MetaTreeNode) {
|
|
|
43
38
|
return `{{ ${paths.join('.')} }}`;
|
|
44
39
|
}
|
|
45
40
|
|
|
46
|
-
/**
|
|
47
|
-
* Pull the `$env` sub-tree off the FlowContext meta registry and eagerly
|
|
48
|
-
* resolve lazy `children` thunks so the picker can render labels on first
|
|
49
|
-
* paint. Empty tree (no env-variables plugin or no defined vars) yields `[]`.
|
|
50
|
-
*/
|
|
51
|
-
function useEnvMetaTree(): MetaTreeNode[] {
|
|
52
|
-
const ctx = useFlowContext();
|
|
53
|
-
const { data } = useRequest<MetaTreeNode[], []>(
|
|
54
|
-
async () => {
|
|
55
|
-
const tree = ctx.getPropertyMetaTree().filter((node) => node.name === '$env');
|
|
56
|
-
for (const node of tree) {
|
|
57
|
-
if (typeof node.children === 'function') {
|
|
58
|
-
try {
|
|
59
|
-
const resolved = await (node.children as () => Promise<MetaTreeNode[]>)();
|
|
60
|
-
node.children = Array.isArray(resolved) ? resolved : [];
|
|
61
|
-
} catch {
|
|
62
|
-
node.children = [];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return tree.filter((node) => Array.isArray(node.children) && node.children.length > 0);
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
cacheKey: META_TREE_CACHE_KEY,
|
|
70
|
-
refreshOnWindowFocus: true,
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
return data ?? [];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
41
|
export interface EnvVariableInputProps {
|
|
78
42
|
value?: string;
|
|
79
43
|
onChange?: (value: string) => void;
|
|
@@ -91,15 +55,16 @@ export interface EnvVariableInputProps {
|
|
|
91
55
|
const isVariableExpr = (value?: string) => typeof value === 'string' && /\{\{\s*[^{}]+?\s*\}\}/.test(value);
|
|
92
56
|
|
|
93
57
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
58
|
+
* Convenience wrapper around `VariableInput` constrained to the `$env`
|
|
59
|
+
* namespace, with optional password-input masking for plain values. Use for
|
|
60
|
+
* fields that accept either a literal credential or a `{{ $env.X }}`
|
|
61
|
+
* reference (S3 access keys, OAuth secrets, etc.). The `$env` tree is
|
|
62
|
+
* provided by the environment-variables plugin's
|
|
63
|
+
* `flowEngine.context.defineProperty('$env', ...)`; this component degrades
|
|
64
|
+
* gracefully to an empty picker when no env variables are defined.
|
|
99
65
|
*/
|
|
100
66
|
export function EnvVariableInput(props: EnvVariableInputProps) {
|
|
101
67
|
const { password, ...rest } = props;
|
|
102
|
-
const metaTree = useEnvMetaTree();
|
|
103
68
|
|
|
104
69
|
const converters = useMemo<VariableHybridInputConverters>(
|
|
105
70
|
() => ({
|
|
@@ -122,5 +87,5 @@ export function EnvVariableInput(props: EnvVariableInputProps) {
|
|
|
122
87
|
);
|
|
123
88
|
}
|
|
124
89
|
|
|
125
|
-
return <
|
|
90
|
+
return <VariableInput {...rest} namespaces={ENV_NAMESPACES} converters={converters} />;
|
|
126
91
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
useFlowContext,
|
|
12
|
+
VariableHybridInput,
|
|
13
|
+
type MetaTreeNode,
|
|
14
|
+
type VariableHybridInputConverters,
|
|
15
|
+
} from '@nocobase/flow-engine';
|
|
16
|
+
import { useRequest } from 'ahooks';
|
|
17
|
+
import React, { useMemo } from 'react';
|
|
18
|
+
import { TextAreaWithContextSelector } from '../../flow/components/TextAreaWithContextSelector';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The flow-engine defaults emit `{{ ctx.$X.Y }}` and only parse the same
|
|
22
|
+
* shape back into a path — but NocoBase server templates (and v1 stored
|
|
23
|
+
* values) use the bare `{{$X.Y}}` form without the `ctx.` prefix. These
|
|
24
|
+
* converters keep the picker's output stable against v1 and let already-
|
|
25
|
+
* stored values round-trip to a labelled pill instead of falling back to a
|
|
26
|
+
* raw `{{…}}` literal.
|
|
27
|
+
*/
|
|
28
|
+
const VARIABLE_EXPR_RE = /^\{\{\s*(.+?)\s*\}\}$/;
|
|
29
|
+
|
|
30
|
+
export function parseVariablePath(value?: string): string[] | undefined {
|
|
31
|
+
if (typeof value !== 'string') return undefined;
|
|
32
|
+
const match = value.trim().match(VARIABLE_EXPR_RE);
|
|
33
|
+
if (!match) return undefined;
|
|
34
|
+
let pathString = match[1];
|
|
35
|
+
// Backwards-compat: accept the legacy `ctx.` prefix so values produced by
|
|
36
|
+
// pre-fix versions of the picker still resolve to a labelled pill.
|
|
37
|
+
if (pathString === 'ctx') return [];
|
|
38
|
+
if (pathString.startsWith('ctx.')) pathString = pathString.slice(4);
|
|
39
|
+
return pathString.split('.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatVariablePath(meta?: MetaTreeNode): string | undefined {
|
|
43
|
+
const paths = meta?.paths || [];
|
|
44
|
+
if (paths.length === 0) return undefined;
|
|
45
|
+
// No inner spaces — matches the v1 storage shape exactly so round-trips
|
|
46
|
+
// through the API stay byte-stable.
|
|
47
|
+
return `{{${paths.join('.')}}}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const META_TREE_CACHE_PREFIX = '@nocobase/client-v2:VariableInput:metaTree';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the meta tree the variable picker should expose. Filters the global
|
|
54
|
+
* meta tree by `namespaces` (top-level property names like `'$env'`,
|
|
55
|
+
* `'$user'`), appends `extraNodes`, and eagerly resolves any lazy `children`
|
|
56
|
+
* thunks so labels render on first paint.
|
|
57
|
+
*
|
|
58
|
+
* Returns `[]` while loading or when no nodes survive the filter, mirroring
|
|
59
|
+
* the existing EnvVariableInput behavior so the picker still opens but offers
|
|
60
|
+
* nothing.
|
|
61
|
+
*/
|
|
62
|
+
export function useFilteredMetaTree(options: { namespaces?: string[]; extraNodes?: MetaTreeNode[] }): MetaTreeNode[] {
|
|
63
|
+
const { namespaces, extraNodes } = options;
|
|
64
|
+
const ctx = useFlowContext();
|
|
65
|
+
const cacheKey = useMemo(() => {
|
|
66
|
+
const ns = namespaces ? [...namespaces].sort().join(',') : '*';
|
|
67
|
+
return `${META_TREE_CACHE_PREFIX}:${ns}`;
|
|
68
|
+
}, [namespaces]);
|
|
69
|
+
|
|
70
|
+
const { data } = useRequest<MetaTreeNode[], []>(
|
|
71
|
+
async () => {
|
|
72
|
+
const all = ctx.getPropertyMetaTree?.() ?? [];
|
|
73
|
+
const filtered = namespaces ? all.filter((node) => namespaces.includes(node.name)) : all;
|
|
74
|
+
for (const node of filtered) {
|
|
75
|
+
if (typeof node.children === 'function') {
|
|
76
|
+
try {
|
|
77
|
+
const resolved = await (node.children as () => Promise<MetaTreeNode[]>)();
|
|
78
|
+
node.children = Array.isArray(resolved) ? resolved : [];
|
|
79
|
+
} catch {
|
|
80
|
+
node.children = [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const withChildren = filtered.filter(
|
|
85
|
+
(node) => !Array.isArray(node.children) || node.children.length > 0 || node.type !== 'object',
|
|
86
|
+
);
|
|
87
|
+
return withChildren;
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
cacheKey,
|
|
91
|
+
refreshOnWindowFocus: true,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return useMemo(() => {
|
|
96
|
+
const base = data ?? [];
|
|
97
|
+
if (!extraNodes?.length) return base;
|
|
98
|
+
return [...base, ...extraNodes];
|
|
99
|
+
}, [data, extraNodes]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface VariableInputProps {
|
|
103
|
+
value?: string;
|
|
104
|
+
onChange?: (value: string) => void;
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
placeholder?: string;
|
|
107
|
+
addonBefore?: React.ReactNode;
|
|
108
|
+
/**
|
|
109
|
+
* Restrict the picker to specific top-level meta tree namespaces (e.g.
|
|
110
|
+
* `['$env', '$user']`). When omitted, every registered top-level property is
|
|
111
|
+
* exposed. Filter happens at the picker level — the underlying regex used
|
|
112
|
+
* for pill rendering still matches any `{{ ... }}` expression so pre-existing
|
|
113
|
+
* out-of-scope values stay legible.
|
|
114
|
+
*/
|
|
115
|
+
namespaces?: string[];
|
|
116
|
+
/**
|
|
117
|
+
* Static leaves appended to the picker, after the namespace-filtered nodes.
|
|
118
|
+
* Use for ad-hoc local-only variables (e.g. `$resetLink`) that are not part
|
|
119
|
+
* of the global FlowContext registry.
|
|
120
|
+
*/
|
|
121
|
+
extraNodes?: MetaTreeNode[];
|
|
122
|
+
/**
|
|
123
|
+
* Override the converters used by the underlying `VariableHybridInput`.
|
|
124
|
+
* Mostly useful when the caller wants to constrain `formatPathToValue` to a
|
|
125
|
+
* specific namespace (see `EnvVariableInput` for that pattern).
|
|
126
|
+
*/
|
|
127
|
+
converters?: VariableHybridInputConverters;
|
|
128
|
+
className?: string;
|
|
129
|
+
style?: React.CSSProperties;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inline (single-line) variable input. Renders the literal text and any
|
|
134
|
+
* `{{ ... }}` references as styled pills via `VariableHybridInput`. Use for
|
|
135
|
+
* fields like form input titles, email subject lines, or any place a single
|
|
136
|
+
* line of mixed literal+variable content is appropriate.
|
|
137
|
+
*/
|
|
138
|
+
export function VariableInput(props: VariableInputProps) {
|
|
139
|
+
const { namespaces, extraNodes, converters, ...rest } = props;
|
|
140
|
+
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
|
141
|
+
const mergedConverters = useMemo<VariableHybridInputConverters>(
|
|
142
|
+
() => ({
|
|
143
|
+
formatPathToValue: formatVariablePath,
|
|
144
|
+
parseValueToPath: parseVariablePath,
|
|
145
|
+
...converters,
|
|
146
|
+
}),
|
|
147
|
+
[converters],
|
|
148
|
+
);
|
|
149
|
+
return <VariableHybridInput {...rest} converters={mergedConverters} metaTree={metaTree} />;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface VariableTextAreaProps extends Omit<VariableInputProps, 'converters' | 'addonBefore'> {
|
|
153
|
+
rows?: number;
|
|
154
|
+
maxRows?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Multi-line variable input. Variables are inserted as raw `{{ ... }}` text at
|
|
159
|
+
* the caret rather than rendered as pills — use for email body templates and
|
|
160
|
+
* other free-form long-form text where literal display of variable expressions
|
|
161
|
+
* is desirable (the server expands them at render time).
|
|
162
|
+
*/
|
|
163
|
+
export function VariableTextArea(props: VariableTextAreaProps) {
|
|
164
|
+
const { namespaces, extraNodes, rows, maxRows, style, ...rest } = props;
|
|
165
|
+
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
|
166
|
+
const metaTreeGetter = useMemo(() => () => metaTree, [metaTree]);
|
|
167
|
+
return (
|
|
168
|
+
<TextAreaWithContextSelector
|
|
169
|
+
{...rest}
|
|
170
|
+
rows={rows}
|
|
171
|
+
maxRows={maxRows}
|
|
172
|
+
style={style}
|
|
173
|
+
metaTree={metaTreeGetter}
|
|
174
|
+
formatPathToValue={(meta) => formatVariablePath(meta) ?? ''}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import { FlowContext, FlowContextProvider, MetaTreeNode } from '@nocobase/flow-engine';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import { EnvVariableInput, formatEnvPath, parseEnvPath } from '../EnvVariableInput';
|
|
15
|
+
|
|
16
|
+
function createContextWithEnv() {
|
|
17
|
+
const ctx = new FlowContext();
|
|
18
|
+
(ctx as any).t = (key: string) => key;
|
|
19
|
+
|
|
20
|
+
ctx.defineProperty('$env', {
|
|
21
|
+
value: { API_KEY: 'secret', BASE_URL: 'https://example.com' },
|
|
22
|
+
meta: {
|
|
23
|
+
title: 'Env',
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
API_KEY: { title: 'API Key', type: 'string' },
|
|
27
|
+
BASE_URL: { title: 'Base URL', type: 'string' },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
ctx.defineProperty('$user', {
|
|
33
|
+
value: { name: 'John' },
|
|
34
|
+
meta: {
|
|
35
|
+
title: 'User',
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
name: { title: 'Name', type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return ctx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderWithCtx(ctx: FlowContext, node: React.ReactNode) {
|
|
47
|
+
return render(<FlowContextProvider context={ctx}>{node}</FlowContextProvider>);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('parseEnvPath', () => {
|
|
51
|
+
it('parses single segment', () => {
|
|
52
|
+
expect(parseEnvPath('{{ $env.API_KEY }}')).toEqual(['$env', 'API_KEY']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('parses nested segments', () => {
|
|
56
|
+
expect(parseEnvPath('{{ $env.foo.bar.baz }}')).toEqual(['$env', 'foo', 'bar', 'baz']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('trims surrounding whitespace', () => {
|
|
60
|
+
expect(parseEnvPath(' {{ $env.API_KEY }} ')).toEqual(['$env', 'API_KEY']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns undefined for plain text', () => {
|
|
64
|
+
expect(parseEnvPath('plain value')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns undefined for non-$env variable', () => {
|
|
68
|
+
expect(parseEnvPath('{{ $user.name }}')).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns undefined for mixed content', () => {
|
|
72
|
+
expect(parseEnvPath('prefix {{ $env.API_KEY }} suffix')).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns undefined for empty / undefined', () => {
|
|
76
|
+
expect(parseEnvPath('')).toBeUndefined();
|
|
77
|
+
expect(parseEnvPath(undefined)).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('formatEnvPath', () => {
|
|
82
|
+
it('formats a single nested env path', () => {
|
|
83
|
+
expect(formatEnvPath({ paths: ['$env', 'API_KEY'] } as MetaTreeNode)).toBe('{{ $env.API_KEY }}');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('formats multi-level env path', () => {
|
|
87
|
+
expect(formatEnvPath({ paths: ['$env', 'foo', 'bar'] } as MetaTreeNode)).toBe('{{ $env.foo.bar }}');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns undefined for non-$env namespace', () => {
|
|
91
|
+
expect(formatEnvPath({ paths: ['$user', 'name'] } as MetaTreeNode)).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns undefined for $env root only', () => {
|
|
95
|
+
expect(formatEnvPath({ paths: ['$env'] } as MetaTreeNode)).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns undefined for empty meta', () => {
|
|
99
|
+
expect(formatEnvPath(undefined)).toBeUndefined();
|
|
100
|
+
expect(formatEnvPath({ paths: [] } as MetaTreeNode)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('EnvVariableInput component', () => {
|
|
105
|
+
it('renders contenteditable editor with the variable selector button', async () => {
|
|
106
|
+
const ctx = createContextWithEnv();
|
|
107
|
+
renderWithCtx(ctx, <EnvVariableInput value="" onChange={() => undefined} />);
|
|
108
|
+
|
|
109
|
+
await waitFor(() => {
|
|
110
|
+
expect(screen.getByRole('textbox', { name: 'textbox' })).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByRole('button')).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renders the variable as a styled pill when value is a $env expression', async () => {
|
|
116
|
+
const ctx = createContextWithEnv();
|
|
117
|
+
renderWithCtx(ctx, <EnvVariableInput value="{{ $env.API_KEY }}" onChange={() => undefined} />);
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
const tag = screen.getByText((_, node) => node?.textContent === 'Env/API Key' && node.tagName === 'SPAN');
|
|
121
|
+
expect(tag).toBeInTheDocument();
|
|
122
|
+
expect(tag.className).toContain('nb-variable-tag');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders password input when value is plain text and password=true', async () => {
|
|
127
|
+
const ctx = createContextWithEnv();
|
|
128
|
+
renderWithCtx(ctx, <EnvVariableInput value="my-secret" password onChange={() => undefined} />);
|
|
129
|
+
|
|
130
|
+
const input = await screen.findByDisplayValue('my-secret');
|
|
131
|
+
expect(input).toBeInTheDocument();
|
|
132
|
+
expect(input.getAttribute('type')).toBe('password');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does NOT mask when value is a $env expression even with password=true', async () => {
|
|
136
|
+
const ctx = createContextWithEnv();
|
|
137
|
+
renderWithCtx(ctx, <EnvVariableInput value="{{ $env.API_KEY }}" password onChange={() => undefined} />);
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
const tag = screen.getByText((_, node) => node?.textContent === 'Env/API Key' && node.tagName === 'SPAN');
|
|
141
|
+
expect(tag).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
expect(screen.queryByDisplayValue('{{ $env.API_KEY }}')).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('propagates onChange when typing into the password input', async () => {
|
|
147
|
+
const ctx = createContextWithEnv();
|
|
148
|
+
const handleChange = vi.fn();
|
|
149
|
+
renderWithCtx(ctx, <EnvVariableInput value="initial" password onChange={handleChange} />);
|
|
150
|
+
|
|
151
|
+
const input = await screen.findByDisplayValue('initial');
|
|
152
|
+
fireEvent.change(input, { target: { value: 'next-value' } });
|
|
153
|
+
expect(handleChange).toHaveBeenCalledWith('next-value');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('renders placeholder text on the editor', async () => {
|
|
157
|
+
const ctx = createContextWithEnv();
|
|
158
|
+
renderWithCtx(ctx, <EnvVariableInput value="" placeholder="enter or pick" onChange={() => undefined} />);
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
const editor = screen.getByRole('textbox', { name: 'textbox' });
|
|
162
|
+
expect(editor.getAttribute('data-placeholder')).toBe('enter or pick');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('honors disabled state', async () => {
|
|
167
|
+
const ctx = createContextWithEnv();
|
|
168
|
+
renderWithCtx(ctx, <EnvVariableInput value="" disabled onChange={() => undefined} />);
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
const editor = screen.getByRole('textbox', { name: 'textbox' });
|
|
172
|
+
expect(editor.getAttribute('contenteditable')).toBe('false');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { cx } from '@emotion/css';
|
|
11
|
+
import { theme } from 'antd';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { overlayCellStylesClassName } from './styles';
|
|
14
|
+
import type { RowSnapshot } from './utils';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Floating clone of the row being dragged, rendered inside a `<DragOverlay>`
|
|
18
|
+
* so it follows the cursor while the source row stays in place. We inject the
|
|
19
|
+
* snapshot's `outerHTML` straight into a mini `<table>` so every antd class
|
|
20
|
+
* (`ant-table-cell`, `ant-table-selection-column`, theme tokens, etc.) is
|
|
21
|
+
* preserved. A `<colgroup>` re-applies the measured cell widths so the clone
|
|
22
|
+
* lines up column-for-column with the source row; the wrapper's pinned
|
|
23
|
+
* `height` + table `height:100%` keeps vertical sizing consistent regardless
|
|
24
|
+
* of which antd table size variant the caller picked.
|
|
25
|
+
*
|
|
26
|
+
* The wrapper also carries `overlayCellStylesClassName` so the index numeric
|
|
27
|
+
* (`.nb-table-index`) is hidden while dragging — only the handle + checkbox
|
|
28
|
+
* are surfaced inside the floating clone.
|
|
29
|
+
*/
|
|
30
|
+
export function RowOverlayPreview(props: { snapshot: RowSnapshot }) {
|
|
31
|
+
const { token } = theme.useToken();
|
|
32
|
+
const { html, cellWidths, totalWidth, totalHeight } = props.snapshot;
|
|
33
|
+
const colGroupHTML = cellWidths.map((width) => `<col style="width:${width}px" />`).join('');
|
|
34
|
+
const tableHTML = `<table style="table-layout:fixed;width:100%;height:100%;border-collapse:collapse"><colgroup>${colGroupHTML}</colgroup><tbody class="ant-table-tbody">${html}</tbody></table>`;
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cx('ant-table-wrapper', overlayCellStylesClassName)}
|
|
38
|
+
style={{
|
|
39
|
+
width: totalWidth || 'auto',
|
|
40
|
+
height: totalHeight || 'auto',
|
|
41
|
+
background: token.colorBgContainer,
|
|
42
|
+
boxShadow: token.boxShadowSecondary,
|
|
43
|
+
borderRadius: token.borderRadius,
|
|
44
|
+
pointerEvents: 'none',
|
|
45
|
+
opacity: 0.95,
|
|
46
|
+
overflow: 'hidden',
|
|
47
|
+
}}
|
|
48
|
+
dangerouslySetInnerHTML={{ __html: tableHTML }}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { cx } from '@emotion/css';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { SORT_HANDLE_GUTTER } from './constants';
|
|
13
|
+
import { SortHandle } from './dnd/SortableRow';
|
|
14
|
+
|
|
15
|
+
export interface SelectionCellProps {
|
|
16
|
+
/** Whether the row is currently selected — drives the index/checkbox swap. */
|
|
17
|
+
checked: boolean;
|
|
18
|
+
/** Zero-based row index (within the current page). `TableIndex` displays it as `index + 1`. */
|
|
19
|
+
index: number;
|
|
20
|
+
/** Render the drag handle on the left gutter. Mutually exclusive with `showStandaloneHandle`. */
|
|
21
|
+
showHandle: boolean;
|
|
22
|
+
/** Render the row index numeric. When `false` only the antd checkbox is shown (default antd UX). */
|
|
23
|
+
showIndex: boolean;
|
|
24
|
+
/** The original antd checkbox node produced by `rowSelection.renderCell`. */
|
|
25
|
+
originalNode: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handleSpanStyle: React.CSSProperties = {
|
|
29
|
+
position: 'absolute',
|
|
30
|
+
left: 0,
|
|
31
|
+
top: 0,
|
|
32
|
+
bottom: 0,
|
|
33
|
+
width: SORT_HANDLE_GUTTER,
|
|
34
|
+
display: 'inline-flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'center',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The body half of the rowSelection column. Renders, in order:
|
|
41
|
+
* 1. An absolute-positioned drag handle pinned to the left gutter (only
|
|
42
|
+
* when `showHandle` is true). The parent `<td>` must be
|
|
43
|
+
* `position: relative` for the handle to land inside the gutter —
|
|
44
|
+
* `selectionGutterClassName` in `styles.ts` adds that.
|
|
45
|
+
* 2. A `.nb-row-selection-cell` wrapper that contains both the row index
|
|
46
|
+
* and the checkbox. CSS in `indexSwapClassName` swaps which one is
|
|
47
|
+
* visible depending on hover / `checked` state.
|
|
48
|
+
*
|
|
49
|
+
* When `showIndex` is false we drop the swap entirely and render only the
|
|
50
|
+
* original antd checkbox — matches the default antd selection column UX for
|
|
51
|
+
* tables that don't want the index numeric.
|
|
52
|
+
*/
|
|
53
|
+
export function SelectionCell(props: SelectionCellProps) {
|
|
54
|
+
const { checked, index, showHandle, showIndex, originalNode } = props;
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{showHandle ? (
|
|
58
|
+
<span style={handleSpanStyle}>
|
|
59
|
+
<SortHandle />
|
|
60
|
+
</span>
|
|
61
|
+
) : null}
|
|
62
|
+
{showIndex ? (
|
|
63
|
+
<span className={cx('nb-row-selection-cell', { checked })}>
|
|
64
|
+
<span className="nb-table-index">{index + 1}</span>
|
|
65
|
+
<span className="nb-origin-node">{originalNode}</span>
|
|
66
|
+
</span>
|
|
67
|
+
) : (
|
|
68
|
+
originalNode
|
|
69
|
+
)}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|