[Glitch] Profile editing: Field inline messages

Port 57a4f6b6ec to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2026-03-27 13:41:54 +01:00
committed by Claire
parent 22e156ea83
commit 131b9acfab
6 changed files with 120 additions and 79 deletions

View File

@@ -7,15 +7,11 @@ import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react'; import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import type { FieldStatus } from '../form_fields/form_field_wrapper';
import { Icon } from '../icon'; import { Icon } from '../icon';
import classes from './styles.module.css'; import classes from './styles.module.css';
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = { const iconMap: Record<FieldStatus['variant'], React.FunctionComponent> = {
error: ErrorIcon, error: ErrorIcon,
warning: WarningIcon, warning: WarningIcon,

View File

@@ -2,11 +2,9 @@ import type {
ChangeEvent, ChangeEvent,
ChangeEventHandler, ChangeEventHandler,
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
Dispatch,
FC, FC,
ReactNode, ReactNode,
RefObject, RefObject,
SetStateAction,
} from 'react'; } from 'react';
import { useCallback, useId, useRef } from 'react'; import { useCallback, useId, useRef } from 'react';
@@ -25,7 +23,7 @@ import { TextInput } from './text_input_field';
export type EmojiInputProps = { export type EmojiInputProps = {
value?: string; value?: string;
onChange?: Dispatch<SetStateAction<string>>; onChange?: (newValue: string) => void;
counterMax?: number; counterMax?: number;
recommended?: boolean; recommended?: boolean;
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>; } & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
@@ -138,12 +136,15 @@ const EmojiFieldWrapper: FC<
const handlePickEmoji = useCallback( const handlePickEmoji = useCallback(
(emoji: string) => { (emoji: string) => {
onChange?.((prev) => { if (!value) {
const position = inputRef.current?.selectionStart ?? prev.length; onChange?.('');
return insertEmojiAtPosition(prev, emoji, position); return;
}); }
const position = inputRef.current?.selectionStart ?? value.length;
const newValue = insertEmojiAtPosition(value, emoji, position);
onChange?.(newValue);
}, },
[onChange, inputRef], [inputRef, value, onChange],
); );
const handleChange = useCallback( const handleChange = useCallback(

View File

@@ -4,10 +4,10 @@ import type { ReactNode, FC } from 'react';
import { createContext, useId } from 'react'; import { createContext, useId } from 'react';
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region'; import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
import { CalloutInline } from 'flavours/glitch/components/callout_inline'; import { CalloutInline } from 'flavours/glitch/components/callout_inline';
import classes from './fieldset.module.scss'; import classes from './fieldset.module.scss';
import type { FieldStatus } from './form_field_wrapper';
import { getFieldStatus } from './form_field_wrapper'; import { getFieldStatus } from './form_field_wrapper';
import formFieldWrapperClasses from './form_field_wrapper.module.scss'; import formFieldWrapperClasses from './form_field_wrapper.module.scss';

View File

@@ -8,7 +8,6 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region'; import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region';
import type { FieldStatus } from 'flavours/glitch/components/callout_inline';
import { CalloutInline } from 'flavours/glitch/components/callout_inline'; import { CalloutInline } from 'flavours/glitch/components/callout_inline';
import { FieldsetNameContext } from './fieldset'; import { FieldsetNameContext } from './fieldset';
@@ -20,11 +19,16 @@ export interface InputProps {
'aria-describedby'?: string; 'aria-describedby'?: string;
} }
export interface FieldStatus {
variant: 'error' | 'warning' | 'info' | 'success';
message?: string;
}
interface FieldWrapperProps { interface FieldWrapperProps {
label: ReactNode; label: ReactNode;
hint?: ReactNode; hint?: ReactNode;
required?: boolean; required?: boolean;
status?: FieldStatus['variant'] | FieldStatus; status?: FieldStatus['variant'] | FieldStatus | null;
inputId?: string; inputId?: string;
describedById?: string; describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end'; inputPlacement?: 'inline-start' | 'inline-end';

View File

@@ -1,3 +1,4 @@
export type { FieldStatus } from './form_field_wrapper';
export { FormFieldWrapper } from './form_field_wrapper'; export { FormFieldWrapper } from './form_field_wrapper';
export { FormStack } from './form_stack'; export { FormStack } from './form_stack';
export { Fieldset } from './fieldset'; export { Fieldset } from './fieldset';

View File

@@ -1,11 +1,5 @@
import { import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
forwardRef, import type { FC, FocusEventHandler } from 'react';
useCallback,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@@ -13,7 +7,7 @@ import type { Map as ImmutableMap } from 'immutable';
import { closeModal } from '@/flavours/glitch/actions/modal'; import { closeModal } from '@/flavours/glitch/actions/modal';
import { Button } from '@/flavours/glitch/components/button'; import { Button } from '@/flavours/glitch/components/button';
import { Callout } from '@/flavours/glitch/components/callout'; import type { FieldStatus } from '@/flavours/glitch/components/form_fields';
import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields'; import { EmojiTextInputField } from '@/flavours/glitch/components/form_fields';
import { import {
removeField, removeField,
@@ -71,6 +65,26 @@ const messages = defineMessages({
id: 'account_edit.field_edit_modal.discard_confirm', id: 'account_edit.field_edit_modal.discard_confirm',
defaultMessage: 'Discard', defaultMessage: 'Discard',
}, },
errorBlank: {
id: 'form_error.blank',
defaultMessage: 'Field cannot be blank.',
},
warningLength: {
id: 'account_edit.field_edit_modal.length_warning',
defaultMessage:
'Recommended character limit exceeded. Mobile users might not see your field in full.',
},
warningUrlEmoji: {
id: 'account_edit.field_edit_modal.link_emoji_warning',
defaultMessage:
'We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.',
},
warningUrlProtocol: {
id: 'account_edit.field_edit_modal.url_warning',
defaultMessage:
'To add a link, please include {protocol} at the beginning.',
description: '{protocol} is https://',
},
}); });
// We have two different values- the hard limit set by the server, // We have two different values- the hard limit set by the server,
@@ -124,34 +138,86 @@ export const EditFieldModal = forwardRef<
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits); const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
const isPending = useAppSelector((state) => state.profileEdit.isPending); const isPending = useAppSelector((state) => state.profileEdit.isPending);
const disabled = const [fieldStatuses, setFieldStatuses] = useState<{
!newLabel.trim() || label?: FieldStatus;
!newValue.trim() || value?: FieldStatus;
!isDirty || }>({});
!nameLimit ||
!valueLimit ||
newLabel.length > nameLimit ||
newValue.length > valueLimit;
const customEmojiCodes = useAppSelector(selectEmojiCodes); const customEmojiCodes = useAppSelector(selectEmojiCodes);
const hasLinkAndEmoji = useMemo(() => { const checkField = useCallback(
const text = `${newLabel} ${newValue}`; // Combine text, as we're searching it all. (value: string): FieldStatus | null => {
const hasLink = /https?:\/\//.test(text); if (!value.trim()) {
const hasEmoji = customEmojiCodes.some((code) => return {
text.includes(`:${code}:`), variant: 'error',
); message: intl.formatMessage(messages.errorBlank),
return hasLink && hasEmoji; };
}, [customEmojiCodes, newLabel, newValue]); }
const hasLinkWithoutProtocol = useMemo(
() => isUrlWithoutProtocol(newValue), if (value.length > RECOMMENDED_LIMIT) {
[newValue], return {
variant: 'warning',
message: intl.formatMessage(messages.warningLength, {
max: RECOMMENDED_LIMIT,
}),
};
}
const hasLink = /https?:\/\//.test(value);
const hasEmoji = customEmojiCodes.some((code) =>
value.includes(`:${code}:`),
);
if (hasLink && hasEmoji) {
return {
variant: 'warning',
message: intl.formatMessage(messages.warningUrlEmoji),
};
}
if (isUrlWithoutProtocol(value)) {
return {
variant: 'warning',
message: intl.formatMessage(messages.warningUrlProtocol, {
protocol: 'https://',
}),
};
}
return null;
},
[customEmojiCodes, intl],
);
const handleBlur: FocusEventHandler<HTMLInputElement> = useCallback(
(event) => {
const { name, value } = event.target;
const result = checkField(value);
if (name !== 'label' && name !== 'value') {
return;
}
setFieldStatuses((statuses) => ({
...statuses,
[name]: result ?? undefined,
}));
},
[checkField],
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
if (disabled || isPending) { if (isPending) {
return; return;
} }
const labelStatus = checkField(newLabel);
const valueStatus = checkField(newValue);
if (labelStatus || valueStatus) {
setFieldStatuses({
label: labelStatus ?? undefined,
value: valueStatus ?? undefined,
});
return;
}
void dispatch( void dispatch(
updateField({ id: fieldKey, name: newLabel, value: newValue }), updateField({ id: fieldKey, name: newLabel, value: newValue }),
).then(() => { ).then(() => {
@@ -163,7 +229,7 @@ export const EditFieldModal = forwardRef<
}), }),
); );
}); });
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue]); }, [checkField, dispatch, fieldKey, isPending, newLabel, newValue]);
useImperativeHandle( useImperativeHandle(
ref, ref,
@@ -198,60 +264,33 @@ export const EditFieldModal = forwardRef<
confirm={intl.formatMessage(messages.save)} confirm={intl.formatMessage(messages.save)}
onConfirm={handleSave} onConfirm={handleSave}
updating={isPending} updating={isPending}
disabled={disabled}
className={classes.wrapper} className={classes.wrapper}
> >
<EmojiTextInputField <EmojiTextInputField
name='label'
value={newLabel} value={newLabel}
onChange={setNewLabel} onChange={setNewLabel}
onBlur={handleBlur}
label={intl.formatMessage(messages.editLabelField)} label={intl.formatMessage(messages.editLabelField)}
hint={intl.formatMessage(messages.editLabelHint)} hint={intl.formatMessage(messages.editLabelHint)}
status={fieldStatuses.label}
maxLength={nameLimit} maxLength={nameLimit}
counterMax={RECOMMENDED_LIMIT} counterMax={RECOMMENDED_LIMIT}
recommended recommended
/> />
<EmojiTextInputField <EmojiTextInputField
name='value'
value={newValue} value={newValue}
onChange={setNewValue} onChange={setNewValue}
onBlur={handleBlur}
label={intl.formatMessage(messages.editValueField)} label={intl.formatMessage(messages.editValueField)}
hint={intl.formatMessage(messages.editValueHint)} hint={intl.formatMessage(messages.editValueHint)}
status={fieldStatuses.value}
maxLength={valueLimit} maxLength={valueLimit}
counterMax={RECOMMENDED_LIMIT} counterMax={RECOMMENDED_LIMIT}
recommended recommended
/> />
{hasLinkAndEmoji && (
<Callout variant='warning'>
<FormattedMessage
id='account_edit.field_edit_modal.link_emoji_warning'
defaultMessage='We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.'
/>
</Callout>
)}
{(newLabel.length > RECOMMENDED_LIMIT ||
newValue.length > RECOMMENDED_LIMIT) && (
<Callout variant='warning'>
<FormattedMessage
id='account_edit.field_edit_modal.limit_warning'
defaultMessage='Recommended character limit exceeded. Mobile users might not see your field in full.'
/>
</Callout>
)}
{hasLinkWithoutProtocol && (
<Callout variant='warning'>
<FormattedMessage
id='account_edit.field_edit_modal.url_warning'
defaultMessage='To add a link, please include {protocol} at the beginning.'
description='{protocol} is https://'
values={{
protocol: <code>https://</code>,
}}
/>
</Callout>
)}
</ConfirmationModal> </ConfirmationModal>
); );
}); });