diff --git a/package-lock.json b/package-lock.json index 0bafecc..628bb7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "manh-react-survey", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "manh-react-survey", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@reduxjs/toolkit": "1.9.5", "@travelperksl/fabricator": "7.0.1", @@ -15,6 +15,7 @@ "classnames": "2.3.2", "date-fns": "2.30.0", "lodash": "4.17.21", + "rc-slider": "10.2.1", "react": "18.2.0", "react-dom": "18.2.0", "react-redux": "8.1.0", @@ -21259,6 +21260,41 @@ "node": ">=0.10.0" } }, + "node_modules/rc-slider": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-10.2.1.tgz", + "integrity": "sha512-l355C/65iV4UFp7mXq5xBTNX2/tF2g74VWiTVlTpNp+6vjE/xaHHNiQq5Af+Uu28uUiqCuH/QXs5HfADL9KJ/A==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.27.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.34.1", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.34.1.tgz", + "integrity": "sha512-SqiUT8Ssgh5C+hu4y887xwCrMNcxLm6ScOo8AFlWYYF3z9uNNiPpwwSjvicqOlWd79rNw1g44rnP7tz9MrO1ZQ==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^16.12.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", diff --git a/package.json b/package.json index 588ef3b..e4fec65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "manh-react-survey", - "version": "0.5.0", + "version": "0.6.0", "private": true, "dependencies": { "@reduxjs/toolkit": "1.9.5", @@ -10,6 +10,7 @@ "classnames": "2.3.2", "date-fns": "2.30.0", "lodash": "4.17.21", + "rc-slider": "10.2.1", "react": "18.2.0", "react-dom": "18.2.0", "react-redux": "8.1.0", diff --git a/src/App.tsx b/src/App.tsx index 0d5cf81..7bacb1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useRoutes } from 'react-router-dom'; import 'dummy.scss'; import 'assets/stylesheets/application.scss'; +import 'rc-slider/assets/index.css'; import { ToastContainer } from 'react-toastify'; import routes from './routes'; diff --git a/src/assets/images/icons/arrow-dropdown.svg b/src/assets/images/icons/arrow-dropdown.svg new file mode 100644 index 0000000..3656b08 --- /dev/null +++ b/src/assets/images/icons/arrow-dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/icons/close-btn.svg b/src/assets/images/icons/close-btn.svg new file mode 100644 index 0000000..04b9f73 --- /dev/null +++ b/src/assets/images/icons/close-btn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/components/AppSlider/index.test.tsx b/src/components/AppSlider/index.test.tsx new file mode 100644 index 0000000..680fe17 --- /dev/null +++ b/src/components/AppSlider/index.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import AppSlider, { appSliderDataTestIds } from '.'; + +describe('AppSlider', () => { + it('renders AppSlider component', () => { + render(); + + const appSlider = screen.getByTestId(appSliderDataTestIds.base); + const slider = screen.getByRole('slider'); + + expect(appSlider).toBeVisible(); + expect(slider).toBeVisible(); + }); +}); diff --git a/src/components/AppSlider/index.tsx b/src/components/AppSlider/index.tsx new file mode 100644 index 0000000..3eb2217 --- /dev/null +++ b/src/components/AppSlider/index.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import Slider from 'rc-slider'; + +export const appSliderDataTestIds = { + base: 'app-slider__base', +}; + +interface SliderProps { + min?: number; + max?: number; + step?: number; + onValueChanged: (position: number) => void; +} + +const AppSlider = ({ min = 1, max = 100, step = 1, onValueChanged }: SliderProps): JSX.Element => { + const handleOnChange = (value: number) => { + onValueChanged(value); + }; + + return ( +
+ handleOnChange(value as number)} + /> +
+ ); +}; + +export default AppSlider; diff --git a/src/components/Checkbox/index.test.tsx b/src/components/Checkbox/index.test.tsx new file mode 100644 index 0000000..3ed75e5 --- /dev/null +++ b/src/components/Checkbox/index.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { render, screen, within } from '@testing-library/react'; + +import Checkbox, { checkboxDataTestIds } from '.'; + +describe('Checkbox', () => { + describe('given the checkbox is checked', () => { + it('renders a checkbox component as checked', () => { + render(); + + const checkBox = screen.getByTestId(checkboxDataTestIds.base); + + expect(checkBox).toBeVisible(); + expect(within(checkBox).getByRole('checkbox')).toBeChecked(); + }); + }); + + describe('given the checkbox is unchecked', () => { + it('renders a checkbox component as unchecked', () => { + render(); + + const checkBox = screen.getByTestId(checkboxDataTestIds.base); + + expect(checkBox).toBeVisible(); + expect(within(checkBox).getByRole('checkbox')).not.toBeChecked(); + }); + }); +}); diff --git a/src/components/Checkbox/index.tsx b/src/components/Checkbox/index.tsx new file mode 100644 index 0000000..11c7247 --- /dev/null +++ b/src/components/Checkbox/index.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import CheckboxSvg from 'components/CheckboxSvg'; + +export const checkboxDataTestIds = { + base: 'checkbox__base', +}; + +interface CheckboxProps { + isValueChecked: boolean; +} + +const Checkbox = ({ isValueChecked }: CheckboxProps): JSX.Element => { + return ( +
+ { + // Do nothing + }} + className="peer relative shrink-0 appearance-none w-[28px] h-[30px] border-[1px] border-white rounded-full mt-1 checked:bg-white" + /> + + +
+ ); +}; + +export default Checkbox; diff --git a/src/components/CheckboxSvg/index.test.tsx b/src/components/CheckboxSvg/index.test.tsx new file mode 100644 index 0000000..548075c --- /dev/null +++ b/src/components/CheckboxSvg/index.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import CheckboxSvg, { checkboxSvgDataTestIds } from '.'; + +describe('CheckboxSvg', () => { + it('renders a checkbox svg component', () => { + render(); + + const checkBoxSvg = screen.getByTestId(checkboxSvgDataTestIds.base); + + expect(checkBoxSvg).toBeVisible(); + }); +}); diff --git a/src/components/CheckboxSvg/index.tsx b/src/components/CheckboxSvg/index.tsx new file mode 100644 index 0000000..e7bc4e2 --- /dev/null +++ b/src/components/CheckboxSvg/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +export const checkboxSvgDataTestIds = { + base: 'checkbox-svg__base', +}; + +interface CheckboxSvgProps { + className?: string; +} + +const DEFAULT_CLASSNAMES = 'absolute pointer-events-none hidden peer-checked:block mt-1 outline-none'; + +const CheckboxSvg = ({ className = DEFAULT_CLASSNAMES }: CheckboxSvgProps): JSX.Element => { + return ( + + + + + ); +}; + +export default CheckboxSvg; diff --git a/src/components/Dropdown/index.test.tsx b/src/components/Dropdown/index.test.tsx new file mode 100644 index 0000000..aee601b --- /dev/null +++ b/src/components/Dropdown/index.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { act, render, screen, within } from '@testing-library/react'; + +import { answerFabricator } from 'tests/fabricator'; +import { Answer } from 'types/answer'; + +import Dropdown, { dropdownDataTestIds } from '.'; + +describe('Dropdown', () => { + const answers = answerFabricator.times(5); + + it('renders a dropdown component', () => { + const dropdownProps = { + questionId: 'question-id', + items: answers, + onValueChanged: () => { + // Do nothing + }, + }; + + render(); + + const dropdown = screen.getByTestId(dropdownDataTestIds.base); + + expect(dropdown).toBeVisible(); + }); + + describe('given a value is clicked', () => { + it('returns the proper value', () => { + let selectedValue = ''; + const onValueChanged = (answer: Answer) => { + selectedValue = answer.text ?? ''; + }; + const dropdownProps = { + questionId: 'question-id', + items: answers, + onValueChanged: onValueChanged, + }; + + render(); + + const dropdown = screen.getByTestId(dropdownDataTestIds.base); + + const firstValue = answers[0].text; + const thirdValue = answers[2].text; + + act(() => { + within(dropdown).getByText(firstValue).click(); + }); + + act(() => { + within(dropdown) + .getByRole('button', { + name: thirdValue, + }) + .click(); + }); + + expect(selectedValue).toBe(thirdValue); + }); + }); +}); diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx new file mode 100644 index 0000000..3e5f167 --- /dev/null +++ b/src/components/Dropdown/index.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; + +import { ReactComponent as ArrowDropdown } from 'assets/images/icons/arrow-dropdown.svg'; +import { Answer } from 'types/answer'; + +export const dropdownDataTestIds = { + base: 'dropdown__base', +}; + +interface DropdownProps { + questionId: string; + items: Answer[]; + onValueChanged: (value: Answer) => void; +} +const Dropdown = ({ questionId, items, onValueChanged }: DropdownProps): JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const [selectedValue, setSelectedValue] = useState(items[0].text); + + const toggleDropdown = () => { + setIsOpen(!isOpen); + }; + + const handleOnSelectValue = (value: Answer) => { + setSelectedValue(value.text); + onValueChanged(value); + toggleDropdown(); + }; + + useEffect(() => { + setSelectedValue(items[0].text); + }, [questionId, items]); + + return ( +
+ + {isOpen && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +}; + +export default Dropdown; diff --git a/src/components/MultiChoice/index.test.tsx b/src/components/MultiChoice/index.test.tsx new file mode 100644 index 0000000..7941ba5 --- /dev/null +++ b/src/components/MultiChoice/index.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { act, render, screen, within } from '@testing-library/react'; + +import { answerFabricator } from 'tests/fabricator'; +import { Answer } from 'types/answer'; + +import MultiChoice, { multiChoiceDataTestIds } from '.'; + +describe('MultiChoice', () => { + const answers = answerFabricator.times(3); + + it('renders a multi choice component', () => { + render( + { + // Do nothing + }} + /> + ); + + const multiChoice = screen.getByTestId(multiChoiceDataTestIds.base); + + expect(multiChoice).toBeVisible(); + expect(within(multiChoice).getAllByRole('presentation')).toHaveLength(3); + }); + + describe('given can pick many choices', () => { + describe('given the first two items is clicked', () => { + it('renders the first two components as checked state', () => { + let selectedValues: Answer[] = []; + + const handleValuesChanged = (answers: Answer[]) => { + selectedValues = answers; + }; + + render( handleValuesChanged(answers)} />); + + const multiChoice = screen.getByTestId(multiChoiceDataTestIds.base); + + act(() => { + within(multiChoice).getAllByRole('presentation').at(0)?.click(); + }); + + act(() => { + within(multiChoice).getAllByRole('presentation').at(1)?.click(); + }); + + // The checked state of an item should NOT have `opacity-50` class + expect(within(multiChoice).getByText(answers[0].text)).not.toHaveClass('opacity-50'); + expect(within(multiChoice).getByText(answers[1].text)).not.toHaveClass('opacity-50'); + expect(selectedValues).toHaveLength(2); + }); + }); + }); + + describe('given can pick one choice', () => { + describe('given the first item is clicked', () => { + it('renders the first component as checked state', () => { + let selectedValues: Answer[] = []; + + const handleValuesChanged = (answers: Answer[]) => { + selectedValues = answers; + }; + + render( handleValuesChanged(answers)} />); + + const multiChoice = screen.getByTestId(multiChoiceDataTestIds.base); + + act(() => { + within(multiChoice).getAllByRole('presentation').at(0)?.click(); + }); + + // The checked state of an item should NOT have `opacity-50` class + expect(within(multiChoice).getByText(answers[0].text)).not.toHaveClass('opacity-50'); + expect(selectedValues).toHaveLength(1); + }); + }); + }); +}); diff --git a/src/components/MultiChoice/index.tsx b/src/components/MultiChoice/index.tsx new file mode 100644 index 0000000..980b540 --- /dev/null +++ b/src/components/MultiChoice/index.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; + +import classNames from 'classnames'; + +import Checkbox from 'components/Checkbox'; +import { Answer } from 'types/answer'; + +export const multiChoiceDataTestIds = { + base: 'multi-choice__base', +}; + +interface MultiChoiceProps { + items: Answer[]; + isPickOne: boolean; + onValuesChanged: (answers: Answer[]) => void; +} + +const MultiChoice = ({ items, isPickOne, onValuesChanged }: MultiChoiceProps): JSX.Element => { + const [selectedValues, setSelectedValues] = useState([]); + + const isSelected = (answer: Answer): boolean => { + return !!selectedValues.find((item) => item.id === answer.id); + }; + + const getExtraClassNames = (answer: Answer): string => { + return isSelected(answer) ? '' : 'opacity-50'; + }; + + const DEFAULT_TEXT_CLASSNAMES = 'text-white text-[20px] leading-[25px] tracking-survey-wider self-end'; + + const toggleCheckbox = (answer: Answer) => { + let newSelectedValues = []; + + if (isSelected(answer)) { + newSelectedValues = selectedValues.filter((currentAnswer) => currentAnswer.id !== answer.id); + } else { + if (isPickOne) { + newSelectedValues = [answer]; + } else { + newSelectedValues = [...selectedValues, answer]; + } + } + + setSelectedValues(newSelectedValues); + onValuesChanged(newSelectedValues); + }; + + return ( +
+ {items.map((item, index) => ( +
+
toggleCheckbox(item)}> +

{item.text}

+ +
+ {index !== items.length - 1 &&
} +
+ ))} +
+ ); +}; + +export default MultiChoice; diff --git a/src/components/Rating/index.test.tsx b/src/components/Rating/index.test.tsx new file mode 100644 index 0000000..a85c2b5 --- /dev/null +++ b/src/components/Rating/index.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { act, render, screen, within } from '@testing-library/react'; + +import { answerFabricator } from 'tests/fabricator'; +import { Answer } from 'types/answer'; +import { DisplayType } from 'types/question'; + +import Rating, { ratingDataTestIds } from '.'; + +describe('Rating', () => { + const answers = answerFabricator.times(5); + + it('renders a rating component', () => { + const ratingProps = { + questionId: 'question-id', + items: answers, + displayType: DisplayType.Star, + }; + + const onValueChanged = () => { + // Do nothing + }; + + render(); + + const rating = screen.getByTestId(ratingDataTestIds.base); + + expect(rating).toBeVisible(); + }); + + describe('given the last value is clicked', () => { + it('returns the proper value', () => { + let selectedValue = ''; + const onValueChanged = (answer: Answer) => { + selectedValue = answer.id; + }; + const ratingProps = { + questionId: 'question-id', + items: answers, + displayType: DisplayType.Star, + onValueChanged: onValueChanged, + }; + + render(); + + const rating = screen.getByTestId(ratingDataTestIds.base); + + act(() => + within(rating) + .getAllByRole('button', { + name: '⭐️', + }) + .at(4) + ?.click() + ); + + expect(selectedValue).toBe('5'); + }); + }); +}); diff --git a/src/components/Rating/index.tsx b/src/components/Rating/index.tsx new file mode 100644 index 0000000..fdabde6 --- /dev/null +++ b/src/components/Rating/index.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from 'react'; + +import { Answer } from 'types/answer'; +import { DisplayType } from 'types/question'; + +export const ratingDataTestIds = { + base: 'rating__base', +}; + +interface RatingProps { + questionId: string; + items: Answer[]; + displayType: DisplayType; + onValueChanged: (answer: Answer) => void; +} + +const Rating = ({ questionId, items, displayType, onValueChanged }: RatingProps): JSX.Element => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const handleOnSelectRating = (index: number, answer: Answer) => { + setSelectedIndex(index); + + onValueChanged(answer); + }; + + const faceModes = ['😡', '😕', '😐', '🙂', '😄']; + + const generateDisplayTypeContent = (index: number): string => { + switch (displayType) { + case DisplayType.Heart: + return '❤️'; + case DisplayType.Star: + return '⭐️'; + case DisplayType.Smiley: + return faceModes.at(index) ?? faceModes[4]; + case DisplayType.Thumbs: + default: + return '👍🏻'; + } + }; + + const buildClassAttribute = (index: number): string => { + if (displayType === DisplayType.Smiley) { + return selectedIndex === index ? '' : 'opacity-50'; + } else { + return selectedIndex >= index ? '' : 'opacity-50'; + } + }; + + useEffect(() => { + setSelectedIndex(0); + }, [questionId]); + + return ( +
+ {items.map((item, index) => { + return ( + + ); + })} +
+ ); +}; + +export default Rating; diff --git a/src/components/TextArea/index.test.tsx b/src/components/TextArea/index.test.tsx new file mode 100644 index 0000000..b548c91 --- /dev/null +++ b/src/components/TextArea/index.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; + +import { answerFabricator } from 'tests/fabricator'; +import { Answer } from 'types/answer'; + +import TextArea, { textAreaDataTestIds } from '.'; + +describe('TextArea', () => { + it('renders a text area component', () => { + const answers: Answer[] = answerFabricator.times(2); + + render(