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:
Nabhag Motivaras
2024-09-21 00:55:50 +05:30
committed by GitHub
parent c8c1890ad7
commit c97c71762e
22 changed files with 249 additions and 187 deletions

View File

@ -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 (

View File

@ -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 />

View File

@ -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}

View File

@ -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 (

View File

@ -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;
};

View File

@ -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 = {

View File

@ -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': {

View File

@ -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) {

View File

@ -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}
/> />

View File

@ -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);

View File

@ -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({

View File

@ -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}

View File

@ -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}
{...{ {...{

View File

@ -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) {

View File

@ -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>

View File

@ -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>
)}
</>
); );
}; };

View File

@ -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>

View File

@ -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,

View File

@ -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,

View 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(), ''];
};

View File

@ -0,0 +1,3 @@
export const turnIntoEmptyStringIfWhitespacesOnly = (value: string): string => {
return value.trim().length > 0 ? value : '';
};

View File

@ -0,0 +1,5 @@
export const turnIntoUndefinedIfWhitespacesOnly = (
value: string,
): string | undefined => {
return value.trim() === '' ? undefined : value.trim();
};