diff --git a/package-lock.json b/package-lock.json index deccb635d..448056ee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69905,6 +69905,7 @@ "@swc/core": "^1.3.20", "@swc/jest": "^0.2.23", "@testing-library/dom": "^8.20.0", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", "@types/box-intersect": "^1.0.0", @@ -74198,6 +74199,41 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "packages/dashboard/node_modules/@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "packages/dashboard/node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "packages/dashboard/node_modules/@testing-library/react": { "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", @@ -98270,6 +98306,7 @@ "@swc/jest": "^0.2.23", "@synchro-charts/core": "^7.1.5", "@testing-library/dom": "^8.20.0", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", "@types/box-intersect": "^1.0.0", @@ -101460,6 +101497,35 @@ } } }, + "@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "@testing-library/react": { "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", diff --git a/packages/dashboard/jest.config.ts b/packages/dashboard/jest.config.ts index e96bfadd7..69e97c0cc 100644 --- a/packages/dashboard/jest.config.ts +++ b/packages/dashboard/jest.config.ts @@ -29,7 +29,7 @@ export default { }, ], }, - setupFilesAfterEnv: ['mutationobserver-shim'], + setupFilesAfterEnv: ['mutationobserver-shim', '/testing/jest-setup.ts'], //transform: { // '.+\\.ts$': 'ts-jest', // '^.+\\.tsx?$': 'ts-jest', diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 9d8610049..eb8059115 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -47,6 +47,7 @@ "@swc/core": "^1.3.20", "@swc/jest": "^0.2.23", "@testing-library/dom": "^8.20.0", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", "@types/box-intersect": "^1.0.0", diff --git a/packages/dashboard/rollup.config.js b/packages/dashboard/rollup.config.js index a805c9b40..3f718bea2 100644 --- a/packages/dashboard/rollup.config.js +++ b/packages/dashboard/rollup.config.js @@ -36,7 +36,12 @@ export default [ }), commonjs(), json(), - typescript({ tsconfig: './tsconfig.json' }), + typescript({ + tsconfig: './tsconfig.json', + tsconfigOverride: { + exclude: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], + }, + }), postcss({ plugins: [ url({ diff --git a/packages/dashboard/src/components/palette/icons/input-component.tsx b/packages/dashboard/src/components/palette/icons/input-component.tsx index 26464684f..4d4ff75ad 100644 --- a/packages/dashboard/src/components/palette/icons/input-component.tsx +++ b/packages/dashboard/src/components/palette/icons/input-component.tsx @@ -1,5 +1,14 @@ import React from 'react'; -const InputComponent: React.FC = () =>
input
; +const InputComponent: React.FC = () => { + return ( + + + I + + + + ); +}; export default InputComponent; diff --git a/packages/dashboard/src/components/sidePanel/index.tsx b/packages/dashboard/src/components/sidePanel/index.tsx index 82c5a1283..e2f6e13cd 100644 --- a/packages/dashboard/src/components/sidePanel/index.tsx +++ b/packages/dashboard/src/components/sidePanel/index.tsx @@ -6,6 +6,7 @@ import { DashboardMessages } from '~/messages'; import { AppKitComponentTags } from '~/types'; import TextSettings from './sections/textSettingSection/text'; import LinkSettings from './sections/textSettingSection/link'; +import InputSettings from './sections/inputSettingsSection'; import { BaseSettings } from './sections/baseSettingSection'; import AxisSetting from './sections/axisSettingSection'; import ThresholdsSection from './sections/thresholdsSection/thresholdsSection'; @@ -20,6 +21,7 @@ const SidePanel: FC<{ messageOverrides: DashboardMessages }> = ({ messageOverrid const selectedWidget = selectedWidgets[0]; const isAppKitWidget = AppKitComponentTags.find((tag) => tag === selectedWidget.componentTag); const isTextWidget = selectedWidget.componentTag === 'text'; + const isInputWidget = selectedWidget.componentTag === 'input'; return (
@@ -28,6 +30,7 @@ const SidePanel: FC<{ messageOverrides: DashboardMessages }> = ({ messageOverrid {isTextWidget && } {isTextWidget && } + {isInputWidget && } {isAppKitWidget && ( <> diff --git a/packages/dashboard/src/components/sidePanel/sections/inputSettingsSection/index.spec.tsx b/packages/dashboard/src/components/sidePanel/sections/inputSettingsSection/index.spec.tsx new file mode 100644 index 000000000..759bfc3bd --- /dev/null +++ b/packages/dashboard/src/components/sidePanel/sections/inputSettingsSection/index.spec.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import createWrapper from '@cloudscape-design/components/test-utils/dom'; +import { Provider } from 'react-redux'; +import InputSettings from './index'; +import { InputWidget, RecursivePartial } from '~/types'; +import { DashboardState } from '~/store/state'; +import { DefaultDashboardMessages, DashboardMessages } from '~/messages'; +import { configureDashboardStore } from '~/store'; +import { MOCK_INPUT_WIDGET } from '../../../../../testing/mocks'; +import { expect } from '@jest/globals'; + +const widget: InputWidget = { ...MOCK_INPUT_WIDGET }; + +const state: Partial = { + dashboardConfiguration: { + widgets: [widget], + viewport: { duration: '5m' }, + }, + selectedWidgets: [widget], +}; + +const TestComponent = ( + { + stateOverride = state, + messageOverride = DefaultDashboardMessages, + }: { + stateOverride?: RecursivePartial; + messageOverride?: DashboardMessages; + } = { stateOverride: state, messageOverride: DefaultDashboardMessages } +) => ( + + + +); + +it('renders without options from state', () => { + const props = { stateOverride: {} }; + const { container } = render(); + const inputSettings = createWrapper(container); + const options = inputSettings.findTokenGroup('[data-test-id="input-widget-token-list"]'); + + expect(options.findTokens().length).toBe(0); +}); + +it('renders with options from state', () => { + const { container } = render(); + const inputSettings = createWrapper(container); + const options = inputSettings.findTokenGroup('[data-test-id="input-widget-token-list"]'); + + MOCK_INPUT_WIDGET.options.forEach(({ label }) => { + expect(options.getElement()).toHaveTextContent(label); + }); +}); + +it('can add option', () => { + const { container } = render(); + const inputSettings = createWrapper(container); + const addOptionButton = inputSettings.findButton('[data-test-id="input-widget-add-option-btn"]'); + const optionInput = inputSettings.findInput('[data-test-id="input-widget-option-input"]'); + const options = inputSettings.findTokenGroup('[data-test-id="input-widget-token-list"]'); + const newOption = 'lorem ipsum'; + + expect(addOptionButton.isDisabled()).toBeTruthy(); + expect(options.findTokens().length).toBe(3); + expect(options.getElement()).not.toHaveTextContent(newOption); + + optionInput.setInputValue(newOption); + + expect(addOptionButton.isDisabled()).toBeFalsy(); + addOptionButton.click(); + + expect(options.findTokens().length).toBe(4); + expect(options.getElement()).toHaveTextContent(newOption); +}); + +it('can remove option', () => { + const { container } = render(); + const inputSettings = createWrapper(container); + const options = inputSettings.findTokenGroup('[data-test-id="input-widget-token-list"]'); + + expect(options.findTokens().length).toBe(3); + + options.findToken(2).findDismiss().click(); + + expect(options.findTokens().length).toBe(2); + expect(options.findToken(1).getElement()).toHaveTextContent(widget.options[0].label); + expect(options.findToken(2).getElement()).toHaveTextContent(widget.options[2].label); +}); + +it('correctly renders translations', () => { + const optionPlaceholder = 'lorem ipsum'; + const addOptionLabel = 'lorem ipsum 2'; + const props = { + messageOverride: { + ...DefaultDashboardMessages, + sidePanel: { + ...DefaultDashboardMessages.sidePanel, + inputSettings: { + ...DefaultDashboardMessages.sidePanel.inputSettings, + optionPlaceholder, + addOptionLabel, + }, + }, + }, + }; + const { container } = render(); + const inputSettings = createWrapper(container); + const addOptionButton = inputSettings.findButton('[data-test-id="input-widget-add-option-btn"]'); + const optionInput = inputSettings.findInput('[data-test-id="input-widget-option-input"]'); + + expect(addOptionButton.getElement()).toHaveTextContent(addOptionLabel); + expect(optionInput.findNativeInput().getElement()).toHaveAttribute('placeholder', optionPlaceholder); +}); diff --git a/packages/dashboard/src/components/sidePanel/sections/inputSettingsSection/index.tsx b/packages/dashboard/src/components/sidePanel/sections/inputSettingsSection/index.tsx new file mode 100644 index 000000000..065985bc1 --- /dev/null +++ b/packages/dashboard/src/components/sidePanel/sections/inputSettingsSection/index.tsx @@ -0,0 +1,64 @@ +import React, { FC, useState } from 'react'; +import { + ExpandableSection, + Grid, + Input, + Button, + TokenGroup, + InputProps, + TokenGroupProps, +} from '@cloudscape-design/components'; +import { NonCancelableEventHandler } from '@cloudscape-design/components/internal/events'; +import { useInputWidgetInput } from '../../utils'; +import { DashboardMessages } from '~/messages'; + +export type InputComponentProps = { + messageOverride: DashboardMessages; +}; + +const InputSettings: FC = ({ messageOverride }) => { + const { + sidePanel: { inputSettings }, + } = messageOverride; + const [options, setOptions] = useInputWidgetInput('options'); + const [label, setLabel] = useState(); + + const addOption: NonCancelableEventHandler = ({ detail: { value } }) => { + setLabel(value); + }; + + const saveOption = () => { + if (label) { + setOptions([...options, { label }]); + setLabel(''); + } + }; + + const removeOption: NonCancelableEventHandler = ({ detail: { itemIndex } }) => { + setOptions([...options.slice(0, itemIndex), ...options.slice(itemIndex + 1)]); + }; + + return ( + + + + + + + + ); +}; + +export default InputSettings; diff --git a/packages/dashboard/src/components/sidePanel/utils/index.ts b/packages/dashboard/src/components/sidePanel/utils/index.ts index ce1bb242e..f41f90fae 100644 --- a/packages/dashboard/src/components/sidePanel/utils/index.ts +++ b/packages/dashboard/src/components/sidePanel/utils/index.ts @@ -1,10 +1,9 @@ import { Dispatch, useEffect, useState } from 'react'; -// import { useDispatch } from 'react-redux'; import { cloneDeep, get, set } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { DashboardState } from '~/store/state'; import { onUpdateWidgetsAction } from '~/store/actions'; -import { AppKitWidget, TextWidget, Widget } from '~/types'; +import { AppKitWidget, TextWidget, InputWidget, Widget } from '~/types'; export type typedInputHook = (key: K) => [T[K], Dispatch]; @@ -46,4 +45,5 @@ export const useInput: (path: string, validator?: (newValue: T) => boolean) = // this helps Typescript finding correct types given attribute names export const useTextWidgetInput: typedInputHook = useInput; +export const useInputWidgetInput: typedInputHook = useInput; export const useAppKitWidgetInput: typedInputHook = useInput; diff --git a/packages/dashboard/src/components/widgets/primitives/input/index.spec.tsx b/packages/dashboard/src/components/widgets/primitives/input/index.spec.tsx new file mode 100644 index 000000000..31bbcc04d --- /dev/null +++ b/packages/dashboard/src/components/widgets/primitives/input/index.spec.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import createWrapper from '@cloudscape-design/components/test-utils/dom'; +import InputWidget from './index'; +import { MOCK_INPUT_WIDGET } from '../../../../../testing/mocks'; + +it('is disabled in edit mode', () => { + const props = { ...MOCK_INPUT_WIDGET, readOnly: false }; + const { container } = render(); + const widget = createWrapper(container); + + expect(widget.findButton('[data-test-id="input-widget-submit-btn"]').isDisabled()).toBeTruthy(); + expect(widget.findSelect('[data-test-id="input-widget-options"]').isDisabled()).toBeTruthy(); +}); + +it('is enabled in read-only mode', () => { + const props = { ...MOCK_INPUT_WIDGET, readOnly: true }; + const { container } = render(); + const widget = createWrapper(container); + + expect(widget.findButton('[data-test-id="input-widget-submit-btn"]').isDisabled()).toBeFalsy(); + expect(widget.findSelect('[data-test-id="input-widget-options"]').isDisabled()).toBeFalsy(); +}); + +it('is disabled in read-only mode if no options', () => { + const props = { ...MOCK_INPUT_WIDGET, options: [], readOnly: true }; + const { container } = render(); + const widget = createWrapper(container); + + expect(widget.findButton('[data-test-id="input-widget-submit-btn"]').isDisabled()).toBeTruthy(); + expect(widget.findSelect('[data-test-id="input-widget-options"]').isDisabled()).toBeTruthy(); +}); + +it('correctly renders translations', () => { + const submitLabel = 'lorem ipsum'; + const props = { ...MOCK_INPUT_WIDGET, messageOverrides: { submitLabel }, readOnly: true }; + const { container } = render(); + const widget = createWrapper(container); + + expect(widget.findButton('[data-test-id="input-widget-submit-btn"]').getElement()).toHaveTextContent(submitLabel); +}); + +it('correctly re-renders when options update', () => { + const props = { ...MOCK_INPUT_WIDGET, readOnly: true }; + const { container, rerender } = render(); + const widget = createWrapper(container); + const options = widget.findSelect('[data-test-id="input-widget-options"]'); + + options!.openDropdown(); + + // finds all options + MOCK_INPUT_WIDGET.options.forEach(({ label }) => { + expect(options.getElement()).toHaveTextContent(label); + }); + + // first option is selected by default + expect(options.findDropdown().findSelectedOptions()[0].getElement()).toHaveTextContent( + MOCK_INPUT_WIDGET.options[0].label + ); + + // if selected option is removed, set new selected option to first option + rerender(); + expect(options.getElement()).toHaveTextContent(MOCK_INPUT_WIDGET.options[1].label); + + // if all options are removed, there is no selected option + rerender(); + expect(options.findDropdown().findSelectedOptions().length).toBe(0); + + // if new options added, first option is selected + rerender(); + expect(options.findDropdown().findSelectedOptions()[0].getElement()).toHaveTextContent( + MOCK_INPUT_WIDGET.options[2].label + ); +}); + +it('can select option', () => { + const props = { ...MOCK_INPUT_WIDGET, readOnly: true }; + const { container } = render(); + const widget = createWrapper(container); + const options = widget.findSelect('[data-test-id="input-widget-options"]'); + + options.openDropdown(); + + // first option is selected by default + expect(options.findDropdown().findSelectedOptions()[0].getElement()).toHaveTextContent( + MOCK_INPUT_WIDGET.options[0].label + ); + + options.selectOption(2); + options.openDropdown(); + + // new option is selected + expect(options.findDropdown().findSelectedOptions()[0].getElement()).toHaveTextContent( + MOCK_INPUT_WIDGET.options[1].label + ); +}); diff --git a/packages/dashboard/src/components/widgets/primitives/input/index.tsx b/packages/dashboard/src/components/widgets/primitives/input/index.tsx index 8ac3ce127..44b293b39 100644 --- a/packages/dashboard/src/components/widgets/primitives/input/index.tsx +++ b/packages/dashboard/src/components/widgets/primitives/input/index.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import { NonCancelableEventHandler } from '@cloudscape-design/components/internal/events'; import { Button, Select, SelectProps } from '@cloudscape-design/components'; import SpaceBetween from '@cloudscape-design/components/space-between'; import { InputWidget as InputWidgetType } from '~/types'; @@ -6,17 +7,35 @@ import { InputWidget as InputWidgetType } from '~/types'; export type InputWidgetProps = InputWidgetType & { readOnly: boolean }; const Input: React.FC = ({ readOnly, ...widget }) => { - const [selectedOption, setSelectedOption] = useState(widget.options[0]); + const options = widget.options.map(({ label }) => ({ label, value: label })); + const [selectedOption, setSelectedOption] = useState(options[0]); + const disabled = !readOnly || options.length === 0; + const isSelectedOptionValid = options.filter(({ label }) => selectedOption?.label === label).length > 0; + + const changeOption: NonCancelableEventHandler = ({ detail: { selectedOption } }) => { + setSelectedOption(selectedOption); + }; + + useEffect(() => { + if (options.length === 0) { + setSelectedOption(null); + } else if (!isSelectedOptionValid) { + setSelectedOption(options[0]); + } + }, [options]); return (