diff --git a/api-specifications/properties.json b/api-specifications/properties.json index c63db86f..51ce5b4f 100644 --- a/api-specifications/properties.json +++ b/api-specifications/properties.json @@ -70,6 +70,11 @@ "type": "#/definitions/Action" } }, + "runtimeExpressionEvaluation": { + "description": "Boolean to determine the parallel evaluation of expression. Set to true if you want the expression to be evaluated between the actions (instead of upfront).", + "kind": "literal", + "value": false + }, "navigation": { "description": "Navigation action to move to new sheet/URL, performed after all other actions", "type": "#/definitions/NavigationAction" diff --git a/src/__tests__/ext.spec.js b/src/__tests__/ext.spec.js index 5045e4ae..335e031f 100644 --- a/src/__tests__/ext.spec.js +++ b/src/__tests__/ext.spec.js @@ -20,7 +20,7 @@ describe("ext", () => { ]), }; const props = ext({ translator, shouldHide, senseNavigation }); - const actionItems = props.definition.items.actions.items.actions.items; + const actionItems = props.definition.items.actions.items.actionArray.items.actions.items; const navigationItems = props.definition.items.actions.items.navigation.items; const { icon } = props.definition.items.settings.items; const { @@ -38,7 +38,7 @@ describe("ext", () => { }); describe("itemTitleRef", () => { - const { itemTitleRef } = props.definition.items.actions.items.actions; + const { itemTitleRef } = props.definition.items.actions.items.actionArray.items.actions; beforeEach(() => { data = { diff --git a/src/components/__tests__/action-button.spec.js b/src/components/__tests__/action-button.spec.js index e0860da9..2f92ee99 100644 --- a/src/components/__tests__/action-button.spec.js +++ b/src/components/__tests__/action-button.spec.js @@ -1,9 +1,10 @@ import defaultValues from "../../__tests__/default-button-props"; import { renderButton, runActions } from "../action-button"; -let actionList; +let actionCallList; let button; let defaults; +let app; describe("action button", () => { describe("renderButton", () => { @@ -23,6 +24,8 @@ describe("action button", () => { goToSheet: jest.fn(), getCurrentStoryId: () => false, }; + defaults.model = { getProperties: jest.fn() }; + defaults.app.evaluate = jest.fn(); }); it("should render action button", () => { renderButton(defaults); @@ -156,15 +159,60 @@ describe("action button", () => { expect(button.removeAttribute).toHaveBeenCalledWith("disabled"); expect(defaults.senseNavigation.openOdagPopup).toHaveBeenCalledTimes(1); }); + + it("should handle missing actions", async () => { + const actions = [ + { actionType: "missing", value: { qStringExpression: { qExpr: "missingExpression" } } }, + { actionType: "back", value: { qStringExpression: { qExpr: "missingExpression" } } }, + ]; + defaults.layout = { + runtimeExpressionEvaluation: true, + actions, + }; + defaults.app.back = jest.fn(); + defaults.model = { getProperties: jest.fn().mockReturnValue({ actions }) }; + renderButton(defaults); + await defaults.element.firstElementChild.onclick(); + expect(defaults.app.back).toHaveBeenCalled(); + expect(defaults.app.evaluate).toHaveBeenCalledTimes(2); + }); }); describe("runActions", () => { + const actions = [ + { value: { qStringExpression: { qExpr: "someExpression" } } }, + {}, + { variable: { qStringExpression: { qExpr: "variableExpression" } } }, + ]; + let model; beforeEach(() => { - actionList = [jest.fn(), jest.fn()]; + actionCallList = [jest.fn(), jest.fn(), jest.fn()]; + app = { evaluate: jest.fn().mockReturnValue("Evaluated Expression") }; + model = { getProperties: jest.fn().mockReturnValue({ actions }) }; }); it("should call all functions in array", async () => { - await runActions(actionList); - expect(actionList[0]).toHaveBeenCalledTimes(1); - expect(actionList[1]).toHaveBeenCalledTimes(1); + await runActions({ actionCallList, layout: {}, model, app }); + expect(actionCallList[0]).toHaveBeenCalledTimes(1); + expect(actionCallList[1]).toHaveBeenCalledTimes(1); + expect(app.evaluate).not.toHaveBeenCalled(); + }); + + it("should evaluate expressions when runtimeExpressionEvaluation is true", async () => { + await runActions({ + actionCallList, + layout: { + runtimeExpressionEvaluation: true, + }, + app, + model, + }); + expect(actionCallList[0]).toHaveBeenCalledTimes(1); + expect(actionCallList[0]).toHaveBeenCalledWith("Evaluated Expression", undefined); + expect(actionCallList[1]).toHaveBeenCalledTimes(1); + expect(actionCallList[1]).toHaveBeenCalledWith(undefined, undefined); + expect(actionCallList[2]).toHaveBeenCalledTimes(1); + expect(actionCallList[2]).toHaveBeenCalledWith(undefined, "Evaluated Expression"); + expect(app.evaluate).toHaveBeenCalledWith("someExpression"); + expect(app.evaluate).toHaveBeenCalledWith("variableExpression"); }); }); }); diff --git a/src/components/action-button.js b/src/components/action-button.js index b8ccf69a..67f11fc3 100644 --- a/src/components/action-button.js +++ b/src/components/action-button.js @@ -1,11 +1,22 @@ +/* eslint-disable no-await-in-loop */ import allActions from "../utils/actions"; import navigationActions from "../utils/navigation-actions"; import styleFormatter from "../utils/style-formatter"; -export const runActions = async (actionList) => { - for (let i = 0; i < actionList.length; i++) { - // eslint-disable-next-line no-await-in-loop - await actionList[i](); +export const runActions = async ({ actionCallList, model, layout, app }) => { + const properties = await model.getProperties(); + for (let i = 0; i < actionCallList.length; i++) { + if (layout.runtimeExpressionEvaluation) { + const overrideValue = + properties.actions[i].value?.qStringExpression?.qExpr && + (await app.evaluate(properties.actions[i].value.qStringExpression.qExpr)); + const overrideVariable = + properties.actions[i].variable?.qStringExpression?.qExpr && + (await app.evaluate(properties.actions[i].variable.qStringExpression.qExpr)); + await actionCallList[i](overrideValue, overrideVariable); + } else { + await actionCallList[i](); + } } }; @@ -18,6 +29,7 @@ export const renderButton = ({ element, multiUserAutomation, translator, + model, }) => { const isSense = !!senseNavigation; const button = element.firstElementChild; @@ -42,20 +54,21 @@ export const renderButton = ({ const { actions } = layout; actions.forEach((action) => { const actionObj = allActions.find((act) => act.value === action.actionType); - actionObj && - actionCallList.push( - actionObj.getActionCall({ - app, - qStateName, - ...action, - senseNavigation, - multiUserAutomation, - translator, - }) - ); + actionObj + ? actionCallList.push( + actionObj.getActionCall({ + app, + qStateName, + ...action, + senseNavigation, + multiUserAutomation, + translator, + }) + ) + : actionCallList.push(() => {}); }); button.setAttribute("disabled", true); - await runActions(actionCallList); + await runActions({ actionCallList, model, layout, app }); if (senseNavigation && !senseNavigation.getCurrentStoryId()) { const navigationObject = navigation && navigationActions.find((nav) => nav.value === navigation.action); if (senseNavigation && navigationObject && typeof navigationObject.navigationCall === "function") { diff --git a/src/ext.js b/src/ext.js index 3cfa30a2..12d26430 100644 --- a/src/ext.js +++ b/src/ext.js @@ -33,164 +33,175 @@ export default function ext({ translator, shouldHide, senseNavigation, theme, is translation: "Object.ActionButton.ActionsAndNavigation", grouped: true, items: { - actions: { - type: "array", - translation: "Object.ActionButton.Actions", - ref: "actions", - itemTitleRef: (data) => { - if (data.actionLabel) { - return data.actionLabel; - } - // If actionType exists but it's not found in the actions list, - // the action is invalid for the current version of the button - const fallbackTitle = data.actionType - ? "Object.ActionButton.InvalidAction" - : "Object.ActionButton.NewAction"; - const action = actions.find((act) => data.actionType === act.value); - return translator.get((action && action.translation) || fallbackTitle); - }, - allowAdd: true, - allowRemove: true, - allowMove: true, - addTranslation: "Object.ActionButton.AddAction", + actionArray: { + type: "items", items: { - label: { - component: "string", - ref: "actionLabel", - translation: "Common.Label", - expression: "optional", - defaultValue: "", - }, - actionType: { - type: "string", - ref: "actionType", - component: "expression-with-dropdown", - translation: "Object.ActionButton.Action", - defaultValue: "", - options: getActionsList(shouldHide), - dropdownOnly: true, - }, - bookmark: { - type: "string", - ref: "bookmark", - component: "expression-with-dropdown", - translation: "ExpressionEditor.SetExpresions.Bookmark", - defaultValue: "", - dropdownOnly: true, - options: async (action, hyperCubeHandler) => { - const bms = await hyperCubeHandler.app.getBookmarkList(); - return bms.map((bookmark) => ({ - label: bookmark.qData.title, - value: bookmark.qInfo.qId, - })); - }, - show: (data) => checkShowAction(data, "bookmark"), + actionExecution: { + type: "boolean", + ref: "runtimeExpressionEvaluation", + translation: "Object.ActionButton.RuntimeExpressionEvaluation", + show: shouldHide.isEnabled("IM-5699_RUNTIME_EXPRESSION_EVALUATION"), }, - field: { - type: "string", - ref: "field", - component: "expression-with-dropdown", - translation: "Common.Field", - defaultValue: "", - dropdownOnly: true, - options: async (action, hyperCubeHandler) => { - const fields = await hyperCubeHandler.app.getFieldList(); - return fields.map((field) => ({ - label: field.qName, - value: field.qName, - })); + actions: { + type: "array", + translation: "Object.ActionButton.Actions", + ref: "actions", + itemTitleRef: (data) => { + if (data.actionLabel) { + return data.actionLabel; + } + // If actionType exists but it's not found in the actions list, + // the action is invalid for the current version of the button + const fallbackTitle = data.actionType + ? "Object.ActionButton.InvalidAction" + : "Object.ActionButton.NewAction"; + const action = actions.find((act) => data.actionType === act.value); + return translator.get((action && action.translation) || fallbackTitle); }, - show: (data) => checkShowAction(data, "field"), - }, - cyclicGroup: { - type: "string", - ref: "cyclicGroupId", - component: "expression-with-dropdown", - translation: "Common.Dimension", - defaultValue: "", - dropdownOnly: true, - options: async (action, hyperCubeHandler) => { - const dimensions = await hyperCubeHandler.app.getDimensionList(); - return dimensions - .filter((dim) => dim.qData.grouping === "C") - .map((dim) => ({ - label: dim.qMeta.title, - value: dim.qInfo.qId, - })); - }, - show: (data) => checkShowAction(data, "cyclicGroup"), - }, - indexStepper: { - type: "string", - ref: "indexStepper", - component: "expression-with-dropdown", - translation: "Object.ActionButton.Step", - defaultValue: 1, - dropdownOnly: true, - options: async () => [ - { - translation: "Object.ActionButton.Forward", - value: 1, + allowAdd: true, + allowRemove: true, + allowMove: true, + addTranslation: "Object.ActionButton.AddAction", + items: { + label: { + component: "string", + ref: "actionLabel", + translation: "Common.Label", + expression: "optional", + defaultValue: "", }, - { - translation: "Object.ActionButton.Backward", - value: -1, + actionType: { + type: "string", + ref: "actionType", + component: "expression-with-dropdown", + translation: "Object.ActionButton.Action", + defaultValue: "", + options: getActionsList(shouldHide), + dropdownOnly: true, + }, + bookmark: { + type: "string", + ref: "bookmark", + component: "expression-with-dropdown", + translation: "ExpressionEditor.SetExpresions.Bookmark", + defaultValue: "", + dropdownOnly: true, + options: async (action, hyperCubeHandler) => { + const bms = await hyperCubeHandler.app.getBookmarkList(); + return bms.map((bookmark) => ({ + label: bookmark.qData.title, + value: bookmark.qInfo.qId, + })); + }, + show: (data) => checkShowAction(data, "bookmark"), + }, + field: { + type: "string", + ref: "field", + component: "expression-with-dropdown", + translation: "Common.Field", + defaultValue: "", + dropdownOnly: true, + options: async (action, hyperCubeHandler) => { + const fields = await hyperCubeHandler.app.getFieldList(); + return fields.map((field) => ({ + label: field.qName, + value: field.qName, + })); + }, + show: (data) => checkShowAction(data, "field"), + }, + variable: { + type: "string", + ref: "variable", + component: "expression-with-dropdown", + translation: "Common.Variable", + defaultValue: "", + expressionType: "StringExpression", + options: async (action, hyperCubeHandler) => { + const variables = await hyperCubeHandler.app.getVariableList(); + return variables + .filter((v) => !v.qIsReserved || (v.qIsReserved && action.showSystemVariables)) + .map((v) => ({ + label: v.qName, + value: v.qName, + })); + }, + show: (data) => checkShowAction(data, "variable"), + }, + showSystemVariables: { + type: "boolean", + ref: "showSystemVariables", + translation: "ExpressionEditor.SystemVariables", + defaultValue: false, + show: (data) => checkShowAction(data, "variable"), + }, + softLock: { + type: "boolean", + ref: "softLock", + translation: "Object.ActionButton.Softlock", + defaultValue: false, + show: (data) => checkShowAction(data, "softLock"), + }, + value: { + type: "string", + ref: "value", + component: "string", + translation: "properties.value", + expression: "optional", + show: (data) => checkShowAction(data, "value"), + }, + partial: { + type: "boolean", + ref: "partial", + translation: "Object.ActionButton.Partial", + defaultValue: false, + show: (data) => checkShowAction(data, "partial"), + }, + automationProps: { + type: "items", + grouped: false, + items: getAutomationProps(multiUserAutomation, getAutomations), + show: (data) => checkShowAction(data, "automation"), + }, + cyclicGroup: { + type: "string", + ref: "cyclicGroupId", + component: "expression-with-dropdown", + translation: "Common.Dimension", + defaultValue: "", + dropdownOnly: true, + options: async (action, hyperCubeHandler) => { + const dimensions = await hyperCubeHandler.app.getDimensionList(); + return dimensions + .filter((dim) => dim.qData.grouping === "C") + .map((dim) => ({ + label: dim.qMeta.title, + value: dim.qInfo.qId, + })); + }, + show: (data) => checkShowAction(data, "cyclicGroup"), + }, + indexStepper: { + type: "string", + ref: "indexStepper", + component: "expression-with-dropdown", + translation: "Object.ActionButton.Step", + defaultValue: 1, + dropdownOnly: true, + options: async () => [ + { + translation: "Object.ActionButton.Forward", + value: 1, + }, + { + translation: "Object.ActionButton.Backward", + value: -1, + }, + ], + show: (data) => checkShowAction(data, "indexStepper"), }, - ], - show: (data) => checkShowAction(data, "indexStepper"), - }, - variable: { - type: "string", - ref: "variable", - component: "expression-with-dropdown", - translation: "Common.Variable", - defaultValue: "", - expressionType: "StringExpression", - options: async (action, hyperCubeHandler) => { - const variables = await hyperCubeHandler.app.getVariableList(); - return variables - .filter((v) => !v.qIsReserved || (v.qIsReserved && action.showSystemVariables)) - .map((v) => ({ - label: v.qName, - value: v.qName, - })); }, - show: (data) => checkShowAction(data, "variable"), - }, - showSystemVariables: { - type: "boolean", - ref: "showSystemVariables", - translation: "ExpressionEditor.SystemVariables", - defaultValue: false, - show: (data) => checkShowAction(data, "variable"), - }, - softLock: { - type: "boolean", - ref: "softLock", - translation: "Object.ActionButton.Softlock", - defaultValue: false, - show: (data) => checkShowAction(data, "softLock"), - }, - value: { - type: "string", - ref: "value", - component: "string", - translation: "properties.value", - expression: "optional", - show: (data) => checkShowAction(data, "value"), - }, - partial: { - type: "boolean", - ref: "partial", - translation: "Object.ActionButton.Partial", - defaultValue: false, - show: (data) => checkShowAction(data, "partial"), - }, - automationProps: { - type: "items", - grouped: false, - items: getAutomationProps(multiUserAutomation, getAutomations), - show: (data) => checkShowAction(data, "automation"), }, }, }, diff --git a/src/index.js b/src/index.js index 66f29ee3..f34f7bd7 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ import { useImperativeHandle, useInteractionState, useMemo, + useModel, useStaleLayout, useTheme, } from "@nebula.js/stardust"; @@ -46,6 +47,7 @@ export default function supernova(env) { }, []); const layout = useStaleLayout(); + const model = useModel(); const app = useApp(); const interactions = useInteractionState(); useLoadImage(layout, app); @@ -59,6 +61,7 @@ export default function supernova(env) { senseNavigation, multiUserAutomation, translator, + model, }); useEffect( diff --git a/src/object-properties.js b/src/object-properties.js index fb735d04..768bce5b 100644 --- a/src/object-properties.js +++ b/src/object-properties.js @@ -47,6 +47,10 @@ const properties = { * @type {Action[]} */ actions: [], + /** + * Boolean to determine the parallel evaluation of expression. Set to true if you want the expression to be evaluated between the actions (instead of upfront). + */ + runtimeExpressionEvaluation: false, /** * Navigation action to move to new sheet/URL, performed after all other actions * @type {NavigationAction} diff --git a/src/utils/actions.js b/src/utils/actions.js index d9f781b2..f82ff504 100644 --- a/src/utils/actions.js +++ b/src/utils/actions.js @@ -124,11 +124,12 @@ const actions = [ group: "selection", getActionCall: ({ app, qStateName, field, value, softLock }) => - async () => { - if (field && value) { + async (overrideValue) => { + const useValue = overrideValue ?? value; + if (field) { const fieldObj = await app.getField(field, qStateName); const fieldInfo = await app.getFieldDescription(field); - const valueList = await getValueList(app, value, fieldInfo.qTags.includes("$date")); + const valueList = await getValueList(app, useValue, fieldInfo.qTags.includes("$date")); await fieldObj.selectValues(valueList, false, softLock); } }, @@ -140,11 +141,12 @@ const actions = [ group: "selection", getActionCall: ({ app, qStateName, field, value }) => - async () => { - if (field && value) { + async (overrideValue) => { + const useValue = overrideValue ?? value; + if (field) { const fieldObj = await app.getField(field, qStateName); const softLock = false; - await fieldObj.select(value, false, softLock); + await fieldObj.select(useValue, false, softLock); } }, requiredInput: ["field", "value"], @@ -214,11 +216,12 @@ const actions = [ group: "selection", getActionCall: ({ app, qStateName, field, value }) => - async () => { - if (field && value) { + async (overrideValue) => { + const useValue = overrideValue ?? value; + if (field) { const fieldObj = await app.getField(field, qStateName); const softLock = false; - await fieldObj.toggleSelect(value, softLock); + await fieldObj.toggleSelect(useValue, softLock); } }, requiredInput: ["field", "value"], @@ -283,11 +286,13 @@ const actions = [ group: "variables", getActionCall: ({ app, variable, value }) => - async () => { - if (variable && value) { + async (overrideValue, overrideVariable) => { + const useValue = overrideValue ?? value; + const useVariable = overrideVariable ?? variable; + if (useVariable) { try { - const variableObj = await app.getVariableByName(variable); - await variableObj.setStringValue(value); + const variableObj = await app.getVariableByName(useVariable); + await variableObj.setStringValue(useValue); } catch (e) { // no-op }