mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 03:42:30 +00:00
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:
@@ -2,7 +2,12 @@ import { useTextFieldDisplay } from '@/object-record/record-field/meta-types/hoo
|
||||
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
|
||||
|
||||
export const TextFieldDisplay = () => {
|
||||
const { fieldValue } = useTextFieldDisplay();
|
||||
const { fieldValue, fieldDefinition } = useTextFieldDisplay();
|
||||
|
||||
return <TextDisplay text={fieldValue} />;
|
||||
return (
|
||||
<TextDisplay
|
||||
text={fieldValue}
|
||||
displayedMaxRows={fieldDefinition.metadata?.settings?.displayedMaxRows}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,11 +2,15 @@ import { useContext } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
export const useTextFieldDisplay = () => {
|
||||
const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
|
||||
|
||||
assertFieldMetadata(FieldMetadataType.Text, isFieldText, fieldDefinition);
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
|
||||
const fieldValue =
|
||||
|
||||
@@ -23,7 +23,9 @@ export type FieldTextMetadata = {
|
||||
objectMetadataNameSingular?: string;
|
||||
placeHolder: string;
|
||||
fieldName: string;
|
||||
settings?: Record<string, never>;
|
||||
settings?: {
|
||||
displayedMaxRows?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type FieldDateTimeMetadata = {
|
||||
|
||||
@@ -14,6 +14,9 @@ import { RecordInlineCellValue } from '@/object-record/record-inline-cell/compon
|
||||
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
|
||||
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';
|
||||
|
||||
const StyledIconContainer = styled.div`
|
||||
@@ -36,6 +39,7 @@ const StyledLabelAndIconContainer = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const StyledValueContainer = styled.div`
|
||||
@@ -52,11 +56,12 @@ const StyledLabelContainer = styled.div<{ width?: number }>`
|
||||
`;
|
||||
|
||||
const StyledInlineCellBaseContainer = styled.div`
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 24px;
|
||||
height: fit-content;
|
||||
line-height: 24px;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
user-select: none;
|
||||
justify-content: center;
|
||||
@@ -88,6 +93,10 @@ export const RecordInlineCellContainer = () => {
|
||||
|
||||
const { recordId, fieldDefinition } = useContext(FieldContext);
|
||||
|
||||
if (isFieldText(fieldDefinition)) {
|
||||
assertFieldMetadata(FieldMetadataType.Text, isFieldText, fieldDefinition);
|
||||
}
|
||||
|
||||
const { setIsFocused } = useFieldFocus();
|
||||
|
||||
const handleContainerMouseEnter = () => {
|
||||
@@ -122,7 +131,7 @@ export const RecordInlineCellContainer = () => {
|
||||
)}
|
||||
{showLabel && label && (
|
||||
<StyledLabelContainer width={labelWidth}>
|
||||
<OverflowingTextWithTooltip text={label} />
|
||||
<OverflowingTextWithTooltip text={label} isLabel={true} />
|
||||
</StyledLabelContainer>
|
||||
)}
|
||||
{/* TODO: Displaying Tooltips on the board is causing performance issues https://react-tooltip.com/docs/examples/render */}
|
||||
|
||||
@@ -23,8 +23,8 @@ const StyledRecordInlineCellNormalModeOuterContainer = styled.div<
|
||||
isDisplayModeFixHeight ? '16px' : 'auto'};
|
||||
min-height: 16px;
|
||||
overflow: hidden;
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
padding-right: ${({ theme }) => theme.spacing(1)};
|
||||
padding-left: ${({ theme }) => theme.spacing(1)};
|
||||
${(props) => {
|
||||
if (props.isHovered === true) {
|
||||
return css`
|
||||
@@ -39,15 +39,17 @@ const StyledRecordInlineCellNormalModeOuterContainer = styled.div<
|
||||
`;
|
||||
|
||||
const StyledRecordInlineCellNormalModeInnerContainer = styled.div`
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: 'inherit';
|
||||
|
||||
font-weight: 'inherit';
|
||||
|
||||
height: fit-content;
|
||||
|
||||
min-height: 24px;
|
||||
overflow: hidden;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/componen
|
||||
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
|
||||
import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
|
||||
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 { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
|
||||
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
|
||||
@@ -58,6 +60,10 @@ const numberFieldFormSchema = z
|
||||
.object({ type: z.literal(FieldMetadataType.Number) })
|
||||
.merge(settingsDataModelFieldNumberFormSchema);
|
||||
|
||||
const textFieldFormSchema = z
|
||||
.object({ type: z.literal(FieldMetadataType.Text) })
|
||||
.merge(settingsDataModelFieldtextFormSchema);
|
||||
|
||||
const otherFieldsFormSchema = z.object({
|
||||
type: z.enum(
|
||||
Object.keys(
|
||||
@@ -70,6 +76,7 @@ const otherFieldsFormSchema = z.object({
|
||||
FieldMetadataType.Date,
|
||||
FieldMetadataType.DateTime,
|
||||
FieldMetadataType.Number,
|
||||
FieldMetadataType.Text,
|
||||
]),
|
||||
) as [FieldMetadataType, ...FieldMetadataType[]],
|
||||
),
|
||||
@@ -86,6 +93,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
|
||||
selectFieldFormSchema,
|
||||
multiSelectFieldFormSchema,
|
||||
numberFieldFormSchema,
|
||||
textFieldFormSchema,
|
||||
otherFieldsFormSchema,
|
||||
],
|
||||
);
|
||||
@@ -183,6 +191,15 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMetadataItem.type === FieldMetadataType.Text) {
|
||||
return (
|
||||
<SettingsDataModelFieldTextSettingsFormCard
|
||||
fieldMetadataItem={fieldMetadataItem}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMetadataItem.type === FieldMetadataType.Select ||
|
||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -30,18 +30,21 @@ export type SettingsDataModelFieldPreviewProps = {
|
||||
};
|
||||
|
||||
const StyledFieldPreview = styled.div<{ shrink?: boolean }>`
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
height: fit-content;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
padding: 0
|
||||
${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))};
|
||||
white-space: nowrap;
|
||||
margin-top: ${({ theme }) => theme.spacing(2)};
|
||||
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledFieldLabel = styled.div`
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { TextDisplay } from './TextDisplay';
|
||||
|
||||
export const DoubleTextDisplay = TextDisplay;
|
||||
@@ -2,8 +2,9 @@ import { OverflowingTextWithTooltip } from 'twenty-ui';
|
||||
|
||||
type TextDisplayProps = {
|
||||
text: string;
|
||||
displayedMaxRows?: number;
|
||||
};
|
||||
|
||||
export const TextDisplay = ({ text }: TextDisplayProps) => (
|
||||
<OverflowingTextWithTooltip text={text} />
|
||||
export const TextDisplay = ({ text, displayedMaxRows }: TextDisplayProps) => (
|
||||
<OverflowingTextWithTooltip text={text} displayedMaxRows={displayedMaxRows} />
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Max,
|
||||
Min,
|
||||
validateOrReject,
|
||||
} from 'class-validator';
|
||||
@@ -31,6 +32,12 @@ class SettingsValidation {
|
||||
@IsOptional()
|
||||
@IsEnum(ValueType)
|
||||
type?: 'percentage' | 'number';
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
displayedMaxRows?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -48,23 +55,26 @@ export class FieldMetadataValidationService<
|
||||
}) {
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.NUMBER:
|
||||
await this.validateNumberSettings(settings);
|
||||
case FieldMetadataType.TEXT:
|
||||
await this.validateSettings(settings);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async validateNumberSettings(settings: any) {
|
||||
private async validateSettings(settings: any) {
|
||||
try {
|
||||
const settingsInstance = plainToInstance(SettingsValidation, settings);
|
||||
|
||||
await validateOrReject(settingsInstance);
|
||||
} catch (errors) {
|
||||
const errorMessages = errors
|
||||
.map((error: any) => Object.values(error.constraints))
|
||||
.flat()
|
||||
.join(', ');
|
||||
} catch (error) {
|
||||
const errorMessages = Array.isArray(error)
|
||||
? error
|
||||
.map((err: any) => Object.values(err.constraints))
|
||||
.flat()
|
||||
.join(', ')
|
||||
: error.message;
|
||||
|
||||
throw new FieldMetadataException(
|
||||
`Value for settings is invalid: ${errorMessages}`,
|
||||
|
||||
@@ -16,6 +16,10 @@ type FieldMetadataNumberSettings = {
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type FieldMetadataTextSettings = {
|
||||
displayedMaxRows?: number;
|
||||
};
|
||||
|
||||
type FieldMetadataDateSettings = {
|
||||
displayAsRelativeDate?: boolean;
|
||||
};
|
||||
@@ -28,6 +32,7 @@ type FieldMetadataSettingsMapping = {
|
||||
[FieldMetadataType.NUMBER]: FieldMetadataNumberSettings;
|
||||
[FieldMetadataType.DATE]: FieldMetadataDateSettings;
|
||||
[FieldMetadataType.DATE_TIME]: FieldMetadataDateTimeSettings;
|
||||
[FieldMetadataType.TEXT]: FieldMetadataTextSettings;
|
||||
};
|
||||
|
||||
type SettingsByFieldMetadata<T extends FieldMetadataType | 'default'> =
|
||||
|
||||
@@ -11,6 +11,8 @@ const spacing4 = THEME_COMMON.spacing(4);
|
||||
const StyledOverflowingText = styled.div<{
|
||||
cursorPointer: boolean;
|
||||
size: 'large' | 'small';
|
||||
displayedMaxRows?: number;
|
||||
isLabel: boolean;
|
||||
}>`
|
||||
cursor: ${({ cursorPointer }) => (cursorPointer ? 'pointer' : 'inherit')};
|
||||
font-family: inherit;
|
||||
@@ -26,6 +28,15 @@ const StyledOverflowingText = styled.div<{
|
||||
|
||||
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 {
|
||||
text-overflow: ${({ cursorPointer }) =>
|
||||
cursorPointer ? 'clip' : 'ellipsis'};
|
||||
@@ -37,11 +48,15 @@ const StyledOverflowingText = styled.div<{
|
||||
export const OverflowingTextWithTooltip = ({
|
||||
size = 'small',
|
||||
text,
|
||||
mutliline,
|
||||
isTooltipMultiline,
|
||||
displayedMaxRows,
|
||||
isLabel,
|
||||
}: {
|
||||
size?: 'large' | 'small';
|
||||
text: string | null | undefined;
|
||||
mutliline?: boolean;
|
||||
isTooltipMultiline?: boolean;
|
||||
displayedMaxRows?: number;
|
||||
isLabel?: boolean;
|
||||
}) => {
|
||||
const textElementId = `title-id-${+new Date()}`;
|
||||
|
||||
@@ -74,6 +89,8 @@ export const OverflowingTextWithTooltip = ({
|
||||
data-testid="tooltip"
|
||||
cursorPointer={isTitleOverflowing}
|
||||
size={size}
|
||||
displayedMaxRows={displayedMaxRows}
|
||||
isLabel={isLabel ?? false}
|
||||
ref={textRef}
|
||||
id={textElementId}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
@@ -86,7 +103,7 @@ export const OverflowingTextWithTooltip = ({
|
||||
<div onClick={handleTooltipClick}>
|
||||
<AppTooltip
|
||||
anchorSelect={`#${textElementId}`}
|
||||
content={mutliline ? undefined : (text ?? '')}
|
||||
content={isTooltipMultiline ? undefined : (text ?? '')}
|
||||
offset={5}
|
||||
isOpen
|
||||
noArrow
|
||||
@@ -94,7 +111,7 @@ export const OverflowingTextWithTooltip = ({
|
||||
positionStrategy="absolute"
|
||||
delay={TooltipDelay.mediumDelay}
|
||||
>
|
||||
{mutliline ? <pre>{text}</pre> : ''}
|
||||
{isTooltipMultiline ? <pre>{text}</pre> : ''}
|
||||
</AppTooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
Reference in New Issue
Block a user