Skip to content

Commit 103f0f4

Browse files
authored
feat(orchestrator): ActiveTextInput widget (#719)
* feat(orchestrator): add ActiveTextInput widget Signed-off-by: Marek Libra <marek.libra@gmail.com> * add jsonata dependency * fix autocomplete handler * Add validation * changeset * fix typos * Introduce useRequestInit and useEvaluateTemplate --------- Signed-off-by: Marek Libra <marek.libra@gmail.com>
1 parent 80d9115 commit 103f0f4

17 files changed

Lines changed: 618 additions & 159 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
3+
---
4+
5+
Adding ActiveTextInput form widget

workspaces/orchestrator/plugins/orchestrator-form-widgets/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Example of response:
7575

7676
Moreover, it does extensive logging of the received HTTP requests for debugging during the widget's development.
7777

78-
Refer [http-test-server](./http-test-server/README.md) for more details.
78+
Refer to [http-test-server](./http-test-server/README.md) for more details.
7979

8080
## How to develop widgets
8181

@@ -84,7 +84,7 @@ We do not have a dedicated developer Backstage instance to host dev environment
8484
So far the development can be done via Orchestrator's dev instance (see `./workspaces/orchestrator/packages/backend` and `./workspaces/orchestrator/packages/frontend`).
8585
This instance is already configured to statically load the widget library (see `createApp()` in `./workspaces/orchestrator/packages/app/src/App.tsx`).
8686

87-
To develop the widgets, we recommend to uncomment or farther configure the `integrations:` and `auth:` sections in the `./workspaces/orchestrator/app-config.yaml` to be able to test various SCM Auth providers within `$${{}}` templates.
87+
To develop the widgets, we recommend to uncomment or further configure the `integrations:` and `auth:` sections in the `./workspaces/orchestrator/app-config.yaml` to be able to test various SCM Auth providers within `$${{}}` templates.
8888

8989
Please note, that the `proxy:` section is already pre-configured to match the `http-test-server` listening on localhost.
9090

@@ -143,12 +143,12 @@ yarn start
143143
### The dynamic_schema workflow
144144

145145
There is `dynamic_schema` workflow located under `http-test-server/exampleWorkflows`.
146-
It's purpose is to have a playground when developing the widgets.
146+
Its purpose is to have a playground when developing the widgets.
147147

148148
The URLs referenced from this workflow's data input schema rely on proxy configured in the `./workspaces/orchestrator/app-config.yaml` which assumes the `http-test-server` to be running.
149149

150150
This dev-only workflow is similar to https://github.com/rhdhorchestrator/backstage-orchestrator-workflows/blob/main/workflows/dynamic.schema.sw.json .
151-
The difference is in the URLs used - the backstage-orchestrator-workflows' one references public Git Hub HTTP server, so no extra steps in running the `http-test-server` are needed.
151+
The difference is in the URLs used - the backstage-orchestrator-workflows' one references public GitHub HTTP server, so no extra steps in running the `http-test-server` are needed.
152152

153153
## Development of a workflow using orchestrator-form-widgets
154154

workspaces/orchestrator/plugins/orchestrator-form-widgets/http-test-server/exampleWorkflows/schemas/dynamic_schema__main-schema.json

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,34 @@
1010
"properties": {
1111
"fooTheFirst": {
1212
"type": "string",
13-
"title": "First page simple input field"
13+
"title": "Standard input field. Write something to see it in the autocomplete field."
14+
},
15+
"myActiveText": {
16+
"type": "string",
17+
"title": "Example of ActiveTextInput widget with autocomplete",
18+
"ui:widget": "ActiveTextInput",
19+
"ui:props": {
20+
"fetch:url": "http://localhost:7007/api/proxy/mytesthttpserver/activeTextWhisperer?mydata=$${{current.firstStep.fooTheFirst}}",
21+
"fetch:response:value": "myresult.foo.default",
22+
"fetch:response:autocomplete": "bar.something.myautocompleteoptions",
23+
"fetch:retrigger": ["current.firstStep.fooTheFirst"]
24+
}
25+
},
26+
"mySimpleActiveText": {
27+
"type": "string",
28+
"title": "Example of simple ActiveTextInput with validation",
29+
"ui:widget": "ActiveTextInput",
30+
"ui:props": {
31+
"fetch:url": "http://localhost:7007/api/proxy/mytesthttpserver/activeTextWhisperer",
32+
"fetch:response:value": "myresult.foo.default",
33+
"validate:url": "http://localhost:7007/api/proxy/mytesthttpserver/validate",
34+
"validate:method": "POST",
35+
"validate:body": {
36+
"field": "mySimpleActiveText",
37+
"value": "$${{current.firstStep.mySimpleActiveText}}",
38+
"moreDataForMyValidator": "$${{current.firstStep.fooTheFirst}}"
39+
}
40+
}
1441
},
1542
"mySchemaUpdater": {
1643
"type": "string",

workspaces/orchestrator/plugins/orchestrator-form-widgets/http-test-server/index.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const logRequest = req => {
2727
console.log('request: ', {
2828
originalUrl: req.originalUrl,
2929
method: req.method,
30+
query: req.query,
3031
headers: req.headers,
3132
body: req.body,
3233
});
@@ -116,9 +117,52 @@ app.post('/chunk02', (req, res) => {
116117
res.send(JSON.stringify(chunk02));
117118
});
118119

120+
app.get('/activeTextWhisperer', (req, res) => {
121+
logRequest(req);
122+
123+
const mydata = req.query.mydata;
124+
125+
const autocomplete = ['my option one', 'my option two', 'Jack', 'Joe'];
126+
if (mydata && mydata !== '___undefined___') {
127+
autocomplete.push(mydata);
128+
}
129+
130+
const result = {
131+
myresult: { foo: { default: 'This is dynamically fetched default value' } },
132+
bar: { something: { myautocompleteoptions: autocomplete } },
133+
};
134+
135+
res.send(JSON.stringify(result));
136+
});
137+
119138
app.listen(port, () => {
120139
// eslint-disable-next-line no-console
121140
console.info(
122141
`Simple HTTP server for orchestrator-form-widgets development only. Listening on ${port} port.`,
123142
);
124143
});
144+
145+
app.post('/validate', (req, res) => {
146+
logRequest(req);
147+
148+
const field = req.body?.field;
149+
const value = req.body?.value;
150+
const moreDataForMyValidator = req.body?.moreDataForMyValidator;
151+
152+
if (
153+
field === 'mySimpleActiveText' &&
154+
moreDataForMyValidator !== 'ignoreerror'
155+
) {
156+
if (!value || value.length < 5) {
157+
res.status(422);
158+
res.send({
159+
[field]: ['The field must be 5 or more characters long.'],
160+
});
161+
162+
return;
163+
}
164+
}
165+
166+
res.status(200);
167+
res.send('Valid');
168+
});

workspaces/orchestrator/plugins/orchestrator-form-widgets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"@red-hat-developer-hub/backstage-plugin-orchestrator-form-api": "workspace:^",
6262
"@rjsf/utils": "^5.21.2",
6363
"json-schema": "^0.4.0",
64+
"jsonata": "^2.0.6",
6465
"lodash": "^4.17.21",
6566
"react-use": "^17.2.4"
6667
},

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormWidgetsApi.tsx

Lines changed: 5 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,13 @@ import React, { useCallback } from 'react';
1818
import {
1919
FormDecoratorProps,
2020
OrchestratorFormApi,
21-
OrchestratorFormContextProps,
2221
useWrapperFormPropsContext,
2322
} from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api';
24-
import { ErrorSchema, FormValidation } from '@rjsf/utils';
25-
import { JsonObject, JsonValue } from '@backstage/types';
23+
import { FormValidation } from '@rjsf/utils';
24+
import { JsonObject } from '@backstage/types';
2625

2726
import { SchemaUpdater, ActiveTextInput } from './widgets';
28-
29-
const sleep = (ms: number) => {
30-
return new Promise(resolve => setTimeout(resolve, ms));
31-
};
27+
import { useGetExtraErrors } from './utils';
3228

3329
const customValidate = (
3430
_formData: JsonObject | undefined,
@@ -39,55 +35,6 @@ const customValidate = (
3935
return errors;
4036
};
4137

42-
// Walks through the uiSchema and calls the "callback" for every field which is backed by the dynamic ui:widget.
43-
// The callback is provided with the uiSchema path, content of the uiSchema part and the corresponding entered formData value.
44-
const walkThrough: (
45-
uiSchema: OrchestratorFormContextProps['uiSchema'],
46-
formData: JsonObject | undefined,
47-
callback: (
48-
path: string,
49-
uiSchemaProperty: JsonObject,
50-
formDataValue: JsonValue | undefined,
51-
) => void,
52-
pathPrefix: string,
53-
) => void = (uiSchema, formData, callback, pathPrefix) => {
54-
let dottedPathPrefix = pathPrefix;
55-
if (pathPrefix) {
56-
dottedPathPrefix += '.';
57-
}
58-
59-
for (const key of Object.keys(uiSchema)) {
60-
// tune this condition to match just those properties which are relevant for the orchestrator-form-widget
61-
if (typeof uiSchema[key] === 'object') {
62-
if (uiSchema[key]?.['ui:widget']) {
63-
callback(`${dottedPathPrefix}${key}`, uiSchema[key], formData?.[key]);
64-
} else {
65-
walkThrough(
66-
uiSchema[key],
67-
formData?.[key] as JsonObject,
68-
callback,
69-
`${dottedPathPrefix}${key}`,
70-
);
71-
}
72-
}
73-
}
74-
};
75-
76-
const safeSet: (errors: JsonObject, path: string, value: JsonValue) => void = (
77-
errors,
78-
path,
79-
value,
80-
) => {
81-
const steps = path.split('.', 2);
82-
if (steps.length === 1) {
83-
errors[steps[0]] = value;
84-
} else {
85-
const safeObject = (errors[steps[0]] ?? {}) as JsonObject;
86-
errors[steps[0]] = safeObject;
87-
safeSet(safeObject, steps[1], value);
88-
}
89-
};
90-
9138
const widgets = {
9239
SchemaUpdater,
9340
ActiveTextInput,
@@ -97,8 +44,8 @@ export class FormWidgetsApi implements OrchestratorFormApi {
9744
getFormDecorator: OrchestratorFormApi['getFormDecorator'] = () => {
9845
return (FormComponent: React.ComponentType<FormDecoratorProps>) => {
9946
return () => {
100-
const { formData, setFormData, uiSchema } =
101-
useWrapperFormPropsContext();
47+
const { formData, setFormData } = useWrapperFormPropsContext();
48+
const getExtraErrors = useGetExtraErrors();
10249

10350
const onChange = useCallback(
10451
(data: JsonObject | undefined) => {
@@ -109,41 +56,6 @@ export class FormWidgetsApi implements OrchestratorFormApi {
10956
[setFormData],
11057
);
11158

112-
const getExtraErrors: (
113-
currentFormData: JsonObject,
114-
) => Promise<ErrorSchema<JsonObject>> = async (
115-
currentFormData: JsonObject,
116-
) => {
117-
// Asynchronous validation on wizard step transition or submit
118-
119-
// TODO
120-
return sleep(1000 /* The sleep mimics async fetch, remove it */).then(
121-
() => {
122-
const errors: ErrorSchema<JsonObject> = {};
123-
walkThrough(
124-
uiSchema,
125-
currentFormData,
126-
(path, uiSchemaProperty, formDataValue) => {
127-
if (uiSchemaProperty?.['ui:widget'] === 'ActiveTextInput') {
128-
if (formDataValue) {
129-
safeSet(errors, path, {
130-
__errors: [
131-
'This is a dummy error from async validator',
132-
`Yet another issue with this field of value: ${formDataValue}`,
133-
'Remove the value if you want this silly validator to pass',
134-
],
135-
});
136-
}
137-
}
138-
},
139-
'',
140-
);
141-
142-
return errors;
143-
},
144-
);
145-
};
146-
14759
return (
14860
<FormComponent
14961
widgets={widgets}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import jsonata from 'jsonata';
17+
import { JsonObject } from '@backstage/types';
18+
19+
export const applySelectorArray = async (
20+
data: JsonObject,
21+
selector: string,
22+
): Promise<string[]> => {
23+
const expression = jsonata(selector);
24+
const value = await expression.evaluate(data);
25+
26+
if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
27+
return value;
28+
}
29+
30+
throw new Error(
31+
`Unexpected result of "${selector}" selector, expected string[] type. Value "${value}"`,
32+
);
33+
};
34+
35+
export const applySelectorString = async (
36+
data: JsonObject,
37+
selector: string,
38+
): Promise<string> => {
39+
const expression = jsonata(selector);
40+
const value = await expression.evaluate(data);
41+
42+
if (typeof value === 'string') {
43+
return value;
44+
}
45+
46+
throw new Error(
47+
`Unexpected result of "${selector}" selector, expected string type. Value "${value}"`,
48+
);
49+
};

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/evaluateTemplate.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { useEffect, useState } from 'react';
1718
import { JsonObject, JsonValue } from '@backstage/types';
19+
import { useTemplateUnitEvaluator } from './useTemplateUnitEvaluator';
1820

1921
export type evaluateTemplateProps = {
2022
template?: JsonValue;
@@ -45,10 +47,17 @@ export const evaluateTemplate = async (
4547
if (stopIndex < 0) {
4648
throw new Error(`Template unit is not closed by }}`);
4749
}
48-
evaluated += await unitEvaluator(
50+
51+
let evaluatedUnit = await unitEvaluator(
4952
template.substring(startIndex + 4, stopIndex),
5053
formData,
5154
);
55+
if (evaluatedUnit === undefined) {
56+
evaluatedUnit = '___undefined___';
57+
}
58+
59+
evaluated += evaluatedUnit;
60+
5261
if (template.length > stopIndex + 2) {
5362
evaluated += await evaluateTemplate({
5463
...props,
@@ -59,3 +68,31 @@ export const evaluateTemplate = async (
5968

6069
return evaluated;
6170
};
71+
72+
export const useEvaluateTemplate = ({
73+
template,
74+
key,
75+
formData,
76+
setError,
77+
}: {
78+
template?: string;
79+
key: string;
80+
formData: JsonObject;
81+
setError: (e: string) => void;
82+
}) => {
83+
const unitEvaluator = useTemplateUnitEvaluator();
84+
const [evaluated, setEvaluated] = useState<string>();
85+
86+
useEffect(() => {
87+
evaluateTemplate({
88+
template,
89+
key,
90+
unitEvaluator,
91+
formData,
92+
})
93+
.then(setEvaluated)
94+
.catch(reason => setError(reason.toString()));
95+
}, [template, unitEvaluator, formData, key, setError]);
96+
97+
return evaluated;
98+
};

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/utils/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
export * from './getRequestInit';
16+
export * from './useRequestInit';
1717
export * from './evaluateTemplate';
1818
export * from './useRetriggerEvaluate';
1919
export * from './useTemplateUnitEvaluator';
20+
export * from './safeSet';
21+
export * from './useGetExtraErrors';

0 commit comments

Comments
 (0)