Skip to content

Commit

Permalink
Merge pull request #10115 from CitizenLabDotCo/TAN-3604-survey-option…
Browse files Browse the repository at this point in the history
…-multiline-pasting

TAN-3604 Survey option multiline pasting
  • Loading branch information
luucvanderzee authored Jan 22, 2025
2 parents 60476ec + 6c34e1a commit f848175
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 19 deletions.
19 changes: 19 additions & 0 deletions front/app/component-library/components/Input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface InputProps {
onBlur?: (arg: FormEvent<HTMLInputElement>) => void;
setRef?: (arg: HTMLInputElement) => void | undefined;
onKeyDown?: (event: KeyboardEvent) => void;
onMultilinePaste?: (lines: string[]) => void;
autoFocus?: boolean;
min?: string;
max?: string;
Expand Down Expand Up @@ -154,6 +155,8 @@ class Input extends PureComponent<InputProps> {
autocomplete,
size = 'medium',
'data-testid': dataTestId,
onChange,
onMultilinePaste,
} = this.props;
const hasError = !isNil(this.props.error) && !isEmpty(this.props.error);
const optionalProps = isBoolean(spellCheck) ? { spellCheck } : null;
Expand Down Expand Up @@ -204,6 +207,22 @@ class Input extends PureComponent<InputProps> {
required={required}
autoComplete={autocomplete}
onKeyDown={onKeyDown}
onPaste={
onMultilinePaste
? (e) => {
e.preventDefault();
navigator.clipboard.readText().then((text) => {
const split = text.split('\n');

if (split.length === 1) {
onChange?.(split[0], this.props.locale);
} else {
onMultilinePaste(split);
}
});
}
: undefined
}
{...optionalProps}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface Props {
locale: SupportedLocale;
removeOption: (index: number) => void;
onChoiceUpdate: (choice: IOptionsType, index: number) => void;
onMultilinePaste?: (lines: string[], index: number) => void;
optionImages: OptionImageType | undefined;
}

Expand All @@ -42,6 +43,7 @@ const SelectFieldOption = memo(
locale,
removeOption,
onChoiceUpdate,
onMultilinePaste,
optionImages,
}: Props) => {
const [isUploading, setIsUploading] = useState(false);
Expand Down Expand Up @@ -107,6 +109,13 @@ const SelectFieldOption = memo(
onChoiceUpdate(choice, index);
}}
autoFocus={false}
onMultilinePaste={
onMultilinePaste
? (lines) => {
onMultilinePaste(lines, index);
}
: undefined
}
/>

{showImageSettings && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ import usePrevious from 'hooks/usePrevious';
import { List, Row } from 'components/admin/ResourceList';
import SortableRow from 'components/admin/ResourceList/SortableRow';
import { SectionField } from 'components/admin/Section';
import { generateTempId } from 'components/FormBuilder/utils';
import Error, { TFieldName } from 'components/UI/Error';

import { useIntl } from 'utils/cl-intl';
import { convertUrlToUploadFile } from 'utils/fileUtils';
import { isNilOrError } from 'utils/helperUtils';
import { generateTempId, isNilOrError } from 'utils/helperUtils';

import messages from './messages';
import SelectFieldOption, { OptionImageType } from './SelectFieldOption';
import { allowMultilinePaste, updateFormOnMultlinePaste } from './utils';

interface Props {
name: string;
Expand Down Expand Up @@ -194,6 +194,23 @@ const ConfigSelectWithLocaleSwitcher = ({
[update]
);

const handleMultilinePaste = useCallback(
(lines, index) => {
if (!selectedLocale) return;
if (lines.length > 20) return;

updateFormOnMultlinePaste({
update,
append,
locale: selectedLocale,
lines,
index,
options: selectOptions,
});
},
[update, append, selectOptions, selectedLocale]
);

const defaultOptionValues = [{}];
const errors = get(formContextErrors, name) as RHFErrors;
const apiError = errors?.error && ([errors] as CLError[]);
Expand Down Expand Up @@ -276,6 +293,12 @@ const ConfigSelectWithLocaleSwitcher = ({
return aValue - bValue;
})
.map((choice, index) => {
const multilinePasteAllowed = allowMultilinePaste({
options,
index,
locale: selectedLocale,
});

return (
<Box key={index}>
{choice.other === true ? (
Expand Down Expand Up @@ -311,9 +334,14 @@ const ConfigSelectWithLocaleSwitcher = ({
locale={selectedLocale}
inputType={inputType}
canDeleteLastOption={canDeleteLastOption}
optionImages={optionImages}
removeOption={removeOption}
onChoiceUpdate={updateChoice}
optionImages={optionImages}
onMultilinePaste={
multilinePasteAllowed
? handleMultilinePaste
: undefined
}
/>
</SortableRow>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { allowMultilinePaste, updateFormOnMultlinePaste } from './utils';

describe('allowMultilinePaste', () => {
it('should return true if all options from the given index are empty', () => {
const options = [
{ title_multiloc: { en: 'Text' } },
{ title_multiloc: { en: '' } },
{ title_multiloc: { en: undefined } },
];

const result = allowMultilinePaste({ options, index: 1, locale: 'en' });
expect(result).toBe(true);
});

it('should return false if not all options from the given index are empty', () => {
const options = [
{ title_multiloc: { en: 'Text' } },
{ title_multiloc: { en: '' } },
{ title_multiloc: { en: 'Something' } },
];

const result = allowMultilinePaste({ options, index: 1, locale: 'en' });
expect(result).toBe(false);
});
});

describe('updateFormOnMultlinePaste', () => {
it('correctly sanitizes line with summarization points', () => {
const lines = ['• One', '• Two', '• Three'];

const options = [{ title_multiloc: { en: '' } }];

const update = jest.fn();
const append = jest.fn();

updateFormOnMultlinePaste({
update,
append,
locale: 'en',
lines,
index: 0,
options,
});

expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(0, {
title_multiloc: {
en: 'One',
},
temp_id: expect.any(String),
});

expect(append).toHaveBeenCalledTimes(2);
expect(append).toHaveBeenCalledWith({
title_multiloc: {
en: 'Two',
},
temp_id: expect.any(String),
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { SupportedLocale } from 'typings';

import { IOptionsType } from 'api/custom_fields/types';

import { generateTempId } from 'utils/helperUtils';

interface AllowMultilinePasteParams {
options: IOptionsType[];
index: number;
locale: SupportedLocale;
}

// Ensure that for the given locale,
// the option at the given index and all
// subsequent indices are empty
export const allowMultilinePaste = ({
options,
index,
locale,
}: AllowMultilinePasteParams) => {
for (let i = index; i < options.length; i++) {
if (options[i].title_multiloc[locale]) {
return false;
}
}

return true;
};

interface UpdateFormOnMultilinePasteParams {
update: (index: number, newOption: IOptionsType) => void;
append: (newOption: IOptionsType) => void;
locale: SupportedLocale;
lines: string[];
index: number;
options: IOptionsType[];
}

export const updateFormOnMultlinePaste = ({
update,
append,
locale,
lines,
index,
options,
}: UpdateFormOnMultilinePasteParams) => {
lines.forEach((line, i) => {
const optionIndex = index + i;
const option =
optionIndex >= options.length ? undefined : options[optionIndex];

if (option) {
update(optionIndex, {
...option,
title_multiloc: {
...option.title_multiloc,
[locale]: sanitizeLine(line),
},
...(!option.id && !option.temp_id ? { temp_id: generateTempId() } : {}),
});
} else {
append({
title_multiloc: {
[locale]: sanitizeLine(line),
},
temp_id: generateTempId(),
});
}
});
};

const REMOVABLE_PREFIXES = new Set(['•', '-']);

const sanitizeLine = (line: string) => {
const trimmedLine = line.trim();
const cleanedLine = REMOVABLE_PREFIXES.has(trimmedLine[0])
? trimmedLine.slice(1)
: trimmedLine;
return cleanedLine.trim();
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import { useFormContext } from 'react-hook-form';

import { IFlatCustomFieldWithIndex } from 'api/custom_fields/types';

import { generateTempId } from 'components/FormBuilder/utils';

import { useIntl } from 'utils/cl-intl';
import { generateTempId } from 'utils/helperUtils';

import messages from './messages';
import { getFieldSwitchOptions } from './utils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,10 @@ import {
import useFeatureFlag from 'hooks/useFeatureFlag';
import useLocale from 'hooks/useLocale';

import {
generateTempId,
FormBuilderConfig,
} from 'components/FormBuilder/utils';
import { FormBuilderConfig } from 'components/FormBuilder/utils';

import { FormattedMessage, useIntl } from 'utils/cl-intl';
import { isNilOrError } from 'utils/helperUtils';
import { generateTempId, isNilOrError } from 'utils/helperUtils';

import messages from '../messages';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ import useDuplicateMapConfig from 'api/map_config/useDuplicateMapConfig';
import {
FormBuilderConfig,
builtInFieldKeys,
generateTempId,
} from 'components/FormBuilder/utils';
import Modal from 'components/UI/Modal';
import MoreActionsMenu from 'components/UI/MoreActionsMenu';

import { useIntl } from 'utils/cl-intl';
import { generateTempId } from 'utils/helperUtils';

import { FlexibleRow } from '../../FlexibleRow';
import { getFieldBackgroundColor } from '../utils';
Expand Down
5 changes: 0 additions & 5 deletions front/app/components/FormBuilder/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';

import { uuid4 } from '@sentry/utils';
import { MessageDescriptor } from 'react-intl';
import { RouteType } from 'routes';
import { SupportedLocale } from 'typings';
Expand Down Expand Up @@ -81,10 +80,6 @@ export const getIsPostingEnabled = (
return false;
};

export function generateTempId() {
return `TEMP-ID-${uuid4()}`;
}

// TODO: BE key for survey end options should be replaced with form_end, then we can update this value.
export const formEndOption = 'survey_end';

Expand Down
3 changes: 1 addition & 2 deletions front/app/components/HookForm/OptionList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ import { IOptionsType } from 'api/custom_fields/types';
import { List } from 'components/admin/ResourceList';
import SortableRow from 'components/admin/ResourceList/SortableRow';
import { SectionField } from 'components/admin/Section';
import { generateTempId } from 'components/FormBuilder/utils';
import Error, { TFieldName } from 'components/UI/Error';

import { isNilOrError } from 'utils/helperUtils';
import { isNilOrError, generateTempId } from 'utils/helperUtils';

export type Option = {
id?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import useUserCustomFields from 'api/user_custom_fields/useUserCustomFields';

import useLocale from 'hooks/useLocale';

import { generateTempId } from 'components/FormBuilder/utils';
import Modal from 'components/UI/Modal';

import { FormattedMessage, useIntl } from 'utils/cl-intl';
import { generateTempId } from 'utils/helperUtils';

import { AddFieldScreen } from './AddFieldScreen';
import messages from './messages';
Expand Down
5 changes: 5 additions & 0 deletions front/app/utils/helperUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { uuid4 } from '@sentry/utils';
import { trim, isUndefined } from 'lodash-es';
import { SupportedLocale, Multiloc, GraphqlLocale } from 'typings';

Expand Down Expand Up @@ -223,3 +224,7 @@ export function hexToRGBA(hex: string, alpha: number) {
export const classNames = (...classes: (string | undefined)[]) => {
return classes.filter(Boolean).join(' ');
};

export function generateTempId() {
return `TEMP-ID-${uuid4()}`;
}

0 comments on commit f848175

Please sign in to comment.