From 19773c7d03acc95f1e3c7eef8aa7c120dd4a1407 Mon Sep 17 00:00:00 2001 From: Vitali Pinchuk <146737590+vitPinchuk@users.noreply.github.com> Date: Fri, 15 Nov 2024 05:46:52 +0300 Subject: [PATCH] Add functionality for updating sections dynamically (#542) * add section and remove section functions added * remove copy test * test cases added * test cases for element value change code * add sections update * update useFormElements * rename useFormElements to useFormLayout * format form panel * ci test update * format + ci test added * update provisioning * provisioning updated * Update CHANGELOG.md * Lint fix * update sections helpers * update section helpers test cases and calls from code editor Form panel * add another section params and migration * Formatting --------- Co-authored-by: Mikhail Volkov Co-authored-by: asimonok --- CHANGELOG.md | 1 + package.json | 2 +- provisioning/dashboards/server-based.json | 673 ++++++++++++++++-- .../ElementSections/ElementSections.test.tsx | 6 + .../ElementSections/ElementSections.tsx | 12 +- .../FormElementsEditor.test.tsx | 2 +- .../FormElementsEditor/FormElementsEditor.tsx | 6 +- src/components/FormPanel/FormPanel.test.tsx | 625 +++++++++++++++- src/components/FormPanel/FormPanel.tsx | 87 ++- .../InitialFieldsEditor.tsx | 6 +- .../LayoutSectionsEditor.test.tsx | 60 +- .../LayoutSectionsEditor.tsx | 21 +- src/hooks/index.ts | 2 +- .../{useFormElements.ts => useFormLayout.ts} | 201 +++++- src/migration.test.ts | 90 +++ src/migration.ts | 47 +- src/types/form-element.ts | 2 +- src/types/layout.ts | 8 +- src/utils/code-parameters.ts | 69 +- 19 files changed, 1710 insertions(+), 210 deletions(-) rename src/hooks/{useFormElements.ts => useFormLayout.ts} (51%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 425188c4..41a32b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Updated options to use datasource ID instead of name (#539) - Updated E2E tests (#538) - Updated refresh function in the Update Request (#547) +- Added functionality for updating sections dynamically (#542) - Removed default payload from Update Request (#550) ## 4.8.0 (2024-10-25) diff --git a/package.json b/package.json index 6c9c2f7d..d7ff4e2d 100644 --- a/package.json +++ b/package.json @@ -74,10 +74,10 @@ "start:main": "docker compose pull grafana-main && docker compose --profile main up", "stop": "docker compose down", "test": "jest --watch --onlyChanged", + "test:ci": "jest --maxWorkers 4 --coverage", "test:e2e": "npx playwright test", "test:e2e:dev": "npx playwright test --ui", "test:e2e:docker": "docker compose --profile e2e up --exit-code-from test", - "test:ci": "jest --maxWorkers 4 --coverage", "upgrade": "npm upgrade --save" }, "version": "4.9.0" diff --git a/provisioning/dashboards/server-based.json b/provisioning/dashboards/server-based.json index f77c118a..3a4c2b95 100644 --- a/provisioning/dashboards/server-based.json +++ b/provisioning/dashboards/server-based.json @@ -18,15 +18,18 @@ "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, - "id": 9, + "id": 5, "links": [], - "liveNow": false, "panels": [ { "datasource": { "type": "marcusolsson-json-datasource", "uid": "serverapi" }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, "gridPos": { "h": 8, "w": 12, @@ -53,48 +56,369 @@ "title": "Confirm update request" }, "elementValueChanged": "/**\n * Update device variable\n */\n\nif (context.element.id === 'device') {\n context.grafana.locationService.partial({\n 'var-device': context.element.value,\n })\n}", + "elements": [], + "initial": { + "code": "/**\n * Convert JSON to form elements array\n */\nconst formElements = JSON.parse(context.panel.data.series[0].fields[0].values[0])\n/**\n * Set elements with helpers\n */\ncontext.panel.onChangeElements(formElements.map((element) => {\n const elementInForm = context.panel.elements.find((item) => item.uid === element.uid);\n let value = element.value;\n\n if (element.uid === 'comment' && elementInForm) {\n value = elementInForm.value;\n }\n\n return {\n ...element,\n value,\n helpers: {\n showIf: () => true,\n disableIf: () => false,\n getOptions: () => element.options,\n }\n }\n}),\n);\n", + "contentType": "application/json", + "datasource": "PostgreSQL", + "getPayload": "return {\n rawSql: '',\n format: 'table',\n}", + "highlight": false, + "highlightColor": "red", + "method": "query", + "payload": {} + }, + "layout": { + "orientation": "horizontal", + "padding": 10, + "sectionVariant": "default", + "variant": "single" + }, + "reset": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "process", + "text": "Reset", + "variant": "hidden" + }, + "resetAction": { + "code": "if (context.panel.response && context.panel.response.ok) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occured updating values.']);\n}", + "confirm": false, + "getPayload": "return {\n rawSql: '',\n format: 'table',\n}", + "mode": "initial", + "payload": {} + }, + "saveDefault": { + "icon": "save", + "text": "Save Default", + "variant": "hidden" + }, + "submit": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "cloud-upload", + "text": "Submit", + "variant": "primary" + }, + "sync": true, + "update": { + "code": "if (context.panel.response && context.panel.response.ok) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occured updating values.']);\n}", + "confirm": false, + "contentType": "application/json", + "getPayload": "const payload = {};\n\ncontext.panel.elements.forEach((element) => {\n if (!element.value) {\n return;\n }\n\n payload[element.id] = element.value;\n})\n\nreturn payload;\n\n/**\n * Data Source payload\n */ \nreturn {\n rawSql: '',\n format: 'table',\n};", + "method": "-", + "payload": {}, + "payloadMode": "all" + }, + "updateEnabled": "auto" + }, + "targets": [ + { + "cacheDurationSeconds": 300, + "datasource": { + "type": "marcusolsson-json-datasource", + "uid": "serverapi" + }, + "fields": [ + { + "jsonPath": "$", + "language": "jsonpath", + "name": "elements" + } + ], + "method": "GET", + "params": [["", ""]], + "queryParams": "", + "refId": "A", + "urlPath": "/form?device=$device" + } + ], + "title": "Server-based Elements", + "type": "volkovlabs-form-panel" + }, + { + "datasource": { + "type": "marcusolsson-static-datasource", + "uid": "P1D2C73DC01F2359B" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 5, + "options": { + "buttonGroup": { + "orientation": "center", + "size": "md" + }, + "confirmModal": { + "body": "Please confirm to update changed values", + "cancel": "Cancel", + "columns": { + "include": ["name", "oldValue", "newValue"], + "name": "Label", + "newValue": "New Value", + "oldValue": "Old Value" + }, + "confirm": "Confirm", + "elementDisplayMode": "modified", + "title": "Confirm update request" + }, + "elementValueChanged": "", "elements": [ { - "id": "device", - "options": [ + "hidden": false, + "id": "String", + "labelWidth": 10, + "section": "test-1", + "title": "String", + "tooltip": "", + "type": "string", + "uid": "0b25cbd4-4926-4fc3-9cf7-554cfced3bfe", + "unit": "", + "value": "" + } + ], + "initial": { + "code": "const ids = context.panel.data.series[0].fields[0].values\nconst names = context.panel.data.series[0].fields[1].values\n\nconst sections = ids.map((item, index) => ({\n id: item,\n name: names[index]\n}))\n\ncontext.panel.sectionsUtils.update(sections)", + "contentType": "application/json", + "getPayload": "return {}", + "highlight": false, + "highlightColor": "red", + "method": "-", + "payload": {} + }, + "layout": { + "orientation": "vertical", + "padding": 10, + "sectionVariant": "default", + "sections": [ + { + "id": "test-1", + "name": "Test Section" + } + ], + "variant": "split" + }, + "reset": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "process", + "text": "Reset", + "variant": "hidden" + }, + "resetAction": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "getPayload": "return {}", + "mode": "initial", + "payload": {} + }, + "saveDefault": { + "icon": "save", + "text": "Save Default", + "variant": "hidden" + }, + "submit": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "cloud-upload", + "text": "Submit", + "variant": "primary" + }, + "sync": false, + "update": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "contentType": "application/json", + "getPayload": "const payload = {};\n\ncontext.panel.elements.forEach((element) => {\n if (!element.value) {\n return;\n }\n\n payload[element.id] = element.value;\n})\n\nreturn payload;", + "method": "-", + "payload": {}, + "payloadMode": "all" + }, + "updateEnabled": "auto" + }, + "targets": [ + { + "frame": { + "fields": [ { - "id": "device1", - "label": "device1", + "config": {}, + "name": "id", "type": "string", - "value": "device1" + "values": ["id-1", "id-2", "id-3"] }, { - "id": "device2", - "label": "device2", + "config": {}, + "name": "name", "type": "string", - "value": "device2" + "values": ["Section 1", "Section 2", "Section 3"] } ], - "optionsSource": "Custom", - "title": "Device", - "type": "select", - "uid": "device", - "value": "device1" + "meta": {} }, - { - "id": "device1Field", - "max": 10, - "min": 0, - "title": "Device 1 Field", - "type": "number", - "uid": "device1Field", - "value": 0 + "refId": "A" + } + ], + "title": "Change Sections", + "type": "volkovlabs-form-panel" + }, + { + "datasource": { + "type": "marcusolsson-static-datasource", + "uid": "P1D2C73DC01F2359B" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "buttonGroup": { + "orientation": "center", + "size": "md" + }, + "confirmModal": { + "body": "Please confirm to update changed values", + "cancel": "Cancel", + "columns": { + "include": ["name", "oldValue", "newValue"], + "name": "Label", + "newValue": "New Value", + "oldValue": "Old Value" }, - { - "id": "comment", - "title": "Comment", - "type": "textarea", - "uid": "comment", - "value": "" - } - ], + "confirm": "Confirm", + "elementDisplayMode": "modified", + "title": "Confirm update request" + }, + "elementValueChanged": "", "initial": { - "code": "/**\n * Convert JSON to form elements array\n */\nconst formElements = JSON.parse(context.panel.data.series[0].fields[0].values[0])\n/**\n * Set elements with helpers\n */\ncontext.panel.onChangeElements(formElements.map((element) => {\n const elementInForm = context.panel.elements.find((item) => item.uid === element.uid);\n let value = element.value;\n\n if (element.uid === 'comment' && elementInForm) {\n value = elementInForm.value;\n }\n\n return {\n ...element,\n value,\n helpers: {\n showIf: () => true,\n disableIf: () => false,\n getOptions: () => element.options,\n }\n }\n}),\n);\n", + "code": "const ids = context.panel.data.series[0].fields[0].values\nconst names = context.panel.data.series[0].fields[1].values\n\nconst sections = ids.map((item, index) => ({\n id: item,\n name: names[index]\n}))\n\ncontext.panel.sectionsUtils.add({ name: sections[0].name, id: sections[0].id, elements: [] })\ncontext.panel.sectionsUtils.add({ name: sections[1].name, id: sections[1].id, elements: [] })\ncontext.panel.sectionsUtils.add({ name: sections[2].name, id: sections[2].id, elements: [] })\n", + "contentType": "application/json", + "getPayload": "return {}", + "highlight": false, + "highlightColor": "red", + "method": "-", + "payload": {} + }, + "layout": { + "orientation": "vertical", + "padding": 10, + "sectionVariant": "default", + "variant": "split" + }, + "reset": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "process", + "text": "Reset", + "variant": "hidden" + }, + "resetAction": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "getPayload": "return {}", + "mode": "initial", + "payload": {} + }, + "saveDefault": { + "icon": "save", + "text": "Save Default", + "variant": "hidden" + }, + "submit": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "cloud-upload", + "text": "Submit", + "variant": "primary" + }, + "sync": false, + "update": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "contentType": "application/json", + "getPayload": "const payload = {};\n\ncontext.panel.elements.forEach((element) => {\n if (!element.value) {\n return;\n }\n\n payload[element.id] = element.value;\n})\n\nreturn payload;", + "method": "-", + "payload": {}, + "payloadMode": "all" + }, + "updateEnabled": "auto" + }, + "targets": [ + { + "frame": { + "fields": [ + { + "config": {}, + "name": "id", + "type": "string", + "values": ["id-1", "id-2", "id-3"] + }, + { + "config": {}, + "name": "name", + "type": "string", + "values": ["Section 1", "Section 2", "Section 3"] + } + ], + "meta": {} + }, + "refId": "A" + } + ], + "title": "Add new section", + "type": "volkovlabs-form-panel" + }, + { + "datasource": { + "type": "marcusolsson-json-datasource", + "uid": "serverapi" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 3, + "options": { + "buttonGroup": { + "orientation": "center", + "size": "md" + }, + "confirmModal": { + "body": "Please confirm to update changed values", + "cancel": "Cancel", + "columns": { + "include": ["name", "oldValue", "newValue"], + "name": "Label", + "newValue": "New Value", + "oldValue": "Old Value" + }, + "confirm": "Confirm", + "elementDisplayMode": "modified", + "title": "Confirm update request" + }, + "elementValueChanged": "/**\n * Update device variable\n */\n\nif (context.element.id === 'device') {\n context.grafana.locationService.partial({\n 'var-device': context.element.value,\n })\n}", + "elements": [], + "initial": { + "code": "/**\n * Convert JSON to form elements array\n */\n\nconst formElements = JSON.parse(context.panel.data.series[0].fields[0].values[0])\n/**\n * Set elements with helpers\n */\nconst elements = formElements.map((element) => {\n const elementInForm = context.panel.elements.find((item) => item.uid === element.uid);\n let value = element.value;\n\n if (element.uid === 'comment' && elementInForm) {\n value = elementInForm.value;\n }\n\n return {\n ...element,\n value,\n helpers: {\n showIf: () => true,\n disableIf: () => false,\n getOptions: () => element.options,\n }\n }\n})\n\n\n\ncontext.panel.onChangeElements(elements)\ncontext.panel.sectionsUtils.add({ name: 'Section 1', id: 'section-1', elements: elements.map(element => element.id) })", "contentType": "application/json", "datasource": "PostgreSQL", "getPayload": "return {\n rawSql: '',\n format: 'table',\n}", @@ -104,10 +428,10 @@ "payload": {} }, "layout": { - "orientation": "horizontal", + "orientation": "vertical", "padding": 10, - "sectionVariant": "default", - "variant": "single" + "sectionVariant": "collapsable", + "variant": "split" }, "reset": { "backgroundColor": "purple", @@ -147,7 +471,6 @@ }, "updateEnabled": "auto" }, - "pluginVersion": "4.1.0", "targets": [ { "cacheDurationSeconds": 300, @@ -169,18 +492,284 @@ "urlPath": "/form?device=$device" } ], - "title": "Server-based Elements", + "title": "Server-based Elements add section from elements", + "type": "volkovlabs-form-panel" + }, + { + "datasource": { + "type": "marcusolsson-static-datasource", + "uid": "P1D2C73DC01F2359B" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 7, + "options": { + "buttonGroup": { + "orientation": "center", + "size": "md" + }, + "confirmModal": { + "body": "Please confirm to update changed values", + "cancel": "Cancel", + "columns": { + "include": ["name", "oldValue", "newValue"], + "name": "Label", + "newValue": "New Value", + "oldValue": "Old Value" + }, + "confirm": "Confirm", + "elementDisplayMode": "modified", + "title": "Confirm update request" + }, + "elementValueChanged": "", + "elements": [ + { + "hidden": false, + "id": "String", + "labelWidth": 10, + "section": "test-1", + "title": "String", + "tooltip": "", + "type": "string", + "uid": "0b25cbd4-4926-4fc3-9cf7-554cfced3bfe", + "unit": "", + "value": "" + } + ], + "initial": { + "code": "context.panel.sectionsUtils.expand('test-1')", + "contentType": "application/json", + "getPayload": "return {}", + "highlight": false, + "highlightColor": "red", + "method": "-", + "payload": {} + }, + "layout": { + "orientation": "vertical", + "padding": 10, + "sectionVariant": "collapsable", + "sections": [ + { + "id": "test-1", + "name": "Test Section" + } + ], + "variant": "split" + }, + "reset": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "process", + "text": "Reset", + "variant": "hidden" + }, + "resetAction": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "getPayload": "return {}", + "mode": "initial", + "payload": {} + }, + "saveDefault": { + "icon": "save", + "text": "Save Default", + "variant": "hidden" + }, + "submit": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "cloud-upload", + "text": "Submit", + "variant": "primary" + }, + "sync": false, + "update": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "contentType": "application/json", + "getPayload": "const payload = {};\n\ncontext.panel.elements.forEach((element) => {\n if (!element.value) {\n return;\n }\n\n payload[element.id] = element.value;\n})\n\nreturn payload;", + "method": "-", + "payload": {}, + "payloadMode": "all" + }, + "updateEnabled": "auto" + }, + "targets": [ + { + "frame": { + "fields": [ + { + "config": {}, + "name": "id", + "type": "string", + "values": ["id-1", "id-2", "id-3"] + }, + { + "config": {}, + "name": "name", + "type": "string", + "values": ["Section 1", "Section 2", "Section 3"] + } + ], + "meta": {} + }, + "refId": "A" + } + ], + "title": "Expand Section with migration", + "type": "volkovlabs-form-panel" + }, + { + "datasource": { + "type": "marcusolsson-static-datasource", + "uid": "P1D2C73DC01F2359B" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 8, + "options": { + "buttonGroup": { + "orientation": "center", + "size": "md" + }, + "confirmModal": { + "body": "Please confirm to update changed values", + "cancel": "Cancel", + "columns": { + "include": ["name", "oldValue", "newValue"], + "name": "Label", + "newValue": "New Value", + "oldValue": "Old Value" + }, + "confirm": "Confirm", + "elementDisplayMode": "modified", + "title": "Confirm update request" + }, + "elementValueChanged": "", + "elements": [ + { + "hidden": false, + "id": "String", + "labelWidth": 10, + "section": "test-1", + "title": "String", + "tooltip": "", + "type": "string", + "uid": "0b25cbd4-4926-4fc3-9cf7-554cfced3bfe", + "unit": "", + "value": "" + } + ], + "initial": { + "code": "context.panel.sectionsUtils.remove('test-1')", + "contentType": "application/json", + "getPayload": "return {}", + "highlight": false, + "highlightColor": "red", + "method": "-", + "payload": {} + }, + "layout": { + "orientation": "vertical", + "padding": 10, + "sectionVariant": "collapsable", + "sections": [ + { + "id": "test-1", + "name": "Test Section" + } + ], + "variant": "split" + }, + "reset": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "process", + "text": "Reset", + "variant": "hidden" + }, + "resetAction": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "getPayload": "return {}", + "mode": "initial", + "payload": {} + }, + "saveDefault": { + "icon": "save", + "text": "Save Default", + "variant": "hidden" + }, + "submit": { + "backgroundColor": "purple", + "foregroundColor": "yellow", + "icon": "cloud-upload", + "text": "Submit", + "variant": "primary" + }, + "sync": false, + "update": { + "code": "if (context.panel.response) {\n context.grafana.notifySuccess(['Update', 'Values updated successfully.']);\n context.grafana.locationService.reload();\n} else {\n context.grafana.notifyError(['Update', 'An error occurred updating values.']);\n}", + "confirm": false, + "contentType": "application/json", + "getPayload": "const payload = {};\n\ncontext.panel.elements.forEach((element) => {\n if (!element.value) {\n return;\n }\n\n payload[element.id] = element.value;\n})\n\nreturn payload;", + "method": "-", + "payload": {}, + "payloadMode": "all" + }, + "updateEnabled": "auto" + }, + "targets": [ + { + "frame": { + "fields": [ + { + "config": {}, + "name": "id", + "type": "string", + "values": ["id-1", "id-2", "id-3"] + }, + { + "config": {}, + "name": "name", + "type": "string", + "values": ["Section 1", "Section 2", "Section 3"] + } + ], + "meta": {} + }, + "refId": "A" + } + ], + "title": "Remove Section", "type": "volkovlabs-form-panel" } ], + "preload": false, "refresh": "", - "schemaVersion": 39, + "schemaVersion": 40, "tags": [], "templating": { "list": [ { "current": { - "selected": true, "text": "device2", "value": "device2" }, @@ -189,9 +778,7 @@ "uid": "P1D2C73DC01F2359B" }, "definition": "", - "hide": 0, "includeAll": false, - "multi": false, "name": "device", "options": [], "query": { @@ -209,8 +796,6 @@ }, "refresh": 1, "regex": "", - "skipUrlSync": false, - "sort": 0, "type": "query" } ] @@ -223,6 +808,6 @@ "timezone": "", "title": "Server-based", "uid": "f430fe0c-2487-4a02-a495-941c85b1e21d", - "version": 1, + "version": 21, "weekStart": "" } diff --git a/src/components/ElementSections/ElementSections.test.tsx b/src/components/ElementSections/ElementSections.test.tsx index 30d30fcf..6e34d072 100644 --- a/src/components/ElementSections/ElementSections.test.tsx +++ b/src/components/ElementSections/ElementSections.test.tsx @@ -43,6 +43,10 @@ describe('Form Elements', () => { }; describe('Render elements', () => { + const sections = [ + { name: 'section1', id: 'section1', expanded: true }, + { name: 'section2', id: 'section2', expanded: true }, + ]; const options: PanelOptions = { sync: true, updateEnabled: UpdateEnabledMode.MANUAL, @@ -77,6 +81,7 @@ describe('Form Elements', () => { render( getComponent({ options, + sections, onChangeElement, initial: { changed: 'bye' }, sectionsExpandedState: { section1: true }, @@ -93,6 +98,7 @@ describe('Form Elements', () => { render( getComponent({ options, + sections, onChangeElement, onChangeSectionExpandedState, initial: { changed: 'bye' }, diff --git a/src/components/ElementSections/ElementSections.tsx b/src/components/ElementSections/ElementSections.tsx index c8bf2568..78eebff4 100644 --- a/src/components/ElementSections/ElementSections.tsx +++ b/src/components/ElementSections/ElementSections.tsx @@ -5,7 +5,7 @@ import { CollapsableSection } from '@volkovlabs/components'; import React from 'react'; import { LayoutOrientation, SectionVariant, TEST_IDS } from '../../constants'; -import { ExecuteCustomCodeParams, LocalFormElement, PanelOptions } from '../../types'; +import { ExecuteCustomCodeParams, LayoutSection, LocalFormElement, PanelOptions } from '../../types'; import { FormElements } from '../FormElements'; import { getStyles } from './ElementSections.styles'; @@ -71,6 +71,13 @@ interface Props { * @type {string} */ timeZone: string; + + /** + * Sections + * + * @type {string} + */ + sections: LayoutSection[]; } /** @@ -87,6 +94,7 @@ export const ElementSections: React.FC = ({ onChangeSectionExpandedState, executeCustomCode, timeZone, + sections, }) => { /** * Theme and Styles @@ -100,7 +108,7 @@ export const ElementSections: React.FC = ({ vertical: options.layout.orientation === LayoutOrientation.VERTICAL, })} > - {options.layout?.sections?.map((section, id) => { + {sections?.map((section, id) => { const isOpen = sectionsExpandedState[section.id]; const renderContainer = (children: React.ReactNode) => { diff --git a/src/components/FormElementsEditor/FormElementsEditor.test.tsx b/src/components/FormElementsEditor/FormElementsEditor.test.tsx index 0cea780c..65f10f02 100644 --- a/src/components/FormElementsEditor/FormElementsEditor.test.tsx +++ b/src/components/FormElementsEditor/FormElementsEditor.test.tsx @@ -501,7 +501,7 @@ describe('Form Elements Editor', () => { }); /** - * Multi Select + * File */ it('Should find component with File', () => { const elements = [ diff --git a/src/components/FormElementsEditor/FormElementsEditor.tsx b/src/components/FormElementsEditor/FormElementsEditor.tsx index 5e664a64..0fdf1a68 100644 --- a/src/components/FormElementsEditor/FormElementsEditor.tsx +++ b/src/components/FormElementsEditor/FormElementsEditor.tsx @@ -5,7 +5,7 @@ import { Collapse } from '@volkovlabs/components'; import React, { useCallback, useMemo, useState } from 'react'; import { TEST_IDS } from '../../constants'; -import { useFormElements } from '../../hooks'; +import { useFormLayout } from '../../hooks'; import { FormElement, LayoutSection, LocalFormElement, PanelOptions } from '../../types'; import { getElementUniqueId, reorder } from '../../utils'; import { ElementEditor } from '../ElementEditor'; @@ -53,8 +53,8 @@ export const FormElementsEditor: React.FC = ({ value, onChange, context } onChangeElement, onChangeElementOption, onElementRemove, - } = useFormElements({ - onChange, + } = useFormLayout({ + onChangeElementsOption: onChange, value, }); diff --git a/src/components/FormPanel/FormPanel.test.tsx b/src/components/FormPanel/FormPanel.test.tsx index 0781f733..6ef0370c 100644 --- a/src/components/FormPanel/FormPanel.test.tsx +++ b/src/components/FormPanel/FormPanel.test.tsx @@ -430,6 +430,586 @@ describe('Panel', () => { expect(fetchCalledOptions.headers.get('customHeader')).toEqual('123'); }); + /** + * Sections helper + */ + describe('Sections helper', () => { + it('Should add section from the initial code', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.add({name:'Section 2', id:'section2'}); + `, + }, + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(selectors.splitLayoutContent(false, 'Section 2')).toBeInTheDocument(); + }); + + it('Should add section from the initial code with elements', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.add({name:'Section 2', id:'section2', elements:['test']}); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(selectors.splitLayoutContent(false, 'Section 2')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section2', 'Section 2')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section2', 'Section 2')); + expect(elementsSelectors.element(false, 'test', FormElementType.STRING)).toBeInTheDocument(); + }); + + it('Should add section from the initial code and not include element if id`s not present in elements', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.add({name:'Section 2', id:'section2', elements:['test-2']}); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(selectors.splitLayoutContent(false, 'Section 2')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section2', 'Section 2')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section2', 'Section 2')); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + }); + + it('Should assign element if section present', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.assign('section1', ['test']); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section1', 'Section 1')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section1', 'Section 1')); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).toBeInTheDocument(); + }); + + it('Should not assign element if section not present', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.assign('section12', ['test']); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section1', 'Section 1')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section1', 'Section 1')); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + }); + + it('Should not assign not existed elements', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.assign('section1', ['test15']); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section1', 'Section 1')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section1', 'Section 1')); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test15', FormElementType.STRING)).not.toBeInTheDocument(); + }); + + it('Should unassign element from section', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.unassign(['test']); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + section: 'section1', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section1', 'Section 1')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section1', 'Section 1')); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + }); + + it('Should no unassign wrong element from section', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.unassign(['test11']); + `, + }, + elements: [ + { + ...FORM_ELEMENT_DEFAULT, + id: 'test', + section: 'section1', + queryField: undefined, + }, + ], + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.COLLAPSABLE, + sections: [section], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, 'Section 1')).toBeInTheDocument(); + expect(sectionSelectors.sectionHeader(false, 'section1', 'Section 1')).toBeInTheDocument(); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).not.toBeInTheDocument(); + + fireEvent.click(sectionSelectors.sectionHeader(true, 'section1', 'Section 1')); + expect(elementsSelectors.element(true, 'test', FormElementType.STRING)).toBeInTheDocument(); + }); + + it('Should remove section from the initial code', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + const section2 = { id: 'section2', name: 'Section 2', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.remove('section2'); + `, + }, + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.DEFAULT, + sections: [section, section2], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(false, section.name)).toBeInTheDocument(); + expect(selectors.splitLayoutContent(true, section2.name)).not.toBeInTheDocument(); + }); + + it('Should change sections from the initial code', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + const section2 = { id: 'section2', name: 'Section 2', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + context.panel.sectionsUtils.update([{name:'Section 3', id:'section3'}]); + `, + }, + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.DEFAULT, + sections: [section, section2], + }, + }, + }) + ) + ); + + expect(selectors.splitLayoutContent(true, section.name)).not.toBeInTheDocument(); + expect(selectors.splitLayoutContent(true, section2.name)).not.toBeInTheDocument(); + expect(selectors.splitLayoutContent(true, 'Section 3')).toBeInTheDocument(); + }); + + it('Should call getAll from sections and return correct data', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + const section2 = { id: 'section2', name: 'Section 2', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + const sections = context.panel.sectionsUtils.getAll(); const sectionsIds = sections.map(element => element.id).join(); context.grafana.notifySuccess(['Sections',sectionsIds]); + `, + }, + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.DEFAULT, + sections: [section, section2], + }, + }, + }) + ) + ); + + expect(appEventsMock.publish).toHaveBeenCalledWith({ + type: AppEvents.alertSuccess.name, + payload: ['Sections', 'section1,section2'], + }); + }); + + it('Should call get from sections and return correct data', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + const section2 = { id: 'section2', name: 'Section 2', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + const section = context.panel.sectionsUtils.get('section1'); context.grafana.notifySuccess(['Sections',section.id]); + `, + }, + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.DEFAULT, + sections: [section, section2], + }, + }, + }) + ) + ); + + expect(appEventsMock.publish).toHaveBeenCalledWith({ + type: AppEvents.alertSuccess.name, + payload: ['Sections', 'section1'], + }); + }); + + it('Should call get from sections and return correct data if section not found', async () => { + /** + * Render + */ + const replaceVariables = jest.fn((code) => code); + + const section = { id: 'section1', name: 'Section 1', expanded: false }; + const section2 = { id: 'section2', name: 'Section 2', expanded: false }; + + await act(async () => + render( + getComponent({ + props: { + replaceVariables, + }, + options: { + sync: false, + initial: { + method: RequestMethod.NONE, + code: ` + const section = context.panel.sectionsUtils.get('section12'); context.grafana.notifySuccess(['Sections', section]); + `, + }, + layout: { + variant: LayoutVariant.SPLIT, + orientation: LayoutOrientation.VERTICAL, + sectionVariant: SectionVariant.DEFAULT, + sections: [section, section2], + }, + }, + }) + ) + ); + + expect(appEventsMock.publish).toHaveBeenCalledWith({ + type: AppEvents.alertSuccess.name, + payload: ['Sections', undefined], + }); + }); + }); + it('Should make initial request once if sync disabled', async () => { let fetchCalledOptions: any = {}; jest.mocked(fetch).mockImplementationOnce((url, options) => { @@ -1285,39 +1865,6 @@ describe('Panel', () => { expect(fetch).toHaveBeenCalledTimes(2); }); - it('Should make initial request once', async () => { - let fetchCalledOptions: any = {}; - jest.mocked(fetch).mockImplementationOnce((url, options) => { - fetchCalledOptions = options; - return Promise.resolve({ - json: Promise.resolve({}), - } as any); - }); - - /** - * Render - */ - await act(async () => - render( - getComponent({ - props: {}, - }) - ) - ); - - /** - * Check if fetch is called - */ - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith( - 'some-url', - expect.objectContaining({ - method: RequestMethod.POST, - }) - ); - expect(fetchCalledOptions.headers.get('customHeader')).toEqual('123'); - }); - it('Should enable submit from code', async () => { /** * Render @@ -3440,7 +3987,7 @@ describe('Panel', () => { initial: { method: RequestMethod.NONE, code: ` - context.panel.expandSection("section1"); + context.panel.sectionsUtils.expand("section1"); `, }, layout: { @@ -3477,7 +4024,7 @@ describe('Panel', () => { initial: { method: RequestMethod.NONE, code: ` - context.panel.collapseSection("section1"); + context.panel.sectionsUtils.collapse("section1"); `, }, layout: { @@ -3514,7 +4061,7 @@ describe('Panel', () => { initial: { method: RequestMethod.NONE, code: ` - context.panel.toggleSection("section1"); + context.panel.sectionsUtils.toggle("section1"); `, }, layout: { @@ -3552,7 +4099,7 @@ describe('Panel', () => { method: RequestMethod.NONE, }, elementValueChanged: ` - context.panel.expandSection("section1"); + context.panel.sectionsUtils.expand("section1"); `, layout: { variant: LayoutVariant.SPLIT, @@ -3595,7 +4142,7 @@ describe('Panel', () => { method: RequestMethod.NONE, }, elementValueChanged: ` - context.panel.collapseSection("section1"); + context.panel.sectionsUtils.collapse("section1"); `, layout: { variant: LayoutVariant.SPLIT, @@ -3647,7 +4194,7 @@ describe('Panel', () => { method: RequestMethod.NONE, }, elementValueChanged: ` - context.panel.toggleSection("section1"); + context.panel.sectionsUtils.toggle("section1"); `, layout: { variant: LayoutVariant.SPLIT, diff --git a/src/components/FormPanel/FormPanel.tsx b/src/components/FormPanel/FormPanel.tsx index ec0b8aaa..21fe5671 100644 --- a/src/components/FormPanel/FormPanel.tsx +++ b/src/components/FormPanel/FormPanel.tsx @@ -36,11 +36,12 @@ import { ResetActionMode, TEST_IDS, } from '@/constants'; -import { useFormElements, useMutableState } from '@/hooks'; +import { useFormLayout, useMutableState } from '@/hooks'; import { ButtonVariant, FormElement, FormElementType, + LayoutSection, LocalFormElement, ModalColumnName, PanelOptions, @@ -128,6 +129,22 @@ export const FormPanel: React.FC = ({ [onOptionsChange, options] ); + /** + * Change Sections Options + */ + const onChangeSectionsOption = useCallback( + (sections: LayoutSection[]) => { + onOptionsChange({ + ...options, + layout: { + ...options.layout, + sections: sections, + }, + }); + }, + [onOptionsChange, options] + ); + /** * Form Elements */ @@ -143,11 +160,20 @@ export const FormPanel: React.FC = ({ patchFormValue, setFormValue, getFormValue, - } = useFormElements({ - onChange: onChangeOptions, + addSection, + removeSection, + onChangeSections, + sections, + assignToSection, + unassignFromSection, + getSection, + getAllSections, + } = useFormLayout({ + onChangeElementsOption: onChangeOptions, value: options.elements, isAutoSave: false, - sections: options.layout.sections, + layoutSections: options.layout.sections, + onChangeSectionsOption: onChangeSectionsOption, }); /** @@ -287,10 +313,19 @@ export const FormPanel: React.FC = ({ response, enableSubmit: () => setSubmitEnabled(true), disableSubmit: () => setSubmitEnabled(false), - collapseSection: (id: string) => onChangeSectionExpandedState(id, false), - expandSection: (id: string) => onChangeSectionExpandedState(id, true), - toggleSection: (id: string) => onChangeSectionExpandedState(id, !sectionsExpandedState[id]), - sectionsExpandedState, + sectionsUtils: { + add: addSection, + update: onChangeSections, + remove: removeSection, + assign: assignToSection, + unassign: unassignFromSection, + get: getSection, + getAll: getAllSections, + collapse: (id: string) => onChangeSectionExpandedState(id, false), + expand: (id: string) => onChangeSectionExpandedState(id, true), + toggle: (id: string) => onChangeSectionExpandedState(id, !sectionsExpandedState[id]), + expandedState: sectionsExpandedState, + }, }, utils: { toDataQueryResponse, @@ -312,7 +347,6 @@ export const FormPanel: React.FC = ({ notifyWarning, eventBus, appEvents, - refreshDashboard, options, data, onOptionsChange, @@ -322,7 +356,15 @@ export const FormPanel: React.FC = ({ setFormValue, getFormValue, setInitial, + addSection, + onChangeSections, + removeSection, + assignToSection, + unassignFromSection, + getSection, + getAllSections, sectionsExpandedState, + refreshDashboard, onChangeSectionExpandedState, ] ); @@ -869,10 +911,19 @@ export const FormPanel: React.FC = ({ patchFormValue, setFormValue, formValue: getFormValue, - collapseSection: (id: string) => onChangeSectionExpandedState(id, false), - expandSection: (id: string) => onChangeSectionExpandedState(id, true), - toggleSection: (id: string) => onChangeSectionExpandedState(id, !sectionsExpandedState[id]), - sectionsExpandedState, + sectionsUtils: { + add: addSection, + update: onChangeSections, + remove: removeSection, + assign: assignToSection, + unassign: unassignFromSection, + get: getSection, + getAll: getAllSections, + collapse: (id: string) => onChangeSectionExpandedState(id, false), + expand: (id: string) => onChangeSectionExpandedState(id, true), + toggle: (id: string) => onChangeSectionExpandedState(id, !sectionsExpandedState[id]), + expandedState: sectionsExpandedState, + }, initial: initialRef.current, setError, enableReset: () => setResetEnabled(true), @@ -898,16 +949,23 @@ export const FormPanel: React.FC = ({ notifyWarning, eventBus, appEvents, - refreshDashboard, data, onOptionsChange, onChangeElements, patchFormValue, setFormValue, getFormValue, + addSection, + onChangeSections, + removeSection, + assignToSection, + unassignFromSection, + getSection, + getAllSections, sectionsExpandedState, initialRef, initialRequest, + refreshDashboard, onChangeSectionExpandedState, ] ); @@ -972,6 +1030,7 @@ export const FormPanel: React.FC = ({ data={data} options={options} elements={elements} + sections={sections} onChangeElement={onChangeElement} initial={initial} replaceVariables={replaceVariables} diff --git a/src/components/InitialFieldsEditor/InitialFieldsEditor.tsx b/src/components/InitialFieldsEditor/InitialFieldsEditor.tsx index d57b99cf..beab35ae 100644 --- a/src/components/InitialFieldsEditor/InitialFieldsEditor.tsx +++ b/src/components/InitialFieldsEditor/InitialFieldsEditor.tsx @@ -3,7 +3,7 @@ import { Button, InlineField, InlineFieldRow, Input, Select } from '@grafana/ui' import React, { ChangeEvent } from 'react'; import { RequestMethod, TEST_IDS } from '../../constants'; -import { useFormElements, useQueryFields } from '../../hooks'; +import { useFormLayout, useQueryFields } from '../../hooks'; import { FormElement, PanelOptions } from '../../types'; /** @@ -18,8 +18,8 @@ export const InitialFieldsEditor: React.FC = ({ value, onChange, context /** * Form Elements State */ - const { elements, isChanged, onSaveUpdates, onChangeElement } = useFormElements({ - onChange, + const { elements, isChanged, onSaveUpdates, onChangeElement } = useFormLayout({ + onChangeElementsOption: onChange, value, }); diff --git a/src/components/LayoutSectionsEditor/LayoutSectionsEditor.test.tsx b/src/components/LayoutSectionsEditor/LayoutSectionsEditor.test.tsx index de464c1d..e17690cb 100644 --- a/src/components/LayoutSectionsEditor/LayoutSectionsEditor.test.tsx +++ b/src/components/LayoutSectionsEditor/LayoutSectionsEditor.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import React from 'react'; import { LayoutOrientation, SectionVariant } from '../../constants'; @@ -72,7 +72,7 @@ describe('Layout Sections Editor', () => { /** * Change id */ - it('Should change id value', () => { + it('Should change id value', async () => { const sections = [ { id: '1', name: 'Section' }, { id: '2', name: '' }, @@ -94,18 +94,9 @@ describe('Layout Sections Editor', () => { * Change section name */ const sectionSelectors = getLayoutSectionsEditorSelectors(within(section)); - fireEvent.change(sectionSelectors.fieldId(), { target: { value: '11' } }); - - /** - * Check if id is changed - */ - expect(onChange).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: '2', - }), - ]) - ); + expect(sectionSelectors.fieldId()).toHaveValue('1'); + await act(() => fireEvent.change(sectionSelectors.fieldId(), { target: { value: '11' } })); + expect(sectionSelectors.fieldId()).toHaveValue('11'); }); it('Should clean id value', () => { @@ -126,19 +117,11 @@ describe('Layout Sections Editor', () => { /** * Change section name */ + const sectionSelectors = getLayoutSectionsEditorSelectors(within(section)); + expect(sectionSelectors.fieldId()).toHaveValue('1'); fireEvent.change(sectionSelectors.fieldId(), { target: { value: '' } }); - - /** - * Check if id is changed - */ - expect(onChange).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: '', - }), - ]) - ); + expect(sectionSelectors.fieldId()).toHaveValue(''); }); it('Should not allow use already existing id', () => { @@ -190,18 +173,10 @@ describe('Layout Sections Editor', () => { * Change section name */ const sectionSelectors = getLayoutSectionsEditorSelectors(within(section)); - fireEvent.change(sectionSelectors.fieldName(), { target: { value: 'newName' } }); + expect(sectionSelectors.fieldName()).toHaveValue('Section'); - /** - * Check if name is changed - */ - expect(onChange).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - name: 'newName', - }), - ]) - ); + fireEvent.change(sectionSelectors.fieldName(), { target: { value: 'newName' } }); + expect(sectionSelectors.fieldName()).toHaveValue('newName'); }); it('Should change expanded value for collapsable section variable', () => { @@ -232,18 +207,9 @@ describe('Layout Sections Editor', () => { */ const sectionSelectors = getLayoutSectionsEditorSelectors(within(section)); expect(sectionSelectors.fieldExpanded()).toBeInTheDocument(); + expect(sectionSelectors.fieldExpanded()).not.toBeChecked(); fireEvent.click(sectionSelectors.fieldExpanded()); - - /** - * Check if expanded is changed - */ - expect(onChange).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - expanded: true, - }), - ]) - ); + expect(sectionSelectors.fieldExpanded()).toBeChecked(); }); it('Should hide expanded field if no collapsable variant', () => { diff --git a/src/components/LayoutSectionsEditor/LayoutSectionsEditor.tsx b/src/components/LayoutSectionsEditor/LayoutSectionsEditor.tsx index bcbf4cf4..5941fae5 100644 --- a/src/components/LayoutSectionsEditor/LayoutSectionsEditor.tsx +++ b/src/components/LayoutSectionsEditor/LayoutSectionsEditor.tsx @@ -3,6 +3,7 @@ import { Button, Checkbox, InlineField, InlineFieldRow, Input, useStyles2 } from import React, { ChangeEvent, useCallback, useMemo } from 'react'; import { LayoutOrientation, SectionVariant, TEST_IDS } from '../../constants'; +import { useFormLayout } from '../../hooks'; import { LayoutSection, PanelOptions } from '../../types'; import { isSectionCollisionExists } from '../../utils'; import { getStyles } from './LayoutSectionsEditor.styles'; @@ -24,21 +25,19 @@ export const LayoutSectionsEditor: React.FC = ({ value, onChange, context /** * Sections */ - const sections = useMemo(() => { - if (Array.isArray(value)) { - return value; - } - return []; - }, [value]); + const { sections, onChangeSections } = useFormLayout({ + onChangeSectionsOption: onChange, + layoutSections: value, + }); /** * Change Section */ const onChangeSection = useCallback( (updatedSection: LayoutSection, id = updatedSection.id) => { - onChange(sections.map((section) => (section.id === id ? updatedSection : section))); + onChangeSections(sections.map((section) => (section.id === id ? updatedSection : section))); }, - [onChange, sections] + [onChangeSections, sections] ); /** @@ -46,9 +45,9 @@ export const LayoutSectionsEditor: React.FC = ({ value, onChange, context */ const onRemoveSection = useCallback( (removedSection: LayoutSection) => { - onChange(sections.filter((section) => section.id !== removedSection.id)); + onChangeSections(sections.filter((section) => section.id !== removedSection.id)); }, - [onChange, sections] + [onChangeSections, sections] ); /** @@ -136,7 +135,7 @@ export const LayoutSectionsEditor: React.FC = ({ value, onChange, context