Chore/move records related to record folder (#2859)

* WIP

* Finished multi select filter

* Cleaned console log

* Fix naming

* Fixed naming

* Moved RelationPicker folder

* Moved EntitySelect components

* Moved story

* Moved RelationPicker non component folders

* Moved everything else
This commit is contained in:
Lucas Bordeau
2023-12-07 12:43:10 +01:00
committed by GitHub
parent ef536ebb06
commit a8ecc23cbe
445 changed files with 407 additions and 412 deletions

View File

@ -5,9 +5,14 @@ import { CompanyTeam } from '@/companies/components/CompanyTeam';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { FieldContext } from '@/object-record/field/contexts/FieldContext';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { filterAvailableFieldMetadataItem } from '@/object-record/utils/filterAvailableFieldMetadataItem';
import { IconBuildingSkyscraper } from '@/ui/display/icon';
import { useRelationPicker } from '@/ui/input/components/internal/relation-picker/hooks/useRelationPicker';
import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageFavoriteButton } from '@/ui/layout/page/PageFavoriteButton';
@ -18,11 +23,6 @@ import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPage
import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { ShowPageRecoilScopeContext } from '@/ui/layout/states/ShowPageRecoilScopeContext';
import { FieldContext } from '@/ui/object/field/contexts/FieldContext';
import { entityFieldsFamilyState } from '@/ui/object/field/states/entityFieldsFamilyState';
import { RecordInlineCell } from '@/ui/object/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/ui/object/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/ui/object/record-inline-cell/types/InlineCellHotkeyScope';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FileFolder, useUploadImageMutation } from '~/generated/graphql';

View File

@ -4,10 +4,10 @@ import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/u
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordTable } from '@/ui/object/record-table/components/RecordTable';
import { TableOptionsDropdownId } from '@/ui/object/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { TableOptionsDropdown } from '@/ui/object/record-table/options/components/TableOptionsDropdown';
import { RecordTable } from '@/object-record/record-table/components/RecordTable';
import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown';
import { ViewBar } from '@/views/components/ViewBar';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';

View File

@ -4,8 +4,8 @@ import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/u
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useRecordTableContextMenuEntries } from '@/object-record/hooks/useRecordTableContextMenuEntries';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';
import { useRecordTable } from '@/ui/object/record-table/hooks/useRecordTable';
import { useViewBar } from '@/views/hooks/useViewBar';
import { ViewType } from '@/views/types/ViewType';

View File

@ -8,14 +8,14 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { RecordTableActionBar } from '@/object-record/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableContextMenu } from '@/object-record/record-table/context-menu/components/RecordTableContextMenu';
import { useLazyLoadIcons } from '@/ui/input/hooks/useLazyLoadIcons';
import { PageAddButton } from '@/ui/layout/page/PageAddButton';
import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageHeader } from '@/ui/layout/page/PageHeader';
import { PageHotkeysEffect } from '@/ui/layout/page/PageHotkeysEffect';
import { RecordTableActionBar } from '@/ui/object/record-table/action-bar/components/RecordTableActionBar';
import { RecordTableContextMenu } from '@/ui/object/record-table/context-menu/components/RecordTableContextMenu';
import { RecordTableContainer } from './RecordTableContainer';

View File

@ -0,0 +1,66 @@
import { useContext } from 'react';
import { FieldContext } from '../contexts/FieldContext';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyFieldDisplay';
import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay';
import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay';
import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay';
import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay';
import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay';
import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay';
import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay';
import { SelectFieldDisplay } from '../meta-types/display/components/SelectFieldDisplay';
import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisplay';
import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay';
import { isFieldCurrency } from '../types/guards/isFieldCurrency';
import { isFieldDateTime } from '../types/guards/isFieldDateTime';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldFullName } from '../types/guards/isFieldFullName';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldNumber } from '../types/guards/isFieldNumber';
import { isFieldPhone } from '../types/guards/isFieldPhone';
import { isFieldRelation } from '../types/guards/isFieldRelation';
import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText';
import { isFieldUuid } from '../types/guards/isFieldUuid';
export const FieldDisplay = () => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
if (
isLabelIdentifier &&
(isFieldText(fieldDefinition) || isFieldFullName(fieldDefinition))
) {
return <ChipFieldDisplay />;
}
return (
<>
{isFieldRelation(fieldDefinition) ? (
<RelationFieldDisplay />
) : isFieldText(fieldDefinition) ? (
<TextFieldDisplay />
) : isFieldUuid(fieldDefinition) ? (
<UuidFieldDisplay />
) : isFieldEmail(fieldDefinition) ? (
<EmailFieldDisplay />
) : isFieldDateTime(fieldDefinition) ? (
<DateFieldDisplay />
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldDisplay />
) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay />
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldDisplay />
) : isFieldPhone(fieldDefinition) ? (
<PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : (
<></>
)}
</>
);
};

View File

@ -0,0 +1,130 @@
import { useContext } from 'react';
import { FullNameFieldInput } from '@/object-record/field/meta-types/input/components/FullNameFieldInput';
import { isFieldFullName } from '@/object-record/field/types/guards/isFieldFullName';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FieldContext } from '../contexts/FieldContext';
import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput';
import { CurrencyFieldInput } from '../meta-types/input/components/CurrencyFieldInput';
import { DateFieldInput } from '../meta-types/input/components/DateFieldInput';
import { EmailFieldInput } from '../meta-types/input/components/EmailFieldInput';
import { LinkFieldInput } from '../meta-types/input/components/LinkFieldInput';
import { NumberFieldInput } from '../meta-types/input/components/NumberFieldInput';
import { PhoneFieldInput } from '../meta-types/input/components/PhoneFieldInput';
import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInput';
import { RelationFieldInput } from '../meta-types/input/components/RelationFieldInput';
import { TextFieldInput } from '../meta-types/input/components/TextFieldInput';
import { FieldInputEvent } from '../types/FieldInputEvent';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldCurrency } from '../types/guards/isFieldCurrency';
import { isFieldDateTime } from '../types/guards/isFieldDateTime';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldNumber } from '../types/guards/isFieldNumber';
import { isFieldPhone } from '../types/guards/isFieldPhone';
import { isFieldRating } from '../types/guards/isFieldRating';
import { isFieldRelation } from '../types/guards/isFieldRelation';
import { isFieldText } from '../types/guards/isFieldText';
type FieldInputProps = {
onSubmit?: FieldInputEvent;
onCancel?: () => void;
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const FieldInput = ({
onCancel,
onSubmit,
onEnter,
onEscape,
onShiftTab,
onTab,
onClickOutside,
}: FieldInputProps) => {
const { fieldDefinition } = useContext(FieldContext);
return (
<>
{isFieldRelation(fieldDefinition) ? (
<RecoilScope>
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</RecoilScope>
) : isFieldPhone(fieldDefinition) ? (
<PhoneFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldText(fieldDefinition) ? (
<TextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldEmail(fieldDefinition) ? (
<EmailFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldDateTime(fieldDefinition) ? (
<DateFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldNumber(fieldDefinition) ? (
<NumberFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldLink(fieldDefinition) ? (
<LinkFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldBoolean(fieldDefinition) ? (
<BooleanFieldInput onSubmit={onSubmit} />
) : isFieldRating(fieldDefinition) ? (
<RatingFieldInput onSubmit={onSubmit} />
) : (
<></>
)}
</>
);
};

View File

@ -0,0 +1,19 @@
import { createContext } from 'react';
import { FieldDefinition } from '../types/FieldDefinition';
import { FieldMetadata } from '../types/FieldMetadata';
export type GenericFieldContextType = {
fieldDefinition: FieldDefinition<FieldMetadata>;
// TODO: add better typing for mutation web-hook
useUpdateEntityMutation?: () => [(params: any) => void, any];
entityId: string;
recoilScopeId?: string;
hotkeyScope: string;
isLabelIdentifier: boolean;
basePathToShowPage?: string;
};
export const FieldContext = createContext<GenericFieldContextType>(
{} as GenericFieldContextType,
);

View File

@ -0,0 +1,18 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldContext } from '../contexts/FieldContext';
import { entityFieldInitialValueFamilyState } from '../states/entityFieldInitialValueFamilyState';
export const useFieldInitialValue = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldInitialValue = useRecoilValue(
entityFieldInitialValueFamilyState({
fieldMetadataId: fieldDefinition.fieldMetadataId,
entityId,
}),
);
return fieldInitialValue;
};

View File

@ -0,0 +1,33 @@
import { useContext } from 'react';
import { isFieldRelation } from '@/object-record/field/types/guards/isFieldRelation';
import { IconPencil } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldPhone } from '../types/guards/isFieldPhone';
export const useGetButtonIcon = (): IconComponent | undefined => {
const { fieldDefinition } = useContext(FieldContext);
if (!fieldDefinition) return undefined;
if (
isFieldLink(fieldDefinition) ||
isFieldEmail(fieldDefinition) ||
isFieldPhone(fieldDefinition)
) {
return IconPencil;
}
if (isFieldRelation(fieldDefinition)) {
if (
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember'
) {
return IconPencil;
}
}
};

View File

@ -0,0 +1,21 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { FieldContext } from '../contexts/FieldContext';
import { isEntityFieldEmptyFamilySelector } from '../states/selectors/isEntityFieldEmptyFamilySelector';
export const useIsFieldEmpty = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const isFieldEmpty = useRecoilValue(
isEntityFieldEmptyFamilySelector({
fieldDefinition: {
type: fieldDefinition.type,
},
fieldName: fieldDefinition.metadata.fieldName,
entityId,
}),
);
return isFieldEmpty;
};

View File

@ -0,0 +1,15 @@
import { useContext } from 'react';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldRating } from '../types/guards/isFieldRating';
export const useIsFieldInputOnly = () => {
const { fieldDefinition } = useContext(FieldContext);
if (isFieldBoolean(fieldDefinition) || isFieldRating(fieldDefinition)) {
return true;
}
return false;
};

View File

@ -0,0 +1,138 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isFieldFullName } from '@/object-record/field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/field/types/guards/isFieldFullNameValue';
import { FieldContext } from '../contexts/FieldContext';
import { entityFieldsFamilySelector } from '../states/selectors/entityFieldsFamilySelector';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue';
import { isFieldCurrency } from '../types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '../types/guards/isFieldCurrencyValue';
import { isFieldDateTime } from '../types/guards/isFieldDateTime';
import { isFieldDateTimeValue } from '../types/guards/isFieldDateTimeValue';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldEmailValue } from '../types/guards/isFieldEmailValue';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldLinkValue } from '../types/guards/isFieldLinkValue';
import { isFieldNumber } from '../types/guards/isFieldNumber';
import { isFieldNumberValue } from '../types/guards/isFieldNumberValue';
import { isFieldPhone } from '../types/guards/isFieldPhone';
import { isFieldPhoneValue } from '../types/guards/isFieldPhoneValue';
import { isFieldRating } from '../types/guards/isFieldRating';
import { isFieldRatingValue } from '../types/guards/isFieldRatingValue';
import { isFieldRelation } from '../types/guards/isFieldRelation';
import { isFieldRelationValue } from '../types/guards/isFieldRelationValue';
import { isFieldText } from '../types/guards/isFieldText';
import { isFieldTextValue } from '../types/guards/isFieldTextValue';
export const usePersistField = () => {
const {
entityId,
fieldDefinition,
useUpdateEntityMutation = () => [],
} = useContext(FieldContext);
const [updateEntity] = useUpdateEntityMutation();
const persistField = useRecoilCallback(
({ set }) =>
(valueToPersist: unknown) => {
const fieldIsRelation =
isFieldRelation(fieldDefinition) &&
isFieldRelationValue(valueToPersist);
const fieldIsText =
isFieldText(fieldDefinition) && isFieldTextValue(valueToPersist);
const fieldIsEmail =
isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist);
const fieldIsDateTime =
isFieldDateTime(fieldDefinition) &&
isFieldDateTimeValue(valueToPersist);
const fieldIsLink =
isFieldLink(fieldDefinition) && isFieldLinkValue(valueToPersist);
const fieldIsBoolean =
isFieldBoolean(fieldDefinition) &&
isFieldBooleanValue(valueToPersist);
const fieldIsProbability =
isFieldRating(fieldDefinition) && isFieldRatingValue(valueToPersist);
const fieldIsNumber =
isFieldNumber(fieldDefinition) && isFieldNumberValue(valueToPersist);
const fieldIsCurrency =
isFieldCurrency(fieldDefinition) &&
isFieldCurrencyValue(valueToPersist);
const fieldIsFullName =
isFieldFullName(fieldDefinition) &&
isFieldFullNameValue(valueToPersist);
const fieldIsPhone =
isFieldPhone(fieldDefinition) && isFieldPhoneValue(valueToPersist);
if (fieldIsRelation) {
const fieldName = fieldDefinition.metadata.fieldName;
set(
entityFieldsFamilySelector({ entityId, fieldName }),
valueToPersist,
);
updateEntity?.({
variables: {
where: { id: entityId },
data: {
// TODO: find a more elegant way to do this ?
// Maybe have a link between the RELATION field and the UUID field ?
[`${fieldName}Id`]: valueToPersist?.id ?? null,
},
},
});
} else if (
fieldIsText ||
fieldIsBoolean ||
fieldIsEmail ||
fieldIsProbability ||
fieldIsNumber ||
fieldIsDateTime ||
fieldIsPhone ||
fieldIsLink ||
fieldIsCurrency ||
fieldIsFullName
) {
const fieldName = fieldDefinition.metadata.fieldName;
set(
entityFieldsFamilySelector({ entityId, fieldName }),
valueToPersist,
);
updateEntity?.({
variables: {
where: { id: entityId },
data: {
[fieldName]: valueToPersist,
},
},
});
} else {
throw new Error(
`Invalid value to persist: ${JSON.stringify(
valueToPersist,
)} for type : ${
fieldDefinition.type
}, type may not be implemented in usePersistField.`,
);
}
},
[entityId, fieldDefinition, updateEntity],
);
return persistField;
};

