Wrap Long text fields (textarea) (#8557)

Here we add the option for Text inputs to be wrapped, and to select on
how many lines text should be displayed.

Fix #7552

---------

Co-authored-by: guillim <guillaume@twenty.com>
This commit is contained in:
Guillim
2024-11-18 17:36:19 +01:00
committed by GitHub
parent 83b5eb69b0
commit 2f3c41620c
14 changed files with 235 additions and 27 deletions

View File

@@ -2,7 +2,12 @@ import { useTextFieldDisplay } from '@/object-record/record-field/meta-types/hoo
import { TextDisplay } from '@/ui/field/display/components/TextDisplay'; import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
export const TextFieldDisplay = () => { export const TextFieldDisplay = () => {
const { fieldValue } = useTextFieldDisplay(); const { fieldValue, fieldDefinition } = useTextFieldDisplay();
return <TextDisplay text={fieldValue} />; return (
<TextDisplay
text={fieldValue}
displayedMaxRows={fieldDefinition.metadata?.settings?.displayedMaxRows}
/>
);
}; };

View File

@@ -2,11 +2,15 @@ import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext'; import { FieldContext } from '../../contexts/FieldContext';
export const useTextFieldDisplay = () => { export const useTextFieldDisplay = () => {
const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext); const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(FieldMetadataType.Text, isFieldText, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName; const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = const fieldValue =

View File

@@ -23,7 +23,9 @@ export type FieldTextMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: {
displayedMaxRows?: number;
};
}; };
export type FieldDateTimeMetadata = { export type FieldDateTimeMetadata = {

View File

@@ -14,6 +14,9 @@ import { RecordInlineCellValue } from '@/object-record/record-inline-cell/compon
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId'; import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useRecordInlineCellContext } from './RecordInlineCellContext'; import { useRecordInlineCellContext } from './RecordInlineCellContext';
const StyledIconContainer = styled.div` const StyledIconContainer = styled.div`
@@ -36,6 +39,7 @@ const StyledLabelAndIconContainer = styled.div`
color: ${({ theme }) => theme.font.color.tertiary}; color: ${({ theme }) => theme.font.color.tertiary};
display: flex; display: flex;
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
`; `;
const StyledValueContainer = styled.div` const StyledValueContainer = styled.div`
@@ -52,11 +56,12 @@ const StyledLabelContainer = styled.div<{ width?: number }>`
`; `;
const StyledInlineCellBaseContainer = styled.div` const StyledInlineCellBaseContainer = styled.div`
align-items: center; align-items: flex-start;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
display: flex; display: flex;
height: 24px; height: fit-content;
line-height: 24px;
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
user-select: none; user-select: none;
justify-content: center; justify-content: center;
@@ -88,6 +93,10 @@ export const RecordInlineCellContainer = () => {
const { recordId, fieldDefinition } = useContext(FieldContext); const { recordId, fieldDefinition } = useContext(FieldContext);
if (isFieldText(fieldDefinition)) {
assertFieldMetadata(FieldMetadataType.Text, isFieldText, fieldDefinition);
}
const { setIsFocused } = useFieldFocus(); const { setIsFocused } = useFieldFocus();
const handleContainerMouseEnter = () => { const handleContainerMouseEnter = () => {
@@ -122,7 +131,7 @@ export const RecordInlineCellContainer = () => {
)} )}
{showLabel && label && ( {showLabel && label && (
<StyledLabelContainer width={labelWidth}> <StyledLabelContainer width={labelWidth}>
<OverflowingTextWithTooltip text={label} /> <OverflowingTextWithTooltip text={label} isLabel={true} />
</StyledLabelContainer> </StyledLabelContainer>
)} )}
{/* TODO: Displaying Tooltips on the board is causing performance issues https://react-tooltip.com/docs/examples/render */} {/* TODO: Displaying Tooltips on the board is causing performance issues https://react-tooltip.com/docs/examples/render */}

View File

@@ -23,8 +23,8 @@ const StyledRecordInlineCellNormalModeOuterContainer = styled.div<
isDisplayModeFixHeight ? '16px' : 'auto'}; isDisplayModeFixHeight ? '16px' : 'auto'};
min-height: 16px; min-height: 16px;
overflow: hidden; overflow: hidden;
padding: ${({ theme }) => theme.spacing(1)}; padding-right: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(1)};
${(props) => { ${(props) => {
if (props.isHovered === true) { if (props.isHovered === true) {
return css` return css`
@@ -39,15 +39,17 @@ const StyledRecordInlineCellNormalModeOuterContainer = styled.div<
`; `;
const StyledRecordInlineCellNormalModeInnerContainer = styled.div` const StyledRecordInlineCellNormalModeInnerContainer = styled.div`
align-content: center;
align-items: center; align-items: center;
color: ${({ theme }) => theme.font.color.primary}; color: ${({ theme }) => theme.font.color.primary};
font-size: 'inherit'; font-size: 'inherit';
font-weight: 'inherit'; font-weight: 'inherit';
height: fit-content; height: fit-content;
min-height: 24px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `;

View File

@@ -7,6 +7,8 @@ import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/componen
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs'; import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm'; import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard'; import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard';
import { settingsDataModelFieldtextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import { SettingsDataModelFieldTextSettingsFormCard } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard';
import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm'; import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm';
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard'; import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm'; import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
@@ -58,6 +60,10 @@ const numberFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Number) }) .object({ type: z.literal(FieldMetadataType.Number) })
.merge(settingsDataModelFieldNumberFormSchema); .merge(settingsDataModelFieldNumberFormSchema);
const textFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Text) })
.merge(settingsDataModelFieldtextFormSchema);
const otherFieldsFormSchema = z.object({ const otherFieldsFormSchema = z.object({
type: z.enum( type: z.enum(
Object.keys( Object.keys(
@@ -70,6 +76,7 @@ const otherFieldsFormSchema = z.object({
FieldMetadataType.Date, FieldMetadataType.Date,
FieldMetadataType.DateTime, FieldMetadataType.DateTime,
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Text,
]), ]),
) as [FieldMetadataType, ...FieldMetadataType[]], ) as [FieldMetadataType, ...FieldMetadataType[]],
), ),
@@ -86,6 +93,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
selectFieldFormSchema, selectFieldFormSchema,
multiSelectFieldFormSchema, multiSelectFieldFormSchema,
numberFieldFormSchema, numberFieldFormSchema,
textFieldFormSchema,
otherFieldsFormSchema, otherFieldsFormSchema,
], ],
); );
@@ -183,6 +191,15 @@ export const SettingsDataModelFieldSettingsFormCard = ({
); );
} }
if (fieldMetadataItem.type === FieldMetadataType.Text) {
return (
<SettingsDataModelFieldTextSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
);
}
if ( if (
fieldMetadataItem.type === FieldMetadataType.Select || fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect fieldMetadataItem.type === FieldMetadataType.MultiSelect

View File

@@ -0,0 +1,91 @@
import { Controller, useFormContext } from 'react-hook-form';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { Select } from '@/ui/input/components/Select';
import styled from '@emotion/styled';
import { CardContent } from 'twenty-ui';
import { z } from 'zod';
const StyledFormCardTitle = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
type SettingsDataModelFieldTextFormProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
};
export const textFieldDefaultValueSchema = z.object({
displayedMaxRows: z.number().nullable(),
});
export const settingsDataModelFieldtextFormSchema = z.object({
settings: textFieldDefaultValueSchema,
});
export type SettingsDataModelFieldTextFormValues = z.infer<
typeof settingsDataModelFieldtextFormSchema
>;
export const SettingsDataModelFieldTextForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldTextFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
return (
<CardContent>
<Controller
name="settings"
defaultValue={{
displayedMaxRows: fieldMetadataItem?.settings?.displayedMaxRows || 0,
}}
control={control}
render={({ field: { onChange, value } }) => {
const displayedMaxRows = value?.displayedMaxRows ?? 0;
return (
<>
<StyledFormCardTitle>Wrap on record pages</StyledFormCardTitle>
<Select
disabled={disabled}
dropdownId="selectTextWrap"
options={[
{
label: 'Deactivated',
value: 0,
},
{
label: 'First 2 lines',
value: 2,
},
{
label: 'First 5 lines',
value: 5,
},
{
label: 'First 10 lines',
value: 10,
},
{
label: 'All lines',
value: 99,
},
]}
value={displayedMaxRows}
onChange={(value) => onChange({ displayedMaxRows: value })}
withSearchInput={false}
dropdownWidthAuto={true}
/>
</>
);
}}
/>
</CardContent>
);
};

View File

@@ -0,0 +1,45 @@
import styled from '@emotion/styled';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldTextForm } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
type SettingsDataModelFieldTextSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
export const SettingsDataModelFieldTextSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldTextSettingsFormCardProps) => {
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
}
form={
<SettingsDataModelFieldTextForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
}
/>
);
};

View File

@@ -30,18 +30,21 @@ export type SettingsDataModelFieldPreviewProps = {
}; };
const StyledFieldPreview = styled.div<{ shrink?: boolean }>` const StyledFieldPreview = styled.div<{ shrink?: boolean }>`
align-items: center; align-items: flex-start;
background-color: ${({ theme }) => theme.background.primary}; background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm}; border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex; display: flex;
gap: ${({ theme }) => theme.spacing(2)}; gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(8)}; height: fit-content;
line-height: 24px;
overflow: hidden; overflow: hidden;
padding: 0 padding: 0
${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))}; ${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))};
white-space: nowrap; white-space: nowrap;
margin-top: ${({ theme }) => theme.spacing(2)}; margin-top: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledFieldLabel = styled.div` const StyledFieldLabel = styled.div`

View File

@@ -1,3 +0,0 @@
import { TextDisplay } from './TextDisplay';
export const DoubleTextDisplay = TextDisplay;

View File

@@ -2,8 +2,9 @@ import { OverflowingTextWithTooltip } from 'twenty-ui';
type TextDisplayProps = { type TextDisplayProps = {
text: string; text: string;
displayedMaxRows?: number;
}; };
export const TextDisplay = ({ text }: TextDisplayProps) => ( export const TextDisplay = ({ text, displayedMaxRows }: TextDisplayProps) => (
<OverflowingTextWithTooltip text={text} /> <OverflowingTextWithTooltip text={text} displayedMaxRows={displayedMaxRows} />
); );

View File

@@ -5,6 +5,7 @@ import {
IsEnum, IsEnum,
IsInt, IsInt,
IsOptional, IsOptional,
Max,
Min, Min,
validateOrReject, validateOrReject,
} from 'class-validator'; } from 'class-validator';
@@ -31,6 +32,12 @@ class SettingsValidation {
@IsOptional() @IsOptional()
@IsEnum(ValueType) @IsEnum(ValueType)
type?: 'percentage' | 'number'; type?: 'percentage' | 'number';
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
displayedMaxRows?: number;
} }
@Injectable() @Injectable()
@@ -48,23 +55,26 @@ export class FieldMetadataValidationService<
}) { }) {
switch (fieldType) { switch (fieldType) {
case FieldMetadataType.NUMBER: case FieldMetadataType.NUMBER:
await this.validateNumberSettings(settings); case FieldMetadataType.TEXT:
await this.validateSettings(settings);
break; break;
default: default:
break; break;
} }
} }
private async validateNumberSettings(settings: any) { private async validateSettings(settings: any) {
try { try {
const settingsInstance = plainToInstance(SettingsValidation, settings); const settingsInstance = plainToInstance(SettingsValidation, settings);
await validateOrReject(settingsInstance); await validateOrReject(settingsInstance);
} catch (errors) { } catch (error) {
const errorMessages = errors const errorMessages = Array.isArray(error)
.map((error: any) => Object.values(error.constraints)) ? error
.map((err: any) => Object.values(err.constraints))
.flat() .flat()
.join(', '); .join(', ')
: error.message;
throw new FieldMetadataException( throw new FieldMetadataException(
`Value for settings is invalid: ${errorMessages}`, `Value for settings is invalid: ${errorMessages}`,

View File

@@ -16,6 +16,10 @@ type FieldMetadataNumberSettings = {
type?: string; type?: string;
}; };
type FieldMetadataTextSettings = {
displayedMaxRows?: number;
};
type FieldMetadataDateSettings = { type FieldMetadataDateSettings = {
displayAsRelativeDate?: boolean; displayAsRelativeDate?: boolean;
}; };
@@ -28,6 +32,7 @@ type FieldMetadataSettingsMapping = {
[FieldMetadataType.NUMBER]: FieldMetadataNumberSettings; [FieldMetadataType.NUMBER]: FieldMetadataNumberSettings;
[FieldMetadataType.DATE]: FieldMetadataDateSettings; [FieldMetadataType.DATE]: FieldMetadataDateSettings;
[FieldMetadataType.DATE_TIME]: FieldMetadataDateTimeSettings; [FieldMetadataType.DATE_TIME]: FieldMetadataDateTimeSettings;
[FieldMetadataType.TEXT]: FieldMetadataTextSettings;
}; };
type SettingsByFieldMetadata<T extends FieldMetadataType | 'default'> = type SettingsByFieldMetadata<T extends FieldMetadataType | 'default'> =

View File

@@ -11,6 +11,8 @@ const spacing4 = THEME_COMMON.spacing(4);
const StyledOverflowingText = styled.div<{ const StyledOverflowingText = styled.div<{
cursorPointer: boolean; cursorPointer: boolean;
size: 'large' | 'small'; size: 'large' | 'small';
displayedMaxRows?: number;
isLabel: boolean;
}>` }>`
cursor: ${({ cursorPointer }) => (cursorPointer ? 'pointer' : 'inherit')}; cursor: ${({ cursorPointer }) => (cursorPointer ? 'pointer' : 'inherit')};
font-family: inherit; font-family: inherit;
@@ -26,6 +28,15 @@ const StyledOverflowingText = styled.div<{
height: ${({ size }) => (size === 'large' ? spacing4 : 'auto')}; height: ${({ size }) => (size === 'large' ? spacing4 : 'auto')};
text-wrap-mode: ${({ isLabel, displayedMaxRows }) =>
isLabel === false && displayedMaxRows ? 'wrap' : 'nowrap'};
-webkit-line-clamp: ${({ isLabel, displayedMaxRows }) =>
isLabel === false && displayedMaxRows ? displayedMaxRows : 'inherit'};
display: ${({ isLabel, displayedMaxRows }) =>
isLabel === false && displayedMaxRows ? `-webkit-box` : 'block'};
-webkit-box-orient: ${({ isLabel, displayedMaxRows }) =>
isLabel === false && displayedMaxRows ? 'vertical' : 'inherit'};
& :hover { & :hover {
text-overflow: ${({ cursorPointer }) => text-overflow: ${({ cursorPointer }) =>
cursorPointer ? 'clip' : 'ellipsis'}; cursorPointer ? 'clip' : 'ellipsis'};
@@ -37,11 +48,15 @@ const StyledOverflowingText = styled.div<{
export const OverflowingTextWithTooltip = ({ export const OverflowingTextWithTooltip = ({
size = 'small', size = 'small',
text, text,
mutliline, isTooltipMultiline,
displayedMaxRows,
isLabel,
}: { }: {
size?: 'large' | 'small'; size?: 'large' | 'small';
text: string | null | undefined; text: string | null | undefined;
mutliline?: boolean; isTooltipMultiline?: boolean;
displayedMaxRows?: number;
isLabel?: boolean;
}) => { }) => {
const textElementId = `title-id-${+new Date()}`; const textElementId = `title-id-${+new Date()}`;
@@ -74,6 +89,8 @@ export const OverflowingTextWithTooltip = ({
data-testid="tooltip" data-testid="tooltip"
cursorPointer={isTitleOverflowing} cursorPointer={isTitleOverflowing}
size={size} size={size}
displayedMaxRows={displayedMaxRows}
isLabel={isLabel ?? false}
ref={textRef} ref={textRef}
id={textElementId} id={textElementId}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
@@ -86,7 +103,7 @@ export const OverflowingTextWithTooltip = ({
<div onClick={handleTooltipClick}> <div onClick={handleTooltipClick}>
<AppTooltip <AppTooltip
anchorSelect={`#${textElementId}`} anchorSelect={`#${textElementId}`}
content={mutliline ? undefined : (text ?? '')} content={isTooltipMultiline ? undefined : (text ?? '')}
offset={5} offset={5}
isOpen isOpen
noArrow noArrow
@@ -94,7 +111,7 @@ export const OverflowingTextWithTooltip = ({
positionStrategy="absolute" positionStrategy="absolute"
delay={TooltipDelay.mediumDelay} delay={TooltipDelay.mediumDelay}
> >
{mutliline ? <pre>{text}</pre> : ''} {isTooltipMultiline ? <pre>{text}</pre> : ''}
</AppTooltip> </AppTooltip>
</div>, </div>,
document.body, document.body,