From 7782c96ba45cff0320ae197455c1759741a9a5d9 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 7 Jun 2022 11:11:28 -0400 Subject: [PATCH] Improve design of the Poll Form --- app/soapbox/components/autosuggest_input.tsx | 12 +- app/soapbox/components/ui/input/input.tsx | 2 +- .../features/compose/components/poll-form.tsx | 140 ++++++++---------- .../__tests__/duration-selector.test.tsx | 77 ++++++++++ .../components/polls/duration-selector.tsx | 93 ++++++++++++ app/soapbox/locales/en.json | 10 +- app/styles/polls.scss | 43 ------ 7 files changed, 246 insertions(+), 131 deletions(-) create mode 100644 app/soapbox/features/compose/components/polls/__tests__/duration-selector.test.tsx create mode 100644 app/soapbox/features/compose/components/polls/duration-selector.tsx diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index cac979337..383d740c6 100644 --- a/app/soapbox/components/autosuggest_input.tsx +++ b/app/soapbox/components/autosuggest_input.tsx @@ -20,7 +20,7 @@ export type AutoSuggestion = string | Emoji; const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => { let word: string; - const left: number = str.slice(0, caretPosition).search(/\S+$/); + const left: number = str.slice(0, caretPosition).search(/\S+$/); const right: number = str.slice(caretPosition).search(/\s/); if (right < 0) { @@ -201,13 +201,13 @@ export default class AutosuggestInput extends ImmutablePureComponent; - key = suggestion.id; + key = suggestion.id; } else if (suggestion[0] === '#') { inner = suggestion; - key = suggestion; + key = suggestion; } else { inner = ; - key = suggestion; + key = suggestion; } return ( @@ -279,13 +279,13 @@ export default class AutosuggestInput extends ImmutablePureComponent +
( type={revealed ? 'text' : type} ref={ref} className={classNames({ - 'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500': + 'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-primary-500 focus:border-primary-500': true, 'pr-7': isPassword, 'text-red-600 border-red-600': hasError, diff --git a/app/soapbox/features/compose/components/poll-form.tsx b/app/soapbox/features/compose/components/poll-form.tsx index 0f8e87387..4939b9e3f 100644 --- a/app/soapbox/features/compose/components/poll-form.tsx +++ b/app/soapbox/features/compose/components/poll-form.tsx @@ -1,24 +1,23 @@ 'use strict'; -import classNames from 'classnames'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import AutosuggestInput from 'soapbox/components/autosuggest_input'; -import Icon from 'soapbox/components/icon'; -import IconButton from 'soapbox/components/icon_button'; -import { HStack } from 'soapbox/components/ui'; +import { Button, HStack, Stack, Text } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; +import DurationSelector from './polls/duration-selector'; + import type { AutoSuggestion } from 'soapbox/components/autosuggest_input'; const messages = defineMessages({ - option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, - add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, - remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, + option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Answer #{number}' }, + add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add an answer' }, + remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this answer' }, poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, - switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' }, - switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' }, + switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple answers' }, + switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single answer' }, minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, @@ -26,7 +25,6 @@ const messages = defineMessages({ interface IOption { index: number - isPollMultiple?: boolean maxChars: number numOptions: number onChange(index: number, value: string): void @@ -35,7 +33,6 @@ interface IOption { onRemove(index: number): void onRemovePoll(): void onSuggestionSelected(tokenStart: number, token: string, value: string, key: (string | number)[]): void - onToggleMultiple(): void suggestions?: any // list title: string } @@ -43,7 +40,6 @@ interface IOption { const Option = (props: IOption) => { const { index, - isPollMultiple, maxChars, numOptions, onChange, @@ -51,7 +47,6 @@ const Option = (props: IOption) => { onFetchSuggestions, onRemove, onRemovePoll, - onToggleMultiple, suggestions, title, } = props; @@ -68,20 +63,20 @@ const Option = (props: IOption) => { } }; - const handleToggleMultiple = (event: React.MouseEvent | React.KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); + // const handleToggleMultiple = (event: React.MouseEvent | React.KeyboardEvent) => { + // event.preventDefault(); + // event.stopPropagation(); - onToggleMultiple(); - }; + // onToggleMultiple(); + // }; const onSuggestionsClearRequested = () => onClearSuggestions(); - const handleCheckboxKeypress = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - handleToggleMultiple(event); - } - }; + // const handleCheckboxKeypress = (event: React.KeyboardEvent) => { + // if (event.key === 'Enter' || event.key === ' ') { + // handleToggleMultiple(event); + // } + // }; const onSuggestionsFetchRequested = (token: string) => onFetchSuggestions(token); @@ -92,17 +87,11 @@ const Option = (props: IOption) => { }; return ( -
  • - + -
    - -
    -
  • + {index > 1 && ( +
    + +
    + )} + ); }; @@ -156,27 +143,21 @@ const PollForm = (props: IPollForm) => { ...filteredProps } = props; - const intl = useIntl(); - const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any); const maxOptions = pollLimits.get('max_options'); const maxOptionChars = pollLimits.get('max_characters_per_option'); const handleAddOption = () => onAddOption(''); - - const handleSelectDuration = (event: React.ChangeEvent) => - onChangeSettings(event.target.value, isMultiple); - - const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple); - + const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple); + // const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple); if (!options) { return null; } return ( -
    -
      + + {options.map((title: string, i: number) => (
    - - - {options.size < maxOptions && ( - - )} - - - -
    + + +
    + + {options.size < maxOptions && ( + + )} + + + + + {/* Duration */} + + Duration + + + + + {/* Remove Poll */} +
    + +
    + ); }; diff --git a/app/soapbox/features/compose/components/polls/__tests__/duration-selector.test.tsx b/app/soapbox/features/compose/components/polls/__tests__/duration-selector.test.tsx new file mode 100644 index 000000000..cf689ab43 --- /dev/null +++ b/app/soapbox/features/compose/components/polls/__tests__/duration-selector.test.tsx @@ -0,0 +1,77 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { render, screen } from '../../../../../jest/test-helpers'; +import DurationSelector from '../duration-selector'; + +describe('', () => { + it('defaults to 2 days', () => { + const handler = jest.fn(); + render(); + + expect(screen.getByTestId('duration-selector-days')).toHaveValue('2'); + expect(screen.getByTestId('duration-selector-hours')).toHaveValue('0'); + expect(screen.getByTestId('duration-selector-minutes')).toHaveValue('0'); + }); + + describe('when changing the day', () => { + it('calls the "onDurationChange" callback', async() => { + const handler = jest.fn(); + render(); + + await userEvent.selectOptions( + screen.getByTestId('duration-selector-days'), + screen.getByRole('option', { name: '1 day' }), + ); + + expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days + expect(handler.mock.calls[1][0]).toEqual(86400); // 1 day + }); + + it('should disable the hour/minute select if 7 days selected', async() => { + const handler = jest.fn(); + render(); + + expect(screen.getByTestId('duration-selector-hours')).not.toBeDisabled(); + expect(screen.getByTestId('duration-selector-minutes')).not.toBeDisabled(); + + await userEvent.selectOptions( + screen.getByTestId('duration-selector-days'), + screen.getByRole('option', { name: '7 days' }), + ); + + expect(screen.getByTestId('duration-selector-hours')).toBeDisabled(); + expect(screen.getByTestId('duration-selector-minutes')).toBeDisabled(); + }); + }); + + describe('when changing the hour', () => { + it('calls the "onDurationChange" callback', async() => { + const handler = jest.fn(); + render(); + + await userEvent.selectOptions( + screen.getByTestId('duration-selector-hours'), + screen.getByRole('option', { name: '1 hour' }), + ); + + expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days + expect(handler.mock.calls[1][0]).toEqual(176400); // 2 days, 1 hour + }); + }); + + describe('when changing the minute', () => { + it('calls the "onDurationChange" callback', async() => { + const handler = jest.fn(); + render(); + + await userEvent.selectOptions( + screen.getByTestId('duration-selector-minutes'), + screen.getByRole('option', { name: '15 minutes' }), + ); + + expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days + expect(handler.mock.calls[1][0]).toEqual(173700); // 2 days, 1 minute + }); + }); +}); diff --git a/app/soapbox/features/compose/components/polls/duration-selector.tsx b/app/soapbox/features/compose/components/polls/duration-selector.tsx new file mode 100644 index 000000000..491530d22 --- /dev/null +++ b/app/soapbox/features/compose/components/polls/duration-selector.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Select } from 'soapbox/components/ui'; + +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}); + +interface IDurationSelector { + onDurationChange(expiresIn: number): void +} + +const DurationSelector = ({ onDurationChange }: IDurationSelector) => { + const intl = useIntl(); + + const [days, setDays] = useState(2); + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + + const value = useMemo(() => { + const now: any = new Date(); + const future: any = new Date(); + now.setDate(now.getDate() + days); + now.setMinutes(now.getMinutes() + minutes); + now.setHours(now.getHours() + hours); + + return (now - future) / 1000; + }, [days, hours, minutes]); + + useEffect(() => { + if (days === 7) { + setHours(0); + setMinutes(0); + } + }, [days]); + + useEffect(() => { + onDurationChange(value); + }, [value]); + + return ( +
    +
    + +
    + +
    + +
    + +
    + +
    +
    + ); +}; + +export default DurationSelector; diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 74957a487..9f3894ac5 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -271,12 +271,12 @@ "compose_form.markdown.unmarked": "Post markdown disabled", "compose_form.message": "Message", "compose_form.placeholder": "What's on your mind?", - "compose_form.poll.add_option": "Add a choice", + "compose_form.poll.add_option": "Add an answer", "compose_form.poll.duration": "Poll duration", - "compose_form.poll.option_placeholder": "Choice {number}", - "compose_form.poll.remove_option": "Remove this choice", - "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices", - "compose_form.poll.switch_to_single": "Change poll to allow for a single choice", + "compose_form.poll.option_placeholder": "Answer #{number}", + "compose_form.poll.remove_option": "Remove this answer", + "compose_form.poll.switch_to_multiple": "Change poll to allow multiple answers", + "compose_form.poll.switch_to_single": "Change poll to allow for a single answer", "compose_form.publish": "Post", "compose_form.publish_loud": "{publish}!", "compose_form.schedule": "Schedule", diff --git a/app/styles/polls.scss b/app/styles/polls.scss index 231dbf195..2d0dd4142 100644 --- a/app/styles/polls.scss +++ b/app/styles/polls.scss @@ -118,49 +118,6 @@ } } -.compose-form__poll-wrapper { - border-top: 1px solid var(--foreground-color); - - ul { - padding: 10px; - } - - .button.button-secondary { - @apply h-auto py-1.5 px-2.5 text-primary-600 dark:text-primary-400 border-primary-600; - } - - li { - display: flex; - align-items: center; - - .poll__text { - flex: 0 0 auto; - width: calc(100% - (23px + 6px)); - margin-right: 6px; - } - } - - select { - @apply border border-solid border-primary-600 bg-white dark:bg-slate-800; - box-sizing: border-box; - font-size: 14px; - display: inline-block; - width: auto; - outline: 0; - font-family: inherit; - background-repeat: no-repeat; - background-position: right 8px center; - background-size: auto 16px; - border-radius: 4px; - padding: 6px 10px; - padding-right: 30px; - } - - .icon-button.disabled { - color: var(--brand-color); - } -} - .muted .poll { color: var(--primary-text-color);