View File

@ -0,0 +1,51 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { FieldContext } from '../contexts/FieldContext';
import { entityFieldsFamilySelector } from '../states/selectors/entityFieldsFamilySelector';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
export const useToggleEditOnlyInput = () => {
const {
entityId,
fieldDefinition,
useUpdateEntityMutation = () => [],
} = useContext(FieldContext);
const [updateEntity] = useUpdateEntityMutation();
const toggleField = useRecoilCallback(
({ set, snapshot }) =>
() => {
const fieldIsBoolean = isFieldBoolean(fieldDefinition);
if (fieldIsBoolean) {
const fieldName = fieldDefinition.metadata.fieldName;
const oldValue = snapshot
.getLoadable(entityFieldsFamilySelector({ entityId, fieldName }))
.valueOrThrow();
const valueToPersist = !oldValue;
set(
entityFieldsFamilySelector({ entityId, fieldName }),
valueToPersist,
);
updateEntity?.({
variables: {
where: { id: entityId },
data: {
[fieldName]: valueToPersist,
},
},
});
} else {
throw new Error(
`Invalid value to toggle for type : ${fieldDefinition}, type may not be implemented in useToggleEditOnlyInput.`,
);
}
},
[entityId, fieldDefinition, updateEntity],
);
return toggleField;
};

View File

@ -0,0 +1,31 @@
import {
FieldContext,
GenericFieldContextType,
} from '@/object-record/field/contexts/FieldContext';
type FieldContextProviderProps = {
children: React.ReactNode;
fieldDefinition: GenericFieldContextType['fieldDefinition'];
entityId?: string;
};
export const FieldContextProvider = ({
children,
fieldDefinition,
entityId,
}: FieldContextProviderProps) => {
return (
<FieldContext.Provider
value={{
entityId: entityId ?? '1',
isLabelIdentifier: false,
recoilScopeId: '1',
hotkeyScope: 'hotkey-scope',
fieldDefinition,
useUpdateEntityMutation: () => [() => undefined, {}],
}}
>
{children}
</FieldContext.Provider>
);
};

View File

@ -0,0 +1,24 @@
import { useChipField } from '@/object-record/field/meta-types/hooks/useChipField';
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
export const ChipFieldDisplay = () => {
const {
record,
entityId,
identifiersMapper,
objectNameSingular,
basePathToShowPage,
} = useChipField();
const identifiers = identifiersMapper?.(record, objectNameSingular ?? '');
return (
<EntityChip
name={identifiers?.name ?? ''}
avatarUrl={identifiers?.avatarUrl}
avatarType={identifiers?.avatarType}
entityId={entityId}
linkToEntity={basePathToShowPage + entityId}
/>
);
};

View File

@ -0,0 +1,8 @@
import { useCurrencyField } from '../../hooks/useCurrencyField';
import { CurrencyDisplay } from '../content-display/components/CurrencyDisplay';
export const CurrencyFieldDisplay = () => {
const { initialAmount } = useCurrencyField();
return <CurrencyDisplay amount={initialAmount} />;
};

View File

@ -0,0 +1,9 @@
import { DateDisplay } from '@/object-record/field/meta-types/display/content-display/components/DateDisplay';
import { useDateTimeField } from '../../hooks/useDateTimeField';
export const DateFieldDisplay = () => {
const { fieldValue } = useDateTimeField();
return <DateDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,8 @@
import { useEmailField } from '../../hooks/useEmailField';
import { EmailDisplay } from '../content-display/components/EmailDisplay';
export const EmailFieldDisplay = () => {
const { fieldValue } = useEmailField();
return <EmailDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,13 @@
import { useFullNameField } from '@/object-record/field/meta-types/hooks/useFullNameField';
import { TextDisplay } from '../content-display/components/TextDisplay';
export const FullNameFieldDisplay = () => {
const { fieldValue } = useFullNameField();
const content = [fieldValue.firstName, fieldValue.lastName]
.filter(Boolean)
.join(' ');
return <TextDisplay text={content} />;
};

View File

@ -0,0 +1,8 @@
import { useLinkField } from '../../hooks/useLinkField';
import { LinkDisplay } from '../content-display/components/LinkDisplay';
export const LinkFieldDisplay = () => {
const { fieldValue } = useLinkField();
return <LinkDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,9 @@
import { NumberDisplay } from '@/object-record/field/meta-types/display/content-display/components/NumberDisplay';
import { useNumberField } from '../../hooks/useNumberField';
export const NumberFieldDisplay = () => {
const { fieldValue } = useNumberField();
return <NumberDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,9 @@
import { PhoneDisplay } from '@/object-record/field/meta-types/display/content-display/components/PhoneDisplay';
import { usePhoneField } from '../../hooks/usePhoneField';
export const PhoneFieldDisplay = () => {
const { fieldValue } = usePhoneField();
return <PhoneDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,28 @@
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useRelationField();
const { identifiersMapper } = useRelationPicker();
if (!fieldValue || !fieldDefinition || !identifiersMapper) {
return <></>;
}
const objectIdentifiers = identifiersMapper(
fieldValue,
fieldDefinition.metadata.relationObjectMetadataNameSingular,
);
return (
<EntityChip
entityId={fieldValue.id}
name={objectIdentifiers?.name ?? ''}
avatarUrl={objectIdentifiers?.avatarUrl}
avatarType={objectIdentifiers?.avatarType}
/>
);
};

View File

@ -0,0 +1,9 @@
import { Tag } from '@/ui/display/tag/components/Tag';
import { useSelectField } from '../../hooks/useSelectField';
export const SelectFieldDisplay = () => {
const { fieldValue } = useSelectField();
return <Tag color={fieldValue.color} text={fieldValue.label} />;
};

View File

@ -0,0 +1,9 @@
import { TextDisplay } from '@/object-record/field/meta-types/display/content-display/components/TextDisplay';
import { useTextField } from '../../hooks/useTextField';
export const TextFieldDisplay = () => {
const { fieldValue } = useTextField();
return <TextDisplay text={fieldValue} />;
};

View File

@ -0,0 +1,8 @@
import { TextDisplay } from '@/object-record/field/meta-types/display/content-display/components/TextDisplay';
import { useUuidField } from '@/object-record/field/meta-types/hooks/useUuidField';
export const UuidFieldDisplay = () => {
const { fieldValue } = useUuidField();
return <TextDisplay text={fieldValue} />;
};

View File

@ -0,0 +1,65 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useDateTimeField } from '../../../hooks/useDateTimeField';
import { DateFieldDisplay } from '../DateFieldDisplay';
const formattedDate = new Date('2023-04-01');
const DateFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useDateTimeField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/DateFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'date',
label: 'Date',
type: 'DATE_TIME',
iconName: 'IconCalendarEvent',
metadata: {
fieldName: 'Date',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<DateFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: DateFieldDisplay,
argTypes: { value: { control: 'date' } },
args: {
value: formattedDate,
},
};
export default meta;
type Story = StoryObj<typeof DateFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};

View File

@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useEmailField } from '../../../hooks/useEmailField';
import { EmailFieldDisplay } from '../EmailFieldDisplay';
const EmailFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useEmailField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/EmailFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'email',
label: 'Email',
type: 'EMAIL',
iconName: 'IconLink',
metadata: {
fieldName: 'Email',
placeHolder: 'Email',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<MemoryRouter>
<EmailFieldValueSetterEffect value={args.value} />
<Story />
</MemoryRouter>
</FieldContext.Provider>
),
ComponentDecorator,
],
component: EmailFieldDisplay,
args: {
value: 'Test@Test.test',
},
};
export default meta;
type Story = StoryObj<typeof EmailFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};

View File

@ -0,0 +1,81 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useNumberField } from '../../../hooks/useNumberField';
import { NumberFieldDisplay } from '../NumberFieldDisplay';
const NumberFieldValueSetterEffect = ({ value }: { value: number }) => {
const { setFieldValue } = useNumberField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/NumberFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'number',
label: 'Number',
type: 'NUMBER',
iconName: 'Icon123',
metadata: {
fieldName: 'Number',
placeHolder: 'Number',
isPositive: true,
},
},
hotkeyScope: 'hotkey-scope',
useUpdateEntityMutation: () => [() => undefined, undefined],
}}
>
<NumberFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: NumberFieldDisplay,
};
export default meta;
type Story = StoryObj<typeof NumberFieldDisplay>;
export const Default: Story = {
args: {
value: 100,
},
};
export const Elipsis: Story = {
args: {
value: 1e100,
},
parameters: {
container: { width: 100 },
},
};
export const Negative: Story = {
args: {
value: -1000,
},
};
export const Float: Story = {
args: {
value: 1.357802,
},
};

View File

@ -0,0 +1,68 @@
import { useEffect } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FieldContext } from '../../../../contexts/FieldContext';
import { usePhoneField } from '../../../hooks/usePhoneField';
import { PhoneFieldDisplay } from '../PhoneFieldDisplay';
const PhoneFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = usePhoneField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/PhoneFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'phone',
label: 'Phone',
type: 'TEXT',
iconName: 'IconPhone',
metadata: {
fieldName: 'phone',
placeHolder: 'Phone',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
useUpdateEntityMutation: () => [() => undefined, undefined],
}}
>
<MemoryRouter>
<PhoneFieldValueSetterEffect value={args.value} />
<Story />
</MemoryRouter>
</FieldContext.Provider>
),
ComponentDecorator,
],
component: PhoneFieldDisplay,
args: {
value: '362763872687362',
},
};
export default meta;
type Story = StoryObj<typeof PhoneFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};

