Skip to content

Commit 6cc76f1

Browse files
authored
Merge pull request #2385 from brionmario/feat-custom-flow-components
Add support to render a Custom component during the flow execution
2 parents 15b784c + e9278b7 commit 6cc76f1

14 files changed

Lines changed: 188 additions & 11 deletions

File tree

frontend/apps/thunder-console/src/features/flows/components/resource-property-panel/ResourceProperties.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ function ResourceProperties(): ReactElement {
317317
if (propertyKey === 'data') {
318318
// When propertyKey is exactly 'data', replace the entire data object
319319
updatedResource.data = newValue as StepData;
320-
} else if (topLevelEditableProps.includes(propertyKey)) {
320+
} else if (propertyKey === 'id' || topLevelEditableProps.includes(propertyKey)) {
321321
set(updatedResource as unknown as Record<string, unknown>, propertyKey, newValue);
322322
} else if (propertyKey.startsWith('config.') || propertyKey.startsWith('data.')) {
323323
// Properties starting with 'config.' or 'data.' should be set on the resource directly

frontend/apps/thunder-console/src/features/flows/components/resources/elements/CommonElementFactory.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import ButtonAdapter from './adapters/ButtonAdapter';
2222
import CaptchaAdapter from './adapters/CaptchaAdapter';
2323
import ChoiceAdapter from './adapters/ChoiceAdapter';
2424
import ConsentAdapter from './adapters/ConsentAdapter';
25+
import CustomAdapter from './adapters/CustomAdapter';
2526
import DividerAdapter from './adapters/DividerAdapter';
2627
import FormAdapter from './adapters/FormAdapter';
2728
import IconAdapter from './adapters/IconAdapter';
@@ -165,6 +166,9 @@ function CommonElementFactory({
165166
if (resource.type === ElementTypes.Consent) {
166167
return <ConsentAdapter />;
167168
}
169+
if (resource.type === ElementTypes.Custom) {
170+
return <CustomAdapter resource={resource} />;
171+
}
168172

169173
return null;
170174
}

frontend/apps/thunder-console/src/features/flows/components/resources/elements/__tests__/CommonElementFactory.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ vi.mock('../adapters/TimerAdapter', () => ({
158158
),
159159
}));
160160

161+
vi.mock('../adapters/CustomAdapter', () => ({
162+
default: ({resource}: {resource: Element}) => (
163+
<div data-testid="custom-adapter" data-resource-id={resource.id}>
164+
Custom Adapter
165+
</div>
166+
),
167+
}));
168+
161169
describe('CommonElementFactory', () => {
162170
const createMockElement = (overrides: Partial<Element> = {}): Element =>
163171
({
@@ -456,6 +464,19 @@ describe('CommonElementFactory', () => {
456464
});
457465
});
458466

467+
describe('Custom Element', () => {
468+
it('should render CustomAdapter for Custom type', () => {
469+
const customElement = createMockElement({
470+
type: ElementTypes.Custom,
471+
});
472+
473+
render(<CommonElementFactory stepId="step-1" resource={customElement} />);
474+
475+
expect(screen.getByTestId('custom-adapter')).toBeInTheDocument();
476+
expect(screen.getByTestId('custom-adapter')).toHaveAttribute('data-resource-id', 'element-1');
477+
});
478+
});
479+
459480
describe('Unknown Element Type', () => {
460481
it('should return null for unknown element type', () => {
461482
const unknownElement = createMockElement({
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {Box, Typography} from '@wso2/oxygen-ui';
20+
import {PuzzleIcon} from '@wso2/oxygen-ui-icons-react';
21+
import {type ReactElement} from 'react';
22+
import {useTranslation} from 'react-i18next';
23+
import type {Element as FlowElement} from '@/features/flows/models/elements';
24+
25+
export interface CustomAdapterPropsInterface {
26+
resource: FlowElement;
27+
}
28+
29+
function CustomAdapter({resource}: CustomAdapterPropsInterface): ReactElement {
30+
const {t} = useTranslation();
31+
32+
return (
33+
<Box
34+
display="flex"
35+
flexDirection="column"
36+
alignItems="center"
37+
justifyContent="center"
38+
sx={{
39+
width: '100%',
40+
minHeight: 64,
41+
backgroundColor: 'rgba(0, 0, 0, 0.04)',
42+
borderRadius: 1,
43+
border: '1px dashed rgba(0, 0, 0, 0.2)',
44+
px: 1,
45+
py: 1.5,
46+
gap: 0.5,
47+
}}
48+
>
49+
<PuzzleIcon size={20} />
50+
<Typography variant="h5">{t('flows:core.placeholders.customComponent', 'Custom')}</Typography>
51+
<Typography variant="subtitle2" color="textSecondary" sx={{fontFamily: 'monospace', fontSize: '0.7rem'}}>
52+
{t('flows:core.placeholders.customComponent.identifier', 'Identifier: {{id}}', {id: resource.id})}
53+
</Typography>
54+
</Box>
55+
);
56+
}
57+
58+
export default CustomAdapter;

frontend/apps/thunder-console/src/features/flows/components/resources/elements/adapters/FormAdapter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ function FormAdapter({
9292
(element as FlowElement & {eventType?: string}).eventType === ActionEventTypes.Submit,
9393
);
9494

95-
const shouldShowFormFieldsPlaceholder = !hasInputFields;
95+
const shouldShowFormFieldsPlaceholder = !hasInputFields && !resource?.components?.length;
9696

9797
const errorId = `${resource.id}_FORM_NO_SUBMIT_BUTTON`;
9898

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {render, screen} from '@testing-library/react';
20+
import {describe, it, expect, vi} from 'vitest';
21+
import CustomAdapter from '../CustomAdapter';
22+
import type {Element as FlowElement} from '@/features/flows/models/elements';
23+
24+
vi.mock('@wso2/oxygen-ui-icons-react', async (importOriginal) => {
25+
const actual = await importOriginal<typeof import('@wso2/oxygen-ui-icons-react')>();
26+
return {
27+
...actual,
28+
PuzzleIcon: ({size = 24}: {size?: number} = {}) => <svg data-testid="puzzle-icon" data-size={size} />,
29+
};
30+
});
31+
32+
describe('CustomAdapter', () => {
33+
const createMockElement = (overrides: Partial<FlowElement> = {}): FlowElement =>
34+
({
35+
id: 'custom-1',
36+
type: 'CUSTOM',
37+
category: 'MISCELLANEOUS',
38+
...overrides,
39+
}) as FlowElement;
40+
41+
it('should render the puzzle icon', () => {
42+
render(<CustomAdapter resource={createMockElement()} />);
43+
44+
expect(screen.getByTestId('puzzle-icon')).toBeInTheDocument();
45+
});
46+
47+
it('should render the "Custom" label', () => {
48+
render(<CustomAdapter resource={createMockElement()} />);
49+
50+
expect(screen.getByText('Custom')).toBeInTheDocument();
51+
});
52+
53+
it('should display the resource identifier', () => {
54+
render(<CustomAdapter resource={createMockElement({id: 'my-custom-element'})} />);
55+
56+
expect(screen.getByText(/my-custom-element/)).toBeInTheDocument();
57+
});
58+
59+
it('should pass size 20 to the puzzle icon', () => {
60+
render(<CustomAdapter resource={createMockElement()} />);
61+
62+
expect(screen.getByTestId('puzzle-icon')).toHaveAttribute('data-size', '20');
63+
});
64+
});

frontend/apps/thunder-console/src/features/flows/components/resources/elements/adapters/__tests__/FormAdapter.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ describe('FormAdapter', () => {
139139
expect(screen.getByText('flows:core.adapters.form.placeholder')).toBeInTheDocument();
140140
});
141141

142-
it('should show placeholder when only non-FIELD components exist', () => {
142+
it('should not show placeholder when non-FIELD components exist', () => {
143143
const components = [
144144
createMockElement({id: 'comp-1', category: ElementCategories.Action}),
145145
createMockElement({id: 'comp-2', category: ElementCategories.Display}),
@@ -148,6 +148,14 @@ describe('FormAdapter', () => {
148148

149149
render(<FormAdapter resource={resource} stepId="step-1" />);
150150

151+
expect(screen.queryByText('flows:core.adapters.form.placeholder')).not.toBeInTheDocument();
152+
});
153+
154+
it('should show placeholder when form is empty', () => {
155+
const resource = createMockElement({components: []});
156+
157+
render(<FormAdapter resource={resource} stepId="step-1" />);
158+
151159
expect(screen.getByText('flows:core.adapters.form.placeholder')).toBeInTheDocument();
152160
});
153161

frontend/apps/thunder-console/src/features/flows/constants/VisualFlowConstants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class VisualFlowConstants {
9898
ElementTypes.Divider,
9999
ElementTypes.Image,
100100
ElementTypes.Captcha,
101+
ElementTypes.Custom,
101102
// Input types are allowed for drop detection, but handled specially to show dialog
102103
ElementTypes.TextInput,
103104
ElementTypes.PasswordInput,
@@ -149,6 +150,7 @@ class VisualFlowConstants {
149150
ElementTypes.Divider,
150151
ElementTypes.Image,
151152
ElementTypes.Timer,
153+
ElementTypes.Custom,
152154
];
153155

154156
public static readonly FLOW_BUILDER_STACK_ALLOWED_RESOURCE_TYPES: string[] = [

frontend/apps/thunder-console/src/features/flows/data/elements.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,5 +440,15 @@
440440
"showOnResourcePanel": true
441441
},
442442
"label": "Time remaining: {time}"
443+
},
444+
{
445+
"resourceType": "ELEMENT",
446+
"category": "MISCELLANEOUS",
447+
"type": "CUSTOM",
448+
"display": {
449+
"label": "Custom",
450+
"image": "Puzzle",
451+
"showOnResourcePanel": true
452+
}
443453
}
444454
]

frontend/apps/thunder-console/src/features/flows/models/__tests__/elements.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ describe('elements models', () => {
4747
expect(ElementCategories.Field).toBe('FIELD');
4848
});
4949

50-
it('should have exactly 4 categories', () => {
51-
expect(Object.keys(ElementCategories)).toHaveLength(4);
50+
it('should have Miscellaneous category', () => {
51+
expect(ElementCategories.Miscellaneous).toBe('MISCELLANEOUS');
52+
});
53+
54+
it('should have exactly 5 categories', () => {
55+
expect(Object.keys(ElementCategories)).toHaveLength(5);
5256
});
5357
});
5458

@@ -78,8 +82,12 @@ describe('elements models', () => {
7882
expect(ElementTypes.Timer).toBe('TIMER');
7983
});
8084

81-
it('should have exactly 21 element types', () => {
82-
expect(Object.keys(ElementTypes)).toHaveLength(21);
85+
it('should have Custom type', () => {
86+
expect(ElementTypes.Custom).toBe('CUSTOM');
87+
});
88+
89+
it('should have exactly 22 element types', () => {
90+
expect(Object.keys(ElementTypes)).toHaveLength(22);
8391
});
8492
});
8593

0 commit comments

Comments
 (0)