From b8eeb63ddf69558cd2cc89f3d8e7f2f5c4b1f0a2 Mon Sep 17 00:00:00 2001 From: Szymon Oleksy Date: Thu, 28 Nov 2024 11:34:09 +0100 Subject: [PATCH 1/7] docs: use asterix as bolder --- .markdownlint.yaml | 6 +++++- docs/CHANGELOG.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.markdownlint.yaml b/.markdownlint.yaml index fdc2c1b12..673f636df 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -50,4 +50,8 @@ MD040: false MD041: true # MD046/code-block-style -MD046: false \ No newline at end of file +MD046: false + +# MD050/strong-style +MD050: + style: "asterisk" \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5bf8cf38b..bd3e15930 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,7 +15,7 @@ * add license during init command ([#1475](https://github.com/splunk/addonfactory-ucc-generator/issues/1475)) ([471294a](https://github.com/splunk/addonfactory-ucc-generator/commit/471294ae8e4c266a2f685aa4c01eb75fd1974db1)) * confirmation modal when activate/deactivate single input ([#1421](https://github.com/splunk/addonfactory-ucc-generator/issues/1421)) ([34c8ec2](https://github.com/splunk/addonfactory-ucc-generator/commit/34c8ec250861eb06bd1cd4b22b430e5aa7e26a7c)) -* do not create __pycache__ in lib dir ([#1469](https://github.com/splunk/addonfactory-ucc-generator/issues/1469)) ([ad58e50](https://github.com/splunk/addonfactory-ucc-generator/commit/ad58e50ca2b5588f6824a4da95a15e8c0857f032)) +* do not create \_\_pycache\_\_ in lib dir ([#1469](https://github.com/splunk/addonfactory-ucc-generator/issues/1469)) ([ad58e50](https://github.com/splunk/addonfactory-ucc-generator/commit/ad58e50ca2b5588f6824a4da95a15e8c0857f032)) * **inputs:** show input services status count ([#1430](https://github.com/splunk/addonfactory-ucc-generator/issues/1430)) ([2574451](https://github.com/splunk/addonfactory-ucc-generator/commit/257445159898a2207cdf7a397345c218678c8fcb)) ## [5.53.2](https://github.com/splunk/addonfactory-ucc-generator/compare/v5.53.1...v5.53.2) (2024-11-21) From 547eceef1ce00f8881ab9066dc9d04b5221b082f Mon Sep 17 00:00:00 2001 From: soleksy-splunk <143183665+soleksy-splunk@users.noreply.github.com> Date: Thu, 28 Nov 2024 12:06:55 +0100 Subject: [PATCH 2/7] Update docs/CHANGELOG.md Co-authored-by: Viktor Tsvetkov <142901247+vtsvetkov-splunk@users.noreply.github.com> --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bd3e15930..4495116c0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,7 +15,7 @@ * add license during init command ([#1475](https://github.com/splunk/addonfactory-ucc-generator/issues/1475)) ([471294a](https://github.com/splunk/addonfactory-ucc-generator/commit/471294ae8e4c266a2f685aa4c01eb75fd1974db1)) * confirmation modal when activate/deactivate single input ([#1421](https://github.com/splunk/addonfactory-ucc-generator/issues/1421)) ([34c8ec2](https://github.com/splunk/addonfactory-ucc-generator/commit/34c8ec250861eb06bd1cd4b22b430e5aa7e26a7c)) -* do not create \_\_pycache\_\_ in lib dir ([#1469](https://github.com/splunk/addonfactory-ucc-generator/issues/1469)) ([ad58e50](https://github.com/splunk/addonfactory-ucc-generator/commit/ad58e50ca2b5588f6824a4da95a15e8c0857f032)) +* do not create `__pycache__` in lib dir ([#1469](https://github.com/splunk/addonfactory-ucc-generator/issues/1469)) ([ad58e50](https://github.com/splunk/addonfactory-ucc-generator/commit/ad58e50ca2b5588f6824a4da95a15e8c0857f032)) * **inputs:** show input services status count ([#1430](https://github.com/splunk/addonfactory-ucc-generator/issues/1430)) ([2574451](https://github.com/splunk/addonfactory-ucc-generator/commit/257445159898a2207cdf7a397345c218678c8fcb)) ## [5.53.2](https://github.com/splunk/addonfactory-ucc-generator/compare/v5.53.1...v5.53.2) (2024-11-21) From 6e6f9e40527d523f4d73b26db7cd38e1d9c9fda2 Mon Sep 17 00:00:00 2001 From: Szymon Oleksy Date: Thu, 28 Nov 2024 12:10:33 +0100 Subject: [PATCH 3/7] docs: remove lint rule --- .markdownlint.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 673f636df..fdc2c1b12 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -50,8 +50,4 @@ MD040: false MD041: true # MD046/code-block-style -MD046: false - -# MD050/strong-style -MD050: - style: "asterisk" \ No newline at end of file +MD046: false \ No newline at end of file From a7c36ffc599af03d67c04830ffa6e5bd65799fc8 Mon Sep 17 00:00:00 2001 From: Hetang Modi <62056057+hetangmodi-crest@users.noreply.github.com> Date: Mon, 2 Dec 2024 19:45:15 +0530 Subject: [PATCH 4/7] feat: add author during init (#1483) **Issue number:** ADDON-76501 ### PR Type **What kind of change does this PR introduce?** * [x] Feature * [ ] Bug Fix * [ ] Refactoring (no functional or API changes) * [x] Documentation Update * [ ] Maintenance (dependency updates, CI, etc.) ## Summary Provided an optional flag `--include-author` in `ucc-gen init` command to add an author name in `app.manifest`. ### Changes Introduced an optional flag `--include-author` in the `ucc-gen init` command, it allows users to specify the name of the author during initialization of add-on. The author's name will appear in `app.manifest` under `info -> author -> name` and in `app.conf` (after building your add-on) under `launcher -> author` field. ### User experience Users can now specify the author name using this flag and include them in their `app.manifest`. ## Checklist If an item doesn't apply to your changes, leave it unchecked. * [x] I have performed a self-review of this change according to the [development guidelines](https://splunk.github.io/addonfactory-ucc-generator/contributing/#development-guidelines) * [x] Tests have been added/modified to cover the changes [(testing doc)](https://splunk.github.io/addonfactory-ucc-generator/contributing/#build-and-test) * [x] Changes are documented * [x] PR title and description follows the [contributing principles](https://splunk.github.io/addonfactory-ucc-generator/contributing/#pull-requests) --- docs/commands.md | 1 + splunk_add_on_ucc_framework/commands/build.py | 1 + splunk_add_on_ucc_framework/commands/init.py | 9 +++ splunk_add_on_ucc_framework/main.py | 8 ++ .../templates/app.manifest.init-template | 4 + splunk_add_on_ucc_framework/utils.py | 19 ++++- tests/smoke/test_ucc_init.py | 12 +++ .../package/app.manifest | 2 +- tests/unit/commands/test_init.py | 75 +++++++++++++++++++ tests/unit/test_main.py | 30 ++++++++ tests/unit/test_utils.py | 36 ++++++++- 11 files changed, 194 insertions(+), 3 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 1a7199895..4587d6faa 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -77,6 +77,7 @@ It takes the following parameters: * `--overwrite` - [optional] overwrites the already existing folder. By default, you can't generate a new add-on to an already existing folder. * `--add-license` - [optional] Adds license agreement such as [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt), [MIT License](https://mit-license.org/), or [SPLUNK PRE-RELEASE SOFTWARE LICENSE AGREEMENT](https://www.splunk.com/en_us/legal/splunk-pre-release-software-license-agreement.html) in your `package/LICENSES` directory. If not mentioned an empty License.txt will be generated. +* `--include-author` - [optional] Allows you to specify the author of the add-on during initialization. The author's name will appear in `app.manifest` under `info -> author -> name` and in `app.conf` (after building your add-on) under `launcher -> author` field. > **Note:** The add-on will not build if the input for `--add-license` is not one of the following: `Apache License 2.0`, `MIT License`, or `SPLUNK PRE-RELEASE SOFTWARE LICENSE AGREEMENT`. If you want to keep another license in your add-on, place it in `package/LICENSES` directory and it will be shipped diff --git a/splunk_add_on_ucc_framework/commands/build.py b/splunk_add_on_ucc_framework/commands/build.py index bd0641c16..2f547c638 100644 --- a/splunk_add_on_ucc_framework/commands/build.py +++ b/splunk_add_on_ucc_framework/commands/build.py @@ -582,6 +582,7 @@ def generate( removed_list = _remove_listed_files(ignore_list) if removed_list: logger.info("Removed:\n{}".format("\n".join(removed_list))) + utils.check_author_name(source, app_manifest) utils.recursive_overwrite(source, os.path.join(output_directory, ta_name)) logger.info("Copied package directory") diff --git a/splunk_add_on_ucc_framework/commands/init.py b/splunk_add_on_ucc_framework/commands/init.py index 798f77370..d503aeca4 100644 --- a/splunk_add_on_ucc_framework/commands/init.py +++ b/splunk_add_on_ucc_framework/commands/init.py @@ -68,6 +68,7 @@ def _generate_addon( addon_rest_root: str | None = None, overwrite: bool = False, add_license: str | None = None, + include_author: str | None = None, ) -> str: generated_addon_path = os.path.join( os.getcwd(), @@ -131,6 +132,7 @@ def _generate_addon( addon_version=addon_version, addon_display_name=addon_display_name, add_license=add_license, + include_author=include_author, ) ) with open(package_app_manifest_path, "w") as _f: @@ -175,6 +177,7 @@ def init( addon_rest_root: str | None = None, overwrite: bool = False, add_license: str | None = None, + include_author: str | None = None, ) -> str: if not _is_valid_addon_name(addon_name): logger.error( @@ -207,6 +210,11 @@ def init( f"it should follow '{ADDON_INPUT_NAME_RE_STR}' regex and be less than 50 characters." ) sys.exit(1) + if include_author == "": + logger.error("The author name cannot be left empty, please provide some input.") + sys.exit(1) + if include_author: + include_author = include_author.strip() generated_addon_path = _generate_addon( addon_name, addon_display_name, @@ -215,6 +223,7 @@ def init( addon_rest_root, overwrite, add_license, + include_author, ) logger.info(f"Generated add-on is located here {generated_addon_path}") if add_license: diff --git a/splunk_add_on_ucc_framework/main.py b/splunk_add_on_ucc_framework/main.py index 20e8cf734..6ea50c034 100644 --- a/splunk_add_on_ucc_framework/main.py +++ b/splunk_add_on_ucc_framework/main.py @@ -215,6 +215,13 @@ def main(argv: Optional[Sequence[str]] = None) -> int: required=False, default=None, ) + init_parser.add_argument( + "--include-author", + type=str, + help="adds author in app.mainifest under `info -> author -> name` field", + required=False, + default=None, + ) import_from_aob_parser = subparsers.add_parser( "import-from-aob", description="[Experimental] Import from AoB" @@ -251,6 +258,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: addon_version=args.addon_version, overwrite=args.overwrite, add_license=args.add_license, + include_author=args.include_author, ) if args.command == "import-from-aob": import_from_aob.import_from_aob( diff --git a/splunk_add_on_ucc_framework/templates/app.manifest.init-template b/splunk_add_on_ucc_framework/templates/app.manifest.init-template index c3d05058c..edbe03360 100644 --- a/splunk_add_on_ucc_framework/templates/app.manifest.init-template +++ b/splunk_add_on_ucc_framework/templates/app.manifest.init-template @@ -9,7 +9,11 @@ }, "author": [ { + {% if include_author -%} + "name": "{{include_author}}", + {%- else -%} "name": "", + {%- endif %} "email": null, "company": null } diff --git a/splunk_add_on_ucc_framework/utils.py b/splunk_add_on_ucc_framework/utils.py index f0d364b8f..56f995ad0 100644 --- a/splunk_add_on_ucc_framework/utils.py +++ b/splunk_add_on_ucc_framework/utils.py @@ -18,7 +18,8 @@ import shutil from os import listdir, makedirs, path, remove, sep from os.path import basename as bn -from os.path import dirname, exists, isdir, join +from os.path import dirname, exists, isdir, join, isfile +from splunk_add_on_ucc_framework.app_manifest import AppManifest from typing import Any, Dict import addonfactory_splunk_conf_parser_lib as conf_parser @@ -42,6 +43,22 @@ def get_license_path(file_name: str) -> str: return join(dirname(__file__), "templates", "Licenses", f"{file_name}.txt") +def check_author_name(source: str, app_manifest: AppManifest) -> None: + check_path = join(source, "default", "app.conf") + if isfile(check_path): + app_conf = conf_parser.TABConfigParser() + app_conf.read(check_path) + app_conf_content = app_conf.item_dict() + if ( + app_manifest.get_authors()[0]["name"] + != app_conf_content["launcher"]["author"] + ): + logger.warning( + "Conflicting author names are identified between app.manifest and app.conf in the source directory. " + "Please specify the author name in app.manifest." + ) + + def recursive_overwrite(src: str, dest: str, ui_source_map: bool = False) -> None: """ Method to copy from src to dest recursively. diff --git a/tests/smoke/test_ucc_init.py b/tests/smoke/test_ucc_init.py index 87c2f37a7..3eab0ac96 100644 --- a/tests/smoke/test_ucc_init.py +++ b/tests/smoke/test_ucc_init.py @@ -18,6 +18,7 @@ def test_ucc_init(): "demo-addon-for-splunk", overwrite=True, add_license="MIT License", + include_author="test_author", ) expected_folder = os.path.join( os.path.dirname(__file__), @@ -67,3 +68,14 @@ def test_ucc_init_if_same_output_then_sys_exit(): "demo_input", "1.0.0", ) + + +def test_ucc_init_empty_string_passed_for_author(): + with pytest.raises(SystemExit): + init.init( + "test_addon", + "Demo Add-on for Splunk", + "demo_input", + "1.0.0", + include_author="", + ) diff --git a/tests/testdata/expected_addons/expected_addon_after_init/demo_addon_for_splunk/package/app.manifest b/tests/testdata/expected_addons/expected_addon_after_init/demo_addon_for_splunk/package/app.manifest index 7c64a17c7..4f4817f5b 100644 --- a/tests/testdata/expected_addons/expected_addon_after_init/demo_addon_for_splunk/package/app.manifest +++ b/tests/testdata/expected_addons/expected_addon_after_init/demo_addon_for_splunk/package/app.manifest @@ -9,7 +9,7 @@ }, "author": [ { - "name": "", + "name": "test_author", "email": null, "company": null } diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index c5f221e2e..37d0f10b3 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -94,6 +94,7 @@ def test__is_valid_input_name(input_name, expected): "addon_name", False, None, + None, ), ), ( @@ -112,6 +113,7 @@ def test__is_valid_input_name(input_name, expected): "addon_name", False, None, + None, ), ), ( @@ -131,6 +133,51 @@ def test__is_valid_input_name(input_name, expected): "addon_rest_root", False, "Apache License 2.0", + None, + ), + ), + ( + { + "addon_name": "addon_name", + "addon_rest_root": "addon_rest_root", + "addon_display_name": "Addon For Demo", + "addon_input_name": "input_name", + "addon_version": "0.0.1", + "overwrite": True, + "add_license": "Apache License 2.0", + "include_author": "test_author", + }, + ( + "addon_name", + "Addon For Demo", + "input_name", + "0.0.1", + "addon_rest_root", + True, + "Apache License 2.0", + "test_author", + ), + ), + ( + { + "addon_name": "addon_name", + "addon_rest_root": "addon_rest_root", + "addon_display_name": "Addon For Demo", + "addon_input_name": "input_name", + "addon_version": "0.0.1", + "overwrite": True, + "add_license": "Apache License 2.0", + "include_author": " test author ", + }, + ( + "addon_name", + "Addon For Demo", + "input_name", + "0.0.1", + "addon_rest_root", + True, + "Apache License 2.0", + "test author", ), ), ], @@ -178,6 +225,16 @@ def test_init(mock_generate_addon, init_kwargs, expected_args_to_generate_addon) "addon_version": "0.0.1", } ), + ( + { + "addon_name": "addon_name", + "addon_rest_root": "addon_rest_root", + "addon_display_name": "Addon For Demo", + "addon_input_name": "input_name", + "addon_version": "0.0.1", + "include_author": "", + } + ), ], ) def test_init_when_incorrect_parameters_then_sys_exit(init_kwargs): @@ -203,6 +260,24 @@ def test_init_when_folder_already_exists(mock_generate_addon, caplog): assert expected_error_message in caplog.text +@mock.patch("splunk_add_on_ucc_framework.commands.init._generate_addon") +def test_init_when_empty_string_passed_for_author(mock_generate_addon, caplog): + mock_generate_addon.side_effect = SystemExit + + with pytest.raises(SystemExit): + init.init( + "test_addon", + "Addon For Demo Already Exists", + "input_name", + "0.0.1", + include_author="", + ) + expected_error_message = ( + "The author name cannot be left empty, please provide some input. " + ) + assert expected_error_message in caplog.text + + def test_valid_regex(): file_path = f"{helpers.get_path_to_source_dir()}/schema/schema.json" with open(file_path) as file: diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 73a6e8923..9951a6162 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -349,6 +349,7 @@ def test_build_command(mock_ucc_gen_generate, args, expected_parameters): "addon_rest_root": None, "overwrite": False, "add_license": None, + "include_author": None, }, ), ( @@ -373,6 +374,35 @@ def test_build_command(mock_ucc_gen_generate, args, expected_parameters): "addon_rest_root": "splunk_add_on_for_demo", "overwrite": False, "add_license": "MIT License", + "include_author": None, + }, + ), + ( + [ + "init", + "--addon-name", + "splunk_add_on_for_demo", + "--addon-rest-root", + "splunk_add_on_for_demo", + "--addon-display-name", + "Splunk Add-on for Demo", + "--addon-input-name", + "demo_input", + "--overwrite", + "--add-license", + "MIT License", + "--include-author", + "test_author", + ], + { + "addon_name": "splunk_add_on_for_demo", + "addon_display_name": "Splunk Add-on for Demo", + "addon_input_name": "demo_input", + "addon_version": "0.0.1", + "addon_rest_root": "splunk_add_on_for_demo", + "overwrite": True, + "add_license": "MIT License", + "include_author": "test_author", }, ), ], diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index aa4e97c42..fa534f50b 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -3,7 +3,8 @@ import dunamai import pytest -from unittest.mock import patch +from unittest.mock import patch, MagicMock +from os.path import join from splunk_add_on_ucc_framework import exceptions, utils @@ -44,6 +45,39 @@ def test_get_license_path(): assert actual_path == expected_path +@patch("splunk_add_on_ucc_framework.utils.isfile") +@patch("splunk_add_on_ucc_framework.utils.conf_parser.TABConfigParser") +@patch("splunk_add_on_ucc_framework.utils.logger") +def test_check_author_names_conflict(mock_logger, mock_tab_config_parser, mock_isfile): + source = "/path/to/source" + app_manifest = MagicMock() + app_manifest.get_authors.return_value = [{"name": "Author in Manifest"}] + + mock_isfile.return_value = True + app_conf_mock = MagicMock() + app_conf_mock.item_dict.return_value = {"launcher": {"author": "Author in Conf"}} + mock_tab_config_parser.return_value = app_conf_mock + utils.check_author_name(source, app_manifest) + + check_path = join(source, "default", "app.conf") + mock_isfile.assert_called_once_with(check_path) + mock_logger.warning.assert_called_once_with( + "Conflicting author names are identified between app.manifest and app.conf in the source directory. " + "Please specify the author name in app.manifest." + ) + + +@patch("splunk_add_on_ucc_framework.utils.isfile") +def test_check_author_names_no_conflict(mock_isfile): + source = "/path/to/source" + app_manifest = MagicMock() + app_manifest.get_authors.return_value = [{"name": "Author in Manifest"}] + + mock_isfile.return_value = False + utils.check_author_name(source, app_manifest) + mock_isfile.assert_called_once_with(join(source, "default", "app.conf")) + + @mock.patch("splunk_add_on_ucc_framework.utils.dunamai.Version", autospec=True) def test_get_version_from_git_when_runtime_error_from_dunamai(mock_version_class): mock_version_class.from_git.side_effect = RuntimeError From 01c88aabeb7e33963200c80a427aad7793c46f7a Mon Sep 17 00:00:00 2001 From: soleksy-splunk <143183665+soleksy-splunk@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:42:39 +0100 Subject: [PATCH 5/7] fix: group elements use all functionalities (#1500) **Issue number:** N/A ### PR Type **What kind of change does this PR introduce?** * [ ] Feature * [x] Bug Fix * [ ] Refactoring (no functional or API changes) * [ ] Documentation Update * [ ] Maintenance (dependency updates, CI, etc.) ## Summary Modifications did not work for fields inside any group. ### Changes Pass all parameters when rendering groups. Please provide a summary of the changes. ### User experience Modifications works correctly when using groups. Please describe the user experience before and after this change. Screenshots are welcome for additional context. ## Checklist If an item doesn't apply to your changes, leave it unchecked. * [x] I have performed a self-review of this change according to the [development guidelines](https://splunk.github.io/addonfactory-ucc-generator/contributing/#development-guidelines) * [x] Tests have been added/modified to cover the changes [(testing doc)](https://splunk.github.io/addonfactory-ucc-generator/contributing/#build-and-test) * [ ] Changes are documented * [x] PR title and description follows the [contributing principles](https://splunk.github.io/addonfactory-ucc-generator/contributing/#pull-requests) --------- Co-authored-by: srv-rr-github-token <94607705+srv-rr-github-token@users.noreply.github.com> --- .../BaseFormView/BaseFormConfigMock.ts | 351 ++++++++++-------- .../BaseFormView/BaseFormView.test.tsx | 4 +- .../components/BaseFormView/BaseFormView.tsx | 3 + .../BaseFormView/BaseFormViewGrups.test.tsx | 66 ++++ .../stories/BaseFormView.stories.tsx | 18 +- ...ew-group-modifications-config-chromium.png | 3 + 6 files changed, 287 insertions(+), 158 deletions(-) create mode 100644 ui/src/components/BaseFormView/BaseFormViewGrups.test.tsx create mode 100644 ui/src/components/BaseFormView/stories/__images__/BaseFormView-group-modifications-config-chromium.png diff --git a/ui/src/components/BaseFormView/BaseFormConfigMock.ts b/ui/src/components/BaseFormView/BaseFormConfigMock.ts index 7571fbdbb..f5fd0f352 100644 --- a/ui/src/components/BaseFormView/BaseFormConfigMock.ts +++ b/ui/src/components/BaseFormView/BaseFormConfigMock.ts @@ -176,6 +176,115 @@ export function getGlobalConfigMockCustomControl() { return GlobalConfigSchema.parse(globalConfigMockCustomControl); } +const getGlobalConfigMockGroups = ({ + entitiesConfig, + entityGroupsConfig, + entitiesInputs, + entityGroupsInputs, +}: { + entitiesConfig?: z.input[]; + entityGroupsConfig?: typeof GROUPS_FOR_EXAMPLE_ENTITIES; + entitiesInputs?: z.input[]; + entityGroupsInputs?: typeof GROUPS_FOR_EXAMPLE_ENTITIES; +}) => + ({ + 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*$', + }, + ], + field: 'name', + help: 'A unique name for the account.', + required: true, + }, + ...(entitiesConfig || []), + ], + groups: entityGroupsConfig, + 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, + }, + ...(entitiesInputs || []), + ], + groups: entityGroupsInputs, + title: 'demo_input', + }, + ], + title: 'Inputs', + description: 'Manage your data inputs', + table: { + actions: ['edit', 'delete', 'clone'], + header: [ + { + 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); + const EXAMPLE_GROUPS_ENTITIES = [ { type: 'text', @@ -238,171 +347,105 @@ const GROUPS_FOR_EXAMPLE_ENTITIES = [ }, ]; -const globalConfigMockGroupsForConfigPage = { - 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*$', - }, - ], - field: 'name', - help: 'A unique name for the account.', - required: true, - }, - ...EXAMPLE_GROUPS_ENTITIES, - ], - groups: GROUPS_FOR_EXAMPLE_ENTITIES, - title: 'Accounts', - }, - ], - title: 'Configuration', - description: 'Set up your add-on', - }, - inputs: { - services: [], - title: 'Inputs', - description: 'Manage your data inputs', - table: { - actions: ['edit', 'delete', 'clone'], - header: [ - { - 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 getGlobalConfigMockGroupsForConfigPage(): GlobalConfig { - return GlobalConfigSchema.parse(globalConfigMockGroupsForConfigPage); + return GlobalConfigSchema.parse( + getGlobalConfigMockGroups({ + entitiesConfig: EXAMPLE_GROUPS_ENTITIES, + entityGroupsConfig: GROUPS_FOR_EXAMPLE_ENTITIES, + }) + ); } -const globalConfigMockGroupsForInputPage = { - 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*$', - }, - ], - field: 'name', - help: 'A unique name for the account.', - required: true, +export function getGlobalConfigMockGroupsForInputPage(): GlobalConfig { + return GlobalConfigSchema.parse( + getGlobalConfigMockGroups({ + entitiesInputs: EXAMPLE_GROUPS_ENTITIES, + entityGroupsInputs: GROUPS_FOR_EXAMPLE_ENTITIES, + }) + ); +} + +const GROUP_ENTITIES_MODIFICATIONS = [ + { + type: 'text', + label: 'Text 1 Group 2', + field: 'text_field_1_group_2', + required: false, + modifyFieldsOnValue: [ + { + fieldValue: '[[any_other_value]]', + fieldsToModify: [ + { + fieldId: 'text_field_2_group_2', + disabled: false, + required: false, + help: 'help after mods 2-2', + label: 'label after mods 2-2', + markdownMessage: { + text: 'markdown message after mods 2-2', }, - ], - 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, + }, + { + fieldId: 'text_field_2_group_1', + disabled: false, + required: true, + help: 'help after mods 2-1', + label: 'label after mods 2-1', + markdownMessage: { + text: 'markdown message after mods 2-1', }, - ...EXAMPLE_GROUPS_ENTITIES, - ], - groups: GROUPS_FOR_EXAMPLE_ENTITIES, - title: 'demo_input', - }, - ], - title: 'Inputs', - description: 'Manage your data inputs', - table: { - actions: ['edit', 'delete', 'clone'], - header: [ + }, { - label: 'Name', - field: 'name', + fieldId: 'text_field_1_group_1', + disabled: true, }, ], }, + ], + }, + { + type: 'text', + label: 'Text 2 Group 2', + field: 'text_field_2_group_2', + required: false, + }, + { + type: 'text', + label: 'Text 1 Group 1', + field: 'text_field_1_group_1', + required: false, + }, + { + type: 'text', + label: 'Text 2 Group 1', + field: 'text_field_2_group_1', + required: false, + options: { + enable: false, }, }, - 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, + { + type: 'text', + label: 'Text 1 Group 3', + field: 'text_field_1_group_3', + required: false, + options: { + enable: false, + }, }, -} satisfies z.input; + { + type: 'text', + label: 'Text 2 Group 3', + field: 'text_field_2_group_3', + required: false, + }, +] satisfies z.input[]; -export function getGlobalConfigMockGroupsFoInputPage(): GlobalConfig { - return GlobalConfigSchema.parse(globalConfigMockGroupsForInputPage); +export function getGlobalConfigMockModificationToGroupsConfig(): GlobalConfig { + return GlobalConfigSchema.parse( + getGlobalConfigMockGroups({ + entitiesConfig: GROUP_ENTITIES_MODIFICATIONS, + entityGroupsConfig: GROUPS_FOR_EXAMPLE_ENTITIES, + }) + ); } diff --git a/ui/src/components/BaseFormView/BaseFormView.test.tsx b/ui/src/components/BaseFormView/BaseFormView.test.tsx index ac05b2ec3..533c388aa 100644 --- a/ui/src/components/BaseFormView/BaseFormView.test.tsx +++ b/ui/src/components/BaseFormView/BaseFormView.test.tsx @@ -9,7 +9,7 @@ import { getBuildDirPath } from '../../util/script'; import mockCustomControlMockForTest from '../CustomControl/CustomControlMockForTest'; import { getGlobalConfigMockCustomControl, - getGlobalConfigMockGroupsFoInputPage, + getGlobalConfigMockGroupsForInputPage, getGlobalConfigMockGroupsForConfigPage, } from './BaseFormConfigMock'; @@ -97,7 +97,7 @@ it.each([ }, { page: 'inputs' as const, - config: getGlobalConfigMockGroupsFoInputPage(), + config: getGlobalConfigMockGroupsForInputPage(), service: 'demo_input', }, ])('entities grouping for page works properly %s', async ({ config, page, service }) => { diff --git a/ui/src/components/BaseFormView/BaseFormView.tsx b/ui/src/components/BaseFormView/BaseFormView.tsx index b5189c01e..d74fd788e 100644 --- a/ui/src/components/BaseFormView/BaseFormView.tsx +++ b/ui/src/components/BaseFormView/BaseFormView.tsx @@ -1259,6 +1259,7 @@ class BaseFormView extends PureComponent { this.entities?.map((e) => { if (e.field === fieldName) { const temState = this.state?.data?.[e.field]; + return ( { markdownMessage={temState?.markdownMessage} dependencyValues={temState?.dependencyValues || null} page={this.props.page} + fileNameToDisplay={temState.fileNameToDisplay} + modifiedEntitiesData={temState.modifiedEntitiesData} /> ); } diff --git a/ui/src/components/BaseFormView/BaseFormViewGrups.test.tsx b/ui/src/components/BaseFormView/BaseFormViewGrups.test.tsx new file mode 100644 index 000000000..bd7d0bea6 --- /dev/null +++ b/ui/src/components/BaseFormView/BaseFormViewGrups.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, within } from '@testing-library/react'; +import React from 'react'; + +import { setUnifiedConfig } from '../../util/util'; +import BaseFormView from './BaseFormView'; +import { getGlobalConfigMockModificationToGroupsConfig } from './BaseFormConfigMock'; + +const handleFormSubmit = jest.fn(); + +const PAGE_CONF = 'configuration'; +const SERVICE_NAME = 'account'; +const STANZA_NAME = 'stanzaName'; + +it('should modify correctly all properties of field in groups', async () => { + const mockConfig = getGlobalConfigMockModificationToGroupsConfig(); + setUnifiedConfig(mockConfig); + render( + + ); + await screen.findByText('Text 1 Group 2'); + + const getAndValidateGroupFieldLabels = ( + fieldId: string, + label: string, + help: string, + markdownMsg: string + ) => { + const modifiedFieldSameGroup = document.querySelector( + `[data-name="${fieldId}"]` + ) as HTMLElement; + + expect(modifiedFieldSameGroup).toBeInTheDocument(); + + expect(within(modifiedFieldSameGroup).getByTestId('help')).toHaveTextContent(label); + expect(within(modifiedFieldSameGroup).getByTestId('label')).toHaveTextContent(help); + expect(within(modifiedFieldSameGroup).getByTestId('msg-markdown')).toHaveTextContent( + markdownMsg + ); + return modifiedFieldSameGroup; + }; + + const modifiedFieldSameGroup = getAndValidateGroupFieldLabels( + 'text_field_2_group_1', + 'help after mods 2-1', + 'label after mods 2-1', + 'markdown message after mods 2-1' + ); + + expect(within(modifiedFieldSameGroup).queryByText('*')).toBeInTheDocument(); + + const modifiedFieldDiffGroup = getAndValidateGroupFieldLabels( + 'text_field_2_group_2', + 'help after mods 2-2', + 'label after mods 2-2', + 'markdown message after mods 2-2' + ); + + expect(within(modifiedFieldDiffGroup).queryByText('*')).not.toBeInTheDocument(); +}); diff --git a/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx b/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx index 5fdb655cb..43b7d1c5e 100644 --- a/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx +++ b/ui/src/components/BaseFormView/stories/BaseFormView.stories.tsx @@ -13,8 +13,9 @@ import { Mode } from '../../../constants/modes'; import { BaseFormProps } from '../../../types/components/BaseFormTypes'; import { Platforms } from '../../../types/globalConfig/pages'; import { - getGlobalConfigMockGroupsFoInputPage, + getGlobalConfigMockGroupsForInputPage, getGlobalConfigMockGroupsForConfigPage, + getGlobalConfigMockModificationToGroupsConfig, } from '../BaseFormConfigMock'; interface BaseFormStoriesProps extends BaseFormProps { @@ -125,7 +126,20 @@ export const InputPageGroups: Story = { page: 'inputs', stanzaName: 'unknownStanza', handleFormSubmit: fn(), - config: getGlobalConfigMockGroupsFoInputPage(), + config: getGlobalConfigMockGroupsForInputPage(), + platform: 'cloud', + }, +}; + +export const GroupModificationsConfig: Story = { + args: { + currentServiceState: {}, + serviceName: 'account', + mode: 'create' as Mode, + page: 'configuration', + stanzaName: 'unknownStanza', + handleFormSubmit: fn(), + config: getGlobalConfigMockModificationToGroupsConfig(), platform: 'cloud', }, }; diff --git a/ui/src/components/BaseFormView/stories/__images__/BaseFormView-group-modifications-config-chromium.png b/ui/src/components/BaseFormView/stories/__images__/BaseFormView-group-modifications-config-chromium.png new file mode 100644 index 000000000..caeca1a1a --- /dev/null +++ b/ui/src/components/BaseFormView/stories/__images__/BaseFormView-group-modifications-config-chromium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba49279f39361f1c139fa1d0f041317a23bda3fb2199ba343d61c3f0393267c1 +size 29733 From 3fd0501b7a97cbdc4ce4880a2953676c5c6efcfd Mon Sep 17 00:00:00 2001 From: soleksy-splunk <143183665+soleksy-splunk@users.noreply.github.com> Date: Thu, 5 Dec 2024 18:43:55 +0100 Subject: [PATCH 6/7] feat(modifyFieldsOnValue): enable field to modify itself (#1494) **Issue number:** https://splunk.atlassian.net/browse/ADDON-76687 ### PR Type **What kind of change does this PR introduce?** * [x] Feature * [ ] Bug Fix * [ ] Refactoring (no functional or API changes) * [ ] Documentation Update * [ ] Maintenance (dependency updates, CI, etc.) ## Summary ### Changes Remove verification to block field from applying modification to itself after value change. It is not a risk to allow users to modify field itself as we should be concerned only for value. (it is still not a problem but seems like a bad approach to solving problems). It also enables circular dependencies in case where modifications are not about value. (modifying values still should work good but seems like a bad practise) ### User experience User can right now modify state of field by applying changes to currently edited field, it allows to create ie. much more useful labels and help messages referencing value change. ## Checklist If an item doesn't apply to your changes, leave it unchecked. * [ ] I have performed a self-review of this change according to the [development guidelines](https://splunk.github.io/addonfactory-ucc-generator/contributing/#development-guidelines) * [x] Tests have been added/modified to cover the changes [(testing doc)](https://splunk.github.io/addonfactory-ucc-generator/contributing/#build-and-test) * [ ] Changes are documented * [x] PR title and description follows the [contributing principles](https://splunk.github.io/addonfactory-ucc-generator/contributing/#pull-requests) --------- Co-authored-by: srv-rr-github-token <94607705+srv-rr-github-token@users.noreply.github.com> --- docs/entity/modifyFieldsOnValue.md | 10 +- .../global_config_validator.py | 74 ++++---- tests/unit/test_global_config_validator.py | 2 +- ...h_modification_circular_modifications.json | 22 +-- ...ig_with_modification_for_field_itself.json | 2 +- ...fig_with_modification_on_value_change.json | 4 + .../stories/BaseFormView.stories.tsx | 33 +++- ...ield-modify-itself-after-mods-chromium.png | 3 + ...eFormView-field-modify-itself-chromium.png | 3 + .../{ => tests}/BaseFormView.test.tsx | 12 +- .../tests/BaseFormViewModifications.test.tsx | 62 +++++++ .../BaseFormView/tests/configMocks.ts | 169 ++++++++++++++++++ 12 files changed, 341 insertions(+), 55 deletions(-) create mode 100644 ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-after-mods-chromium.png create mode 100644 ui/src/components/BaseFormView/stories/__images__/BaseFormView-field-modify-itself-chromium.png rename ui/src/components/BaseFormView/{ => tests}/BaseFormView.test.tsx (92%) create mode 100644 ui/src/components/BaseFormView/tests/BaseFormViewModifications.test.tsx create mode 100644 ui/src/components/BaseFormView/tests/configMocks.ts 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); +} From 0c1ef992a7b46e3ab02141cf27998ac45779e8fb Mon Sep 17 00:00:00 2001 From: soleksy-splunk <143183665+soleksy-splunk@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:28:03 +0100 Subject: [PATCH 7/7] chore: dimmed as default disable method (#1502) **Issue number:** N/A ### PR Type **What kind of change does this PR introduce?** * [ ] Feature * [ ] Bug Fix * [ ] Refactoring (no functional or API changes) * [ ] Documentation Update * [x] Maintenance (dependency updates, CI, etc.) ## Summary Allows users to use assistance technologies for disabled buttons and text fields. ### Changes Adapt to documentation suggestion to use "dimmed" option for disabled fields: [Text](https://splunkui.splunk.com/Packages/react-ui/Text?section=examples#Dimmed) [Button](https://splunkui.splunk.com/Packages/react-ui/Button?section=examples#Dimmed) ### User experience Users can navigate by disabled buttons and text fields by using tab. ## Checklist If an item doesn't apply to your changes, leave it unchecked. * [x] I have performed a self-review of this change according to the [development guidelines](https://splunk.github.io/addonfactory-ucc-generator/contributing/#development-guidelines) * [ ] Tests have been added/modified to cover the changes [(testing doc)](https://splunk.github.io/addonfactory-ucc-generator/contributing/#build-and-test) * [ ] Changes are documented * [x] PR title and description follows the [contributing principles](https://splunk.github.io/addonfactory-ucc-generator/contributing/#pull-requests) --------- Co-authored-by: srv-rr-github-token <94607705+srv-rr-github-token@users.noreply.github.com> --- ui/jest.setup.ts | 3 +- ui/package.json | 1 + ui/src/components/AcceptModal/AcceptModal.tsx | 9 ++- ui/src/components/ConfigurationFormView.jsx | 10 ++-- .../DashboardInfoModal/DashboardInfoModal.tsx | 7 +-- ui/src/components/DeleteModal/DeleteModal.tsx | 14 ++--- .../EntityModal/EntityModal.test.tsx | 14 ++--- ui/src/components/EntityModal/EntityModal.tsx | 18 +++--- ui/src/components/EntityPage/EntityPage.tsx | 13 ++--- ui/src/components/ErrorModal/ErrorModal.tsx | 4 +- .../FormModifications.test.tsx | 12 ++-- ui/src/components/MenuInput/MenuInput.tsx | 15 ++--- .../MultiInputComponent.test.tsx | 2 +- .../TextComponent/TextComponent.tsx | 2 +- .../stories/TextComponent.stories.tsx | 11 ++++ .../TextComponent-all-props-true-chromium.png | 3 + ui/src/components/UCCButton/UCCButton.tsx | 29 ++++++++++ ui/src/components/table/TableHeader.jsx | 10 +--- ui/src/pages/Dashboard/DataIngestionModal.tsx | 6 +- ui/src/tests/expectExtenders.ts | 55 +++++++++++++++++++ ui/src/types/modules.d.ts | 8 --- ui/yarn.lock | 18 +++++- 22 files changed, 177 insertions(+), 87 deletions(-) create mode 100644 ui/src/components/TextComponent/stories/__images__/TextComponent-all-props-true-chromium.png create mode 100644 ui/src/components/UCCButton/UCCButton.tsx create mode 100644 ui/src/tests/expectExtenders.ts diff --git a/ui/jest.setup.ts b/ui/jest.setup.ts index 72eba6f54..54ea9a007 100644 --- a/ui/jest.setup.ts +++ b/ui/jest.setup.ts @@ -1,8 +1,9 @@ import '@testing-library/jest-dom'; import '@testing-library/jest-dom/jest-globals'; - import { configure } from '@testing-library/react'; + import { server } from './src/mocks/server'; +import './src/tests/expectExtenders'; /** * Configure test attributes diff --git a/ui/package.json b/ui/package.json index 536bdd914..28d9f03cd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -74,6 +74,7 @@ "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.14", + "@types/jest-image-snapshot": "^6.4.0", "@types/js-yaml": "^4.0.9", "@types/node": "^20.17.6", "@types/react": "^16.14.62", diff --git a/ui/src/components/AcceptModal/AcceptModal.tsx b/ui/src/components/AcceptModal/AcceptModal.tsx index 27b5f088e..675f697bd 100644 --- a/ui/src/components/AcceptModal/AcceptModal.tsx +++ b/ui/src/components/AcceptModal/AcceptModal.tsx @@ -2,7 +2,8 @@ import React from 'react'; import Modal from '@splunk/react-ui/Modal'; import Message from '@splunk/react-ui/Message'; import styled from 'styled-components'; -import { StyledButton } from '../../pages/EntryPageStyle'; + +import { UCCButton } from '../UCCButton/UCCButton'; const ModalWrapper = styled(Modal)` width: 600px; @@ -30,13 +31,11 @@ function AcceptModal(props: AcceptModalProps) { - props.handleRequestClose(false)} label={props.declineBtnLabel || 'Cancel'} /> - props.handleRequestClose(true)} label={props.acceptBtnLabel || 'OK'} /> diff --git a/ui/src/components/ConfigurationFormView.jsx b/ui/src/components/ConfigurationFormView.jsx index 8bab71111..99667e260 100644 --- a/ui/src/components/ConfigurationFormView.jsx +++ b/ui/src/components/ConfigurationFormView.jsx @@ -3,10 +3,9 @@ import PropTypes from 'prop-types'; import { _ } from '@splunk/ui-utils/i18n'; import styled from 'styled-components'; -import WaitSpinner from '@splunk/react-ui/WaitSpinner'; import BaseFormView from './BaseFormView/BaseFormView'; -import { StyledButton } from '../pages/EntryPageStyle'; +import { UCCButton } from './UCCButton/UCCButton'; import { getRequest, generateEndPointUrl } from '../util/api'; import { MODE_CONFIG } from '../constants/modes'; import { WaitSpinnerWrapper } from './table/CustomTableStyle'; @@ -88,12 +87,11 @@ function ConfigurationFormView({ serviceName }) { )} - : _('Save')} + label={_('Save')} onClick={handleSubmit} - disabled={isSubmitting} + loading={isSubmitting} /> diff --git a/ui/src/components/DashboardInfoModal/DashboardInfoModal.tsx b/ui/src/components/DashboardInfoModal/DashboardInfoModal.tsx index 7745c79d6..ad0309033 100644 --- a/ui/src/components/DashboardInfoModal/DashboardInfoModal.tsx +++ b/ui/src/components/DashboardInfoModal/DashboardInfoModal.tsx @@ -6,7 +6,7 @@ import Heading from '@splunk/react-ui/Heading'; import P from '@splunk/react-ui/Paragraph'; import QuestionCircle from '@splunk/react-icons/QuestionCircle'; -import { StyledButton } from '../../pages/EntryPageStyle'; +import { UCCButton } from '../UCCButton/UCCButton'; const ModalWrapper = styled(Modal)` width: 700px; @@ -48,15 +48,14 @@ function DashboardInfoModal(props: DashboardInfoModalProps) { {props?.troubleshootingButton?.link ? ( // to do change it into troubleshooting link - } to={props?.troubleshootingButton?.link} label={props.troubleshootingButton?.label || 'Troubleshooting {add-on}'} openInNewContext /> ) : null} - props.handleRequestClose()} label={props.closeBtnLabel || 'Close'} /> diff --git a/ui/src/components/DeleteModal/DeleteModal.tsx b/ui/src/components/DeleteModal/DeleteModal.tsx index d989d1ac2..c9487ecf0 100644 --- a/ui/src/components/DeleteModal/DeleteModal.tsx +++ b/ui/src/components/DeleteModal/DeleteModal.tsx @@ -2,17 +2,16 @@ import React, { Component } from 'react'; import Modal from '@splunk/react-ui/Modal'; import Message from '@splunk/react-ui/Message'; import styled from 'styled-components'; -import WaitSpinner from '@splunk/react-ui/WaitSpinner'; import update from 'immutability-helper'; import { _ } from '@splunk/ui-utils/i18n'; -import { generateToast } from '../../util/util'; -import { StyledButton } from '../../pages/EntryPageStyle'; +import { generateToast } from '../../util/util'; import { deleteRequest, generateEndPointUrl } from '../../util/api'; import TableContext from '../../context/TableContext'; import { parseErrorMsg, getFormattedMessage } from '../../util/messageUtil'; import { PAGE_INPUT } from '../../constants/pages'; import { StandardPages } from '../../types/components/shareableTypes'; +import { UCCButton } from '../UCCButton/UCCButton'; const ModalWrapper = styled(Modal)` width: 800px; @@ -109,17 +108,16 @@ class DeleteModal extends Component {