View File

@ -0,0 +1,67 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useTextField } from '../../../hooks/useTextField';
import { TextFieldDisplay } from '../TextFieldDisplay';
const TextFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useTextField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/TextFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'text',
label: 'Text',
type: 'TEXT',
iconName: 'IconLink',
metadata: {
fieldName: 'Text',
placeHolder: 'Text',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<TextFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: TextFieldDisplay,
args: {
value: 'Lorem ipsum',
},
};
export default meta;
type Story = StoryObj<typeof TextFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
args: {
value:
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae rerum fugiat veniam illum accusantium saepe, voluptate inventore libero doloribus doloremque distinctio blanditiis amet quis dolor a nulla? Placeat nam itaque rerum esse quidem animi, temporibus saepe debitis commodi quia eius eos minus inventore. Voluptates fugit optio sit ab consectetur ipsum, neque eius atque blanditiis. Ullam provident at porro minima, nobis vero dicta consequatur maxime laboriosam fugit repudiandae repellat tempore voluptas non voluptatibus neque aliquam ducimus doloribus ipsa? Sapiente suscipit unde modi commodi possimus doloribus eum voluptatibus, architecto laudantium, magnam, eos numquam exercitationem est maxime explicabo odio nemo qui distinctio temporibus.',
},
parameters: {
container: { width: 100 },
},
};

View File

@ -0,0 +1,10 @@
import { EllipsisDisplay } from './EllipsisDisplay';
type CurrencyDisplayProps = {
amount?: number | null;
};
// TODO: convert currencyCode to currency symbol
export const CurrencyDisplay = ({ amount }: CurrencyDisplayProps) => {
return <EllipsisDisplay>{amount}</EllipsisDisplay>;
};

View File

@ -0,0 +1,11 @@
import { formatToHumanReadableDate } from '~/utils';
import { EllipsisDisplay } from './EllipsisDisplay';
type DateDisplayProps = {
value: Date | string | null | undefined;
};
export const DateDisplay = ({ value }: DateDisplayProps) => (
<EllipsisDisplay>{value && formatToHumanReadableDate(value)}</EllipsisDisplay>
);

View File

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

View File

@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledEllipsisDisplay = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
`;
export { StyledEllipsisDisplay as EllipsisDisplay };

View File

@ -0,0 +1,31 @@
import { MouseEvent } from 'react';
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
import { EllipsisDisplay } from './EllipsisDisplay';
const validateEmail = (email: string) => {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email.trim());
};
type EmailDisplayProps = {
value: string | null;
};
export const EmailDisplay = ({ value }: EmailDisplayProps) => (
<EllipsisDisplay>
{value && validateEmail(value) ? (
<ContactLink
href={`mailto:${value}`}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{value}
</ContactLink>
) : (
<ContactLink href="#">{value}</ContactLink>
)}
</EllipsisDisplay>
);

View File

@ -0,0 +1,73 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { FieldLinkValue } from '@/object-record/field/types/FieldMetadata';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import {
LinkType,
SocialLink,
} from '@/ui/navigation/link/components/SocialLink';
import { EllipsisDisplay } from './EllipsisDisplay';
const StyledRawLink = styled(RoundedLink)`
overflow: hidden;
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
type LinkDisplayProps = {
value?: FieldLinkValue;
};
const checkUrlType = (url: string) => {
if (
/^(http|https):\/\/(?:www\.)?linkedin.com(\w+:{0,1}\w*@)?(\S+)(:([0-9])+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(
url,
)
) {
return LinkType.LinkedIn;
}
if (url.match(/^((http|https):\/\/)?(?:www\.)?twitter\.com\/(\w+)?/i)) {
return LinkType.Twitter;
}
return LinkType.Url;
};
export const LinkDisplay = ({ value }: LinkDisplayProps) => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
};
const absoluteUrl = value?.url
? value.url.startsWith('http')
? value.url
: 'https://' + value.url
: '';
const displayedValue = value?.label || value?.url || '';
const type = checkUrlType(absoluteUrl);
if (type === LinkType.LinkedIn || type === LinkType.Twitter) {
return (
<EllipsisDisplay>
<SocialLink href={absoluteUrl} onClick={handleClick} type={type}>
{displayedValue}
</SocialLink>
</EllipsisDisplay>
);
}
return (
<EllipsisDisplay>
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
{displayedValue}
</StyledRawLink>
</EllipsisDisplay>
);
};

View File

@ -0,0 +1,11 @@
import { formatNumber } from '~/utils/format/number';
import { EllipsisDisplay } from './EllipsisDisplay';
type MoneyDisplayProps = {
value: number | null;
};
export const MoneyDisplay = ({ value }: MoneyDisplayProps) => (
<EllipsisDisplay>{value ? `$${formatNumber(value)}` : ''}</EllipsisDisplay>
);

View File

@ -0,0 +1,11 @@
import { formatNumber } from '~/utils/format/number';
import { EllipsisDisplay } from './EllipsisDisplay';
type NumberDisplayProps = {
value: string | number | null;
};
export const NumberDisplay = ({ value }: NumberDisplayProps) => (
<EllipsisDisplay>{value && formatNumber(Number(value))}</EllipsisDisplay>
);

View File

@ -0,0 +1,27 @@
import { MouseEvent } from 'react';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
import { EllipsisDisplay } from './EllipsisDisplay';
type PhoneDisplayProps = {
value: string | null;
};
export const PhoneDisplay = ({ value }: PhoneDisplayProps) => (
<EllipsisDisplay>
{value && isValidPhoneNumber(value) ? (
<ContactLink
href={parsePhoneNumber(value, 'FR')?.getURI()}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{parsePhoneNumber(value, 'FR')?.formatInternational() || value}
</ContactLink>
) : (
<ContactLink href="#">{value}</ContactLink>
)}
</EllipsisDisplay>
);

View File

@ -0,0 +1,9 @@
import { EllipsisDisplay } from './EllipsisDisplay';
type TextDisplayProps = {
text: string;
};
export const TextDisplay = ({ text }: TextDisplayProps) => (
<EllipsisDisplay>{text}</EllipsisDisplay>
);

View File

