mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Add max character count to inputs (#12006)
This commit is contained in:
@@ -6,6 +6,7 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import Fade from "~/components/Fade";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
export const NativeTextarea = styled.textarea<{
|
||||
@@ -97,6 +98,7 @@ export const Outline = styled(Flex)<{
|
||||
hasError?: boolean;
|
||||
$focused?: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
flex: 1;
|
||||
margin: ${(props) =>
|
||||
props.margin !== undefined ? props.margin : "0 0 16px"};
|
||||
@@ -119,6 +121,19 @@ export const Outline = styled(Flex)<{
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const CharacterCount = styled.span`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 0 0 0 2px;
|
||||
background: ${s("inputBorder")};
|
||||
color: ${s("textTertiary")};
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
export const LabelText = styled.div`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
@@ -141,6 +156,8 @@ export interface Props extends Omit<
|
||||
prefix?: React.ReactNode;
|
||||
/** Optional icon that appears inside the input before the textarea */
|
||||
icon?: React.ReactNode;
|
||||
/** Show a character count near the maxLength limit. Always shown for textareas, opt-in for other types. */
|
||||
showCharacterCount?: boolean;
|
||||
/** Like autoFocus, but also select any text in the input */
|
||||
autoSelect?: boolean;
|
||||
/** Callback is triggered with the CMD+Enter keyboard combo */
|
||||
@@ -157,6 +174,21 @@ function Input(
|
||||
) {
|
||||
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [charCount, setCharCount] = React.useState(() => {
|
||||
if (typeof props.value === "string") {
|
||||
return props.value.length;
|
||||
}
|
||||
if (typeof props.defaultValue === "string") {
|
||||
return props.defaultValue.length;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof props.value === "string") {
|
||||
setCharCount(props.value.length);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const handleBlur = (ev: React.SyntheticEvent) => {
|
||||
setFocused(false);
|
||||
@@ -174,6 +206,15 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
setCharCount(ev.target.value.length);
|
||||
if (props.onChange) {
|
||||
props.onChange(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
@@ -206,13 +247,21 @@ function Input(
|
||||
flex,
|
||||
prefix,
|
||||
labelHidden,
|
||||
maxLength,
|
||||
showCharacterCount,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChange,
|
||||
onRequestSubmit,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const showCharCount =
|
||||
(type === "textarea" || showCharacterCount) &&
|
||||
maxLength !== undefined &&
|
||||
charCount >= maxLength * 0.9;
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -238,8 +287,10 @@ function Input(
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
// set it after "rest" to override props from spread.
|
||||
maxLength={maxLength}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -253,10 +304,19 @@ function Input(
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
|
||||
maxLength={maxLength}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{showCharCount && (
|
||||
<Fade>
|
||||
<CharacterCount>
|
||||
{charCount}/{maxLength}
|
||||
</CharacterCount>
|
||||
</Fade>
|
||||
)}
|
||||
{children}
|
||||
</Outline>
|
||||
</label>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import type User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
@@ -131,6 +132,8 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
onChange={handleChange}
|
||||
error={!name ? t("Name can't be empty") : undefined}
|
||||
value={name}
|
||||
maxLength={UserValidation.maxNameLength}
|
||||
showCharacterCount
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
@@ -192,6 +195,8 @@ export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
onChange={handleChange}
|
||||
error={!email ? t("Email can't be empty") : error}
|
||||
value={email}
|
||||
maxLength={UserValidation.maxEmailLength}
|
||||
showCharacterCount
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
|
||||
@@ -232,6 +232,8 @@ function Details() {
|
||||
autoComplete="organization"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
maxLength={TeamValidation.maxNameLength}
|
||||
showCharacterCount
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -246,6 +248,8 @@ function Details() {
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDescription(ev.target.value);
|
||||
}}
|
||||
maxLength={TeamValidation.maxDescriptionLength}
|
||||
showCharacterCount
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UserChangeEmailDialog } from "~/components/UserDialogs";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
@@ -91,6 +92,8 @@ const Profile = () => {
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
maxLength={UserValidation.maxNameLength}
|
||||
showCharacterCount
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -87,6 +87,8 @@ export function CreateGroupDialog() {
|
||||
label="Name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={name}
|
||||
maxLength={GroupValidation.maxNameLength}
|
||||
showCharacterCount
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
@@ -169,6 +171,8 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
maxLength={GroupValidation.maxNameLength}
|
||||
showCharacterCount
|
||||
disabled={group.isExternallyManaged}
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -63,7 +63,11 @@ class Group extends ParanoidModel<
|
||||
InferAttributes<Group>,
|
||||
Partial<InferCreationAttributes<Group>>
|
||||
> {
|
||||
@Length({ min: 0, max: 255, msg: "name must be 255 characters or less" })
|
||||
@Length({
|
||||
min: 0,
|
||||
max: GroupValidation.maxNameLength,
|
||||
msg: `name must be ${GroupValidation.maxNameLength} characters or less`,
|
||||
})
|
||||
@NotContainsUrl
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@@ -121,7 +121,10 @@ class Team extends ParanoidModel<
|
||||
subdomain: string | null;
|
||||
|
||||
@Unique
|
||||
@Length({ max: 255, msg: "domain must be 255 characters or less" })
|
||||
@Length({
|
||||
max: TeamValidation.maxDomainLength,
|
||||
msg: `domain must be ${TeamValidation.maxDomainLength} characters or less`,
|
||||
})
|
||||
@IsFQDN
|
||||
@Column
|
||||
domain: string | null;
|
||||
|
||||
@@ -31,7 +31,10 @@ class TeamDomain extends IdModel<
|
||||
msg: "You chose a restricted domain, please try another.",
|
||||
})
|
||||
@NotEmpty
|
||||
@Length({ max: 255, msg: "name must be 255 characters or less" })
|
||||
@Length({
|
||||
max: TeamValidation.maxDomainLength,
|
||||
msg: `name must be ${TeamValidation.maxDomainLength} characters or less`,
|
||||
})
|
||||
@IsFQDN
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@@ -48,13 +48,19 @@ class WebhookSubscription extends ParanoidModel<
|
||||
static eventNamespace = "webhookSubscriptions";
|
||||
|
||||
@NotEmpty
|
||||
@Length({ max: 255, msg: "Webhook name be less than 255 characters" })
|
||||
@Length({
|
||||
max: WebhookSubscriptionValidation.maxNameLength,
|
||||
msg: `Webhook name must be ${WebhookSubscriptionValidation.maxNameLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@IsUrl
|
||||
@NotEmpty
|
||||
@Length({ max: 255, msg: "Webhook url be less than 255 characters" })
|
||||
@Length({
|
||||
max: WebhookSubscriptionValidation.maxUrlLength,
|
||||
msg: `Webhook url must be ${WebhookSubscriptionValidation.maxUrlLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
url: string;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export type GroupsInfoReq = z.infer<typeof GroupsInfoSchema>;
|
||||
export const GroupsCreateSchema = z.object({
|
||||
body: z.object({
|
||||
/** Group name */
|
||||
name: z.string(),
|
||||
name: z.string().max(GroupValidation.maxNameLength),
|
||||
/** Group description */
|
||||
description: z
|
||||
.string()
|
||||
@@ -73,7 +73,7 @@ export type GroupsCreateReq = z.infer<typeof GroupsCreateSchema>;
|
||||
export const GroupsUpdateSchema = z.object({
|
||||
body: BaseIdSchema.extend({
|
||||
/** Group name */
|
||||
name: z.string().optional(),
|
||||
name: z.string().max(GroupValidation.maxNameLength).optional(),
|
||||
/** Group description */
|
||||
description: z
|
||||
.string()
|
||||
|
||||
@@ -67,6 +67,8 @@ export const DocumentValidation = {
|
||||
};
|
||||
|
||||
export const GroupValidation = {
|
||||
/** The maximum length of the group name */
|
||||
maxNameLength: 255,
|
||||
/** The maximum length of the group description */
|
||||
maxDescriptionLength: 2000,
|
||||
};
|
||||
@@ -133,6 +135,9 @@ export const TeamValidation = {
|
||||
/** The maximum length of the team subdomain for self-hosted */
|
||||
maxSubdomainSelfHostedLength: 255,
|
||||
|
||||
/** The maximum length of a team domain */
|
||||
maxDomainLength: 255,
|
||||
|
||||
/** The maximum length of MCP workspace guidance */
|
||||
maxGuidanceMCPLength: 2000,
|
||||
};
|
||||
@@ -151,6 +156,10 @@ export const UserValidation = {
|
||||
export const WebhookSubscriptionValidation = {
|
||||
/** The maximum number of webhooks per team */
|
||||
maxSubscriptions: 10,
|
||||
/** The maximum length of the webhook name */
|
||||
maxNameLength: 255,
|
||||
/** The maximum length of the webhook url */
|
||||
maxUrlLength: 255,
|
||||
};
|
||||
|
||||
export const EmojiValidation = {
|
||||
|
||||
Reference in New Issue
Block a user