Skip to content

Commit 2298b72

Browse files
113 parameterized operation widget changes (#39)
* 113 parameterized operation widget changes * update package lock file * update package lock file * Updated 1021.6 to 1021.22.x * npm ci error fix * added cypress config file for operations * renaming cypress config file * config file changes * added test config in angular json * added karma config file * added test file * added service spec file * Added lazy loading and standalone. Added monaco editor. Added placeholder support. Added layout fixes. * Added spacing * widget select image changes * Testing bugfix * Replaced outdated *ng statements with new syntax * Improved debounce handling for code editor. Fixed issues. * operation body reset with empty object bug fix --------- Co-authored-by: Hendrik Näther <hendrik.naether@cumulocity.com>
1 parent e1eaefe commit 2298b72

26 files changed

+935
-283
lines changed

angular.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,15 @@
675675
}
676676
},
677677
"defaultConfiguration": "production"
678+
},
679+
"test": {
680+
"builder": "@angular-devkit/build-angular:karma",
681+
"options": {
682+
"main": "packages/operations-widget/src/test.ts",
683+
"polyfills": "packages/operations-widget/src/polyfills.ts",
684+
"tsConfig": "packages/operations-widget/tsconfig.app.json",
685+
"karmaConfig": "packages/operations-widget/karma.conf.js"
686+
}
678687
}
679688
}
680689
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { IMeasurementValue, IOperation } from '@c8y/client';
2+
import { IMeasurement } from '@c8y/client';
3+
4+
function isObject(value: unknown): value is Record<string, unknown> {
5+
return typeof value === 'object' && value !== null;
6+
}
7+
8+
export function isToCreateIOperation(obj?: unknown): obj is IOperation {
9+
return typeof obj === 'object' && obj !== null && 'deviceId' in obj && !('id' in obj);
10+
}
11+
12+
function isMeasurementValue(value: unknown): value is IMeasurementValue {
13+
return isObject(value) && typeof value.value === 'number';
14+
}
15+
16+
export function isMeasurement(value: unknown): value is IMeasurement {
17+
if (!isObject(value)) return false;
18+
19+
// Required base fields
20+
if (
21+
!('id' in value) ||
22+
!(typeof value.id === 'string' || typeof value.id === 'number') ||
23+
typeof value.type !== 'string' ||
24+
typeof value.time !== 'string' ||
25+
typeof value.self !== 'string' ||
26+
!isObject(value.source)
27+
) {
28+
return false;
29+
}
30+
31+
// Look for at least ONE fragment containing ONE MeasurementValue
32+
for (const key of Object.keys(value)) {
33+
// skip known base properties
34+
if (['id', 'type', 'time', 'self', 'source'].includes(key)) {
35+
continue;
36+
}
37+
38+
const fragment = value[key];
39+
40+
if (!isObject(fragment)) continue;
41+
42+
for (const series of Object.values(fragment)) {
43+
if (isMeasurementValue(series)) {
44+
return true; // ✅ found at least one valid fragment+series
45+
}
46+
}
47+
}
48+
49+
return false;
50+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { extractPlaceholdersFromObject } from './extract-placeholders';
2+
3+
describe('extractPlaceholdersFromObject', () => {
4+
it('extracts templates with paths from nested objects and arrays in encounter order', () => {
5+
const obj = {
6+
deviceId: '123',
7+
op: { example: '{{test}}', nested: 'value-{{a}}-{{b}}' },
8+
arr: ['no-template', '{{arr1}}', { deep: '{{deep_val}} and {{arr1}}' }],
9+
};
10+
11+
const result = extractPlaceholdersFromObject(obj as unknown);
12+
13+
expect(result).toEqual([
14+
{ key: 'test', path: 'op.example' },
15+
{ key: 'a', path: 'op.nested' },
16+
{ key: 'b', path: 'op.nested' },
17+
{ key: 'arr1', path: 'arr[1]' },
18+
{ key: 'deep_val', path: 'arr[2].deep' },
19+
]);
20+
});
21+
22+
it('returns empty array when no templates are present', () => {
23+
expect(extractPlaceholdersFromObject({ a: 'x', b: [1, 2], c: { d: null } })).toEqual([]);
24+
});
25+
26+
it('handles multiple occurrences and trims whitespace', () => {
27+
const obj = { s: 'prefix {{ spaced }} suffix {{spaced}} {{other}}' };
28+
29+
const result = extractPlaceholdersFromObject(obj);
30+
31+
expect(result).toEqual([
32+
{ key: 'spaced', path: 's' },
33+
{ key: 'other', path: 's' },
34+
]);
35+
});
36+
37+
it('handles non-string primitives safely', () => {
38+
const obj = { n: 42, bool: true, nested: { s: '{{x}}' } };
39+
40+
expect(extractPlaceholdersFromObject(obj)).toEqual([{ key: 'x', path: 'nested.s' }]);
41+
});
42+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { set } from "lodash";
2+
3+
/**
4+
* Recursively walks an object/array and extracts unique template placeholders
5+
* of the form `{{ ... }}` along with the path where they were found.
6+
* Returns placeholders in encounter order.
7+
*/
8+
export function extractPlaceholdersFromObject(obj: unknown): { key: string; path: string }[] {
9+
const results: { key: string; path: string }[] = [];
10+
const seen = new Set<string>();
11+
12+
const regex = /{{\s*([^{}\n]+?)\s*}}/g;
13+
14+
function visit(value: unknown, path: string) {
15+
if (value == null) return;
16+
17+
if (typeof value === 'string') {
18+
let match: RegExpExecArray | null;
19+
20+
while ((match = regex.exec(value)) !== null) {
21+
const extracted = match[1].trim();
22+
23+
if (!seen.has(extracted)) {
24+
seen.add(extracted);
25+
results.push({
26+
key: extracted,
27+
path,
28+
});
29+
}
30+
}
31+
32+
// reset for next string
33+
regex.lastIndex = 0;
34+
} else if (Array.isArray(value)) {
35+
value.forEach((item, index) => {
36+
visit(item, `${path}[${index}]`);
37+
});
38+
} else if (typeof value === 'object') {
39+
Object.entries(value as Record<string, unknown>).forEach(([key, val]) => {
40+
visit(val, path ? `${path}.${key}` : key);
41+
});
42+
}
43+
}
44+
45+
visit(obj, '');
46+
47+
return results;
48+
}
49+
50+
export function removePlaceholders(obj: object) {
51+
const tuples = extractPlaceholdersFromObject(obj);
52+
53+
for (const tuple of tuples) {
54+
set(obj, tuple.path, undefined);
55+
}
56+
}

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/operations-widget/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
The Operations Widget enables Cumulocity IoT users to send predefined or custom operations from the Cockpit application.
44
Once you have selected the widget within the widget gallery you will find the widget configuration menu. There you can define multiple buttons, which make defined operations triggerable from inside the widget.
55
For each added button you can configure different parameters of the operation and the style of the buttons.
6+
In Define fields column, user can define the key, label and field type (Text / Number / Dropdown).
7+
Based on the selection of field type, the corresponding options will appear in the popup when the user clicks the button in the widget.
8+
User can define the operation JSON object in Operation value (or) user can define the fields in the configuration menu.
9+
If user defines both fields, user entered values in the popup will be appended in the Operation Value fields.
610

711
![Operation Config](./public/widget.png)
812

@@ -19,6 +23,7 @@ For each added button you can configure different parameters of the operation an
1923
| Description | Description of the operation, which should be triggered by the button. |
2024
| Operation Fragment | The operation fragment, which should be sent. |
2125
| Operation Value | The operation JSON object. |
26+
| Define Fields | Define field types key and label. |
2227
| Button | The button type (color). |
2328
| Icon | The button icon. |
2429

packages/operations-widget/cumulocity.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export default {
2222
name: 'Operations Widget',
2323
module: 'OperationsWidgetModule',
2424
path: './src/app/operations-widget/operations-widget.module.ts',
25-
description: '',
25+
description:
26+
'Configurable action buttons with labels, icons, and input fields to send parameterized operation to device.',
2627
},
2728
],
2829
},
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Karma configuration file, see link for more information
2+
// https://karma-runner.github.io/1.0/config/configuration-file.html
3+
4+
module.exports = function (config) {
5+
config.set({
6+
basePath: '',
7+
frameworks: ['jasmine', '@angular-devkit/build-angular'],
8+
plugins: [
9+
require('karma-jasmine'),
10+
require('karma-chrome-launcher'),
11+
require('karma-jasmine-html-reporter'),
12+
require('karma-coverage'),
13+
require('@angular-devkit/build-angular/plugins/karma')
14+
],
15+
client: {
16+
jasmine: {
17+
// you can add configuration options for Jasmine here
18+
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
19+
// for example, you can disable the random execution with `random: false`
20+
// or set a specific seed with `seed: 4321`
21+
},
22+
},
23+
jasmineHtmlReporter: {
24+
suppressAll: true // removes the duplicated traces
25+
},
26+
coverageReporter: {
27+
dir: require('path').join(__dirname, '../../coverage/plugin.favorites'),
28+
subdir: '.',
29+
reporters: [
30+
{ type: 'html' },
31+
{ type: 'text-summary' }
32+
]
33+
},
34+
reporters: ['progress', 'kjhtml'],
35+
browsers: ['Chrome'],
36+
restartOnFileChange: true
37+
});
38+
};

packages/operations-widget/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "cumulocity-operations-widget-plugin",
33
"version": "0.1.4",
4-
"description": "",
4+
"description": "Configurable action buttons with labels, icons, and input fields to send parameterized operation to device.",
55
"author": "@dirk-peter-c8y",
66
"homepage": "___TODO___",
77
"dependencies": {}
2.02 KB
Loading

0 commit comments

Comments
 (0)