@ -0,0 +1,72 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import {
LinkType,
SocialLink,
} from '@/ui/navigation/link/components/SocialLink';
import { EllipsisDisplay } from './EllipsisDisplay';
const StyledRawLink = styled(RoundedLink)`
overflow: hidden;
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
type URLDisplayProps = {
value: string | null;
};
const checkUrlType = (url: string) => {
if (
/^(http|https):\/\/(?:www\.)?linkedin.com(\w+:{0,1}\w*@)?(\S+)(:([0-9])+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(
url,
)
) {
return LinkType.LinkedIn;
}
if (url.match(/^((http|https):\/\/)?(?:www\.)?twitter\.com\/(\w+)?/i)) {
return LinkType.Twitter;
}
return LinkType.Url;
};
export const URLDisplay = ({ value }: URLDisplayProps) => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
};
const absoluteUrl = value
? value.startsWith('http')
? value
: 'https://' + value
: '';
const displayedValue = value ?? '';
const type = checkUrlType(absoluteUrl);
if (type === LinkType.LinkedIn || type === LinkType.Twitter) {
return (
<EllipsisDisplay>
<SocialLink href={absoluteUrl} onClick={handleClick} type={type}>
{displayedValue}
</SocialLink>
</EllipsisDisplay>
);
}
return (
<EllipsisDisplay>
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
{displayedValue}
</StyledRawLink>
</EllipsisDisplay>
);
};

View File

@ -0,0 +1,20 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { PhoneDisplay } from '../PhoneDisplay'; // Adjust the import path as needed
const meta: Meta = {
title: 'UI/Input/PhoneInputDisplay/PhoneInputDisplay',
component: PhoneDisplay,
decorators: [ComponentWithRouterDecorator],
args: {
value: '+33788901234',
},
};
export default meta;
type Story = StoryObj<typeof PhoneDisplay>;
export const Default: Story = {};

View File

@ -0,0 +1,29 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldBoolean } from '../../types/guards/isFieldBoolean';
export const useBooleanField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('BOOLEAN', isFieldBoolean, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<boolean>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
return {
fieldDefinition,
fieldValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,31 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { isFieldFullName } from '@/object-record/field/types/guards/isFieldFullName';
import { isFieldText } from '@/object-record/field/types/guards/isFieldText';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { FieldContext } from '../../contexts/FieldContext';
export const useChipField = () => {
const { entityId, fieldDefinition, basePathToShowPage } =
useContext(FieldContext);
const objectNameSingular =
isFieldText(fieldDefinition) || isFieldFullName(fieldDefinition)
? fieldDefinition.metadata.objectMetadataNameSingular
: undefined;
const record = useRecoilValue<any | null>(entityFieldsFamilyState(entityId));
const { identifiersMapper } = useRelationPicker();
return {
basePathToShowPage,
entityId,
objectNameSingular,
record,
identifiersMapper,
};
};

View File

@ -0,0 +1,101 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldInitialValue } from '@/object-record/field/types/FieldInitialValue';
import { canBeCastAsIntegerOrNull } from '~/utils/cast-as-integer-or-null';
import {
convertCurrencyMicrosToCurrency,
convertCurrencyToCurrencyMicros,
} from '~/utils/convert-currency-amount';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { usePersistField } from '../../hooks/usePersistField';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { FieldCurrencyValue } from '../../types/FieldMetadata';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldCurrency } from '../../types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '../../types/guards/isFieldCurrencyValue';
const initializeValue = (
fieldInitialValue: FieldInitialValue | undefined,
fieldValue: FieldCurrencyValue,
) => {
if (fieldInitialValue?.isEmpty) {
return { amount: null, currencyCode: 'USD' };
}
if (!isNaN(Number(fieldInitialValue?.value))) {
return {
amount: Number(fieldInitialValue?.value),
currencyCode: 'USD',
};
}
if (!fieldValue) {
return { amount: null, currencyCode: 'USD' };
}
return {
amount: convertCurrencyMicrosToCurrency(fieldValue.amountMicros),
currencyCode: fieldValue.currencyCode,
};
};
export const useCurrencyField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('CURRENCY', isFieldCurrency, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldCurrencyValue>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const persistField = usePersistField();
const persistCurrencyField = ({
amountText,
currencyCode,
}: {
amountText: string;
currencyCode: string;
}) => {
if (!canBeCastAsIntegerOrNull(amountText)) {
return;
}
const amount = parseFloat(amountText);
const newCurrencyValue = {
amountMicros: isNaN(amount)
? null
: convertCurrencyToCurrencyMicros(amount),
currencyCode: currencyCode,
};
if (!isFieldCurrencyValue(newCurrencyValue)) {
return;
}
persistField(newCurrencyValue);
};
const fieldInitialValue = useFieldInitialValue();
const initialValue = initializeValue(fieldInitialValue, fieldValue);
const initialAmount = initialValue.amount;
const initialCurrencyCode = initialValue.currencyCode;
return {
fieldDefinition,
fieldValue,
initialAmount,
initialCurrencyCode,
setFieldValue,
hotkeyScope,
persistCurrencyField,
};
};

View File

@ -0,0 +1,29 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldDateTime } from '../../types/guards/isFieldDateTime';
export const useDateTimeField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('DATE_TIME', isFieldDateTime, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<string>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
return {
fieldDefinition,
fieldValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,37 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldEmail } from '../../types/guards/isFieldEmail';
export const useEmailField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('EMAIL', isFieldEmail, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<string>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const fieldInitialValue = useFieldInitialValue();
const initialValue = fieldInitialValue?.isEmpty
? ''
: fieldInitialValue?.value ?? fieldValue;
return {
fieldDefinition,
fieldValue,
initialValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,51 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { usePersistField } from '../../hooks/usePersistField';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { FieldFullNameValue } from '../../types/FieldMetadata';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldFullName } from '../../types/guards/isFieldFullName';
import { isFieldFullNameValue } from '../../types/guards/isFieldFullNameValue';
export const useFullNameField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('FULL_NAME', isFieldFullName, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldFullNameValue>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const persistField = usePersistField();
const persistFullNameField = (newValue: FieldFullNameValue) => {
if (!isFieldFullNameValue(newValue)) {
return;
}
persistField(newValue);
};
const fieldInitialValue = useFieldInitialValue();
const initialValue: FieldFullNameValue = fieldInitialValue?.isEmpty
? { firstName: '', lastName: '' }
: fieldValue;
return {
fieldDefinition,
fieldValue,
initialValue,
setFieldValue,
hotkeyScope,
persistFullNameField,
};
};

View File

@ -0,0 +1,53 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { usePersistField } from '../../hooks/usePersistField';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { FieldLinkValue } from '../../types/FieldMetadata';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldLink } from '../../types/guards/isFieldLink';
import { isFieldLinkValue } from '../../types/guards/isFieldLinkValue';
export const useLinkField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('LINK', isFieldLink, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldLinkValue>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const fieldInitialValue = useFieldInitialValue();
const initialValue: FieldLinkValue = fieldInitialValue?.isEmpty
? { url: '', label: '' }
: fieldInitialValue?.value
? { url: fieldInitialValue.value, label: '' }
: fieldValue;
const persistField = usePersistField();
const persistLinkField = (newValue: FieldLinkValue) => {
if (!isFieldLinkValue(newValue)) {
return;
}
persistField(newValue);
};
return {
fieldDefinition,
fieldValue,
initialValue,
setFieldValue,
hotkeyScope,
persistLinkField,
};
};

View File

@ -0,0 +1,58 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import {
canBeCastAsIntegerOrNull,
castAsIntegerOrNull,
} from '~/utils/cast-as-integer-or-null';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { usePersistField } from '../../hooks/usePersistField';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldNumber } from '../../types/guards/isFieldNumber';
export const useNumberField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('NUMBER', isFieldNumber, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<number | null>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const persistField = usePersistField();
const persistNumberField = (newValue: string) => {
if (!canBeCastAsIntegerOrNull(newValue)) {
return;
}
const castedValue = castAsIntegerOrNull(newValue);
persistField(castedValue);
};
const fieldInitialValue = useFieldInitialValue();
const initialValue = fieldInitialValue?.isEmpty
? null
: !isNaN(Number(fieldInitialValue?.value))
? Number(fieldInitialValue?.value)
: null ?? fieldValue;
return {
fieldDefinition,
fieldValue,
initialValue,
setFieldValue,
hotkeyScope,
persistNumberField,
};
};

View File

@ -0,0 +1,49 @@
import { useContext } from 'react';
import { isPossiblePhoneNumber } from 'libphonenumber-js';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { usePersistField } from '../../hooks/usePersistField';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldPhone } from '../../types/guards/isFieldPhone';
export const usePhoneField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
//assertFieldMetadata('PHONE', isFieldPhone, fieldDefinition);
assertFieldMetadata('TEXT', isFieldPhone, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<string>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const persistField = usePersistField();
const persistPhoneField = (newPhoneValue: string) => {
if (!isPossiblePhoneNumber(newPhoneValue) && newPhoneValue !== '') return;
persistField(newPhoneValue);
};
const fieldInitialValue = useFieldInitialValue();
const initialValue = fieldInitialValue?.isEmpty
? ''
: fieldInitialValue?.value ?? fieldValue;
return {
fieldDefinition,
fieldValue,
initialValue,
setFieldValue,
hotkeyScope,
persistPhoneField,
};
};

View File

@ -0,0 +1,38 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { FieldRatingValue } from '../../types/FieldMetadata';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldRating } from '../../types/guards/isFieldRating';
export const useRatingField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.Probability,
isFieldRating,
fieldDefinition,
);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldRatingValue | null>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const rating = +(fieldValue ?? 0);
return {
fieldDefinition,
rating,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,40 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldRelation } from '../../types/guards/isFieldRelation';
// TODO: we will be able to type more precisely when we will have custom field and custom entities support
export const useRelationField = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata('RELATION', isFieldRelation, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<any | null>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const fieldInitialValue = useFieldInitialValue();
const initialSearchValue = fieldInitialValue?.isEmpty
? null
: fieldInitialValue?.value;
const initialValue = fieldInitialValue?.isEmpty ? null : fieldValue;
return {
fieldDefinition,
fieldValue,
initialValue,
initialSearchValue,
setFieldValue,
};
};

View File

@ -0,0 +1,47 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { ThemeColor } from '@/ui/theme/constants/colors';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { FieldSelectValue } from '../../types/FieldMetadata';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldSelect } from '../../types/guards/isFieldSelect';
import { isFieldSelectValue } from '../../types/guards/isFieldSelectValue';
export const useSelectField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('ENUM', isFieldSelect, fieldDefinition);
const { fieldName } = fieldDefinition.metadata;
const [fieldValue, setFieldValue] = useRecoilState<FieldSelectValue>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const fieldSelectValue = isFieldSelectValue(fieldValue)
? fieldValue
: { color: 'green' as ThemeColor, label: '' };
const fieldInitialValue = useFieldInitialValue();
const initialValue = {
color: 'green' as ThemeColor,
label: fieldInitialValue?.isEmpty
? ''
: fieldInitialValue?.value ?? fieldSelectValue?.label ?? '',
};
return {
fieldDefinition,
fieldValue: fieldSelectValue,
initialValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,39 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldText } from '../../types/guards/isFieldText';
import { isFieldTextValue } from '../../types/guards/isFieldTextValue';
export const useTextField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('TEXT', isFieldText, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<string>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
const fieldInitialValue = useFieldInitialValue();
const initialValue = fieldInitialValue?.isEmpty
? ''
: fieldInitialValue?.value ?? fieldTextValue;
return {
fieldDefinition,
fieldValue: fieldTextValue,
initialValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,40 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { isFieldUuid } from '@/object-record/field/types/guards/isFieldUuid';
import { FieldContext } from '../../contexts/FieldContext';
import { useFieldInitialValue } from '../../hooks/useFieldInitialValue';
import { entityFieldsFamilySelector } from '../../states/selectors/entityFieldsFamilySelector';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldTextValue } from '../../types/guards/isFieldTextValue';
export const useUuidField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata('UUID', isFieldUuid, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<string>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
);
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
const fieldInitialValue = useFieldInitialValue();
const initialValue = fieldInitialValue?.isEmpty
? ''
: fieldInitialValue?.value ?? fieldTextValue;
return {
fieldDefinition,
fieldValue: fieldTextValue,
initialValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,35 @@
import { BooleanInput } from '@/object-record/field/meta-types/input/components/internal/BooleanInput';
import { usePersistField } from '../../../hooks/usePersistField';
import { useBooleanField } from '../../hooks/useBooleanField';
import { FieldInputEvent } from './DateFieldInput';
export type BooleanFieldInputProps = {
onSubmit?: FieldInputEvent;
readonly?: boolean;
testId?: string;
};
export const BooleanFieldInput = ({
onSubmit,
readonly,
testId,
}: BooleanFieldInputProps) => {
const { fieldValue } = useBooleanField();
const persistField = usePersistField();
const handleToggle = (newValue: boolean) => {
onSubmit?.(() => persistField(newValue));
};
return (
<BooleanInput
value={fieldValue ?? ''}
onToggle={handleToggle}
readonly={readonly}
testId={testId}
/>
);
};

View File

@ -0,0 +1,93 @@
import { TextInput } from '@/object-record/field/meta-types/input/components/internal/TextInput';
import { useCurrencyField } from '../../hooks/useCurrencyField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
import { FieldInputEvent } from './DateFieldInput';
export type CurrencyFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const CurrencyFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: CurrencyFieldInputProps) => {
const {
hotkeyScope,
initialAmount,
initialCurrencyCode,
persistCurrencyField,
} = useCurrencyField();
const handleEnter = (newValue: string) => {
onEnter?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: initialCurrencyCode,
});
});
};
const handleEscape = (newValue: string) => {
onEscape?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: initialCurrencyCode,
});
});
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newValue: string,
) => {
onClickOutside?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: initialCurrencyCode,
});
});
};
const handleTab = (newValue: string) => {
onTab?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: initialCurrencyCode,
});
});
};
const handleShiftTab = (newValue: string) => {
onShiftTab?.(() =>
persistCurrencyField({
amountText: newValue,
currencyCode: initialCurrencyCode,
}),
);
};
return (
<FieldInputOverlay>
<TextInput
value={initialAmount?.toString() ?? ''}
autoFocus
placeholder="Currency"
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,62 @@
import { DateInput } from '@/object-record/field/meta-types/input/components/internal/DateInput';
import { Nullable } from '~/types/Nullable';
import { usePersistField } from '../../../hooks/usePersistField';
import { useDateTimeField } from '../../hooks/useDateTimeField';
export type FieldInputEvent = (persist: () => void) => void;
export type DateFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const DateFieldInput = ({
onEnter,
onEscape,
onClickOutside,
}: DateFieldInputProps) => {
const { fieldValue, hotkeyScope } = useDateTimeField();
const persistField = usePersistField();
const persistDate = (newDate: Nullable<Date>) => {
if (!newDate) {
persistField('');
} else {
const newDateISO = newDate?.toISOString();
persistField(newDateISO);
}
};
const handleEnter = (newDate: Nullable<Date>) => {
onEnter?.(() => persistDate(newDate));
};
const handleEscape = (newDate: Nullable<Date>) => {
onEscape?.(() => persistDate(newDate));
};
const handleClickOutside = (
_event: MouseEvent | TouchEvent,
newDate: Nullable<Date>,
) => {
onClickOutside?.(() => persistDate(newDate));
};
const dateValue = fieldValue ? new Date(fieldValue) : null;
return (
<DateInput
hotkeyScope={hotkeyScope}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
value={dateValue}
/>
);
};

View File

@ -0,0 +1,66 @@
import { TextInput } from '@/object-record/field/meta-types/input/components/internal/TextInput';
import { usePersistField } from '../../../hooks/usePersistField';
import { useEmailField } from '../../hooks/useEmailField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
import { FieldInputEvent } from './DateFieldInput';
export type EmailFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const EmailFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: EmailFieldInputProps) => {
const { fieldDefinition, initialValue, hotkeyScope } = useEmailField();
const persistField = usePersistField();
const handleEnter = (newText: string) => {
onEnter?.(() => persistField(newText));
};
const handleEscape = (newText: string) => {
onEscape?.(() => persistField(newText));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newText: string,
) => {
onClickOutside?.(() => persistField(newText));
};
const handleTab = (newText: string) => {
onTab?.(() => persistField(newText));
};
const handleShiftTab = (newText: string) => {
onShiftTab?.(() => persistField(newText));
};
return (
<FieldInputOverlay>
<TextInput
placeholder={fieldDefinition.metadata.placeHolder}
autoFocus
value={initialValue}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,74 @@
import { useFullNameField } from '@/object-record/field/meta-types/hooks/useFullNameField';
import { DoubleTextInput } from '@/object-record/field/meta-types/input/components/internal/DoubleTextInput';
import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText';
import { usePersistField } from '../../../hooks/usePersistField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
import { FieldInputEvent } from './DateFieldInput';
export type FullNameFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const FullNameFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: FullNameFieldInputProps) => {
const { hotkeyScope, initialValue } = useFullNameField();
const persistField = usePersistField();
const convertToFullName = (newDoubleText: FieldDoubleText) => {
return {
firstName: newDoubleText.firstValue,
lastName: newDoubleText.secondValue,
};
};
const handleEnter = (newDoubleText: FieldDoubleText) => {
onEnter?.(() => persistField(convertToFullName(newDoubleText)));
};
const handleEscape = (newDoubleText: FieldDoubleText) => {
onEscape?.(() => persistField(convertToFullName(newDoubleText)));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newDoubleText: FieldDoubleText,
) => {
onClickOutside?.(() => persistField(convertToFullName(newDoubleText)));
};
const handleTab = (newDoubleText: FieldDoubleText) => {
onTab?.(() => persistField(convertToFullName(newDoubleText)));
};
const handleShiftTab = (newDoubleText: FieldDoubleText) => {
onShiftTab?.(() => persistField(convertToFullName(newDoubleText)));
};
return (
<FieldInputOverlay>
<DoubleTextInput
firstValue={initialValue.firstName}
secondValue={initialValue.lastName}
firstValuePlaceholder={'First name'}
secondValuePlaceholder={'Last name'}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,88 @@
import { TextInput } from '@/object-record/field/meta-types/input/components/internal/TextInput';
import { useLinkField } from '../../hooks/useLinkField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
import { FieldInputEvent } from './DateFieldInput';
export type LinkFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const LinkFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: LinkFieldInputProps) => {
const { initialValue, hotkeyScope, persistLinkField } = useLinkField();
const handleEnter = (newURL: string) => {
onEnter?.(() =>
persistLinkField({
url: newURL,
label: newURL,
}),
);
};
const handleEscape = (newURL: string) => {
onEscape?.(() =>
persistLinkField({
url: newURL,
label: newURL,
}),
);
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newURL: string,
) => {
onClickOutside?.(() =>
persistLinkField({
url: newURL,
label: newURL,
}),
);
};
const handleTab = (newURL: string) => {
onTab?.(() =>
persistLinkField({
url: newURL,
label: newURL,
}),
);
};
const handleShiftTab = (newURL: string) => {
onShiftTab?.(() =>
persistLinkField({
url: newURL,
label: newURL,
}),
);
};
return (
<FieldInputOverlay>
<TextInput
value={initialValue.url}
autoFocus
placeholder="URL"
hotkeyScope={hotkeyScope}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onTab={handleTab}
onShiftTab={handleShiftTab}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,65 @@
import { TextInput } from '@/object-record/field/meta-types/input/components/internal/TextInput';
import { useNumberField } from '../../hooks/useNumberField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
export type FieldInputEvent = (persist: () => void) => void;
export type NumberFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const NumberFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: NumberFieldInputProps) => {
const { fieldDefinition, initialValue, hotkeyScope, persistNumberField } =
useNumberField();
const handleEnter = (newText: string) => {
onEnter?.(() => persistNumberField(newText));
};
const handleEscape = (newText: string) => {
onEscape?.(() => persistNumberField(newText));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newText: string,
) => {
onClickOutside?.(() => persistNumberField(newText));
};
const handleTab = (newText: string) => {
onTab?.(() => persistNumberField(newText));
};
const handleShiftTab = (newText: string) => {
onShiftTab?.(() => persistNumberField(newText));
};
return (
<FieldInputOverlay>
<TextInput
placeholder={fieldDefinition.metadata.placeHolder}
autoFocus
value={initialValue?.toString() ?? ''}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,64 @@
import { PhoneInput } from '@/object-record/field/meta-types/input/components/internal/PhoneInput';
import { usePhoneField } from '../../hooks/usePhoneField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
import { FieldInputEvent } from './DateFieldInput';
export type PhoneFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const PhoneFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: PhoneFieldInputProps) => {
const { fieldDefinition, initialValue, hotkeyScope, persistPhoneField } =
usePhoneField();
const handleEnter = (newText: string) => {
onEnter?.(() => persistPhoneField(newText));
};
const handleEscape = (newText: string) => {
onEscape?.(() => persistPhoneField(newText));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newText: string,
) => {
onClickOutside?.(() => persistPhoneField(newText));
};
const handleTab = (newText: string) => {
onTab?.(() => persistPhoneField(newText));
};
const handleShiftTab = (newText: string) => {
onShiftTab?.(() => persistPhoneField(newText));
};
return (
<FieldInputOverlay>
<PhoneInput
placeholder={fieldDefinition.metadata.placeHolder}
autoFocus
value={initialValue}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,28 @@
import { RatingInput } from '@/object-record/field/meta-types/input/components/internal/RatingInput';
import { usePersistField } from '../../../hooks/usePersistField';
import { useRatingField } from '../../hooks/useRatingField';
import { FieldInputEvent } from './DateFieldInput';
export type RatingFieldInputProps = {
onSubmit?: FieldInputEvent;
readonly?: boolean;
};
export const RatingFieldInput = ({
onSubmit,
readonly,
}: RatingFieldInputProps) => {
const { rating } = useRatingField();
const persistField = usePersistField();
const handleChange = (newRating: number) => {
onSubmit?.(() => persistField(`${newRating}`));
};
return (
<RatingInput value={rating} onChange={handleChange} readonly={readonly} />
);
};

View File

@ -0,0 +1,65 @@
import { useEffect } from 'react';
import styled from '@emotion/styled';
import { RelationPicker } from '@/object-record/relation-picker/components/RelationPicker';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { usePersistField } from '../../../hooks/usePersistField';
import { useRelationField } from '../../hooks/useRelationField';
import { FieldInputEvent } from './DateFieldInput';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
`;
export type RelationFieldInputProps = {
onSubmit?: FieldInputEvent;
onCancel?: () => void;
};
export const RelationFieldInput = ({
onSubmit,
onCancel,
}: RelationFieldInputProps) => {
const { fieldDefinition, initialValue, initialSearchValue } =
useRelationField();
const persistField = usePersistField();
const handleSubmit = (newEntity: EntityForSelect | null) => {
onSubmit?.(() => persistField(newEntity?.record ?? null));
};
useEffect(() => {}, [initialSearchValue]);
return (
<StyledRelationPickerContainer>
<RelationPicker
fieldDefinition={fieldDefinition}
recordId={initialValue?.id ?? ''}
onSubmit={handleSubmit}
onCancel={onCancel}
initialSearchFilter={initialSearchValue}
/>
{/* {fieldDefinition.metadata.fieldName === 'person' ? (
<PeoplePicker
personId={initialValue?.id ?? ''}
companyId={initialValue?.companyId ?? ''}
onSubmit={handleSubmit}
onCancel={onCancel}
initialSearchFilter={initialSearchValue}
/>
) : fieldDefinition.metadata.fieldName === 'company' ? (
<CompanyPicker
companyId={initialValue?.id ?? ''}
onSubmit={handleSubmit}
onCancel={onCancel}
initialSearchFilter={initialSearchValue}
/>
) : null} */}
</StyledRelationPickerContainer>
);
};

