Add form field components: TextInputField, TextAreaField, SelectField (#37578)
This commit is contained in:
parent
1809048105
commit
0924171c0f
3
app/javascript/mastodon/components/form_fields/index.ts
Normal file
3
app/javascript/mastodon/components/form_fields/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { TextInputField } from './text_input_field';
|
||||||
|
export { TextAreaField } from './text_area_field';
|
||||||
|
export { SelectField } from './select_field';
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { SelectField } from './select_field';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Form Fields/SelectField',
|
||||||
|
component: SelectField,
|
||||||
|
args: {
|
||||||
|
label: 'Fruit preference',
|
||||||
|
hint: 'Select your favourite fruit or not. Up to you.',
|
||||||
|
},
|
||||||
|
render(args) {
|
||||||
|
// Component styles require a wrapper class at the moment
|
||||||
|
return (
|
||||||
|
<div className='simple_form'>
|
||||||
|
<SelectField {...args}>
|
||||||
|
<option>Apple</option>
|
||||||
|
<option>Banana</option>
|
||||||
|
<option>Kiwi</option>
|
||||||
|
<option>Lemon</option>
|
||||||
|
<option>Mango</option>
|
||||||
|
<option>Orange</option>
|
||||||
|
<option>Pomelo</option>
|
||||||
|
<option>Strawberries</option>
|
||||||
|
<option>Something else</option>
|
||||||
|
</SelectField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof SelectField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Simple: Story = {};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Optional: Story = {
|
||||||
|
args: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
required: false,
|
||||||
|
hasError: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { FormFieldWrapper } from './wrapper';
|
||||||
|
import type { CommonFieldWrapperProps } from './wrapper';
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple form field for single-item selections.
|
||||||
|
* Provide selectable items via nested `<option>` elements.
|
||||||
|
*
|
||||||
|
* Accepts an optional `hint` and can be marked as required
|
||||||
|
* or optional (by explicitly setting `required={false}`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||||
|
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
|
||||||
|
<FormFieldWrapper
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
required={required}
|
||||||
|
hasError={hasError}
|
||||||
|
inputId={id}
|
||||||
|
>
|
||||||
|
{(inputProps) => (
|
||||||
|
<div className='select-wrapper'>
|
||||||
|
<select {...otherProps} {...inputProps} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormFieldWrapper>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
SelectField.displayName = 'SelectField';
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { TextAreaField } from './text_area_field';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Form Fields/TextAreaField',
|
||||||
|
component: TextAreaField,
|
||||||
|
args: {
|
||||||
|
label: 'Label',
|
||||||
|
hint: 'This is a description of this form field',
|
||||||
|
},
|
||||||
|
render(args) {
|
||||||
|
// Component styles require a wrapper class at the moment
|
||||||
|
return (
|
||||||
|
<div className='simple_form'>
|
||||||
|
<TextAreaField {...args} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof TextAreaField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Simple: Story = {};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Optional: Story = {
|
||||||
|
args: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
required: false,
|
||||||
|
hasError: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { FormFieldWrapper } from './wrapper';
|
||||||
|
import type { CommonFieldWrapperProps } from './wrapper';
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple form field for multi-line text.
|
||||||
|
*
|
||||||
|
* Accepts an optional `hint` and can be marked as required
|
||||||
|
* or optional (by explicitly setting `required={false}`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
|
||||||
|
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||||
|
<FormFieldWrapper
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
required={required}
|
||||||
|
hasError={hasError}
|
||||||
|
inputId={id}
|
||||||
|
>
|
||||||
|
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
|
||||||
|
</FormFieldWrapper>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TextAreaField.displayName = 'TextAreaField';
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { TextInputField } from './text_input_field';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Form Fields/TextInputField',
|
||||||
|
component: TextInputField,
|
||||||
|
args: {
|
||||||
|
label: 'Label',
|
||||||
|
hint: 'This is a description of this form field',
|
||||||
|
},
|
||||||
|
render(args) {
|
||||||
|
// Component styles require a wrapper class at the moment
|
||||||
|
return (
|
||||||
|
<div className='simple_form'>
|
||||||
|
<TextInputField {...args} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof TextInputField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Simple: Story = {};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Optional: Story = {
|
||||||
|
args: {
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
required: false,
|
||||||
|
hasError: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import { FormFieldWrapper } from './wrapper';
|
||||||
|
import type { CommonFieldWrapperProps } from './wrapper';
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple form field for single-line text.
|
||||||
|
*
|
||||||
|
* Accepts an optional `hint` and can be marked as required
|
||||||
|
* or optional (by explicitly setting `required={false}`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||||
|
(
|
||||||
|
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
|
||||||
|
ref,
|
||||||
|
) => (
|
||||||
|
<FormFieldWrapper
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
required={required}
|
||||||
|
hasError={hasError}
|
||||||
|
inputId={id}
|
||||||
|
>
|
||||||
|
{(inputProps) => (
|
||||||
|
<input type={type} {...otherProps} {...inputProps} ref={ref} />
|
||||||
|
)}
|
||||||
|
</FormFieldWrapper>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TextInputField.displayName = 'TextInputField';
|
||||||
100
app/javascript/mastodon/components/form_fields/wrapper.tsx
Normal file
100
app/javascript/mastodon/components/form_fields/wrapper.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||||
|
|
||||||
|
import type { ReactNode, FC } from 'react';
|
||||||
|
import { useId } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
interface InputProps {
|
||||||
|
id: string;
|
||||||
|
required?: boolean;
|
||||||
|
'aria-describedby'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldWrapperProps {
|
||||||
|
label: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
hasError?: boolean;
|
||||||
|
inputId?: string;
|
||||||
|
children: (inputProps: InputProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These types can be extended when creating individual field components.
|
||||||
|
*/
|
||||||
|
export type CommonFieldWrapperProps = Pick<
|
||||||
|
FieldWrapperProps,
|
||||||
|
'label' | 'hint' | 'hasError'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple form field wrapper for adding a label and hint to enclosed components.
|
||||||
|
* Accepts an optional `hint` and can be marked as required
|
||||||
|
* or optional (by explicitly setting `required={false}`)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||||
|
inputId: inputIdProp,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
required,
|
||||||
|
hasError,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const uniqueId = useId();
|
||||||
|
const inputId = inputIdProp || `${uniqueId}-input`;
|
||||||
|
const hintId = `${inputIdProp || uniqueId}-hint`;
|
||||||
|
const hasHint = !!hint;
|
||||||
|
|
||||||
|
const inputProps: InputProps = {
|
||||||
|
required,
|
||||||
|
id: inputId,
|
||||||
|
};
|
||||||
|
if (hasHint) {
|
||||||
|
inputProps['aria-describedby'] = hintId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('input with_block_label', {
|
||||||
|
field_with_errors: hasError,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className='label_input'>
|
||||||
|
<label htmlFor={inputId}>
|
||||||
|
{label}
|
||||||
|
{required !== undefined && <RequiredMark required={required} />}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{hasHint && (
|
||||||
|
<span className='hint' id={hintId}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='label_input__wrapper'>{children(inputProps)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `required` is explicitly set to `false` rather than `undefined`,
|
||||||
|
* the field will be visually marked as "optional".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
|
||||||
|
required ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<abbr aria-hidden='true'>*</abbr>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
|
||||||
|
</>
|
||||||
|
);
|
||||||
@ -20,6 +20,7 @@ import { Avatar } from 'mastodon/components/avatar';
|
|||||||
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
import { AvatarGroup } from 'mastodon/components/avatar_group';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { SelectField, TextInputField } from 'mastodon/components/form_fields';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import type { List } from 'mastodon/models/list';
|
import type { List } from 'mastodon/models/list';
|
||||||
@ -149,68 +150,49 @@ const NewList: React.FC<{ list?: List | null }> = ({ list }) => {
|
|||||||
return (
|
return (
|
||||||
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
<div className='input with_label'>
|
<TextInputField
|
||||||
<div className='label_input'>
|
required
|
||||||
<label htmlFor='list_title'>
|
maxLength={30}
|
||||||
<FormattedMessage
|
label={
|
||||||
id='lists.list_name'
|
<FormattedMessage id='lists.list_name' defaultMessage='List name' />
|
||||||
defaultMessage='List name'
|
}
|
||||||
/>
|
value={title}
|
||||||
</label>
|
onChange={handleTitleChange}
|
||||||
|
id='list_title'
|
||||||
<div className='label_input__wrapper'>
|
/>
|
||||||
<input
|
|
||||||
id='list_title'
|
|
||||||
type='text'
|
|
||||||
value={title}
|
|
||||||
onChange={handleTitleChange}
|
|
||||||
maxLength={30}
|
|
||||||
required
|
|
||||||
placeholder=' '
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
<div className='input with_label'>
|
<SelectField
|
||||||
<div className='label_input'>
|
label={
|
||||||
<label htmlFor='list_replies_policy'>
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id='lists.show_replies_to'
|
||||||
id='lists.show_replies_to'
|
defaultMessage='Include replies from list members to'
|
||||||
defaultMessage='Include replies from list members to'
|
/>
|
||||||
/>
|
}
|
||||||
</label>
|
value={repliesPolicy}
|
||||||
|
onChange={handleRepliesPolicyChange}
|
||||||
<div className='label_input__wrapper'>
|
id='list_replies_policy'
|
||||||
<select
|
>
|
||||||
id='list_replies_policy'
|
<FormattedMessage
|
||||||
value={repliesPolicy}
|
id='lists.replies_policy.none'
|
||||||
onChange={handleRepliesPolicyChange}
|
defaultMessage='No one'
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
{(msg) => <option value='none'>{msg}</option>}
|
||||||
id='lists.replies_policy.none'
|
</FormattedMessage>
|
||||||
defaultMessage='No one'
|
<FormattedMessage
|
||||||
>
|
id='lists.replies_policy.list'
|
||||||
{(msg) => <option value='none'>{msg}</option>}
|
defaultMessage='Members of the list'
|
||||||
</FormattedMessage>
|
>
|
||||||
<FormattedMessage
|
{(msg) => <option value='list'>{msg}</option>}
|
||||||
id='lists.replies_policy.list'
|
</FormattedMessage>
|
||||||
defaultMessage='Members of the list'
|
<FormattedMessage
|
||||||
>
|
id='lists.replies_policy.followed'
|
||||||
{(msg) => <option value='list'>{msg}</option>}
|
defaultMessage='Any followed user'
|
||||||
</FormattedMessage>
|
>
|
||||||
<FormattedMessage
|
{(msg) => <option value='followed'>{msg}</option>}
|
||||||
id='lists.replies_policy.followed'
|
</FormattedMessage>
|
||||||
defaultMessage='Any followed user'
|
</SelectField>
|
||||||
>
|
|
||||||
{(msg) => <option value='followed'>{msg}</option>}
|
|
||||||
</FormattedMessage>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{id && (
|
{id && (
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
|
|||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import { TextAreaField, TextInputField } from 'mastodon/components/form_fields';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
@ -212,62 +213,47 @@ export const Profile: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
<div
|
<TextInputField
|
||||||
className={classNames('input with_block_label', {
|
maxLength={30}
|
||||||
field_with_errors: !!errors?.display_name,
|
label={
|
||||||
})}
|
|
||||||
>
|
|
||||||
<label htmlFor='display_name'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='onboarding.profile.display_name'
|
id='onboarding.profile.display_name'
|
||||||
defaultMessage='Display name'
|
defaultMessage='Display name'
|
||||||
/>
|
/>
|
||||||
</label>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='onboarding.profile.display_name_hint'
|
id='onboarding.profile.display_name_hint'
|
||||||
defaultMessage='Your full name or your fun name…'
|
defaultMessage='Your full name or your fun name…'
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
<div className='label_input'>
|
value={displayName}
|
||||||
<input
|
onChange={handleDisplayNameChange}
|
||||||
id='display_name'
|
hasError={!!errors?.display_name}
|
||||||
type='text'
|
id='display_name'
|
||||||
value={displayName}
|
/>
|
||||||
onChange={handleDisplayNameChange}
|
|
||||||
maxLength={30}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
<div
|
<TextAreaField
|
||||||
className={classNames('input with_block_label', {
|
maxLength={500}
|
||||||
field_with_errors: !!errors?.note,
|
label={
|
||||||
})}
|
|
||||||
>
|
|
||||||
<label htmlFor='note'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='onboarding.profile.note'
|
id='onboarding.profile.note'
|
||||||
defaultMessage='Bio'
|
defaultMessage='Bio'
|
||||||
/>
|
/>
|
||||||
</label>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='onboarding.profile.note_hint'
|
id='onboarding.profile.note_hint'
|
||||||
defaultMessage='You can @mention other people or #hashtags…'
|
defaultMessage='You can @mention other people or #hashtags…'
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
<div className='label_input'>
|
value={note}
|
||||||
<textarea
|
onChange={handleNoteChange}
|
||||||
id='note'
|
hasError={!!errors?.note}
|
||||||
value={note}
|
id='note'
|
||||||
onChange={handleNoteChange}
|
/>
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className='app-form__toggle'>
|
<label className='app-form__toggle'>
|
||||||
|
|||||||
@ -454,6 +454,7 @@
|
|||||||
"footer.source_code": "View source code",
|
"footer.source_code": "View source code",
|
||||||
"footer.status": "Status",
|
"footer.status": "Status",
|
||||||
"footer.terms_of_service": "Terms of service",
|
"footer.terms_of_service": "Terms of service",
|
||||||
|
"form_field.optional": "(optional)",
|
||||||
"generic.saved": "Saved",
|
"generic.saved": "Saved",
|
||||||
"getting_started.heading": "Getting started",
|
"getting_started.heading": "Getting started",
|
||||||
"hashtag.admin_moderation": "Open moderation interface for #{name}",
|
"hashtag.admin_moderation": "Open moderation interface for #{name}",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user