Error invalid link (#10288)

Don't have access to push on
https://github.com/twentyhq/twenty/pull/9942, so close it and open new
PR here

<img width="244" alt="Screenshot 2025-02-18 at 11 09 39"
src="https://github.com/user-attachments/assets/4bc1b436-147a-4d17-88c8-2aff0fffd06a"
/>
<img width="246" alt="Screenshot 2025-02-18 at 11 09 51"
src="https://github.com/user-attachments/assets/3d7b2972-ab7e-4e3b-a177-658325a3bb70"
/>

Ok for RecordInlineCell / RecordTableCell and EmailsFieldInput /
LinksFieldInput.
I think it's too complex for a so small issue (agree with you khuddite)

closes https://github.com/twentyhq/twenty/issues/9778
on top of https://github.com/twentyhq/twenty/pull/9942 from @khuddite

---------

Co-authored-by: khuddite <khuddite@gmail.com>
Co-authored-by: khuddite <62555977+khuddite@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2025-03-21 10:18:55 +01:00
committed by GitHub
parent bd162da318
commit 99438a810c
14 changed files with 200 additions and 115 deletions

View File

@ -14,6 +14,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
@ -113,7 +114,13 @@ export const CalendarEventDetails = ({
maxWidth: 300,
}}
>
<RecordInlineCell readonly />
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: `${calendarEvent.id}-${fieldName}`,
}}
>
<RecordInlineCell readonly />
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
</StyledPropertyBox>
));

View File

@ -8,6 +8,7 @@ import {
RecordUpdateHook,
RecordUpdateHookParams,
} from '@/object-record/record-field/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
@ -61,7 +62,13 @@ export const RecordBoardCardBody = ({
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell />
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: `board-card-${recordId}-${fieldDefinition.fieldMetadataId}`,
}}
>
<RecordInlineCell />
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
</StopPropagationContainer>
))}

View File