View File

@ -0,0 +1,66 @@
import { TextInput } from '@/object-record/field/meta-types/input/components/internal/TextInput';
import { usePersistField } from '../../../hooks/usePersistField';
import { useTextField } from '../../hooks/useTextField';
import { FieldInputOverlay } from './internal/FieldInputOverlay';
import { FieldInputEvent } from './DateFieldInput';
export type TextFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const TextFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: TextFieldInputProps) => {
const { fieldDefinition, initialValue, hotkeyScope } = useTextField();
const persistField = usePersistField();
const handleEnter = (newText: string) => {
onEnter?.(() => persistField(newText));
};
const handleEscape = (newText: string) => {
onEscape?.(() => persistField(newText));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newText: string,
) => {
onClickOutside?.(() => persistField(newText));
};
const handleTab = (newText: string) => {
onTab?.(() => persistField(newText));
};
const handleShiftTab = (newText: string) => {
onShiftTab?.(() => persistField(newText));
};
return (
<FieldInputOverlay>
<TextInput
placeholder={fieldDefinition.metadata.placeHolder}
autoFocus
value={initialValue}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
/>
</FieldInputOverlay>
);
};

View File

@ -0,0 +1,100 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useBooleanField } from '../../../hooks/useBooleanField';
import {
BooleanFieldInput,
BooleanFieldInputProps,
} from '../BooleanFieldInput';
const BooleanFieldValueSetterEffect = ({ value }: { value: boolean }) => {
const { setFieldValue } = useBooleanField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type BooleanFieldInputWithContextProps = BooleanFieldInputProps & {
value: boolean;
entityId?: string;
};
const BooleanFieldInputWithContext = ({
value,
entityId,
onSubmit,
}: BooleanFieldInputWithContextProps) => {
return (
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'boolean',
label: 'Boolean',
iconName: 'Icon123',
type: 'BOOLEAN',
metadata: {
fieldName: 'Boolean',
},
}}
entityId={entityId}
>
<BooleanFieldValueSetterEffect value={value} />
<BooleanFieldInput onSubmit={onSubmit} testId="boolean-field-input" />
</FieldContextProvider>
);
};
const meta: Meta = {
title: 'UI/Data/Field/Input/BooleanFieldInput',
component: BooleanFieldInputWithContext,
args: {
value: true,
},
};
export default meta;
type Story = StoryObj<typeof BooleanFieldInputWithContext>;
const submitJestFn = jest.fn();
export const Default: Story = {};
export const Toggle: Story = {
args: {
onSubmit: submitJestFn,
},
argTypes: {
onSubmit: {
control: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByTestId('boolean-field-input');
const trueText = await within(input).findByText('True');
await expect(trueText).toBeInTheDocument();
await expect(submitJestFn).toHaveBeenCalledTimes(0);
await userEvent.click(input);
await expect(input).toHaveTextContent('False');
await expect(submitJestFn).toHaveBeenCalledTimes(1);
await userEvent.click(input);
await expect(input).toHaveTextContent('True');
await expect(submitJestFn).toHaveBeenCalledTimes(2);
},
};

View File

@ -0,0 +1,131 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useDateTimeField } from '../../../hooks/useDateTimeField';
import { DateFieldInput, DateFieldInputProps } from '../DateFieldInput';
const formattedDate = new Date();
const DateFieldValueSetterEffect = ({ value }: { value: Date }) => {
const { setFieldValue } = useDateTimeField();
useEffect(() => {
setFieldValue(value.toISOString());
}, [setFieldValue, value]);
return <></>;
};
type DateFieldInputWithContextProps = DateFieldInputProps & {
value: Date;
entityId?: string;
};
const DateFieldInputWithContext = ({
value,
entityId,
onEscape,
onEnter,
onClickOutside,
}: DateFieldInputWithContextProps) => {
const setHotkeyScope = useSetHotkeyScope();
useEffect(() => {
setHotkeyScope('hotkey-scope');
}, [setHotkeyScope]);
return (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'date',
label: 'Date',
type: 'DATE_TIME',
iconName: 'IconCalendarEvent',
metadata: {
fieldName: 'Date',
},
}}
entityId={entityId}
>
<DateFieldValueSetterEffect value={value} />
<DateFieldInput
onEscape={onEscape}
onEnter={onEnter}
onClickOutside={onClickOutside}
/>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div"></div>
</div>
);
};
const escapeJestFn = jest.fn();
const enterJestFn = jest.fn();
const clickOutsideJestFn = jest.fn();
const meta: Meta = {
title: 'UI/Data/Field/Input/DateFieldInput',
component: DateFieldInputWithContext,
args: {
value: formattedDate,
onEscape: escapeJestFn,
onEnter: enterJestFn,
onClickOutside: clickOutsideJestFn,
},
argTypes: {
onEscape: {
control: false,
},
onEnter: {
control: false,
},
onClickOutside: {
control: false,
},
},
};
export default meta;
type Story = StoryObj<typeof DateFieldInputWithContext>;
export const Default: Story = {};
export const ClickOutside: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
await userEvent.click(emptyDiv);
await expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
},
};
export const Escape: Story = {
play: async () => {
await expect(escapeJestFn).toHaveBeenCalledTimes(0);
await userEvent.keyboard('{esc}');
await expect(escapeJestFn).toHaveBeenCalledTimes(1);
},
};
export const Enter: Story = {
play: async () => {
await expect(enterJestFn).toHaveBeenCalledTimes(0);
await userEvent.keyboard('{enter}');
await expect(enterJestFn).toHaveBeenCalledTimes(1);
},
};

