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/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/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.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..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, @@ -13,9 +14,12 @@ import { Mode } from '../../../constants/modes'; import { BaseFormProps } from '../../../types/components/BaseFormTypes'; import { Platforms } from '../../../types/globalConfig/pages'; import { - getGlobalConfigMockGroupsFoInputPage, + getGlobalConfigMockGroupsForInputPage, getGlobalConfigMockGroupsForConfigPage, + getGlobalConfigMockModificationToGroupsConfig, } from '../BaseFormConfigMock'; +import { getGlobalConfigMockModificationToFieldItself } from '../tests/configMocks'; +import { invariant } from '../../../util/invariant'; interface BaseFormStoriesProps extends BaseFormProps { config: GlobalConfig; @@ -125,7 +129,48 @@ 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', }, }; + +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/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 diff --git a/ui/src/components/BaseFormView/BaseFormView.test.tsx b/ui/src/components/BaseFormView/tests/BaseFormView.test.tsx similarity index 90% rename from ui/src/components/BaseFormView/BaseFormView.test.tsx rename to ui/src/components/BaseFormView/tests/BaseFormView.test.tsx index ac05b2ec3..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, - getGlobalConfigMockGroupsFoInputPage, + getGlobalConfigMockGroupsForInputPage, getGlobalConfigMockGroupsForConfigPage, -} from './BaseFormConfigMock'; +} from '../BaseFormConfigMock'; +import mockCustomControlMockForTest from '../../CustomControl/CustomControlMockForTest'; +import BaseFormView from '../BaseFormView'; const handleFormSubmit = jest.fn(); @@ -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/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); +} 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"