@ -17,7 +17,6 @@ import { isFieldRelationToOneObject } from '@/object-record/record-field/types/g
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ArrayFieldInput } from '@/object-record/record-field/meta-types/input/components/ArrayFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
@ -72,114 +71,108 @@ export const FieldInput = ({
const { fieldDefinition } = useContext(FieldContext);
return (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: recordFieldInputdId,
}}
<RecordFieldInputScope
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
>
<RecordFieldInputScope
recordFieldInputScopeId={getScopeIdFromComponentId(recordFieldInputdId)}
>
{isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput onSubmit={onSubmit} />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldText(fieldDefinition) ? (
<TextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldDateTime(fieldDefinition) ? (
<DateTimeFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldDate(fieldDefinition) ? (
<DateFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldBoolean(fieldDefinition) ? (
<BooleanFieldInput onSubmit={onSubmit} readonly={isReadOnly} />
) : isFieldRating(fieldDefinition) ? (
<RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldInput onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldRawJson(fieldDefinition) ? (
<RawJsonFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : (
<></>
)}
</RecordFieldInputScope>
</RecordFieldComponentInstanceContext.Provider>
{isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput onSubmit={onSubmit} />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldText(fieldDefinition) ? (
<TextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldDateTime(fieldDefinition) ? (
<DateTimeFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldDate(fieldDefinition) ? (
<DateFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onClear={onSubmit}
onSubmit={onSubmit}
/>
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldBoolean(fieldDefinition) ? (
<BooleanFieldInput onSubmit={onSubmit} readonly={isReadOnly} />
) : isFieldRating(fieldDefinition) ? (
<RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldInput onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldRawJson(fieldDefinition) ? (
<RawJsonFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldArray(fieldDefinition) ? (
<ArrayFieldInput
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : (
<></>
)}
</RecordFieldInputScope>
);
};

View File

@ -1,6 +1,8 @@
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-shared';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -44,6 +46,14 @@ export const EmailsFieldInput = ({
const isPrimaryEmail = (index: number) => index === 0 && emails?.length > 1;
const setIsFieldInError = useSetRecoilComponentStateV2(
recordFieldInputIsFieldInErrorComponentState,
);
const handleError = (hasError: boolean, values: any[]) => {
setIsFieldInError(hasError && values.length === 0);
};
return (
<MultiItemFieldInput
items={emails}
@ -70,6 +80,7 @@ export const EmailsFieldInput = ({
onDelete={handleDelete}
/>
)}
onError={handleError}
hotkeyScope={hotkeyScope}
/>
);

View File

@ -1,5 +1,7 @@
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useMemo } from 'react';
import { absoluteUrlSchema, isDefined } from 'twenty-shared';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -47,6 +49,14 @@ export const LinksFieldInput = ({
const isPrimaryLink = (index: number) => index === 0 && links?.length > 1;
const setIsFieldInError = useSetRecoilComponentStateV2(
recordFieldInputIsFieldInErrorComponentState,
);
const handleError = (hasError: boolean, values: any[]) => {
setIsFieldInError(hasError && values.length === 0);
};
return (
<MultiItemFieldInput
items={links}
@ -59,6 +69,7 @@ export const LinksFieldInput = ({
isValid: absoluteUrlSchema.safeParse(input).success,
errorMessage: '',
})}
onError={handleError}
formatInput={(input) => ({ url: input, label: '' })}
renderItem={({
value: link,

View File

@ -20,6 +20,13 @@ const StyledInput = styled.input<{
border-radius: 4px;
border: 1px solid ${theme.border.color.medium};
`}
${({ hasError, hasItem, theme }) =>
hasError &&
hasItem &&
css`
border: 1px solid ${theme.border.color.danger};
`}
box-sizing: border-box;
font-weight: ${({ theme }) => theme.font.weight.medium};

View File

@ -36,6 +36,7 @@ type MultiItemFieldInputProps<T> = {
fieldMetadataType: FieldMetadataType;
renderInput?: MultiItemBaseInputProps['renderInput'];
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
onError?: (hasError: boolean, values: any[]) => void;
};
// Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ...
@ -53,6 +54,7 @@ export const MultiItemFieldInput = <T,>({
fieldMetadataType,
renderInput,
onClickOutside,
onError,
}: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleDropdownClose = () => {
@ -85,6 +87,7 @@ export const MultiItemFieldInput = <T,>({
setErrorData(
errorData.isValid ? errorData : { isValid: true, errorMessage: '' },
);
onError?.(false, items);
};
const handleAddButtonClick = () => {
@ -123,6 +126,7 @@ export const MultiItemFieldInput = <T,>({
if (validateInput !== undefined) {
const validationData = validateInput(inputValue) ?? { isValid: true };
if (!validationData.isValid) {
onError?.(true, items);
setErrorData(validationData);
return;
}

View File

@ -0,0 +1,9 @@
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const recordFieldInputIsFieldInErrorComponentState =
createComponentStateV2<boolean>({
key: 'recordFieldInputIsFieldInErrorComponentState',
defaultValue: false,
componentInstanceContext: RecordFieldComponentInstanceContext,
});

View File

@ -1,9 +1,11 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { RecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import {
@ -63,6 +65,10 @@ export const RecordInlineCellEditMode = ({
},
};
const isFieldInError = useRecoilComponentValueV2(
recordFieldInputIsFieldInErrorComponentState,
);
const { refs, floatingStyles } = useFloating({
placement: isCentered ? 'bottom' : 'bottom-start',
middleware: [
@ -93,6 +99,7 @@ export const RecordInlineCellEditMode = ({
ref={refs.setFloating}
style={floatingStyles}
borderRadius="sm"
hasDangerBorder={isFieldInError}
>
{children}
</OverlayContainer>,

View File

@ -6,6 +6,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader';
@ -141,7 +142,13 @@ export const FieldsCard = ({
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell loading={recordLoading} />
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: `${objectRecordId}-${fieldMetadataItem.id}`,
}}
>
<RecordInlineCell loading={recordLoading} />
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
))}
</>

View File

@ -28,6 +28,7 @@ import {
} from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
@ -280,7 +281,13 @@ export const RecordDetailRelationRecordsListItem = ({
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell />
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: `${relationRecord.id}-${fieldMetadataItem.id}`,
}}
>
<RecordInlineCell />
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
),
)}

View File

@ -1,8 +1,10 @@
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { getRecordFieldInputId } from '@/object-record/utils/getRecordFieldInputId';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import styled from '@emotion/styled';
import {
@ -35,6 +37,10 @@ export const RecordTableCellEditMode = ({
}: RecordTableCellEditModeProps) => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const isFieldInError = useRecoilComponentValueV2(
recordFieldInputIsFieldInErrorComponentState,
);
const instanceId = getRecordFieldInputId(
recordId,
fieldDefinition?.metadata?.fieldName,
@ -84,6 +90,7 @@ export const RecordTableCellEditMode = ({
ref={refs.setFloating}
style={floatingStyles}
borderRadius="sm"
hasDangerBorder={isFieldInError}
>
{children}
</OverlayContainer>

View File

@ -2,6 +2,7 @@ import { ReactNode, useContext } from 'react';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
@ -87,7 +88,11 @@ export const RecordTableCellFieldContextWrapper = ({
displayedMaxRows: 1,
}}
>
{children}
<RecordFieldComponentInstanceContext.Provider
value={{ instanceId: recordId + columnDefinition.label }}
>
{children}
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
);
};

View File

@ -3,6 +3,7 @@ import styled from '@emotion/styled';
// eslint-disable-next-line @nx/workspace-styled-components-prefixed-with-styled
export const OverlayContainer = styled.div<{
borderRadius?: 'sm' | 'md';
hasDangerBorder?: boolean;
}>`
align-items: center;
display: flex;
@ -14,7 +15,9 @@ export const OverlayContainer = styled.div<{
theme.border.radius[borderRadius ?? 'md']};
background: ${({ theme }) => theme.background.transparent.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border: 1px solid
${({ theme, hasDangerBorder }) =>
theme.border.color[hasDangerBorder ? 'danger' : 'medium']};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
overflow: hidden;