View File

@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useEmailField } from '../../../hooks/useEmailField';
import { EmailFieldInput, EmailFieldInputProps } from '../EmailFieldInput';
const EmailFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useEmailField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type EmailFieldInputWithContextProps = EmailFieldInputProps & {
value: string;
entityId?: string;
};
const EmailFieldInputWithContext = ({
entityId,
value,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: EmailFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'email',
label: 'Email',
type: 'EMAIL',
iconName: 'IconLink',
metadata: {
fieldName: 'email',
placeHolder: 'username@email.com',
},
}}
entityId={entityId}
>
<EmailFieldValueSetterEffect value={value} />
<EmailFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
const enterJestFn = jest.fn();
const escapeJestfn = jest.fn();
const clickOutsideJestFn = jest.fn();
const tabJestFn = jest.fn();
const shiftTabJestFn = jest.fn();
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
enterJestFn.mockClear();
escapeJestfn.mockClear();
clickOutsideJestFn.mockClear();
tabJestFn.mockClear();
shiftTabJestFn.mockClear();
}
return <Story />;
};
const meta: Meta = {
title: 'UI/Data/Field/Input/EmailFieldInput',
component: EmailFieldInputWithContext,
args: {
value: 'username@email.com',
onEnter: enterJestFn,
onEscape: escapeJestfn,
onClickOutside: clickOutsideJestFn,
onTab: tabJestFn,
onShiftTab: shiftTabJestFn,
},
argTypes: {
onEnter: { control: false },
onEscape: { control: false },
onClickOutside: { control: false },
onTab: { control: false },
onShiftTab: { control: false },
},
decorators: [clearMocksDecorator],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof EmailFieldInputWithContext>;
export const Default: Story = {};
export const Enter: Story = {
play: async () => {
expect(enterJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{enter}');
expect(enterJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Escape: Story = {
play: async () => {
expect(escapeJestfn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{esc}');
expect(escapeJestfn).toHaveBeenCalledTimes(1);
});
},
};
export const ClickOutside: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
await waitFor(() => {
userEvent.click(emptyDiv);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Tab: Story = {
play: async () => {
expect(tabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{tab}');
expect(tabJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const ShiftTab: Story = {
play: async () => {
expect(shiftTabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{shift>}{tab}');
expect(shiftTabJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useNumberField } from '../../../hooks/useNumberField';
import { NumberFieldInput, NumberFieldInputProps } from '../NumberFieldInput';
const NumberFieldValueSetterEffect = ({ value }: { value: number }) => {
const { setFieldValue } = useNumberField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type NumberFieldInputWithContextProps = NumberFieldInputProps & {
value: number;
entityId?: string;
};
const NumberFieldInputWithContext = ({
entityId,
value,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: NumberFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'number',
label: 'Number',
iconName: 'Icon123',
type: 'NUMBER',
metadata: {
fieldName: 'number',
placeHolder: 'Enter number',
},
}}
entityId={entityId}
>
<NumberFieldValueSetterEffect value={value} />
<NumberFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
const enterJestFn = jest.fn();
const escapeJestfn = jest.fn();
const clickOutsideJestFn = jest.fn();
const tabJestFn = jest.fn();
const shiftTabJestFn = jest.fn();
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
enterJestFn.mockClear();
escapeJestfn.mockClear();
clickOutsideJestFn.mockClear();
tabJestFn.mockClear();
shiftTabJestFn.mockClear();
}
return <Story />;
};
const meta: Meta = {
title: 'UI/Data/Field/Input/NumberFieldInput',
component: NumberFieldInputWithContext,
args: {
value: 1000,
isPositive: true,
onEnter: enterJestFn,
onEscape: escapeJestfn,
onClickOutside: clickOutsideJestFn,
onTab: tabJestFn,
onShiftTab: shiftTabJestFn,
},
argTypes: {
onEnter: { control: false },
onEscape: { control: false },
onClickOutside: { control: false },
onTab: { control: false },
onShiftTab: { control: false },
},
decorators: [clearMocksDecorator],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof NumberFieldInputWithContext>;
export const Default: Story = {};
export const Enter: Story = {
play: async () => {
expect(enterJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{enter}');
expect(enterJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Escape: Story = {
play: async () => {
expect(escapeJestfn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{esc}');
expect(escapeJestfn).toHaveBeenCalledTimes(1);
});
},
};
export const ClickOutside: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
await waitFor(() => {
userEvent.click(emptyDiv);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Tab: Story = {
play: async () => {
expect(tabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{tab}');
expect(tabJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const ShiftTab: Story = {
play: async () => {
expect(shiftTabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{shift>}{tab}');
expect(shiftTabJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -0,0 +1,177 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { usePhoneField } from '../../../hooks/usePhoneField';
import { PhoneFieldInput, PhoneFieldInputProps } from '../PhoneFieldInput';
const PhoneFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = usePhoneField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type PhoneFieldInputWithContextProps = PhoneFieldInputProps & {
value: string;
entityId?: string;
};
const PhoneFieldInputWithContext = ({
entityId,
value,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: PhoneFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'phone',
label: 'Phone',
type: 'TEXT',
iconName: 'IconPhone',
metadata: {
fieldName: 'phone',
placeHolder: 'Enter phone number',
objectMetadataNameSingular: 'person',
},
}}
entityId={entityId}
>
<PhoneFieldValueSetterEffect value={value} />
<PhoneFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
const enterJestFn = jest.fn();
const escapeJestfn = jest.fn();
const clickOutsideJestFn = jest.fn();
const tabJestFn = jest.fn();
const shiftTabJestFn = jest.fn();
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
enterJestFn.mockClear();
escapeJestfn.mockClear();
clickOutsideJestFn.mockClear();
tabJestFn.mockClear();
shiftTabJestFn.mockClear();
}
return <Story />;
};
const meta: Meta = {
title: 'UI/Data/Field/Input/PhoneFieldInput',
component: PhoneFieldInputWithContext,
args: {
value: '+1-12-123-456',
isPositive: true,
onEnter: enterJestFn,
onEscape: escapeJestfn,
onClickOutside: clickOutsideJestFn,
onTab: tabJestFn,
onShiftTab: shiftTabJestFn,
},
argTypes: {
onEnter: { control: false },
onEscape: { control: false },
onClickOutside: { control: false },
onTab: { control: false },
onShiftTab: { control: false },
},
decorators: [clearMocksDecorator],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof PhoneFieldInputWithContext>;
export const Default: Story = {};
export const Enter: Story = {
play: async () => {
expect(enterJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{enter}');
expect(enterJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Escape: Story = {
play: async () => {
expect(escapeJestfn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{esc}');
expect(escapeJestfn).toHaveBeenCalledTimes(1);
});
},
};
export const ClickOutside: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
await waitFor(() => {
userEvent.click(emptyDiv);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Tab: Story = {
play: async () => {
expect(tabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{tab}');
expect(tabJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const ShiftTab: Story = {
play: async () => {
expect(shiftTabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{shift>}{tab}');
expect(shiftTabJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -0,0 +1,107 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldRatingValue } from '../../../../types/FieldMetadata';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useRatingField } from '../../../hooks/useRatingField';
import { RatingFieldInput, RatingFieldInputProps } from '../RatingFieldInput';
const RatingFieldValueSetterEffect = ({
value,
}: {
value: FieldRatingValue;
}) => {
const { setFieldValue } = useRatingField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type RatingFieldInputWithContextProps = RatingFieldInputProps & {
value: FieldRatingValue;
entityId?: string;
};
const RatingFieldInputWithContext = ({
entityId,
value,
onSubmit,
}: RatingFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'rating',
label: 'Rating',
type: FieldMetadataType.Probability,
iconName: 'Icon123',
metadata: {
fieldName: 'Rating',
},
}}
entityId={entityId}
>
<RatingFieldValueSetterEffect value={value} />
<RatingFieldInput onSubmit={onSubmit} />
</FieldContextProvider>
);
};
const submitJestFn = jest.fn();
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
submitJestFn.mockClear();
}
return <Story />;
};
const meta: Meta = {
title: 'UI/Data/Field/Input/RatingFieldInput',
component: RatingFieldInputWithContext,
args: {
value: '3',
onSubmit: submitJestFn,
},
argTypes: {
onSubmit: { control: false },
},
decorators: [clearMocksDecorator],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof RatingFieldInputWithContext>;
export const Default: Story = {};
export const Submit: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(submitJestFn).toHaveBeenCalledTimes(0);
const input = canvas.getByRole('slider', { name: 'Rating' });
const firstStar = input.firstElementChild;
if (firstStar) userEvent.click(firstStar);
expect(submitJestFn).toHaveBeenCalledTimes(1);
},
};

View File

@ -0,0 +1,137 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useRelationField } from '../../../hooks/useRelationField';
import {
RelationFieldInput,
RelationFieldInputProps,
} from '../RelationFieldInput';
const RelationFieldValueSetterEffect = ({ value }: { value: number }) => {
const { setFieldValue } = useRelationField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type RelationFieldInputWithContextProps = RelationFieldInputProps & {
value: number;
entityId?: string;
};
const RelationFieldInputWithContext = ({
entityId,
value,
onSubmit,
onCancel,
}: RelationFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<div>
<RelationPickerScope relationPickerScopeId="relation-picker">
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'relation',
label: 'Relation',
type: 'RELATION',
iconName: 'IconLink',
metadata: {
fieldName: 'Relation',
},
}}
entityId={entityId}
>
<RelationFieldValueSetterEffect value={value} />
<RelationFieldInput onSubmit={onSubmit} onCancel={onCancel} />
</FieldContextProvider>
</RelationPickerScope>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
const submitJestFn = jest.fn();
const cancelJestFn = jest.fn();
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
submitJestFn.mockClear();
cancelJestFn.mockClear();
}
return <Story />;
};
const meta: Meta = {
title: 'UI/Data/Field/Input/RelationFieldInput',
component: RelationFieldInputWithContext,
args: {
useEditButton: true,
onSubmit: submitJestFn,
onCancel: cancelJestFn,
},
argTypes: {
onSubmit: { control: false },
onCancel: { control: false },
},
decorators: [clearMocksDecorator],
parameters: {
clearMocks: true,
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof RelationFieldInputWithContext>;
export const Default: Story = {
decorators: [ComponentWithRecoilScopeDecorator],
};
export const Submit: Story = {
decorators: [ComponentWithRecoilScopeDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(submitJestFn).toHaveBeenCalledTimes(0);
// FIXME: Failing because the picker is not fetching any items
const item = await canvas.findByText('Jane Doe');
userEvent.click(item);
expect(submitJestFn).toHaveBeenCalledTimes(1);
},
};
export const Cancel: Story = {
decorators: [ComponentWithRecoilScopeDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(cancelJestFn).toHaveBeenCalledTimes(0);
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
await waitFor(() => {
userEvent.click(emptyDiv);
expect(cancelJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { expect, jest } from '@storybook/jest';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
import { useTextField } from '../../../hooks/useTextField';
import { TextFieldInput, TextFieldInputProps } from '../TextFieldInput';
const TextFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useTextField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return <></>;
};
type TextFieldInputWithContextProps = TextFieldInputProps & {
value: string;
entityId?: string;
};
const TextFieldInputWithContext = ({
entityId,
value,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: TextFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope('hotkey-scope');
}, [setHotKeyScope]);
return (
<div>
<FieldContextProvider
fieldDefinition={{
fieldMetadataId: 'text',
label: 'Text',
type: 'TEXT',
iconName: 'IconTag',
metadata: {
fieldName: 'Text',
placeHolder: 'Enter text',
},
}}
entityId={entityId}
>
<TextFieldValueSetterEffect value={value} />
<TextFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
</FieldContextProvider>
<div data-testid="data-field-input-click-outside-div" />
</div>
);
};
const enterJestFn = jest.fn();
const escapeJestfn = jest.fn();
const clickOutsideJestFn = jest.fn();
const tabJestFn = jest.fn();
const shiftTabJestFn = jest.fn();
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks) {
enterJestFn.mockClear();
escapeJestfn.mockClear();
clickOutsideJestFn.mockClear();
tabJestFn.mockClear();
shiftTabJestFn.mockClear();
}
return <Story />;
};
const meta: Meta = {
title: 'UI/Data/Field/Input/TextFieldInput',
component: TextFieldInputWithContext,
args: {
value: 'text',
onEnter: enterJestFn,
onEscape: escapeJestfn,
onClickOutside: clickOutsideJestFn,
onTab: tabJestFn,
onShiftTab: shiftTabJestFn,
},
argTypes: {
onEnter: { control: false },
onEscape: { control: false },
onClickOutside: { control: false },
onTab: { control: false },
onShiftTab: { control: false },
},
decorators: [clearMocksDecorator],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof TextFieldInputWithContext>;
export const Default: Story = {};
export const Enter: Story = {
play: async () => {
expect(enterJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{enter}');
expect(enterJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Escape: Story = {
play: async () => {
expect(escapeJestfn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{esc}');
expect(escapeJestfn).toHaveBeenCalledTimes(1);
});
},
};
export const ClickOutside: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div');
await waitFor(() => {
userEvent.click(emptyDiv);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const Tab: Story = {
play: async () => {
expect(tabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{tab}');
expect(tabJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const ShiftTab: Story = {
play: async () => {
expect(shiftTabJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{shift>}{tab}');
expect(shiftTabJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck, IconX } from '@/ui/display/icon';
const StyledEditableBooleanFieldContainer = styled.div`
align-items: center;
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
display: flex;
height: 100%;
width: 100%;
`;
const StyledEditableBooleanFieldValue = styled.div`
margin-left: ${({ theme }) => theme.spacing(1)};
`;
type BooleanInputProps = {
value: boolean;
onToggle?: (newValue: boolean) => void;
readonly?: boolean;
testId?: string;
};
export const BooleanInput = ({
value,
onToggle,
readonly,
testId,
}: BooleanInputProps) => {
const [internalValue, setInternalValue] = useState(value);
useEffect(() => {
setInternalValue(value);
}, [value]);
const handleClick = () => {
setInternalValue(!internalValue);
onToggle?.(!internalValue);
};
const theme = useTheme();
return (
<StyledEditableBooleanFieldContainer
onClick={readonly ? undefined : handleClick}
data-testid={testId}
>
{internalValue ? (
<IconCheck size={theme.icon.size.sm} />
) : (
<IconX size={theme.icon.size.sm} />
)}
<StyledEditableBooleanFieldValue>
{internalValue ? 'True' : 'False'}
</StyledEditableBooleanFieldValue>
</StyledEditableBooleanFieldContainer>
);
};

View File

@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { flip, offset, useFloating } from '@floating-ui/react';
import { DateDisplay } from '@/object-record/field/meta-types/display/content-display/components/DateDisplay';
import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { Nullable } from '~/types/Nullable';
import { useRegisterInputEvents } from '../../hooks/useRegisterInputEvents';
const StyledCalendarContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
margin-top: 1px;
position: absolute;
z-index: 1;
`;
const StyledInputContainer = styled.div`
padding: ${({ theme }) => theme.spacing(0)} ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export type DateInputProps = {
value: Nullable<Date>;
onEnter: (newDate: Nullable<Date>) => void;
onEscape: (newDate: Nullable<Date>) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDate: Nullable<Date>,
) => void;
hotkeyScope: string;
};
export const DateInput = ({
value,
hotkeyScope,
onEnter,
onEscape,
onClickOutside,
}: DateInputProps) => {
const theme = useTheme();
const [internalValue, setInternalValue] = useState(value);
const wrapperRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
middleware: [
flip(),
offset({
mainAxis: theme.spacingMultiplicator * 2,
}),
],
});
const handleChange = (newDate: Date) => {
setInternalValue(newDate);
};
useEffect(() => {
setInternalValue(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalValue,
onEnter,
onEscape,
onClickOutside,
hotkeyScope,
});
return (
<div ref={wrapperRef}>
<div ref={refs.setReference}>
<StyledInputContainer>
<DateDisplay value={internalValue ?? new Date()} />
</StyledInputContainer>
</div>
<div ref={refs.setFloating} style={floatingStyles}>
<StyledCalendarContainer>
<InternalDatePicker
date={internalValue ?? new Date()}
onChange={handleChange}
onMouseSelect={(newDate: Date) => {
onEnter(newDate);
}}
/>
</StyledCalendarContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,171 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { StyledInput } from './TextInput';
const StyledContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
input {
width: ${({ theme }) => theme.spacing(24)};
}
& > input:last-child {
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
}
`;
type DoubleTextInputProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
hotkeyScope: string;
onEnter: (newDoubleTextValue: FieldDoubleText) => void;
onEscape: (newDoubleTextValue: FieldDoubleText) => void;
onTab?: (newDoubleTextValue: FieldDoubleText) => void;
onShiftTab?: (newDoubleTextValue: FieldDoubleText) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDoubleTextValue: FieldDoubleText,
) => void;
};
export const DoubleTextInput = ({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
hotkeyScope,
onClickOutside,
onEnter,
onEscape,
onShiftTab,
onTab,
}: DoubleTextInputProps) => {
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
const firstValueInputRef = useRef<HTMLInputElement>(null);
const secondValueInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
}, [firstValue, secondValue]);
const handleChange = (
newFirstValue: string,
newSecondValue: string,
): void => {
setFirstInternalValue(newFirstValue);
setSecondInternalValue(newSecondValue);
};
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
useScopedHotkeys(
Key.Enter,
() => {
onEnter({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEnter, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
Key.Escape,
() => {
onEscape({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEscape, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
'tab',
() => {
if (focusPosition === 'left') {
setFocusPosition('right');
secondValueInputRef.current?.focus();
} else {
onTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onTab, firstInternalValue, secondInternalValue, focusPosition],
);
useScopedHotkeys(
'shift+tab',
() => {
if (focusPosition === 'right') {
setFocusPosition('left');
firstValueInputRef.current?.focus();
} else {
onShiftTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onShiftTab, firstInternalValue, secondInternalValue, focusPosition],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
onClickOutside?.(event, {
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
enabled: isDefined(onClickOutside),
});
return (
<StyledContainer ref={containerRef}>
<StyledInput
autoComplete="off"
autoFocus
onFocus={() => setFocusPosition('left')}
ref={firstValueInputRef}
placeholder={firstValuePlaceholder}
value={firstInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleChange(event.target.value, secondInternalValue);
}}
/>
<StyledInput
autoComplete="off"
onFocus={() => setFocusPosition('right')}
ref={secondValueInputRef}
placeholder={secondValuePlaceholder}
value={secondInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleChange(firstInternalValue, event.target.value);
}}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,15 @@
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/theme/constants/effects';
const StyledFieldInputOverlay = styled.div`
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
border-radius: ${({ theme }) => theme.border.radius.sm};
${overlayBackground}
display: flex;
height: 32px;
margin: -1px;
width: 100%;
`;
export const FieldInputOverlay = StyledFieldInputOverlay;

View File

@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import ReactPhoneNumberInput from 'react-phone-number-input';
import styled from '@emotion/styled';
import { CountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/CountryPickerDropdownButton';
import { useRegisterInputEvents } from '../../hooks/useRegisterInputEvents';
import 'react-phone-number-input/style.css';
const StyledContainer = styled.div`
align-items: center;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: center;
`;
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
font-family: ${({ theme }) => theme.font.family};
height: 32px;
.PhoneInputInput {
background: ${({ theme }) => theme.background.transparent.secondary};
border: none;
color: ${({ theme }) => theme.font.color.primary};
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
:focus {
outline: none;
}
}
& svg {
border-radius: ${({ theme }) => theme.border.radius.xs};
height: 12px;
}
`;
export type PhoneInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
};
export const PhoneInput = ({
autoFocus,
value,
onEnter,
onEscape,
onTab,
onShiftTab,
onClickOutside,
hotkeyScope,
}: PhoneInputProps) => {
const [internalValue, setInternalValue] = useState<string | undefined>(value);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setInternalValue(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalValue ?? '',
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
return (
<StyledContainer ref={wrapperRef}>
<StyledCustomPhoneInput
autoFocus={autoFocus}
placeholder="Phone number"
value={value}
onChange={setInternalValue}
international={true}
withCountryCallingCode={true}
countrySelectComponent={CountryPickerDropdownButton}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,61 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconTwentyStarFilled } from '@/ui/display/icon/components/IconTwentyStarFilled';
const StyledContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledRatingIconContainer = styled.div<{ isActive?: boolean }>`
color: ${({ isActive, theme }) =>
isActive ? theme.font.color.secondary : theme.background.quaternary};
display: inline-flex;
`;
type RatingInputProps = {
onChange: (newValue: number) => void;
value: number;
readonly?: boolean;
};
const RATING_LEVELS_NB = 5;
export const RatingInput = ({
onChange,
value,
readonly,
}: RatingInputProps) => {
const theme = useTheme();
const [hoveredValue, setHoveredValue] = useState<number | null>(null);
const currentValue = hoveredValue ?? value;
return (
<StyledContainer
role="slider"
aria-label="Rating"
aria-valuemax={RATING_LEVELS_NB}
aria-valuemin={1}
aria-valuenow={value}
tabIndex={0}
>
{Array.from({ length: RATING_LEVELS_NB }, (_, index) => {
const rating = index + 1;
return (
<StyledRatingIconContainer
key={index}
isActive={rating <= currentValue}
onClick={readonly ? undefined : () => onChange(rating)}
onMouseEnter={readonly ? undefined : () => setHoveredValue(rating)}
onMouseLeave={readonly ? undefined : () => setHoveredValue(null)}
>
<IconTwentyStarFilled size={theme.icon.size.md} />
</StyledRatingIconContainer>
);
})}
</StyledContainer>
);
};

View File

@ -0,0 +1,70 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { useRegisterInputEvents } from '../../hooks/useRegisterInputEvents';
export const StyledInput = styled.input`
margin: 0;
${textInputStyle}
width: 100%;
`;
type TextInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
};
export const TextInput = ({
placeholder,
autoFocus,
value,
hotkeyScope,
onEnter,
onEscape,
onTab,
onShiftTab,
onClickOutside,
}: TextInputProps) => {
const [internalText, setInternalText] = useState(value);
const wrapperRef = useRef<HTMLInputElement>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setInternalText(event.target.value);
};
useEffect(() => {
setInternalText(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalText,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
return (
<StyledInput
autoComplete="off"
ref={wrapperRef}
placeholder={placeholder}
onChange={handleChange}
autoFocus={autoFocus}
value={internalText}
/>
);
};

View File

@ -0,0 +1,69 @@
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
export const useRegisterInputEvents = <T>({
inputRef,
inputValue,
onEscape,
onEnter,
onTab,
onShiftTab,
onClickOutside,
hotkeyScope,
}: {
inputRef: React.RefObject<any>;
inputValue: T;
onEscape: (inputValue: T) => void;
onEnter: (inputValue: T) => void;
onTab?: (inputValue: T) => void;
onShiftTab?: (inputValue: T) => void;
onClickOutside?: (event: MouseEvent | TouchEvent, inputValue: T) => void;
hotkeyScope: string;
}) => {
useListenClickOutside({
refs: [inputRef],
callback: (event) => {
event.stopImmediatePropagation();
onClickOutside?.(event, inputValue);
},
enabled: isDefined(onClickOutside),
});
useScopedHotkeys(
'enter',
() => {
onEnter?.(inputValue);
},
hotkeyScope,
[onEnter, inputValue],
);
useScopedHotkeys(
'esc',
() => {
onEscape?.(inputValue);
},
hotkeyScope,
[onEscape, inputValue],
);
useScopedHotkeys(
'tab',
() => {
onTab?.(inputValue);
},
hotkeyScope,
[onTab, inputValue],
);
useScopedHotkeys(
'shift+tab',
() => {
onShiftTab?.(inputValue);
},
hotkeyScope,
[onShiftTab, inputValue],
);
};

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { FieldInitialValue } from '../types/FieldInitialValue';
export const entityFieldInitialValueFamilyState = atomFamily<
FieldInitialValue | undefined,
{ entityId: string; fieldMetadataId: string }
>({
key: 'entityFieldInitialValueFamilyState',
default: undefined,
});

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const entityFieldsFamilyState = atomFamily<
Record<string, unknown> | null,
string
>({
key: 'entityFieldsFamilyState',
default: null,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isFieldEmptyScopedState = atomFamily<boolean, string>({
key: 'isFieldEmptyScopedState',
default: false,
});

View File

@ -0,0 +1,18 @@
import { selectorFamily } from 'recoil';
import { entityFieldsFamilyState } from '../entityFieldsFamilyState';
export const entityFieldsFamilySelector = selectorFamily({
key: 'entityFieldsFamilySelector',
get:
<T>({ fieldName, entityId }: { fieldName: string; entityId: string }) =>
({ get }) =>
get(entityFieldsFamilyState(entityId))?.[fieldName] as T,
set:
<T>({ fieldName, entityId }: { fieldName: string; entityId: string }) =>
({ set }, newValue: T) =>
set(entityFieldsFamilyState(entityId), (prevState) => ({
...prevState,
[fieldName]: newValue,
})),
});

View File

@ -0,0 +1,90 @@
import { selectorFamily } from 'recoil';
import { isFieldFullName } from '@/object-record/field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/field/types/guards/isFieldFullNameValue';
import { isFieldUuid } from '@/object-record/field/types/guards/isFieldUuid';
import { assertNotNull } from '~/utils/assert';
import { FieldDefinition } from '../../types/FieldDefinition';
import { FieldMetadata } from '../../types/FieldMetadata';
import { isFieldBoolean } from '../../types/guards/isFieldBoolean';
import { isFieldCurrency } from '../../types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '../../types/guards/isFieldCurrencyValue';
import { isFieldDateTime } from '../../types/guards/isFieldDateTime';
import { isFieldEmail } from '../../types/guards/isFieldEmail';
import { isFieldLink } from '../../types/guards/isFieldLink';
import { isFieldLinkValue } from '../../types/guards/isFieldLinkValue';
import { isFieldNumber } from '../../types/guards/isFieldNumber';
import { isFieldRating } from '../../types/guards/isFieldRating';
import { isFieldRelation } from '../../types/guards/isFieldRelation';
import { isFieldRelationValue } from '../../types/guards/isFieldRelationValue';
import { isFieldText } from '../../types/guards/isFieldText';
import { entityFieldsFamilyState } from '../entityFieldsFamilyState';
const isValueEmpty = (value: unknown) => !assertNotNull(value) || value === '';
export const isEntityFieldEmptyFamilySelector = selectorFamily({
key: 'isEntityFieldEmptyFamilySelector',
get: ({
fieldDefinition,
fieldName,
entityId,
}: {
fieldDefinition: Pick<FieldDefinition<FieldMetadata>, 'type'>;
fieldName: string;
entityId: string;
}) => {
return ({ get }) => {
if (
isFieldUuid(fieldDefinition) ||
isFieldText(fieldDefinition) ||
isFieldDateTime(fieldDefinition) ||
isFieldNumber(fieldDefinition) ||
isFieldRating(fieldDefinition) ||
isFieldEmail(fieldDefinition) ||
isFieldBoolean(fieldDefinition)
//|| isFieldPhone(fieldDefinition)
) {
const fieldValue = get(entityFieldsFamilyState(entityId))?.[
fieldName
] as string | number | boolean | null;
return isValueEmpty(fieldValue);
}
if (isFieldRelation(fieldDefinition)) {
const fieldValue = get(entityFieldsFamilyState(entityId))?.[fieldName];
return isFieldRelationValue(fieldValue) && isValueEmpty(fieldValue);
}
if (isFieldCurrency(fieldDefinition)) {
const fieldValue = get(entityFieldsFamilyState(entityId))?.[fieldName];
return (
!isFieldCurrencyValue(fieldValue) ||
isValueEmpty(fieldValue?.amountMicros)
);
}
if (isFieldFullName(fieldDefinition)) {
const fieldValue = get(entityFieldsFamilyState(entityId))?.[fieldName];
return (
!isFieldFullNameValue(fieldValue) ||
isValueEmpty(fieldValue?.firstName + fieldValue?.lastName)
);
}
if (isFieldLink(fieldDefinition)) {
const fieldValue = get(entityFieldsFamilyState(entityId))?.[fieldName];
return !isFieldLinkValue(fieldValue) || isValueEmpty(fieldValue?.url);
}
throw new Error(
`Entity field type not supported in isEntityFieldEmptyFamilySelector : ${fieldDefinition.type}}`,
);
};
},
});

View File

@ -0,0 +1,17 @@
import { FieldMetadata } from './FieldMetadata';
import { FieldType } from './FieldType';
export type FieldDefinitionRelationType =
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type FieldDefinition<T extends FieldMetadata> = {
fieldMetadataId: string;
label: string;
iconName: string;
type: FieldType;
metadata: T;
infoTooltipContent?: string;
};

View File

@ -0,0 +1,7 @@
import { FieldDefinition } from './FieldDefinition';
import { FieldMetadata } from './FieldMetadata';
export type FieldDefinitionSerializable = Omit<
FieldDefinition<FieldMetadata>,
'Icon'
>;

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
import { DoubleTextTypeResolver } from './resolvers/DoubleTextTypeResolver';
export type FieldDoubleText = z.infer<typeof DoubleTextTypeResolver>;

View File

@ -0,0 +1,4 @@
export type FieldInitialValue = {
isEmpty?: boolean;
value?: string;
};

View File

@ -0,0 +1 @@
export type FieldInputEvent = (persist: () => void) => void;

View File

@ -0,0 +1,121 @@
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { ThemeColor } from '@/ui/theme/constants/colors';
export type FieldUuidMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldBooleanMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldTextMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldDateTimeMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldNumberMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
placeHolder: string;
isPositive?: boolean;
};
export type FieldLinkMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldCurrencyMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
placeHolder: string;
isPositive?: boolean;
};
export type FieldFullNameMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldEmailMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldPhoneMetadata = {
objectMetadataNameSingular?: string;
placeHolder: string;
fieldName: string;
};
export type FieldRatingMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldDefinitionRelationType =
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type FieldRelationMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
useEditButton?: boolean;
relationType?: FieldDefinitionRelationType;
relationObjectMetadataNameSingular: string;
relationObjectMetadataNamePlural: string;
};
export type FieldSelectMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
| FieldDateTimeMetadata
| FieldEmailMetadata
| FieldFullNameMetadata
| FieldLinkMetadata
| FieldNumberMetadata
| FieldPhoneMetadata
| FieldRatingMetadata
| FieldRelationMetadata
| FieldSelectMetadata
| FieldTextMetadata
| FieldUuidMetadata;
export type FieldTextValue = string;
export type FieldUUidValue = string;
export type FieldDateTimeValue = string | null;
export type FieldNumberValue = number | null;
export type FieldBooleanValue = boolean;
export type FieldPhoneValue = string;
export type FieldEmailValue = string;
export type FieldLinkValue = { url: string; label: string };
export type FieldCurrencyValue = {
currencyCode: string;
amountMicros: number | null;
};
export type FieldFullNameValue = { firstName: string; lastName: string };
export type FieldRatingValue = '1' | '2' | '3' | '4' | '5';
export type FieldSelectValue = { color: ThemeColor; label: string };
export type FieldRelationValue = EntityForSelect | null;

View File

@ -0,0 +1,18 @@
export type FieldType =
| 'BOOLEAN'
| 'UUID'
| 'TEXT'
| 'RELATION'
| 'CHIP'
| 'DATE_TIME'
| 'DOUBLE_TEXT_CHIP'
| 'DOUBLE_TEXT'
| 'EMAIL'
| 'ENUM'
| 'NUMBER'
| 'PHONE'
| 'PROBABILITY'
| 'URL'
| 'LINK'
| 'CURRENCY'
| 'FULL_NAME';

View File

@ -0,0 +1,71 @@
import { FieldDefinition } from '../FieldDefinition';
import {
FieldBooleanMetadata,
FieldCurrencyMetadata,
FieldDateTimeMetadata,
FieldEmailMetadata,
FieldFullNameMetadata,
FieldLinkMetadata,
FieldMetadata,
FieldNumberMetadata,
FieldPhoneMetadata,
FieldRatingMetadata,
FieldRelationMetadata,
FieldSelectMetadata,
FieldTextMetadata,
FieldUuidMetadata,
} from '../FieldMetadata';
import { FieldType } from '../FieldType';
type AssertFieldMetadataFunction = <
E extends FieldType,
T extends E extends 'BOOLEAN'
? FieldBooleanMetadata
: E extends 'CURRENCY'
? FieldCurrencyMetadata
: E extends 'FULL_NAME'
? FieldFullNameMetadata
: E extends 'DATE_TIME'
? FieldDateTimeMetadata
: E extends 'EMAIL'
? FieldEmailMetadata
: E extends 'ENUM'
? FieldSelectMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'PROBABILITY'
? FieldRatingMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (
a: FieldDefinition<FieldMetadata>,
) => a is FieldDefinition<T>,
fieldDefinition: FieldDefinition<FieldMetadata>,
) => asserts fieldDefinition is FieldDefinition<T>;
export const assertFieldMetadata: AssertFieldMetadataFunction = (
fieldType,
fieldTypeGuard,
fieldDefinition,
) => {
const fieldDefinitionType = fieldDefinition.type;
if (!fieldTypeGuard(fieldDefinition) || fieldDefinitionType !== fieldType) {
throw new Error(
`Trying to use a "${fieldDefinitionType}" field as a "${fieldType}" field. Verify that the field is defined as a type "${fieldDefinitionType}" field in assertFieldMetadata.ts.`,
);
} else {
return;
}
};

View File

@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldBooleanMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldBoolean = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldBooleanMetadata> => field.type === 'BOOLEAN';

View File

@ -0,0 +1,8 @@
import { isBoolean } from '@sniptt/guards';
import { FieldBooleanValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldBooleanValue = (
fieldValue: unknown,
): fieldValue is FieldBooleanValue => isBoolean(fieldValue);

View File

@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldCurrencyMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldCurrency = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldCurrencyMetadata> => field.type === 'CURRENCY';

Some files were not shown because too many files have changed in this diff Show More