diff --git a/back/Gemfile b/back/Gemfile index 32afaff237f9..2391b0e5c9bc 100644 --- a/back/Gemfile +++ b/back/Gemfile @@ -96,7 +96,7 @@ gem 'awesome_nested_set', '~> 3.6.0' gem 'axlsx', '3.0.0.pre' # write xlsx files gem 'rubyXL', '~> 3.4.27' # read xlsx files. Has issues with writing files https://github.com/CitizenLabDotCo/citizenlab/pull/7834 gem 'counter_culture', '~> 3.5' -gem 'groupdate', '~> 4.1' +gem 'groupdate', '~> 6.5' gem 'icalendar', '~> 2.10' gem 'interactor' gem 'interactor-rails' diff --git a/back/Gemfile.lock b/back/Gemfile.lock index 65fe2f48bbe3..fc9697d177be 100644 --- a/back/Gemfile.lock +++ b/back/Gemfile.lock @@ -684,8 +684,8 @@ GEM graphql (2.4.4) base64 fiber-storage - groupdate (4.3.0) - activesupport (>= 5) + groupdate (6.5.1) + activesupport (>= 7) grpc (1.63.0-aarch64-linux) google-protobuf (~> 3.25) googleapis-common-protos-types (~> 1.0) @@ -721,7 +721,7 @@ GEM httpclient (2.8.3) httpi (3.0.1) rack - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) icalendar (2.10.1) ice_cube (~> 0.16) @@ -1283,7 +1283,7 @@ DEPENDENCIES frontend! google-cloud-document_ai (~> 1.4) google_tag_manager! - groupdate (~> 4.1) + groupdate (~> 6.5) icalendar (~> 2.10) ice_cube (~> 0.16) id_auth0! diff --git a/back/engines/commercial/analytics/app/jobs/analytics/import_latest_matomo_data_job.rb b/back/engines/commercial/analytics/app/jobs/analytics/import_latest_matomo_data_job.rb index aa80a253e9f4..f8d73f40f4f2 100644 --- a/back/engines/commercial/analytics/app/jobs/analytics/import_latest_matomo_data_job.rb +++ b/back/engines/commercial/analytics/app/jobs/analytics/import_latest_matomo_data_job.rb @@ -76,10 +76,14 @@ def calculate_lock_name(site_id) def matomo_site_id app_config = AppConfiguration.instance site_id = app_config.settings.dig('matomo', 'tenant_site_id') + default_site_id = ENV.fetch('DEFAULT_MATOMO_TENANT_SITE_ID') + lifecycle = app_config.settings.dig('core', 'lifecycle_stage') - raise MatomoMisconfigurationError, <<~MSG if site_id.blank? || site_id == ENV['DEFAULT_MATOMO_TENANT_SITE_ID'] - Matomo site (= #{site_id.inspect}) for tenant '#{app_config.id}' is misconfigured. - MSG + if (site_id.blank? || site_id == default_site_id) && %w[active trial].include?(lifecycle) + raise MatomoMisconfigurationError, <<~MSG + Matomo site (= #{site_id.inspect}) for tenant '#{app_config.id}' is misconfigured. + MSG + end site_id end diff --git a/back/engines/commercial/analytics/spec/jobs/analytics/import_latest_matomo_data_job_spec.rb b/back/engines/commercial/analytics/spec/jobs/analytics/import_latest_matomo_data_job_spec.rb index 218aaef95e0d..3e3b50d95235 100644 --- a/back/engines/commercial/analytics/spec/jobs/analytics/import_latest_matomo_data_job_spec.rb +++ b/back/engines/commercial/analytics/spec/jobs/analytics/import_latest_matomo_data_job_spec.rb @@ -69,12 +69,44 @@ end end - it 'raises an error if the configured matomo site is the default one' do - stub_const('ENV', ENV.to_h.merge( - 'DEFAULT_MATOMO_TENANT_SITE_ID' => AppConfiguration.instance.settings('matomo', 'tenant_site_id') - )) + describe 'MatomoMisconfigurationError' do + before do + stub_const('ENV', ENV.to_h.merge( + 'DEFAULT_MATOMO_TENANT_SITE_ID' => AppConfiguration.instance.settings('matomo', 'tenant_site_id') + )) + end + + context 'when tenant has active lifecycle' do + it 'raises an error if the configured matomo site is the default one' do + expect { described_class.perform_now(Tenant.current.id) } + .to raise_error(described_class::MatomoMisconfigurationError) + end + end - expect { described_class.perform_now(Tenant.current.id) } - .to raise_error(described_class::MatomoMisconfigurationError) + context 'when tenant has trial lifecycle' do + before do + AppConfiguration.instance.settings['core']['lifecycle_stage'] = 'trial' + AppConfiguration.instance.save! + end + + it 'raises an error if the configured matomo site is the default one' do + expect { described_class.perform_now(Tenant.current.id) } + .to raise_error(described_class::MatomoMisconfigurationError) + end + end + + context 'when tenant has demo lifecycle' do + before do + AppConfiguration.instance.settings['core']['lifecycle_stage'] = 'demo' + AppConfiguration.instance.save!(validate: false) + end + + it 'does not raise an error if the configured matomo site is the default one' do + # We expect this error to be raised because the Matomo client is not configured. + # We shouldn't see the MatomoMisconfigurationError, which would be raised before this one. + expect { described_class.perform_now(Tenant.current.id) } + .to raise_error(Matomo::Client::MissingBaseUriError) + end + end end end diff --git a/front/app/component-library/components/Button/index.tsx b/front/app/component-library/components/Button/index.tsx index e853e79682e2..b8e279fc2881 100644 --- a/front/app/component-library/components/Button/index.tsx +++ b/front/app/component-library/components/Button/index.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, ButtonHTMLAttributes } from 'react'; +import React, { MouseEvent, ButtonHTMLAttributes, forwardRef } from 'react'; import { isNil, get } from 'lodash-es'; import { darken, transparentize, opacify, rgba } from 'polished'; @@ -514,7 +514,6 @@ export interface Props extends ButtonContainerProps { hiddenText?: string | JSX.Element; icon?: IconProps['name']; iconPos?: 'left' | 'right'; - setSubmitButtonRef?: (value: any) => void; text?: string | JSX.Element; theme?: MainThemeProps | undefined; type?: ButtonHTMLAttributes['type']; @@ -526,11 +525,13 @@ export interface Props extends ButtonContainerProps { ariaExpanded?: boolean; ariaPressed?: boolean; ariaDescribedby?: string; + ariaControls?: string; as?: React.ElementType; tabIndex?: number; } +export type Ref = HTMLButtonElement; -const Button = (props: Props) => { +const Button = forwardRef((props, ref) => { const handleOnClick = (event: MouseEvent) => { const { onClick, processing, disabled } = props; @@ -594,6 +595,7 @@ const Button = (props: Props) => { ariaExpanded, ariaPressed, ariaDescribedby, + ariaControls, opacityDisabled, className, onClick: _onClick, @@ -698,8 +700,8 @@ const Button = (props: Props) => { aria-expanded={ariaExpanded} aria-pressed={ariaPressed} aria-describedby={ariaDescribedby} + aria-controls={ariaControls} aria-disabled={disabled || processing} - ref={props.setSubmitButtonRef} className={buttonClassNames} form={form} type={buttonType} @@ -707,11 +709,12 @@ const Button = (props: Props) => { autoFocus={autoFocus} as={as} tabIndex={tabIndex} + ref={ref} > {childContent} ); -}; +}); export default Button; diff --git a/front/app/component-library/components/IconTooltip/index.tsx b/front/app/component-library/components/IconTooltip/index.tsx index 338efed78664..d435b126b85e 100644 --- a/front/app/component-library/components/IconTooltip/index.tsx +++ b/front/app/component-library/components/IconTooltip/index.tsx @@ -105,7 +105,7 @@ const IconTooltip: FC = memo( placement={placement || 'right-end'} theme={theme || ''} maxWidth={maxTooltipWidth || 350} - useWrapper={false} + useContentWrapper={false} content={ {content} diff --git a/front/app/component-library/components/Input/index.tsx b/front/app/component-library/components/Input/index.tsx index 6e331d3fb5f7..65b1346f3399 100644 --- a/front/app/component-library/components/Input/index.tsx +++ b/front/app/component-library/components/Input/index.tsx @@ -80,6 +80,7 @@ export interface InputProps { onBlur?: (arg: FormEvent) => void; setRef?: (arg: HTMLInputElement) => void | undefined; onKeyDown?: (event: KeyboardEvent) => void; + onMultilinePaste?: (lines: string[]) => void; autoFocus?: boolean; min?: string; max?: string; @@ -154,6 +155,8 @@ class Input extends PureComponent { 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; @@ -204,6 +207,22 @@ class Input extends PureComponent { 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} /> diff --git a/front/app/component-library/components/Tooltip/index.tsx b/front/app/component-library/components/Tooltip/index.tsx index 3eaecb7a3b73..722890c85efa 100644 --- a/front/app/component-library/components/Tooltip/index.tsx +++ b/front/app/component-library/components/Tooltip/index.tsx @@ -10,7 +10,7 @@ export type TooltipProps = Omit< 'interactive' | 'plugins' | 'role' > & { width?: string; - useWrapper?: boolean; + useContentWrapper?: boolean; }; const useActiveElement = () => { @@ -64,63 +64,12 @@ const PLUGINS = [ }, ]; -const TippyComponent = ({ - children, - theme, - width, - componentKey, - isFocused, - setIsFocused, - setKey, - tooltipId, - onHidden, - ...rest -}: { - children: React.ReactNode; - theme: string; - width: string | undefined; - componentKey: number; - isFocused: boolean | undefined; - setIsFocused: React.Dispatch>; - setKey: React.Dispatch>; - tooltipId: React.MutableRefObject; -} & TooltipProps) => { - // This component sometimes crashes because of re-renders. - // This useCallback slightly improves the situation (i.e. it makes it - // slightly less likely for the component to crash). - // But in the end we just need to completely rewrite this whole component - // to fix the issue properly. - // https://www.notion.so/govocal/Fix-Tooltip-component-16f9663b7b2680a48aebdf2ace15d1f8 - const handleOnHidden = useCallback(() => { - setIsFocused(undefined); - setKey((prev) => prev + 1); - }, [setIsFocused, setKey]); - - return ( - - - {children} - - - ); -}; - const Tooltip = ({ children, theme = 'light', width, - // This prop is used to determine if the native Tippy component should be wrapped in a Box component - useWrapper = true, + // This prop is used to determine if the native Tippy component content should be wrapped in a Box component + useContentWrapper = true, ...rest }: TooltipProps) => { const tooltipId = useRef( @@ -145,37 +94,52 @@ const Tooltip = ({ } }, [activeElement, isFocused]); - if (useWrapper) { + // This component sometimes crashes because of re-renders. + // This useCallback slightly improves the situation (i.e. it makes it + // slightly less likely for the component to crash). + // But in the end we just need to completely rewrite this whole component + // to fix the issue properly. + // https://www.notion.so/govocal/Fix-Tooltip-component-16f9663b7b2680a48aebdf2ace15d1f8 + const handleOnHidden = useCallback(() => { + setIsFocused(undefined); + setKey((prev) => prev + 1); + }, [setIsFocused, setKey]); + + if (useContentWrapper) { return ( - - {children} - + + {children} + + ); } else { return ( - // This option is used for more accessible tooltips when useWrapper is false + // This Box is used for more accessible tooltips when useContentWrapper is false - {children} - + ); } diff --git a/front/app/component-library/hooks/useInstanceId.ts b/front/app/component-library/hooks/useInstanceId.ts index a2ea20cb5cd8..f171cd923bc3 100644 --- a/front/app/component-library/hooks/useInstanceId.ts +++ b/front/app/component-library/hooks/useInstanceId.ts @@ -1,9 +1,9 @@ import { useState } from 'react'; -import { uuid4 } from '@sentry/utils'; +import { v4 as uuidv4 } from 'uuid'; const useInstanceId = () => { - return useState(() => uuid4())[0]; + return useState(() => uuidv4())[0]; }; export default useInstanceId; diff --git a/front/app/components/EsriMap/utils.tsx b/front/app/components/EsriMap/utils.tsx index 43489e20e820..bb7abd0b0feb 100644 --- a/front/app/components/EsriMap/utils.tsx +++ b/front/app/components/EsriMap/utils.tsx @@ -17,8 +17,8 @@ import MapView from '@arcgis/core/views/MapView'; import WebMap from '@arcgis/core/WebMap'; import Popup from '@arcgis/core/widgets/Popup'; import { colors } from '@citizenlab/cl2-component-library'; -import { uuid4 } from '@sentry/utils'; import { transparentize } from 'polished'; +import { v4 as uuidv4 } from 'uuid'; import { IMapConfig } from 'api/map_config/types'; import { IMapLayerAttributes } from 'api/map_layers/types'; @@ -574,7 +574,7 @@ export const createEsriGeoJsonLayers = ( // create new geojson layer using the created url const geoJsonLayer = new GeoJSONLayer({ - id: `${uuid4()}`, + id: `${uuidv4()}`, url, customParameters: { layerId: layer.id, diff --git a/front/app/components/EventAttendanceButton/index.tsx b/front/app/components/EventAttendanceButton/index.tsx index 73b558595f33..3d5bfd5b30f5 100644 --- a/front/app/components/EventAttendanceButton/index.tsx +++ b/front/app/components/EventAttendanceButton/index.tsx @@ -182,28 +182,27 @@ const EventAttendanceButton = ({ event }: EventAttendanceButtonProps) => { disabled={!disabled_reason} placement="bottom" content={disabledMessage} + useContentWrapper={false} > -
- -
+ {currentTitle} diff --git a/front/app/components/FilterSelector/MultiSelectDropdown.tsx b/front/app/components/FilterSelector/MultiSelectDropdown.tsx index 7ae3c281fc01..5dd741441a8c 100644 --- a/front/app/components/FilterSelector/MultiSelectDropdown.tsx +++ b/front/app/components/FilterSelector/MultiSelectDropdown.tsx @@ -166,7 +166,7 @@ const MultiSelectDropdown = ({ minWidth={minWidth} onKeyDown={handleKeyDown} ariaExpanded={opened} - aria-controls={baseID} + ariaControls={baseID} > {currentTitle} diff --git a/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/SelectFieldOption.tsx b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/SelectFieldOption.tsx index f4ed6d4e5adf..f92ac9f82026 100644 --- a/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/SelectFieldOption.tsx +++ b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/SelectFieldOption.tsx @@ -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; } @@ -42,6 +43,7 @@ const SelectFieldOption = memo( locale, removeOption, onChoiceUpdate, + onMultilinePaste, optionImages, }: Props) => { const [isUploading, setIsUploading] = useState(false); @@ -107,6 +109,13 @@ const SelectFieldOption = memo( onChoiceUpdate(choice, index); }} autoFocus={false} + onMultilinePaste={ + onMultilinePaste + ? (lines) => { + onMultilinePaste(lines, index); + } + : undefined + } /> {showImageSettings && ( diff --git a/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/index.tsx b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/index.tsx index 4a8e4be27d04..71b1c86546f0 100644 --- a/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/index.tsx +++ b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/index.tsx @@ -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; @@ -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[]); @@ -276,6 +293,12 @@ const ConfigSelectWithLocaleSwitcher = ({ return aValue - bValue; }) .map((choice, index) => { + const multilinePasteAllowed = allowMultilinePaste({ + options, + index, + locale: selectedLocale, + }); + return ( {choice.other === true ? ( @@ -311,9 +334,14 @@ const ConfigSelectWithLocaleSwitcher = ({ locale={selectedLocale} inputType={inputType} canDeleteLastOption={canDeleteLastOption} + optionImages={optionImages} removeOption={removeOption} onChoiceUpdate={updateChoice} - optionImages={optionImages} + onMultilinePaste={ + multilinePasteAllowed + ? handleMultilinePaste + : undefined + } /> )} diff --git a/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/utils.test.ts b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/utils.test.ts new file mode 100644 index 000000000000..b688751c8c17 --- /dev/null +++ b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/utils.test.ts @@ -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), + }); + }); +}); diff --git a/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/utils.ts b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/utils.ts new file mode 100644 index 000000000000..b9b57d47b7c0 --- /dev/null +++ b/front/app/components/FormBuilder/components/FormBuilderSettings/ConfigSelectWithLocaleSwitcher/utils.ts @@ -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(); +}; diff --git a/front/app/components/FormBuilder/components/FormBuilderSettings/FieldTypeSwitcher/index.tsx b/front/app/components/FormBuilder/components/FormBuilderSettings/FieldTypeSwitcher/index.tsx index 7109d2e38001..9fd82514de32 100644 --- a/front/app/components/FormBuilder/components/FormBuilderSettings/FieldTypeSwitcher/index.tsx +++ b/front/app/components/FormBuilder/components/FormBuilderSettings/FieldTypeSwitcher/index.tsx @@ -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'; diff --git a/front/app/components/FormBuilder/components/FormBuilderToolbox/index.tsx b/front/app/components/FormBuilder/components/FormBuilderToolbox/index.tsx index 7d84de692620..d5f8457bfda8 100644 --- a/front/app/components/FormBuilder/components/FormBuilderToolbox/index.tsx +++ b/front/app/components/FormBuilder/components/FormBuilderToolbox/index.tsx @@ -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'; diff --git a/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx b/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx index 27d9af7457bd..3d0f03147e31 100644 --- a/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx +++ b/front/app/components/FormBuilder/components/FormFields/FormField/index.tsx @@ -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'; diff --git a/front/app/components/FormBuilder/utils.tsx b/front/app/components/FormBuilder/utils.tsx index 8bb8a4c7dba4..a865a8e4bfff 100644 --- a/front/app/components/FormBuilder/utils.tsx +++ b/front/app/components/FormBuilder/utils.tsx @@ -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'; @@ -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'; diff --git a/front/app/components/HookForm/OptionList/index.tsx b/front/app/components/HookForm/OptionList/index.tsx index 4e84802e29e6..9979ac761e8b 100644 --- a/front/app/components/HookForm/OptionList/index.tsx +++ b/front/app/components/HookForm/OptionList/index.tsx @@ -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; diff --git a/front/app/components/Pagination/index.tsx b/front/app/components/Pagination/index.tsx index 6dace4498f01..2b6e7872a0a4 100644 --- a/front/app/components/Pagination/index.tsx +++ b/front/app/components/Pagination/index.tsx @@ -9,8 +9,11 @@ import { import { rgba } from 'polished'; import styled from 'styled-components'; +import { useIntl } from 'utils/cl-intl'; import { removeFocusAfterMouseClick } from 'utils/helperUtils'; +import messages from './messages'; + const ContainerInner = styled.div` display: flex; align-items: center; @@ -124,6 +127,7 @@ const Pagination = ({ useColorsTheme, loadPage, }: Props) => { + const { formatMessage } = useIntl(); const calculateMenuItems = (currentPage: number, totalPages: number) => { const current = currentPage; const last = totalPages; @@ -183,6 +187,7 @@ const Pagination = ({ onClick={goTo(currentPage - 1)} disabled={currentPage === 1} className={currentPage === 1 ? 'disabled' : ''} + aria-label={formatMessage(messages.back)} > @@ -209,6 +214,7 @@ const Pagination = ({ onClick={goTo(currentPage + 1)} disabled={currentPage === totalPages} className={currentPage === totalPages ? 'disabled' : ''} + aria-label={formatMessage(messages.next)} > diff --git a/front/app/components/Pagination/messages.ts b/front/app/components/Pagination/messages.ts new file mode 100644 index 000000000000..35f42d64d777 --- /dev/null +++ b/front/app/components/Pagination/messages.ts @@ -0,0 +1,12 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + next: { + id: 'app.components.Pagination.next', + defaultMessage: 'Next page', + }, + back: { + id: 'app.components.Pagination.back', + defaultMessage: 'Previous page', + }, +}); diff --git a/front/app/components/UI/ButtonWithLink/index.tsx b/front/app/components/UI/ButtonWithLink/index.tsx index 4289c6d863c7..a4f619a60a34 100644 --- a/front/app/components/UI/ButtonWithLink/index.tsx +++ b/front/app/components/UI/ButtonWithLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { Button, @@ -21,54 +21,52 @@ interface ButtonContainerProps extends ComponentLibraryButtonContainerProps { 'data-cy'?: string; } -const ButtonWithLink = ({ - linkTo, - openLinkInNewTab, - disabled, - scrollToTop, - ...rest -}: Props) => { - const isExternalLink = - linkTo && - (linkTo.startsWith('http') || - linkTo.startsWith('www') || - linkTo.startsWith('mailto')); +type Ref = HTMLButtonElement; - const link = - linkTo && !disabled - ? isExternalLink - ? ({ - children, - ...rest - }: ButtonProps & React.HTMLAttributes) => ( - - {children} - - ) - : ({ - children, - ...rest - }: Omit & - React.HTMLAttributes) => ( - - {children} - - ) - : undefined; +const ButtonWithLink = forwardRef( + ({ linkTo, openLinkInNewTab, disabled, scrollToTop, ...rest }, ref) => { + const isExternalLink = + linkTo && + (linkTo.startsWith('http') || + linkTo.startsWith('www') || + linkTo.startsWith('mailto')); - return