diff --git a/src/rootPages/Designer/properties/PropertyManager.js b/src/rootPages/Designer/properties/PropertyManager.js
index 8527be89..f59651b7 100644
--- a/src/rootPages/Designer/properties/PropertyManager.js
+++ b/src/rootPages/Designer/properties/PropertyManager.js
@@ -50,6 +50,7 @@ export default function (AB) {
require("./process/ABProcessTaskServiceAccountingFPClose.js"),
require("./process/ABProcessTaskServiceAccountingFPYearClose.js"),
require("./process/ABProcessTaskServiceAccountingJEArchive.js"),
+ require("./process/ABProcessTaskServiceApi.js"),
require("./process/ABProcessTaskServiceCalculate.js"),
require("./process/ABProcessTaskServiceGetResetPasswordUrl.js"),
require("./process/ABProcessTaskServiceInsertRecord.js"),
diff --git a/src/rootPages/Designer/properties/process/ABProcessTaskService.js b/src/rootPages/Designer/properties/process/ABProcessTaskService.js
index 39b38905..9d0eefc0 100644
--- a/src/rootPages/Designer/properties/process/ABProcessTaskService.js
+++ b/src/rootPages/Designer/properties/process/ABProcessTaskService.js
@@ -73,6 +73,11 @@ export default function (AB) {
this.switchTo("AccountingJEArchive");
},
},
+ {
+ view: "button",
+ label: L("Api Request"),
+ click: () => this.switchTo("Api"),
+ },
{
view: "button",
label: L("Query Task"),
diff --git a/src/rootPages/Designer/properties/process/ABProcessTaskServiceApi.js b/src/rootPages/Designer/properties/process/ABProcessTaskServiceApi.js
new file mode 100644
index 00000000..cfbddf37
--- /dev/null
+++ b/src/rootPages/Designer/properties/process/ABProcessTaskServiceApi.js
@@ -0,0 +1,402 @@
+/*
+ * UIProcessTaskServiceApi
+ *
+ * Display the form for entering the properties for a new
+ * ServiceApi Task
+ *
+ * @return {ClassUI} The Class Definition for this UI widget.
+ */
+import UI_Class from "../../ui_class";
+
+export default function(AB) {
+ const UIClass = UI_Class(AB);
+ const L = UIClass.L();
+ const uiConfig = AB.Config.uiSettings();
+
+ class UIProcessServiceApi extends UIClass {
+ constructor() {
+ super("properties_process_service_api", {
+ body: "",
+ form: "",
+ headers: "",
+ secrets: "",
+ suggest: "",
+ });
+
+ this.element = null;
+ // A webix datacollection - used to load process data into our mention suggest
+ this.suggestData = new AB.Webix.DataCollection({});
+ this.templateRgx = /<%= (.+?) %>/g;
+ this.hint = L("Use <%= ... %> to add process data / secrets");
+ }
+
+ static get key() {
+ return "Api";
+ }
+
+ ui() {
+ const ids = this.ids;
+ this.AB.Webix.ui({
+ id: ids.suggest,
+ view: "mentionsuggest",
+ symbol: "<",
+ template: "%= #value# %>",
+ data: this.suggestData,
+ });
+ return {
+ rows: [
+ {
+ id: ids.form,
+ view: "form",
+ elementsConfig: {
+ labelWidth: uiConfig.labelWidthLarge,
+ },
+ elements: [
+ {
+ view: "text",
+ name: "name",
+ label: L("Name"),
+ },
+ {
+ view: "texthighlight",
+ name: "url",
+ label: L("Url"),
+ highlight: (t) => this.highlight(t),
+ suggest: this.ids.suggest,
+ placeholder: this.hint,
+ css: "monospace",
+ },
+ {
+ view: "combo",
+ name: "method",
+ label: L("Method"),
+ options: ["GET", "POST", "PUT", "DELETE"],
+ on: {
+ onChange: (val) => {
+ val == "GET"
+ ? $$(ids.body).disable()
+ : $$(ids.body).enable();
+ },
+ },
+ },
+ {
+ cols: [
+ {
+ view: "label",
+ label: L("Headers"),
+ autowidth: true,
+ },
+ {
+ view: "icon",
+ icon: "fa fa-plus",
+ width: 50,
+ on: { onItemClick: () => this.addHeader() },
+ },
+ {},
+ ],
+ },
+ { id: ids.headers, rows: [] },
+ {
+ view: "texthighlight",
+ name: "body",
+ id: ids.body,
+ height: 200,
+ label: L("Request Body"),
+ labelPosition: "top",
+ type: "textarea",
+ placeholder: this.hint,
+ css: "monospace",
+ highlight: (t) => this.highlight(t),
+ suggest: this.ids.suggest,
+ },
+ {
+ cols: [
+ {
+ view: "label",
+ label: L("Secrets"),
+ autowidth: true,
+ },
+ {
+ view: "icon",
+ icon: "fa fa-plus",
+ width: 50,
+ on: { onItemClick: () => this.addSecret() },
+ },
+ {},
+ ],
+ },
+ { id: ids.secrets, rows: [] },
+ /**
+ * TODO: Allow the response to be decoded and saved for
+ * future process tasks
+ {
+ view: "switch",
+ name: "responseJson",
+ value: 1,
+ label: L("Response as"),
+ onLabel: L("JSON"),
+ offLabel: L("Text"),
+ },
+ */
+ ],
+ },
+ ],
+ };
+ }
+
+ populate(element) {
+ // Reset our suggest data
+ this.suggestData.clearAll();
+ this.suggestData.parse(
+ element.storedSecrets?.map((s) => ({
+ value: `Secret: ${s}`,
+ key: `Secret: ${s}`,
+ }))
+ );
+ const processData = element.process.processDataFields(element) ?? [];
+ this.suggestData.parse(
+ processData
+ .filter((i) => !!i)
+ .map?.((i) => ({ value: i.label, key: i.key }))
+ );
+ let { name, url, method, body, responseJson } = element;
+ // These might have process value placeholders, display the label
+ // instead of ids
+ body = this.convertIDToLabel(body);
+ url = this.convertIDToLabel(url);
+
+ $$(this.ids.form).setValues({ name, url, method, body, responseJson });
+
+ element.headers?.forEach?.((header) => {
+ header.value = this.convertIDToLabel(header.value);
+ this.addHeader(header);
+ });
+ element.storedSecrets?.forEach?.((secret) => this.addSecret(secret));
+ }
+
+ values() {
+ const values = {};
+ let form = {};
+ form = $$(this.ids.form).getValues();
+ Object.keys(form).forEach((key) => {
+ key.includes("headers") || key.includes("secrets")
+ ? nestValue(key, form[key], values)
+ : (values[key] = form[key]);
+ });
+ // Convert headers to an array
+ if (values.headers) {
+ const headers = [];
+ Object.keys(values.headers).forEach((key) =>
+ headers.push(values.headers[key])
+ );
+ values.headers = headers;
+ }
+ // These might contain process value placeholders, convert the label to
+ // actuall ids before saving
+ values.body = this.convertLabelToID(values.body);
+ values.url = this.convertLabelToID(values.url);
+ values.headers?.forEach(
+ (h) => (h.value = this.convertLabelToID(h.value))
+ );
+ // Convert secrets to an array
+ if (values.secrets) {
+ const secrets = [];
+ Object.keys(values.secrets).forEach((secret) =>
+ secrets.push(values.secrets[secret])
+ );
+ values.secrets = secrets;
+ }
+ if (this.deleteSecrets) {
+ values.deleteSecrets = this.deleteSecrets;
+ delete this.deleteSecrets;
+ }
+
+ return values;
+ }
+
+ /**
+ * Add fields to the form for a header
+ * @param {object} [header={}]
+ * @param {string} [header.key]
+ * @param {string} [header.value]
+ */
+ addHeader(header = {}) {
+ const uid = AB.Webix.uid(); //this is unique to the page
+ const row = {
+ id: uid,
+ cols: [
+ {
+ view: "text",
+ name: `headers.${uid}.key`,
+ placeholder: L("header"),
+ value: header.key,
+ },
+ {
+ view: "texthighlight",
+ name: `headers.${uid}.value`,
+ placeholder: `${L("value")} (${this.hint})`,
+ value: header.value,
+ gravity: 2,
+ highlight: (t) => this.highlight(t),
+ suggest: this.ids.suggest,
+ css: "monospace",
+ },
+ {
+ view: "icon",
+ icon: "wxi-trash",
+ width: "50",
+ on: {
+ onItemClick: () => $$(this.ids.headers).removeView(uid),
+ },
+ },
+ ],
+ };
+ $$(this.ids.headers).addView(row);
+ }
+
+ /**
+ * Add fields to the form for a secret
+ * @param {string} secret name of an existing secret
+ */
+ addSecret(secret) {
+ const alreadySaved = !!secret;
+ // If the secret is already saved in the db we only allow deleting.
+ // Since secrets aren't saved in the definition, we don't need to send
+ // existing secrets to the server. New one will get encrypted and saved
+ const uid = secret ?? AB.Webix.uid(); //this is unique to the page
+ const self = this;
+ const row = {
+ id: uid,
+ cols: [
+ {
+ view: "text",
+ name: alreadySaved ? undefined : `secrets.${uid}.name`,
+ placeholder: L("Name"),
+ disabled: alreadySaved,
+ value: secret,
+ invalidMessage: L("Secret names must be unique!"),
+ validate: (val) => {
+ // Check that the secret name is unique
+ const opts = this.suggestData.find({
+ key: `Secret: ${val}`,
+ });
+ return opts.length === 1;
+ },
+ on: {
+ onChange: function(n, o) {
+ if (n == o) return;
+ // Add the secret to the suggest data
+ const suggest = {
+ id: uid,
+ key: `Secret: ${n}`,
+ value: `Secret: ${n}`,
+ };
+ if (o == "") self.suggestData.parse(suggest);
+ else self.suggestData.updateItem(uid, suggest);
+ this.validate();
+ },
+ },
+ },
+ {
+ view: "text",
+ type: "password",
+ name: alreadySaved ? undefined : `secrets.${uid}.value`,
+ placeholder: "Value",
+ disabled: alreadySaved,
+ // We don't actually get the existing secret values back
+ // so we'll just mock a value.
+ value: alreadySaved ? ".........." : undefined,
+ gravity: 2,
+ },
+ {
+ view: "icon",
+ icon: "wxi-trash",
+ width: "50",
+ on: {
+ onItemClick: () => {
+ $$(this.ids.secret).removeView(uid);
+ if (alreadySaved) {
+ this.deleteSecrets = this.deleteSecrets ?? [];
+ this.deleteSecrets.push(secret);
+ }
+ },
+ },
+ },
+ ],
+ };
+ $$(this.ids.secrets).addView(row);
+ }
+
+ /**
+ * Highlight function for webix texthighlight elements. Highlights secret and
+ * process data in the text.
+ */
+ highlight(text) {
+ // text = text.replaceAll(" ", `ยท`);
+ text = text.replace(this.templateRgx, (match, value) => {
+ const data = this.suggestData.find({ value }, true);
+ let color = "#FF8C00"; //Not matched - highlight orange
+ let background = "#FFE0B2";
+ if (data) {
+ if (/^Secret:/.test(value)) {
+ color = "#008C8C"; // Matches secret - highlight cyan
+ background = "#A0D7D7";
+ } else {
+ color = "#388E3C"; // Matches process value - highlight green
+ background = "#C4EDC6";
+ }
+ }
+ return `${match}`;
+ });
+ return text;
+ }
+
+ /**
+ * Replace process value labels with ids. Used before saving templates.
+ */
+ convertLabelToID(template) {
+ if (!template) return;
+ return template.replace(this.templateRgx, (match, value) => {
+ const data = this.suggestData.find({ value }, true);
+ if (!data) return match;
+ return `<%= ${data.key} %>`;
+ });
+ }
+
+ /**
+ * Replace process value ids with labels. Used before displaying
+ * templates.
+ */
+ convertIDToLabel(template) {
+ if (!template) return;
+ return template.replace(this.templateRgx, (match, key) => {
+ const data = this.suggestData.find({ key }, true);
+ if (!data) return match;
+ return `<%= ${data.value} %>`;
+ });
+ }
+ }
+
+ return UIProcessServiceApi;
+}
+
+/**
+ * Recursively nests a value within an object based on a dot-separated key.
+ *
+ * @param {string} key - The dot-separated key specifying the nested path.
+ * @param {*} value - The value to assign at the final nested level.
+ * @param {object} [obj={}] - The object to modify (or a new object if not provided).
+ * @returns {object} The modified object with the nested value.
+ */
+function nestValue(key, value, obj = {}) {
+ const [firstKey, ...remainingKeys] = key.split(".");
+
+ if (remainingKeys.length === 0) {
+ obj[firstKey] = value;
+ } else {
+ obj[firstKey] = obj[firstKey] ?? {};
+ nestValue(remainingKeys.join("."), value, obj[firstKey]);
+ }
+
+ return obj;
+}
diff --git a/src/rootPages/Designer/ui_work_object_list_newObject_api_read_response.js b/src/rootPages/Designer/ui_work_object_list_newObject_api_read_response.js
index 147f9e85..d2e91791 100644
--- a/src/rootPages/Designer/ui_work_object_list_newObject_api_read_response.js
+++ b/src/rootPages/Designer/ui_work_object_list_newObject_api_read_response.js
@@ -261,12 +261,13 @@ export default function (AB) {
_addFieldItem(key, type) {
const uiItem = this._fieldItem(key, type);
- $$(this.ids.connections).addView(uiItem);
+ $$(this.ids.fields).addView(uiItem);
}
_clearFieldItems() {
- const $connections = $$(this.ids.connections);
- AB.Webix.ui([], $connections);
+ const $fields = $$(this.ids.connections);
+ if (!$fields) return;
+ AB.Webix.ui([], $fields);
}
_populateDataKeys() {
diff --git a/src/rootPages/Designer/ui_work_process_workspace_model.js b/src/rootPages/Designer/ui_work_process_workspace_model.js
index 77c3c0ba..1f3b598a 100644
--- a/src/rootPages/Designer/ui_work_process_workspace_model.js
+++ b/src/rootPages/Designer/ui_work_process_workspace_model.js
@@ -882,7 +882,7 @@ export default function (AB) {
Object.keys(values).forEach((k) => {
objVals[k] = values[k];
});
- if (!thisObj.name) objVals.name = values.label;
+ if (!objVals.name) objVals.name = values.label;
thisObj.fromValues(objVals);
thisObj.warningsEval(); // resets the warnings
diff --git a/styles/Designer.css b/styles/Designer.css
index b6ebbd80..bfca69da 100644
--- a/styles/Designer.css
+++ b/styles/Designer.css
@@ -264,4 +264,11 @@
.webix_tree_leaves {
display: block !important;
-}
\ No newline at end of file
+}
+
+.monospace > div > textarea,
+.monospace input,
+.monospace .webix_text_highlight_value {
+ word-break: break-all;
+ font-family: monospace;
+}