diff --git a/app/soapbox/components/autosuggest_input.tsx b/app/soapbox/components/autosuggest_input.tsx index cac979337..cd2c74a07 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 +
= ({ status, expanded = false, onE output.push(); } - if (status.poll && typeof status.poll === 'string') { + const hasPoll = status.poll && typeof status.poll === 'string'; + + if (hasPoll) { output.push(); } - return <>{output}; + return
{output}
; } else { const output = [
', () => { + it('renders without text', () => { + render(); + + expect(screen.queryAllByTestId('divider-text')).toHaveLength(0); + }); + + it('renders text', () => { + const text = 'Hello'; + render(); + + expect(screen.getByTestId('divider-text')).toHaveTextContent(text); + }); +}); diff --git a/app/soapbox/components/ui/divider/divider.tsx b/app/soapbox/components/ui/divider/divider.tsx new file mode 100644 index 000000000..0670be123 --- /dev/null +++ b/app/soapbox/components/ui/divider/divider.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +interface IDivider { + text?: string +} + +/** Divider */ +const Divider = ({ text }: IDivider) => ( +
+ +); + +export default Divider; diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 27acc184b..cd48426b6 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -5,6 +5,7 @@ export { default as Checkbox } from './checkbox/checkbox'; export { default as Column } from './column/column'; export { default as Counter } from './counter/counter'; export { default as Datepicker } from './datepicker/datepicker'; +export { default as Divider } from './divider/divider'; export { default as Emoji } from './emoji/emoji'; export { default as EmojiSelector } from './emoji-selector/emoji-selector'; export { default as FileInput } from './file-input/file-input'; diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 183b40b04..8ba0d6ff9 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -64,7 +64,7 @@ const Input = React.forwardRef( 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/compose_form.js b/app/soapbox/features/compose/components/compose_form.js index 9700c03f4..31e80741c 100644 --- a/app/soapbox/features/compose/components/compose_form.js +++ b/app/soapbox/features/compose/components/compose_form.js @@ -38,6 +38,7 @@ const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u20 const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' }, + pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' }, spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, publish: { id: 'compose_form.publish', defaultMessage: 'Post' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, @@ -62,6 +63,7 @@ class ComposeForm extends ImmutablePureComponent { spoilerText: PropTypes.string, focusDate: PropTypes.instanceOf(Date), caretPosition: PropTypes.number, + hasPoll: PropTypes.bool, isSubmitting: PropTypes.bool, isChangingUpload: PropTypes.bool, isEditing: PropTypes.bool, @@ -340,7 +342,7 @@ class ComposeForm extends ImmutablePureComponent { { const { index, - isPollMultiple, maxChars, numOptions, onChange, @@ -51,7 +50,6 @@ const Option = (props: IOption) => { onFetchSuggestions, onRemove, onRemovePoll, - onToggleMultiple, suggestions, title, } = props; @@ -68,21 +66,8 @@ const Option = (props: IOption) => { } }; - const handleToggleMultiple = (event: React.MouseEvent | React.KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - - onToggleMultiple(); - }; - const onSuggestionsClearRequested = () => onClearSuggestions(); - const handleCheckboxKeypress = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - handleToggleMultiple(event); - } - }; - const onSuggestionsFetchRequested = (token: string) => onFetchSuggestions(token); const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => { @@ -92,19 +77,14 @@ const Option = (props: IOption) => { }; return ( -
  • - + -
    - -
    -
  • + {index > 1 && ( +
    + +
    + )} + ); }; @@ -162,21 +140,22 @@ const PollForm = (props: IPollForm) => { 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 handleAddOption = () => { + // autofocus on new input + // use streamfield + onAddOption(''); + }; + 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 */} + + + {intl.formatMessage(messages.pollDuration)} + + + + + + {/* 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/features/compose/containers/compose_form_container.js b/app/soapbox/features/compose/containers/compose_form_container.js index c4928065e..d54f1d082 100644 --- a/app/soapbox/features/compose/containers/compose_form_container.js +++ b/app/soapbox/features/compose/containers/compose_form_container.js @@ -26,6 +26,7 @@ const mapStateToProps = state => { privacy: state.getIn(['compose', 'privacy']), focusDate: state.getIn(['compose', 'focusDate']), caretPosition: state.getIn(['compose', 'caretPosition']), + hasPoll: !!state.getIn(['compose', 'poll']), isSubmitting: state.getIn(['compose', 'is_submitting']), isEditing: state.getIn(['compose', 'id']) !== null, isChangingUpload: state.getIn(['compose', 'is_changing_upload']), 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);