{deleteMsg}

- - : _('Delete')} + diff --git a/ui/src/components/EntityModal/EntityModal.test.tsx b/ui/src/components/EntityModal/EntityModal.test.tsx index c18ddf671..a43d8a556 100644 --- a/ui/src/components/EntityModal/EntityModal.test.tsx +++ b/ui/src/components/EntityModal/EntityModal.test.tsx @@ -62,7 +62,7 @@ describe('Oauth field disabled on edit - diableonEdit property', () => { renderModalWithProps(props); const oauthTextBox = getDisabledOauthField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).not.toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyEnabled(); }); it('Oauth Oauth - disableonEdit = true, oauth field disabled on edit', async () => { @@ -82,7 +82,7 @@ describe('Oauth field disabled on edit - diableonEdit property', () => { const oauthTextBox = getDisabledOauthField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyDisabled(); }); it('Oauth Basic - Enable field equal false, so field disabled', async () => { @@ -102,7 +102,7 @@ describe('Oauth field disabled on edit - diableonEdit property', () => { const oauthTextBox = getDisabledBasicField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyDisabled(); }); it('if oauth field not disabled with create after disableonEdit true', async () => { @@ -120,7 +120,7 @@ describe('Oauth field disabled on edit - diableonEdit property', () => { renderModalWithProps(props); const oauthTextBox = getDisabledBasicField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).not.toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyEnabled(); }); }); @@ -163,7 +163,7 @@ describe('Options - Enable field property', () => { renderModalWithProps(props); const oauthTextBox = getDisabledOauthField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyDisabled(); }); it('Oauth Basic - Enable field equal false, so field disabled', async () => { @@ -181,7 +181,7 @@ describe('Options - Enable field property', () => { renderModalWithProps(props); const oauthTextBox = getDisabledOauthField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyDisabled(); }); it('Oauth Basic - Fully enabled field, enabled: true, disableonEdit: false', async () => { @@ -199,7 +199,7 @@ describe('Options - Enable field property', () => { renderModalWithProps(props); const oauthTextBox = getDisabledOauthField(); expect(oauthTextBox).toBeInTheDocument(); - expect(oauthTextBox).not.toHaveAttribute('disabled'); + expect(oauthTextBox).toBeVisuallyEnabled(); }); }); diff --git a/ui/src/components/EntityModal/EntityModal.tsx b/ui/src/components/EntityModal/EntityModal.tsx index dabe627e8..d7447b19d 100644 --- a/ui/src/components/EntityModal/EntityModal.tsx +++ b/ui/src/components/EntityModal/EntityModal.tsx @@ -1,15 +1,14 @@ -import React, { Component, ReactElement } from 'react'; +import React, { Component } from 'react'; import Modal from '@splunk/react-ui/Modal'; import styled from 'styled-components'; -import WaitSpinner from '@splunk/react-ui/WaitSpinner'; import { _ } from '@splunk/ui-utils/i18n'; - import { ButtonClickHandler } from '@splunk/react-ui/Button'; + import { Mode, MODE_CLONE, MODE_CREATE, MODE_EDIT } from '../../constants/modes'; -import { StyledButton } from '../../pages/EntryPageStyle'; import BaseFormView from '../BaseFormView/BaseFormView'; import { StandardPages } from '../../types/components/shareableTypes'; import PageContext from '../../context/PageContext'; +import { UCCButton } from '../UCCButton/UCCButton'; const ModalWrapper = styled(Modal)` width: 800px; @@ -33,7 +32,7 @@ interface EntityModalState { class EntityModal extends Component { form: React.RefObject; - buttonText: string | ReactElement; + buttonText: string; constructor(props: EntityModalProps) { super(props); @@ -98,18 +97,17 @@ class EntityModal extends Component { - - : this.buttonText} + label={this.buttonText} + loading={this.state.isSubmititng} onClick={this.handleSubmit} - disabled={this.state.isSubmititng} /> diff --git a/ui/src/components/EntityPage/EntityPage.tsx b/ui/src/components/EntityPage/EntityPage.tsx index 181d6b0fd..dff3b1cd3 100644 --- a/ui/src/components/EntityPage/EntityPage.tsx +++ b/ui/src/components/EntityPage/EntityPage.tsx @@ -1,7 +1,6 @@ import React, { memo, useRef, useState } from 'react'; import Link from '@splunk/react-ui/Link'; -import WaitSpinner from '@splunk/react-ui/WaitSpinner'; import ColumnLayout from '@splunk/react-ui/ColumnLayout'; import { _ } from '@splunk/ui-utils/i18n'; import { variables } from '@splunk/themes'; @@ -9,13 +8,14 @@ import { variables } from '@splunk/themes'; import Heading from '@splunk/react-ui/Heading'; import styled from 'styled-components'; import { ButtonClickHandler } from '@splunk/react-ui/Button'; + import { MODE_CLONE, MODE_CREATE, MODE_EDIT, Mode } from '../../constants/modes'; import BaseFormView from '../BaseFormView/BaseFormView'; import { SubTitleComponent } from '../../pages/Input/InputPageStyle'; import { PAGE_INPUT } from '../../constants/pages'; -import { StyledButton } from '../../pages/EntryPageStyle'; import { StandardPages } from '../../types/components/shareableTypes'; import PageContext from '../../context/PageContext'; +import { UCCButton } from '../UCCButton/UCCButton'; interface EntityPageProps { handleRequestClose: () => void; @@ -108,19 +108,18 @@ function EntityPage({ - - : buttonText} + label={buttonText} onClick={handleSubmit} - disabled={isSubmitting} + loading={isSubmitting} style={{ width: '80px' }} /> diff --git a/ui/src/components/ErrorModal/ErrorModal.tsx b/ui/src/components/ErrorModal/ErrorModal.tsx index 6153609f9..e41fe2260 100644 --- a/ui/src/components/ErrorModal/ErrorModal.tsx +++ b/ui/src/components/ErrorModal/ErrorModal.tsx @@ -4,7 +4,7 @@ import Message from '@splunk/react-ui/Message'; import styled from 'styled-components'; import { getFormattedMessage } from '../../util/messageUtil'; -import { StyledButton } from '../../pages/EntryPageStyle'; +import { UCCButton } from '../UCCButton/UCCButton'; const ModalWrapper = styled(Modal)` width: 600px; @@ -31,7 +31,7 @@ function ErrorModal(props: ErrorModalProps) { - + ); diff --git a/ui/src/components/FormModifications/FormModifications.test.tsx b/ui/src/components/FormModifications/FormModifications.test.tsx index 5a0f21d12..5e139b23e 100644 --- a/ui/src/components/FormModifications/FormModifications.test.tsx +++ b/ui/src/components/FormModifications/FormModifications.test.tsx @@ -127,18 +127,22 @@ it('verify modification after text components change', async () => { expect(parentElement).toHaveTextContent(mods.label); }; - expect(componentInput).toBeDisabled(); + expect(componentInput).toBeVisuallyDisabled(); + verifyAllProps(componentParentElement, componentInput, mods1Field1); - expect(component2Input).toBeDisabled(); + expect(component2Input).toBeVisuallyDisabled(); + verifyAllProps(component2ParentElement, component2Input, mods1Field2); await userEvent.type(componentMakingModsTextBox1, secondValueToInput); - expect(componentInput).toBeEnabled(); + expect(component2Input).toBeVisuallyEnabled(); + verifyAllProps(componentParentElement, componentInput, mods2Field1); - expect(component2Input).toBeEnabled(); + expect(component2Input).toBeVisuallyEnabled(); + verifyAllProps(component2ParentElement, component2Input, mods2Field2); }); diff --git a/ui/src/components/MenuInput/MenuInput.tsx b/ui/src/components/MenuInput/MenuInput.tsx index 430cd5665..2d6b0442d 100644 --- a/ui/src/components/MenuInput/MenuInput.tsx +++ b/ui/src/components/MenuInput/MenuInput.tsx @@ -8,13 +8,14 @@ import ChevronLeft from '@splunk/react-icons/ChevronLeft'; import { _ as i18n } from '@splunk/ui-utils/i18n'; import styled from 'styled-components'; import { variables } from '@splunk/themes'; + import { getFormattedMessage } from '../../util/messageUtil'; import { getUnifiedConfigs } from '../../util/util'; import CustomMenu from '../CustomMenu'; -import { StyledButton } from '../../pages/EntryPageStyle'; import { invariant } from '../../util/invariant'; import { usePageContext } from '../../context/usePageContext'; import { shouldHideForPlatform } from '../../util/pageContext'; +import { UCCButton } from '../UCCButton/UCCButton'; const CustomSubTitle = styled.span` color: ${variables.brandColorD20}; @@ -67,14 +68,7 @@ function MenuInput({ handleRequestOpen }: MenuInputProps) { }, [inputs.services, pageContext.platform]); const closeReasons = ['clickAway', 'escapeKey', 'offScreen', 'toggleClick']; - const toggle = ( - - ); + const toggle = ; useEffect(() => { if (!isSubMenu) { @@ -211,9 +205,8 @@ function MenuInput({ handleRequestOpen }: MenuInputProps) { // Making a dropdown if we have one service const makeInputButton = () => ( - { handleRequestOpen({ serviceName: services[0].name }); diff --git a/ui/src/components/MultiInputComponent/MultiInputComponent.test.tsx b/ui/src/components/MultiInputComponent/MultiInputComponent.test.tsx index 30958e470..fed1af8a3 100644 --- a/ui/src/components/MultiInputComponent/MultiInputComponent.test.tsx +++ b/ui/src/components/MultiInputComponent/MultiInputComponent.test.tsx @@ -76,7 +76,7 @@ it('renders as disabled correctly', () => { renderFeature({ disabled: true }); const inputComponent = screen.getByTestId('multiselect'); expect(inputComponent).toBeInTheDocument(); - expect(inputComponent.getAttribute('aria-disabled')).toEqual('true'); + expect(inputComponent).toHaveAttribute('aria-disabled', 'true'); }); it.each(defaultInputProps.controlOptions.items)('handler called correctly', async (item) => { diff --git a/ui/src/components/TextComponent/TextComponent.tsx b/ui/src/components/TextComponent/TextComponent.tsx index ede7349ce..70e63e8f0 100755 --- a/ui/src/components/TextComponent/TextComponent.tsx +++ b/ui/src/components/TextComponent/TextComponent.tsx @@ -29,7 +29,7 @@ class TextComponent extends Component { inline error={this.props.error} className={this.props.field} - disabled={this.props.disabled} + disabled={this.props.disabled && 'dimmed'} value={ this.props.value === null || typeof this.props.value === 'undefined' ? '' diff --git a/ui/src/components/TextComponent/stories/TextComponent.stories.tsx b/ui/src/components/TextComponent/stories/TextComponent.stories.tsx index c3eb48128..f6331a5bf 100644 --- a/ui/src/components/TextComponent/stories/TextComponent.stories.tsx +++ b/ui/src/components/TextComponent/stories/TextComponent.stories.tsx @@ -38,3 +38,14 @@ export const Base: Story = { disabled: false, }, }; + +export const AllPropsTrue: Story = { + args: { + handleChange: fn(), + value: 'default value', + field: 'field', + error: true, + encrypted: true, + disabled: true, + }, +}; diff --git a/ui/src/components/TextComponent/stories/__images__/TextComponent-all-props-true-chromium.png b/ui/src/components/TextComponent/stories/__images__/TextComponent-all-props-true-chromium.png new file mode 100644 index 000000000..3daaf24fb --- /dev/null +++ b/ui/src/components/TextComponent/stories/__images__/TextComponent-all-props-true-chromium.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3f3fdd40742f1150cc38601eef1ebf351e47595c3a184834a246e5805ba6c24 +size 3722 diff --git a/ui/src/components/UCCButton/UCCButton.tsx b/ui/src/components/UCCButton/UCCButton.tsx new file mode 100644 index 000000000..cf708ab2b --- /dev/null +++ b/ui/src/components/UCCButton/UCCButton.tsx @@ -0,0 +1,29 @@ +import React, { ComponentProps } from 'react'; + +import Button from '@splunk/react-ui/Button'; +import WaitSpinner from '@splunk/react-ui/WaitSpinner'; +import styled from 'styled-components'; + +const StyledButton = styled(Button)` + min-width: 80px; +`; + +type Props = { + label: string; + appearance?: 'default' | 'secondary' | 'primary' | 'destructive' | 'pill' | 'toggle' | 'flat'; + disabled?: boolean; + loading?: boolean; +} & Partial>; + +export const UCCButton = React.forwardRef( + ({ disabled, loading, appearance, ...rest }, ref) => ( + : rest.icon} + label={loading ? '' : rest.label} // do not display text nor icon when loading + appearance={appearance || 'primary'} + disabled={(disabled || loading) && 'dimmed'} + /> // disable when loading + ) +); diff --git a/ui/src/components/table/TableHeader.jsx b/ui/src/components/table/TableHeader.jsx index ff3daf6c0..27bf140a8 100644 --- a/ui/src/components/table/TableHeader.jsx +++ b/ui/src/components/table/TableHeader.jsx @@ -7,10 +7,10 @@ import { Typography } from '@splunk/react-ui/Typography'; import styled from 'styled-components'; import { _ } from '@splunk/ui-utils/i18n'; +import { UCCButton } from '../UCCButton/UCCButton'; import TableFilter from './TableFilter'; import { TableSelectBoxWrapper } from './CustomTableStyle'; import { PAGE_INPUT } from '../../constants/pages'; -import { StyledButton } from '../../pages/EntryPageStyle'; import { InteractAllStatusButtons } from '../InteractAllStatusButton'; import { useTableContext } from '../../context/useTableContext'; @@ -124,13 +124,7 @@ function TableHeader({ alwaysShowLastPageLink totalPages={Math.ceil(totalElement / pageSize)} /> - {isTabs && ( - - )} + {isTabs && } - diff --git a/ui/src/tests/expectExtenders.ts b/ui/src/tests/expectExtenders.ts new file mode 100644 index 000000000..05afce0f0 --- /dev/null +++ b/ui/src/tests/expectExtenders.ts @@ -0,0 +1,55 @@ +import { invariant } from '../util/invariant'; + +expect.extend({ + toBeVisuallyEnabled(field: HTMLElement | null | Element) { + invariant(field); + + if (field.getAttribute('readonly')) { + return { pass: false, message: () => 'Field contains "readonly" attribute' }; + } + + const ariaDisabled = field.getAttribute('aria-disabled'); + + if (ariaDisabled === 'false') { + return { pass: true, message: () => 'Field is enabled' }; + } + + return { + pass: false, + message: () => + `Attribute "aria-disabled" is incorrect expected "false", got "${ariaDisabled}"`, + }; + }, + toBeVisuallyDisabled(field: HTMLElement | null | Element) { + invariant(field); + + if (field.getAttribute('readonly') === null) { + return { + pass: false, + message: () => `Field "readonly" attribute is null`, + }; + } + + const ariaDisabled = field.getAttribute('aria-disabled'); + + if (ariaDisabled === 'true') { + return { pass: true, message: () => 'Field is disabled' }; + } + + return { + pass: false, + message: () => + `Attribute "aria-disabled" is incorrect expected "true", got ${ariaDisabled}`, + }; + }, +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toBeVisuallyDisabled(): R; + toBeVisuallyEnabled(): R; + } + } +} diff --git a/ui/src/types/modules.d.ts b/ui/src/types/modules.d.ts index 16698a4a1..65d0329d1 100644 --- a/ui/src/types/modules.d.ts +++ b/ui/src/types/modules.d.ts @@ -84,11 +84,3 @@ declare module '@splunk/search-job'; declare module '@splunk/ui-utils/i18n'; declare module 'uuid'; - -declare global { - namespace jest { - interface Matchers { - toMatchImageSnapshot(): R; - } - } -} diff --git a/ui/yarn.lock b/ui/yarn.lock index 9ffbb60f7..21f4729ef 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -4511,7 +4511,16 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^29.5.14": +"@types/jest-image-snapshot@^6.4.0": + version "6.4.0" + resolved "https://registry.npmjs.org/@types/jest-image-snapshot/-/jest-image-snapshot-6.4.0.tgz#641054d2fa2ff130a49c844ee7a9a68f281b6017" + integrity sha512-8TQ/EgqFCX0UWSpH488zAc21fCkJNpZPnnp3xWFMqElxApoJV5QOoqajnVRV7AhfF0rbQWTVyc04KG7tXnzCPA== + dependencies: + "@types/jest" "*" + "@types/pixelmatch" "*" + ssim.js "^3.1.1" + +"@types/jest@*", "@types/jest@^29.5.14": version "29.5.14" resolved "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== @@ -4613,6 +4622,13 @@ resolved "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404" integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA== +"@types/pixelmatch@*": + version "5.2.6" + resolved "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz#fba6de304ac958495f27d85989f5c6bb7499a686" + integrity sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg== + dependencies: + "@types/node" "*" + "@types/prop-types@*": version "15.7.13" resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz#2af91918ee12d9d32914feb13f5326658461b451"