diff --git a/docs/entity/modifyFieldsOnValue.md b/docs/entity/modifyFieldsOnValue.md index f379f1125..90c8abdb4 100644 --- a/docs/entity/modifyFieldsOnValue.md +++ b/docs/entity/modifyFieldsOnValue.md @@ -4,11 +4,11 @@ This feature allows to specify conditions to modify other fields based on curren ### Modification Object Properties -| Property | Type | Description | -| --------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | -| fieldValue\* | string | Value that will trigger the update, put `[[any_other_value]]` to trigger update for any other values than specified | -| mode | string | Mode that adds possibility to use modification only on certain mode | -| fieldsToModify | array | List of fields modifications that will be applied after com ponent value will match | +| Property | Type | Description | +| --------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- | +| fieldValue\* | string | Value of current field that will trigger the update. Put `[[any_other_value]]` to make update for any other value than specified. | +| mode | string | Mode that adds possibility to use modification only on certain mode. One of ( `create` / `edit` / `clone` / `config` ) | +| fieldsToModify | array | List of fields modifications that will be applied after com ponent value will match. | ### fieldsToModify Properties diff --git a/splunk_add_on_ucc_framework/global_config_validator.py b/splunk_add_on_ucc_framework/global_config_validator.py index e50dc987a..b6ca1d87a 100644 --- a/splunk_add_on_ucc_framework/global_config_validator.py +++ b/splunk_add_on_ucc_framework/global_config_validator.py @@ -530,7 +530,7 @@ def _validate_groups(self) -> None: f"Service {service['name']} uses group field {group_field} which is not defined in entity" ) - def _is_circular( + def _is_circular_modification( self, mods: List[Any], visited: Dict[str, str], @@ -551,61 +551,73 @@ def _is_circular( # no more dependent modification fields visited[current_field] = DEAD_END return visited - else: - for influenced_field in current_field_mods["influenced_fields"]: - if influenced_field not in all_entity_fields: - raise GlobalConfigValidatorException( - f"""Modification in field '{current_field}' for not existing field '{influenced_field}'""" - ) - if influenced_field == current_field: - raise GlobalConfigValidatorException( - f"""Field '{current_field}' tries to modify itself""" - ) + + if current_field in current_field_mods["influenced_fields_value_change"]: + # field can modify itself except "value" property + raise GlobalConfigValidatorException( + f"""Field '{current_field}' tries to modify itself value""" + ) + + for influenced_field in current_field_mods["influenced_fields"]: + if influenced_field not in all_entity_fields: + raise GlobalConfigValidatorException( + f"""Modification in field '{current_field}' for not existing field '{influenced_field}'""" + ) + + if influenced_field in current_field_mods["influenced_fields_value_change"]: if visited[influenced_field] == VISITING: raise GlobalConfigValidatorException( f"""Circular modifications for field '{influenced_field}' in field '{current_field}'""" ) - else: - visited = self._is_circular( - mods, visited, all_entity_fields, influenced_field - ) + # check next influenced by value change field + visited = self._is_circular_modification( + mods, visited, all_entity_fields, influenced_field + ) + # All dependent modifications fields are dead_end visited[current_field] = DEAD_END return visited - def _check_if_circular( + def _check_if_circular_modification( self, all_entity_fields: List[Any], fields_with_mods: List[Any], modifications: List[Any], ) -> None: visited = {field: "not_visited" for field in all_entity_fields} - for start_field in fields_with_mods: # DFS algorithm for all fields with modifications - visited = self._is_circular( + visited = self._is_circular_modification( modifications, visited, all_entity_fields, start_field ) @staticmethod def _get_mods_data_for_single_entity( - fields_with_mods: List[Any], - all_modifications: List[Any], entity: Dict[str, Any], ) -> List[Any]: """ - Add modification entity data to lists and returns them + Get modification entity data as lists """ + entity_modifications = [] if "modifyFieldsOnValue" in entity: + influenced_fields_value_change = set() influenced_fields = set() - fields_with_mods.append(entity["field"]) for mods in entity["modifyFieldsOnValue"]: for mod in mods["fieldsToModify"]: influenced_fields.add(mod["fieldId"]) - all_modifications.append( - {"fieldId": entity["field"], "influenced_fields": influenced_fields} + + if ( + mod.get("value") is not None + ): # circular deps are not a problem if not about value + influenced_fields_value_change.add(mod["fieldId"]) + entity_modifications.append( + { + "fieldId": entity["field"], + "influenced_fields": influenced_fields, + "influenced_fields_value_change": influenced_fields_value_change, + } ) - return [fields_with_mods, all_modifications] + return entity_modifications @staticmethod def _get_all_entities( @@ -638,11 +650,13 @@ def _get_all_modification_data( entities = self._get_all_entities(collections) for entity in entities: - self._get_mods_data_for_single_entity( - fields_with_mods, all_modifications, entity - ) all_fields.append(entity["field"]) + if "modifyFieldsOnValue" in entity: + fields_with_mods.append(entity["field"]) + entity_mods = self._get_mods_data_for_single_entity(entity) + all_modifications.extend(entity_mods) + return [fields_with_mods, all_modifications, all_fields] def _validate_field_modifications(self) -> None: @@ -664,7 +678,7 @@ def _validate_field_modifications(self) -> None: all_fields_config, ) = self._get_all_modification_data(tabs) - self._check_if_circular( + self._check_if_circular_modification( all_fields_config, fields_with_mods_config, all_modifications_config ) @@ -678,7 +692,7 @@ def _validate_field_modifications(self) -> None: all_fields_inputs, ) = self._get_all_modification_data(services) - self._check_if_circular( + self._check_if_circular_modification( all_fields_inputs, fields_with_mods_inputs, all_modifications_inputs ) diff --git a/tests/unit/test_global_config_validator.py b/tests/unit/test_global_config_validator.py index 43dc3b9f2..185dc069e 100644 --- a/tests/unit/test_global_config_validator.py +++ b/tests/unit/test_global_config_validator.py @@ -337,7 +337,7 @@ def test_config_validation_modifications_on_change(): [ ( "invalid_config_with_modification_for_field_itself.json", - "Field 'text1' tries to modify itself", + "Field 'text1' tries to modify itself value", ), ( "invalid_config_with_modification_for_unexisiting_fields.json", diff --git a/tests/unit/testdata/invalid_config_with_modification_circular_modifications.json b/tests/unit/testdata/invalid_config_with_modification_circular_modifications.json index 3c8e0406f..e662dea54 100644 --- a/tests/unit/testdata/invalid_config_with_modification_circular_modifications.json +++ b/tests/unit/testdata/invalid_config_with_modification_circular_modifications.json @@ -53,7 +53,7 @@ "fieldsToModify": [ { "fieldId": "text2", - "disabled": false + "value": "modification" } ] }, @@ -62,7 +62,7 @@ "fieldsToModify": [ { "fieldId": "text3", - "disabled": true + "value": "modification" } ] } @@ -79,7 +79,7 @@ "fieldsToModify": [ { "fieldId": "text3", - "disabled": false + "value": "modification" } ] }, @@ -88,7 +88,7 @@ "fieldsToModify": [ { "fieldId": "text4", - "disabled": true + "value": "modification" } ] } @@ -111,7 +111,7 @@ "fieldsToModify": [ { "fieldId": "text5", - "disabled": false + "value": "modification" } ] }, @@ -120,7 +120,7 @@ "fieldsToModify": [ { "fieldId": "text5", - "disabled": true + "value": "modification" } ] } @@ -137,7 +137,7 @@ "fieldsToModify": [ { "fieldId": "text6", - "disabled": false + "value": "modification" } ] }, @@ -146,7 +146,7 @@ "fieldsToModify": [ { "fieldId": "text6", - "disabled": true + "value": "modification" } ] } @@ -163,7 +163,7 @@ "fieldsToModify": [ { "fieldId": "text7", - "disabled": false + "value": "modification" } ] }, @@ -172,7 +172,7 @@ "fieldsToModify": [ { "fieldId": "text7", - "disabled": true + "value": "modification" } ] } @@ -189,7 +189,7 @@ "fieldsToModify": [ { "fieldId": "text1", - "disabled": false + "value": "modification" } ] } diff --git a/tests/unit/testdata/invalid_config_with_modification_for_field_itself.json b/tests/unit/testdata/invalid_config_with_modification_for_field_itself.json index eb7145f94..a87aabccf 100644 --- a/tests/unit/testdata/invalid_config_with_modification_for_field_itself.json +++ b/tests/unit/testdata/invalid_config_with_modification_for_field_itself.json @@ -53,7 +53,7 @@ "fieldsToModify": [ { "fieldId": "text1", - "disabled": false + "value": "value modification" } ] } diff --git a/tests/unit/testdata/valid_config_with_modification_on_value_change.json b/tests/unit/testdata/valid_config_with_modification_on_value_change.json index f39158c62..13b803d2c 100644 --- a/tests/unit/testdata/valid_config_with_modification_on_value_change.json +++ b/tests/unit/testdata/valid_config_with_modification_on_value_change.json @@ -54,6 +54,10 @@ { "fieldId": "text2", "disabled": false + }, + { + "fieldId": "text1", + "label": "change label for itself" } ] }, diff --git a/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx b/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx index 43b7d1c5e..b49967885 100644 --- a/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx +++ b/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { fn } from '@storybook/test'; +import { fn, userEvent, within } from '@storybook/test'; + import BaseFormView from '../BaseFormView'; import { PAGE_CONFIG_BOTH_OAUTH, @@ -17,6 +18,8 @@ import { getGlobalConfigMockGroupsForConfigPage, getGlobalConfigMockModificationToGroupsConfig, } from '../BaseFormConfigMock'; +import { getGlobalConfigMockModificationToFieldItself } from '../tests/configMocks'; +import { invariant } from '../../../util/invariant'; interface BaseFormStoriesProps extends BaseFormProps { config: GlobalConfig; @@ -143,3 +146,31 @@ export const GroupModificationsConfig: Story = { platform: 'cloud', }, }; + +export const FieldModifyItself: Story = { + args: { + currentServiceState: {}, + serviceName: 'account', + mode: 'create' as Mode, + page: 'configuration', + stanzaName: 'unknownStanza', + handleFormSubmit: fn(), + config: getGlobalConfigMockModificationToFieldItself(), + }, +}; + +export const FieldModifyItselfAfterMods: Story = { + args: FieldModifyItself.args, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const modifyInputText = canvas + .getAllByRole('textbox') + .find((el) => el.getAttribute('value') === 'default value'); + + invariant(modifyInputText, 'modification input field should be defined'); + + await userEvent.clear(modifyInputText); + await userEvent.type(modifyInputText, 'modify itself'); + }, +}; diff --git a/ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-after-mods-chromium.png b/ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-after-mods-chromium.png new file mode 100644 index 000000000..3b7febda1 --- /dev/null +++ b/ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-after-mods-chromium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bb703699c8c301f552a38459fb9b237d1f7a4281ce011d3e3fa1ce4592917c0 +size 22951 diff --git a/ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-chromium.png b/ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-chromium.png new file mode 100644 index 000000000..1a20c14c2 --- /dev/null +++ b/ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-chromium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8f806fad0fff2f0417063c3d1638107f87f3edbcc373218c37ee413df522109 +size 19293 diff --git a/ui/src/components/BaseFormView/BaseFormView.test.tsx b/ui/src/components/BaseFormView/tests/BaseFormView.test.tsx similarity index 92% rename from ui/src/components/BaseFormView/BaseFormView.test.tsx rename to ui/src/components/BaseFormView/tests/BaseFormView.test.tsx index 533c388aa..63b31af09 100644 --- a/ui/src/components/BaseFormView/BaseFormView.test.tsx +++ b/ui/src/components/BaseFormView/tests/BaseFormView.test.tsx @@ -2,16 +2,16 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import userEvent from '@testing-library/user-event'; -import { getGlobalConfigMock } from '../../mocks/globalConfigMock'; -import { setUnifiedConfig } from '../../util/util'; -import BaseFormView from './BaseFormView'; -import { getBuildDirPath } from '../../util/script'; -import mockCustomControlMockForTest from '../CustomControl/CustomControlMockForTest'; +import { getGlobalConfigMock } from '../../../mocks/globalConfigMock'; +import { getBuildDirPath } from '../../../util/script'; +import { setUnifiedConfig } from '../../../util/util'; import { getGlobalConfigMockCustomControl, getGlobalConfigMockGroupsForInputPage, getGlobalConfigMockGroupsForConfigPage, -} from './BaseFormConfigMock'; +} from '../BaseFormConfigMock'; +import mockCustomControlMockForTest from '../../CustomControl/CustomControlMockForTest'; +import BaseFormView from '../BaseFormView'; const handleFormSubmit = jest.fn(); diff --git a/ui/src/components/BaseFormView/tests/BaseFormViewModifications.test.tsx b/ui/src/components/BaseFormView/tests/BaseFormViewModifications.test.tsx new file mode 100644 index 000000000..bdbcb4800 --- /dev/null +++ b/ui/src/components/BaseFormView/tests/BaseFormViewModifications.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, within } from '@testing-library/react'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { setUnifiedConfig } from '../../../util/util'; +import BaseFormView from '../BaseFormView'; +import { getGlobalConfigMockModificationToFieldItself } from './configMocks'; +import { invariant } from '../../../util/invariant'; + +const handleFormSubmit = jest.fn(); + +const PAGE_CONF = 'configuration'; +const SERVICE_NAME = 'account'; +const STANZA_NAME = 'stanzaName'; + +it('should modify correctly all properties of field, self modification', async () => { + const mockConfig = getGlobalConfigMockModificationToFieldItself(); + setUnifiedConfig(mockConfig); + + render( + + ); + + await screen.findByText('default label'); + + const modifyTextField = document.querySelector( + '[data-name="text_field_with_modifications"]' + ) as HTMLElement; + + expect(modifyTextField).toBeInTheDocument(); + + invariant(modifyTextField, 'modification field should be defined'); + + expect(within(modifyTextField).getByTestId('help')).toHaveTextContent('default help'); + expect(within(modifyTextField).getByTestId('label')).toHaveTextContent('default label'); + expect(within(modifyTextField).getByTestId('msg-markdown')).toHaveTextContent( + 'default markdown message' + ); + expect(within(modifyTextField).queryByText('*')).not.toBeInTheDocument(); + + const inputComponent = within(modifyTextField).getByRole('textbox'); + await userEvent.clear(inputComponent); + await userEvent.type(inputComponent, 'modify itself'); + + expect(within(modifyTextField).getByTestId('help')).toHaveTextContent( + 'help after modification' + ); + expect(within(modifyTextField).getByTestId('label')).toHaveTextContent( + 'label after modification' + ); + expect(within(modifyTextField).getByTestId('msg-markdown')).toHaveTextContent( + 'markdown message after modification' + ); + expect(within(modifyTextField).queryByText('*')).toBeInTheDocument(); +}); diff --git a/ui/src/components/BaseFormView/tests/configMocks.ts b/ui/src/components/BaseFormView/tests/configMocks.ts new file mode 100644 index 000000000..c6c338e99 --- /dev/null +++ b/ui/src/components/BaseFormView/tests/configMocks.ts @@ -0,0 +1,169 @@ +import { z } from 'zod'; +import { GlobalConfigSchema } from '../../../types/globalConfig/globalConfig'; + +const CONFIG_MOCK_MODIFICATION_ON_VALUE_CHANGE_CONFIG = { + pages: { + configuration: { + tabs: [ + { + name: 'account', + table: { + actions: ['edit', 'delete', 'clone'], + header: [ + { + label: 'Name', + field: 'name', + }, + ], + }, + entity: [ + { + type: 'text', + label: 'Name', + validators: [ + { + type: 'regex', + errorMsg: + 'Account Name must begin with a letter and consist exclusively of alphanumeric characters and underscores.', + pattern: '^[a-zA-Z]\\w*$', + }, + { + type: 'string', + errorMsg: 'Length of input name should be between 1 and 100', + minLength: 1, + maxLength: 100, + }, + ], + field: 'name', + help: 'A unique name for the account.', + required: true, + }, + { + type: 'text', + label: 'Example text field', + field: 'text_field_with_modifications', + help: 'Example text field with modification', + required: false, + defaultValue: 'default value', + modifyFieldsOnValue: [ + { + fieldValue: 'default value', + fieldsToModify: [ + { + fieldId: 'text_field_with_modifications', + disabled: false, + required: false, + help: 'default help', + label: 'default label', + markdownMessage: { + text: 'default markdown message', + }, + }, + ], + }, + { + fieldValue: 'modify itself', + fieldsToModify: [ + { + fieldId: 'text_field_with_modifications', + disabled: false, + required: true, + help: 'help after modification', + label: 'label after modification', + markdownMessage: { + text: 'markdown message after modification', + }, + }, + ], + }, + ], + }, + { + type: 'text', + label: 'Example text field to be modified', + field: 'text_field_to_be_modified', + help: 'Example text field to be modified', + required: false, + modifyFieldsOnValue: [ + { + fieldValue: '[[any_other_value]]', + fieldsToModify: [ + { + fieldId: 'text_field_to_be_modified', + required: true, + }, + ], + }, + ], + }, + ], + title: 'Accounts', + }, + ], + title: 'Configuration', + description: 'Set up your add-on', + }, + inputs: { + services: [ + { + name: 'demo_input', + entity: [ + { + type: 'text', + label: 'Name', + validators: [ + { + type: 'regex', + errorMsg: + 'Input Name must begin with a letter and consist exclusively of alphanumeric characters and underscores.', + pattern: '^[a-zA-Z]\\w*$', + }, + { + type: 'string', + errorMsg: 'Length of input name should be between 1 and 100', + minLength: 1, + maxLength: 100, + }, + ], + field: 'name', + help: 'A unique name for the data input.', + required: true, + encrypted: false, + }, + ], + title: 'demo_input', + }, + ], + title: 'Inputs', + description: 'Manage your data inputs', + table: { + actions: ['edit', 'delete', 'clone'], + header: [ + { + label: 'Name', + field: 'name', + }, + ], + moreInfo: [ + { + label: 'Name', + field: 'name', + }, + ], + }, + }, + }, + meta: { + name: 'demo_addon_for_splunk', + restRoot: 'demo_addon_for_splunk', + version: '5.31.1R85f0e18e', + displayName: 'Demo Add-on for Splunk', + schemaVersion: '0.0.3', + checkForUpdates: false, + searchViewDefault: false, + }, +} satisfies z.input; + +export function getGlobalConfigMockModificationToFieldItself() { + return GlobalConfigSchema.parse(CONFIG_MOCK_MODIFICATION_ON_VALUE_CHANGE_CONFIG); +}