fix: Input fields to have expected behaviour in case of empty / only whitespaces string (#6736)
# ISSUE - Closes #6734 - Closes #6633 - Closes #6733 - Closes #6816 # Description - [x] Don't allow Empty (whitespaces) Objects to Create, all the keyboard shortcuts are also handled for this. https://github.com/user-attachments/assets/1c9add4e-f13f-458b-8f76-63bd868413a2 https://github.com/user-attachments/assets/e72b6ee3-74e4-4517-a230-3eb10db80dc7 Note: we do have one other issue with FullName field #6740 Inorder to test use **shift**. - [x] Api Keys Input Name Field -> New and Detail View Name field shouldn't be empty or string with whitespaces, we won't able to have whitespaces in both. **Try Entering just spaces** https://github.com/user-attachments/assets/b521b49f-648c-4585-9d15-8ff4faed3c3a - [x] Similar to above, Empty webhook endpoint url under **/settings/developers/webhooks/new** won't be created. **Try Entering just spaces** - [x] New Functions or Updating Functions will not able to have whitespaces, empty string as Name. **Try Entering just spaces** https://github.com/user-attachments/assets/09fcf394-c6d9-4080-8efd-462b054a22d0 - [x] under **settings/workspace-members** changes will lead and solve that user won't be able to enter Invite by email as just whitespaces + button is now getting disabled when there is no correct email. **Try Entering just spaces** https://github.com/user-attachments/assets/b352edfa-113b-4645-80fd-db6f120ab5db - [x] Text Input Field, will not allow to start entering with whitespaces and won't take just whitespaces as value spaces between words will work. https://github.com/user-attachments/assets/8c1a0812-45be-4ed2-bd3d-bb4f92147976 - [x] Similarly Number works as per above including shortcuts. https://github.com/user-attachments/assets/9f69cc87-5c3c-43ee-93c4-fa887bc0d7ee - [x] Similarly FullName field works as per above including shortcuts https://github.com/user-attachments/assets/7bb006b2-abf7-44cd-a214-7a2fc68df169 - [x] Pasting fullName is been Improved. - Case 1 (Two Words): If there are exactly two words, return them as is. - Case 2 (More than Two Words): If there are more than two words, return the first two words only. - Case 3 (One Word): If there is only one word, return it as the first name, with an empty string as the last name. - WhiteSpaces have been handled. ``` console.log(splitFullName("John Doe")); // ["John", "Doe"] console.log(splitFullName(" ")); // ["", ""] console.log(splitFullName("John")); // ["John", ""] console.log(splitFullName(" John Doe ")); // ["John", "Doe"] console.log(splitFullName("John Michael Andrew Doe")); // ["John", "Michael"] ``` --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
@ -3,8 +3,7 @@ import { FieldDoubleText } from '@/object-record/record-field/types/FieldDoubleT
|
|||||||
import { DoubleTextInput } from '@/ui/field/input/components/DoubleTextInput';
|
import { DoubleTextInput } from '@/ui/field/input/components/DoubleTextInput';
|
||||||
import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay';
|
import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay';
|
||||||
|
|
||||||
import { usePersistField } from '../../../hooks/usePersistField';
|
import { isDoubleTextFieldEmpty } from '@/object-record/record-field/meta-types/input/utils/isDoubleTextFieldEmpty';
|
||||||
|
|
||||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||||
|
|
||||||
const FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS =
|
const FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS =
|
||||||
@ -28,46 +27,55 @@ export const FullNameFieldInput = ({
|
|||||||
onTab,
|
onTab,
|
||||||
onShiftTab,
|
onShiftTab,
|
||||||
}: FullNameFieldInputProps) => {
|
}: FullNameFieldInputProps) => {
|
||||||
const { hotkeyScope, draftValue, setDraftValue } = useFullNameField();
|
const { hotkeyScope, draftValue, setDraftValue, persistFullNameField } =
|
||||||
|
useFullNameField();
|
||||||
const persistField = usePersistField();
|
|
||||||
|
|
||||||
const convertToFullName = (newDoubleText: FieldDoubleText) => {
|
const convertToFullName = (newDoubleText: FieldDoubleText) => {
|
||||||
return {
|
return {
|
||||||
firstName: newDoubleText.firstValue,
|
firstName: newDoubleText.firstValue.trim(),
|
||||||
lastName: newDoubleText.secondValue,
|
lastName: newDoubleText.secondValue.trim(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRequiredDraftValueFromDoubleText = (
|
||||||
|
newDoubleText: FieldDoubleText,
|
||||||
|
) => {
|
||||||
|
return isDoubleTextFieldEmpty(newDoubleText)
|
||||||
|
? undefined
|
||||||
|
: convertToFullName(newDoubleText);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnter = (newDoubleText: FieldDoubleText) => {
|
const handleEnter = (newDoubleText: FieldDoubleText) => {
|
||||||
onEnter?.(() => persistField(convertToFullName(newDoubleText)));
|
onEnter?.(() => persistFullNameField(convertToFullName(newDoubleText)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEscape = (newDoubleText: FieldDoubleText) => {
|
const handleEscape = (newDoubleText: FieldDoubleText) => {
|
||||||
onEscape?.(() => persistField(convertToFullName(newDoubleText)));
|
onEscape?.(() => persistFullNameField(convertToFullName(newDoubleText)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = (
|
const handleClickOutside = (
|
||||||
event: MouseEvent | TouchEvent,
|
event: MouseEvent | TouchEvent,
|
||||||
newDoubleText: FieldDoubleText,
|
newDoubleText: FieldDoubleText,
|
||||||
) => {
|
) => {
|
||||||
onClickOutside?.(() => persistField(convertToFullName(newDoubleText)));
|
onClickOutside?.(() =>
|
||||||
|
persistFullNameField(convertToFullName(newDoubleText)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTab = (newDoubleText: FieldDoubleText) => {
|
const handleTab = (newDoubleText: FieldDoubleText) => {
|
||||||
onTab?.(() => persistField(convertToFullName(newDoubleText)));
|
onTab?.(() => persistFullNameField(convertToFullName(newDoubleText)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShiftTab = (newDoubleText: FieldDoubleText) => {
|
const handleShiftTab = (newDoubleText: FieldDoubleText) => {
|
||||||
onShiftTab?.(() => persistField(convertToFullName(newDoubleText)));
|
onShiftTab?.(() => persistFullNameField(convertToFullName(newDoubleText)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (newDoubleText: FieldDoubleText) => {
|
const handleChange = (newDoubleText: FieldDoubleText) => {
|
||||||
setDraftValue(convertToFullName(newDoubleText));
|
setDraftValue(getRequiredDraftValueFromDoubleText(newDoubleText));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaste = (newDoubleText: FieldDoubleText) => {
|
const handlePaste = (newDoubleText: FieldDoubleText) => {
|
||||||
setDraftValue(convertToFullName(newDoubleText));
|
setDraftValue(getRequiredDraftValueFromDoubleText(newDoubleText));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/Men
|
|||||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
|
|
||||||
const StyledRelationPickerContainer = styled.div`
|
const StyledRelationPickerContainer = styled.div`
|
||||||
left: -1px;
|
left: -1px;
|
||||||
@ -109,7 +110,11 @@ export const MultiSelectFieldInput = ({
|
|||||||
<DropdownMenu data-select-disable>
|
<DropdownMenu data-select-disable>
|
||||||
<DropdownMenuSearchInput
|
<DropdownMenuSearchInput
|
||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
onChange={(event) => setSearchFilter(event.currentTarget.value)}
|
onChange={(event) =>
|
||||||
|
setSearchFilter(
|
||||||
|
turnIntoEmptyStringIfWhitespacesOnly(event.currentTarget.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export const NumberFieldInput = ({
|
|||||||
<TextInput
|
<TextInput
|
||||||
placeholder={fieldDefinition.metadata.placeHolder}
|
placeholder={fieldDefinition.metadata.placeHolder}
|
||||||
autoFocus
|
autoFocus
|
||||||
value={draftValue ?? ''}
|
value={draftValue?.toString() ?? ''}
|
||||||
onClickOutside={handleClickOutside}
|
onClickOutside={handleClickOutside}
|
||||||
onEnter={handleEnter}
|
onEnter={handleEnter}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput';
|
|||||||
import { usePersistField } from '../../../hooks/usePersistField';
|
import { usePersistField } from '../../../hooks/usePersistField';
|
||||||
import { useTextField } from '../../hooks/useTextField';
|
import { useTextField } from '../../hooks/useTextField';
|
||||||
|
|
||||||
|
import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndefinedIfWhitespacesOnly';
|
||||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||||
|
|
||||||
export type TextFieldInputProps = {
|
export type TextFieldInputProps = {
|
||||||
@ -25,32 +26,31 @@ export const TextFieldInput = ({
|
|||||||
useTextField();
|
useTextField();
|
||||||
|
|
||||||
const persistField = usePersistField();
|
const persistField = usePersistField();
|
||||||
|
|
||||||
const handleEnter = (newText: string) => {
|
const handleEnter = (newText: string) => {
|
||||||
onEnter?.(() => persistField(newText));
|
onEnter?.(() => persistField(newText.trim()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEscape = (newText: string) => {
|
const handleEscape = (newText: string) => {
|
||||||
onEscape?.(() => persistField(newText));
|
onEscape?.(() => persistField(newText.trim()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = (
|
const handleClickOutside = (
|
||||||
event: MouseEvent | TouchEvent,
|
event: MouseEvent | TouchEvent,
|
||||||
newText: string,
|
newText: string,
|
||||||
) => {
|
) => {
|
||||||
onClickOutside?.(() => persistField(newText));
|
onClickOutside?.(() => persistField(newText.trim()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTab = (newText: string) => {
|
const handleTab = (newText: string) => {
|
||||||
onTab?.(() => persistField(newText));
|
onTab?.(() => persistField(newText.trim()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShiftTab = (newText: string) => {
|
const handleShiftTab = (newText: string) => {
|
||||||
onShiftTab?.(() => persistField(newText));
|
onShiftTab?.(() => persistField(newText.trim()));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (newText: string) => {
|
const handleChange = (newText: string) => {
|
||||||
setDraftValue(newText);
|
setDraftValue(turnIntoUndefinedIfWhitespacesOnly(newText));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { FieldDoubleText } from '@/object-record/record-field/types/FieldDoubleText';
|
||||||
|
|
||||||
|
export const isDoubleTextFieldEmpty = (doubleText: FieldDoubleText) => {
|
||||||
|
const { firstValue, secondValue } = doubleText;
|
||||||
|
|
||||||
|
const totalLength = firstValue.trim().length + secondValue.trim().length;
|
||||||
|
|
||||||
|
return totalLength > 0 ? false : true;
|
||||||
|
};
|
||||||
@ -25,7 +25,7 @@ import {
|
|||||||
} from '@/object-record/record-field/types/FieldMetadata';
|
} from '@/object-record/record-field/types/FieldMetadata';
|
||||||
|
|
||||||
export type FieldTextDraftValue = string;
|
export type FieldTextDraftValue = string;
|
||||||
export type FieldNumberDraftValue = string;
|
export type FieldNumberDraftValue = number;
|
||||||
export type FieldDateTimeDraftValue = string;
|
export type FieldDateTimeDraftValue = string;
|
||||||
export type FieldPhoneDraftValue = string;
|
export type FieldPhoneDraftValue = string;
|
||||||
export type FieldPhonesDraftValue = {
|
export type FieldPhonesDraftValue = {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { isNonEmptyString } from '@sniptt/guards';
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { useRef } from 'react';
|
import { Fragment, useRef } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Key } from 'ts-key-enum';
|
import { Key } from 'ts-key-enum';
|
||||||
import { IconComponent, IconPlus } from 'twenty-ui';
|
import { IconComponent, IconPlus } from 'twenty-ui';
|
||||||
@ -158,16 +158,15 @@ export const SingleEntitySelectMenuItems = ({
|
|||||||
switch (entity.id) {
|
switch (entity.id) {
|
||||||
case 'add-new': {
|
case 'add-new': {
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={entity.id}>
|
||||||
{entitiesToSelect.length > 0 && <DropdownMenuSeparator />}
|
{entitiesToSelect.length > 0 && <DropdownMenuSeparator />}
|
||||||
<CreateNewButton
|
<CreateNewButton
|
||||||
key={entity.id}
|
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
LeftIcon={IconPlus}
|
LeftIcon={IconPlus}
|
||||||
text="Add New"
|
text="Add New"
|
||||||
hovered={isSelectedAddNewButton}
|
hovered={isSelectedAddNewButton}
|
||||||
/>
|
/>
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'select-none': {
|
case 'select-none': {
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||||
import { TextInput } from '@/ui/input/components/TextInput';
|
import { TextInput } from '@/ui/input/components/TextInput';
|
||||||
|
import isEmpty from 'lodash.isempty';
|
||||||
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
|
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||||
@ -40,6 +41,7 @@ export const NameField = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const debouncedUpdate = useCallback(
|
const debouncedUpdate = useCallback(
|
||||||
useDebouncedCallback(async (name: string) => {
|
useDebouncedCallback(async (name: string) => {
|
||||||
|
if (isEmpty(name)) return;
|
||||||
// update local recoil state when workspace name is updated
|
// update local recoil state when workspace name is updated
|
||||||
setCurrentWorkspace((currentValue) => {
|
setCurrentWorkspace((currentValue) => {
|
||||||
if (currentValue === null) {
|
if (currentValue === null) {
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
|||||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
|
import { splitFullName } from '~/utils/format/spiltFullName';
|
||||||
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
import { StyledTextInput } from './TextInput';
|
import { StyledTextInput } from './TextInput';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
@ -167,9 +169,12 @@ export const DoubleTextInput = ({
|
|||||||
|
|
||||||
const name = event.clipboardData.getData('Text');
|
const name = event.clipboardData.getData('Text');
|
||||||
|
|
||||||
const splittedName = name.split(' ');
|
const splittedName = splitFullName(name);
|
||||||
|
|
||||||
onPaste?.({ firstValue: splittedName[0], secondValue: splittedName[1] });
|
onPaste?.({
|
||||||
|
firstValue: splittedName[0],
|
||||||
|
secondValue: splittedName[1],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickToPreventParentClickEvents = (
|
const handleClickToPreventParentClickEvents = (
|
||||||
@ -189,7 +194,10 @@ export const DoubleTextInput = ({
|
|||||||
placeholder={firstValuePlaceholder}
|
placeholder={firstValuePlaceholder}
|
||||||
value={firstInternalValue}
|
value={firstInternalValue}
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
handleChange(event.target.value, secondInternalValue);
|
handleChange(
|
||||||
|
turnIntoEmptyStringIfWhitespacesOnly(event.target.value),
|
||||||
|
secondInternalValue,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onPaste={(event: ClipboardEvent<HTMLInputElement>) =>
|
onPaste={(event: ClipboardEvent<HTMLInputElement>) =>
|
||||||
handleOnPaste(event)
|
handleOnPaste(event)
|
||||||
@ -203,7 +211,10 @@ export const DoubleTextInput = ({
|
|||||||
placeholder={secondValuePlaceholder}
|
placeholder={secondValuePlaceholder}
|
||||||
value={secondInternalValue}
|
value={secondInternalValue}
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
handleChange(firstInternalValue, event.target.value);
|
handleChange(
|
||||||
|
firstInternalValue,
|
||||||
|
turnIntoEmptyStringIfWhitespacesOnly(event.target.value),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onClick={handleClickToPreventParentClickEvents}
|
onClick={handleClickToPreventParentClickEvents}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import styled from '@emotion/styled';
|
|
||||||
import { TEXT_INPUT_STYLE } from 'twenty-ui';
|
import { TEXT_INPUT_STYLE } from 'twenty-ui';
|
||||||
|
|
||||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||||
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
|
|
||||||
export type TextAreaInputProps = {
|
export type TextAreaInputProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -67,10 +68,12 @@ export const TextAreaInput = ({
|
|||||||
copyButton = true,
|
copyButton = true,
|
||||||
}: TextAreaInputProps) => {
|
}: TextAreaInputProps) => {
|
||||||
const [internalText, setInternalText] = useState(value);
|
const [internalText, setInternalText] = useState(value);
|
||||||
|
|
||||||
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setInternalText(event.target.value);
|
const targetValue = turnIntoEmptyStringIfWhitespacesOnly(
|
||||||
onChange?.(event.target.value);
|
event.target.value,
|
||||||
|
);
|
||||||
|
setInternalText(targetValue);
|
||||||
|
onChange?.(targetValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapperRef = useRef<HTMLTextAreaElement>(null);
|
const wrapperRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
import { TEXT_INPUT_STYLE } from 'twenty-ui';
|
import { TEXT_INPUT_STYLE } from 'twenty-ui';
|
||||||
|
|
||||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||||
@ -44,12 +44,11 @@ export const TextInput = ({
|
|||||||
const copyRef = useRef<HTMLDivElement>(null);
|
const copyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
setInternalText(event.target.value);
|
setInternalText(event.target.value.trim());
|
||||||
onChange?.(event.target.value);
|
onChange?.(event.target.value.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInternalText(value);
|
setInternalText(value.trim());
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
useRegisterInputEvents({
|
useRegisterInputEvents({
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
import { FocusEventHandler } from 'react';
|
import { FocusEventHandler } from 'react';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import styled from '@emotion/styled';
|
|
||||||
|
|
||||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||||
|
|
||||||
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
import { InputHotkeyScope } from '../types/InputHotkeyScope';
|
import { InputHotkeyScope } from '../types/InputHotkeyScope';
|
||||||
|
|
||||||
const MAX_ROWS = 5;
|
const MAX_ROWS = 5;
|
||||||
@ -75,7 +76,9 @@ export const TextArea = ({
|
|||||||
maxRows={MAX_ROWS}
|
maxRows={MAX_ROWS}
|
||||||
minRows={computedMinRows}
|
minRows={computedMinRows}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
onChange={(event) =>
|
||||||
|
onChange?.(turnIntoEmptyStringIfWhitespacesOnly(event.target.value))
|
||||||
|
}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { IconComponent, IconEye, IconEyeOff } from 'twenty-ui';
|
import { IconComponent, IconEye, IconEyeOff } from 'twenty-ui';
|
||||||
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
|
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
|
||||||
|
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||||
|
|
||||||
const StyledContainer = styled.div<
|
const StyledContainer = styled.div<
|
||||||
Pick<TextInputV2ComponentProps, 'fullWidth'>
|
Pick<TextInputV2ComponentProps, 'fullWidth'>
|
||||||
@ -180,7 +181,7 @@ const TextInputV2Component = (
|
|||||||
</StyledLeftIconContainer>
|
</StyledLeftIconContainer>
|
||||||
)}
|
)}
|
||||||
<StyledInput
|
<StyledInput
|
||||||
data-testId={dataTestId}
|
data-testid={dataTestId}
|
||||||
autoComplete={autoComplete || 'off'}
|
autoComplete={autoComplete || 'off'}
|
||||||
ref={combinedRef}
|
ref={combinedRef}
|
||||||
tabIndex={tabIndex ?? 0}
|
tabIndex={tabIndex ?? 0}
|
||||||
@ -188,7 +189,9 @@ const TextInputV2Component = (
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
type={passwordVisible ? 'text' : type}
|
type={passwordVisible ? 'text' : type}
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange?.(event.target.value);
|
onChange?.(
|
||||||
|
turnIntoEmptyStringIfWhitespacesOnly(event.target.value),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
{...{
|
{...{
|
||||||
|
|||||||
@ -30,7 +30,6 @@ const StyledTopBarContainer = styled.div<{ width?: number }>`
|
|||||||
padding: ${({ theme }) => theme.spacing(2)};
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: ${({ theme }) => theme.spacing(3)};
|
padding-right: ${({ theme }) => theme.spacing(3)};
|
||||||
z-index: 20;
|
|
||||||
width: ${({ width }) => width + 'px' || '100%'};
|
width: ${({ width }) => width + 'px' || '100%'};
|
||||||
|
|
||||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
|
|||||||
@ -72,13 +72,16 @@ export const WorkspaceInviteTeam = () => {
|
|||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
const { sendInvitation } = useCreateWorkspaceInvitation();
|
const { sendInvitation } = useCreateWorkspaceInvitation();
|
||||||
|
|
||||||
const { reset, handleSubmit, control, formState } = useForm<FormInput>({
|
const { reset, handleSubmit, control, formState, watch } = useForm<FormInput>(
|
||||||
mode: 'onSubmit',
|
{
|
||||||
resolver: zodResolver(validationSchema()),
|
mode: 'onSubmit',
|
||||||
defaultValues: {
|
resolver: zodResolver(validationSchema()),
|
||||||
emails: '',
|
defaultValues: {
|
||||||
|
emails: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
const isEmailsEmpty = !watch('emails');
|
||||||
|
|
||||||
const submit = handleSubmit(async ({ emails }) => {
|
const submit = handleSubmit(async ({ emails }) => {
|
||||||
const emailsList = sanitizeEmailList(emails.split(','));
|
const emailsList = sanitizeEmailList(emails.split(','));
|
||||||
@ -109,7 +112,7 @@ export const WorkspaceInviteTeam = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isSubmitSuccessful } = formState;
|
const { isSubmitSuccessful, errors } = formState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSubmitSuccessful) {
|
if (isSubmitSuccessful) {
|
||||||
@ -144,6 +147,7 @@ export const WorkspaceInviteTeam = () => {
|
|||||||
accent="blue"
|
accent="blue"
|
||||||
title="Invite"
|
title="Invite"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
disabled={isEmailsEmpty || !!errors.emails}
|
||||||
/>
|
/>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -87,125 +87,116 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
navigate(developerPath);
|
navigate(developerPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!webhookData?.targetUrl) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SubMenuTopBarContainer
|
||||||
{webhookData?.targetUrl && (
|
Icon={IconCode}
|
||||||
<SubMenuTopBarContainer
|
title={webhookData.targetUrl}
|
||||||
Icon={IconCode}
|
links={[
|
||||||
title={webhookData.targetUrl}
|
{
|
||||||
links={[
|
children: 'Workspace',
|
||||||
{
|
href: getSettingsPagePath(SettingsPath.Workspace),
|
||||||
children: 'Workspace',
|
},
|
||||||
href: getSettingsPagePath(SettingsPath.Workspace),
|
{
|
||||||
},
|
children: 'Developers',
|
||||||
{
|
href: developerPath,
|
||||||
children: 'Developers',
|
},
|
||||||
href: developerPath,
|
{ children: 'Webhook' },
|
||||||
},
|
]}
|
||||||
{ children: 'Webhook' },
|
actionButton={
|
||||||
]}
|
<SaveAndCancelButtons
|
||||||
actionButton={
|
isSaveDisabled={!isDirty}
|
||||||
<SaveAndCancelButtons
|
onCancel={() => {
|
||||||
isSaveDisabled={!isDirty}
|
navigate(developerPath);
|
||||||
onCancel={() => {
|
}}
|
||||||
navigate(developerPath);
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsPageContainer>
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Endpoint URL"
|
||||||
|
description="We will send POST requests to this endpoint for every new event"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
placeholder="URL"
|
||||||
|
value={webhookData.targetUrl}
|
||||||
|
disabled
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section>
|
||||||
|
<H2Title title="Description" description="An optional description" />
|
||||||
|
<TextArea
|
||||||
|
placeholder="Write a description"
|
||||||
|
minRows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(description) => {
|
||||||
|
setDescription(description);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section>
|
||||||
|
<H2Title
|
||||||
|
title="Filters"
|
||||||
|
description="Select the event you wish to send to this endpoint"
|
||||||
|
/>
|
||||||
|
<StyledFilterRow>
|
||||||
|
<Select
|
||||||
|
fullWidth
|
||||||
|
dropdownId="object-webhook-type-select"
|
||||||
|
value={operationObjectSingularName}
|
||||||
|
onChange={(objectSingularName) => {
|
||||||
|
setIsDirty(true);
|
||||||
|
setOperationObjectSingularName(objectSingularName);
|
||||||
}}
|
}}
|
||||||
onSave={handleSave}
|
options={fieldTypeOptions}
|
||||||
/>
|
/>
|
||||||
}
|
<Select
|
||||||
>
|
fullWidth
|
||||||
<SettingsPageContainer>
|
dropdownId="operation-webhook-type-select"
|
||||||
<Section>
|
value={operationAction}
|
||||||
<H2Title
|
onChange={(operationAction) => {
|
||||||
title="Endpoint URL"
|
setIsDirty(true);
|
||||||
description="We will send POST requests to this endpoint for every new event"
|
setOperationAction(operationAction);
|
||||||
/>
|
}}
|
||||||
<TextInput
|
options={[
|
||||||
placeholder="URL"
|
{ value: '*', label: 'All Actions' },
|
||||||
value={webhookData.targetUrl}
|
{ value: 'create', label: 'Create' },
|
||||||
disabled
|
{ value: 'update', label: 'Update' },
|
||||||
fullWidth
|
{ value: 'delete', label: 'Delete' },
|
||||||
/>
|
]}
|
||||||
</Section>
|
/>
|
||||||
<Section>
|
</StyledFilterRow>
|
||||||
<H2Title
|
</Section>
|
||||||
title="Description"
|
<Section>
|
||||||
description="An optional description"
|
<H2Title title="Danger zone" description="Delete this integration" />
|
||||||
/>
|
<Button
|
||||||
<TextArea
|
accent="danger"
|
||||||
placeholder="Write a description"
|
variant="secondary"
|
||||||
minRows={4}
|
title="Delete"
|
||||||
value={description}
|
Icon={IconTrash}
|
||||||
onChange={(description) => {
|
onClick={() => setIsDeleteWebhookModalOpen(true)}
|
||||||
setDescription(description);
|
/>
|
||||||
setIsDirty(true);
|
<ConfirmationModal
|
||||||
}}
|
confirmationPlaceholder="yes"
|
||||||
/>
|
confirmationValue="yes"
|
||||||
</Section>
|
isOpen={isDeleteWebhookModalOpen}
|
||||||
<Section>
|
setIsOpen={setIsDeleteWebhookModalOpen}
|
||||||
<H2Title
|
title="Delete webhook"
|
||||||
title="Filters"
|
subtitle={
|
||||||
description="Select the event you wish to send to this endpoint"
|
<>Please type "yes" to confirm you want to delete this webhook.</>
|
||||||
/>
|
}
|
||||||
<StyledFilterRow>
|
onConfirmClick={deleteWebhook}
|
||||||
<Select
|
deleteButtonText="Delete webhook"
|
||||||
fullWidth
|
/>
|
||||||
dropdownId="object-webhook-type-select"
|
</Section>
|
||||||
value={operationObjectSingularName}
|
</SettingsPageContainer>
|
||||||
onChange={(objectSingularName) => {
|
</SubMenuTopBarContainer>
|
||||||
setIsDirty(true);
|
|
||||||
setOperationObjectSingularName(objectSingularName);
|
|
||||||
}}
|
|
||||||
options={fieldTypeOptions}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
fullWidth
|
|
||||||
dropdownId="operation-webhook-type-select"
|
|
||||||
value={operationAction}
|
|
||||||
onChange={(operationAction) => {
|
|
||||||
setIsDirty(true);
|
|
||||||
setOperationAction(operationAction);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: '*', label: 'All Actions' },
|
|
||||||
{ value: 'create', label: 'Create' },
|
|
||||||
{ value: 'update', label: 'Update' },
|
|
||||||
{ value: 'delete', label: 'Delete' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</StyledFilterRow>
|
|
||||||
</Section>
|
|
||||||
<Section>
|
|
||||||
<H2Title
|
|
||||||
title="Danger zone"
|
|
||||||
description="Delete this integration"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
accent="danger"
|
|
||||||
variant="secondary"
|
|
||||||
title="Delete"
|
|
||||||
Icon={IconTrash}
|
|
||||||
onClick={() => setIsDeleteWebhookModalOpen(true)}
|
|
||||||
/>
|
|
||||||
<ConfirmationModal
|
|
||||||
confirmationPlaceholder="yes"
|
|
||||||
confirmationValue="yes"
|
|
||||||
isOpen={isDeleteWebhookModalOpen}
|
|
||||||
setIsOpen={setIsDeleteWebhookModalOpen}
|
|
||||||
title="Delete webhook"
|
|
||||||
subtitle={
|
|
||||||
<>
|
|
||||||
Please type "yes" to confirm you want to delete this
|
|
||||||
webhook.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirmClick={deleteWebhook}
|
|
||||||
deleteButtonText="Delete webhook"
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
</SettingsPageContainer>
|
|
||||||
</SubMenuTopBarContainer>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { H2Title, IconCode } from 'twenty-ui';
|
import { H2Title, IconCode, isDefined } from 'twenty-ui';
|
||||||
|
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
||||||
@ -45,7 +45,22 @@ export const SettingsDevelopersWebhooksNew = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canSave =
|
const canSave =
|
||||||
!!formValues.targetUrl && isTargetUrlValid && createOneWebhook;
|
!!formValues.targetUrl && isTargetUrlValid && isDefined(createOneWebhook);
|
||||||
|
|
||||||
|
// TODO: refactor use useScopedHotkeys
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && canSave) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (value: string) => {
|
||||||
|
setFormValues((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
targetUrl: value,
|
||||||
|
}));
|
||||||
|
handleValidate(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubMenuTopBarContainer
|
<SubMenuTopBarContainer
|
||||||
@ -82,18 +97,8 @@ export const SettingsDevelopersWebhooksNew = () => {
|
|||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={formValues.targetUrl}
|
value={formValues.targetUrl}
|
||||||
error={!isTargetUrlValid ? 'Please enter a valid URL' : undefined}
|
error={!isTargetUrlValid ? 'Please enter a valid URL' : undefined}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={handleKeyDown}
|
||||||
if (e.key === 'Enter') {
|
onChange={handleChange}
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(value) => {
|
|
||||||
setFormValues((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
targetUrl: value,
|
|
||||||
}));
|
|
||||||
handleValidate(value);
|
|
||||||
}}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export const SettingsServerlessFunctionDetail = () => {
|
|||||||
const handleSave = usePreventOverlapCallback(save, 1000);
|
const handleSave = usePreventOverlapCallback(save, 1000);
|
||||||
|
|
||||||
const onChange = (key: string) => {
|
const onChange = (key: string) => {
|
||||||
return async (value: string | undefined) => {
|
return async (value: string) => {
|
||||||
setFormValues((prevState) => ({
|
setFormValues((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export const SettingsServerlessFunctionsNew = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onChange = (key: string) => {
|
const onChange = (key: string) => {
|
||||||
return (value: string | undefined) => {
|
return (value: string) => {
|
||||||
setFormValues((prevState) => ({
|
setFormValues((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
|
|||||||
13
packages/twenty-front/src/utils/format/spiltFullName.ts
Normal file
13
packages/twenty-front/src/utils/format/spiltFullName.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export const splitFullName = (name: string) => {
|
||||||
|
const splittedName = name.trim().split(/\s+/);
|
||||||
|
|
||||||
|
if (splittedName.length === 2) {
|
||||||
|
return splittedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (splittedName.length > 2) {
|
||||||
|
return [splittedName[0].trim(), splittedName[1].trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [name.trim(), ''];
|
||||||
|
};
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export const turnIntoEmptyStringIfWhitespacesOnly = (value: string): string => {
|
||||||
|
return value.trim().length > 0 ? value : '';
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export const turnIntoUndefinedIfWhitespacesOnly = (
|
||||||
|
value: string,
|
||||||
|
): string | undefined => {
|
||||||
|
return value.trim() === '' ? undefined : value.trim();
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user