Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions
@@ -0,0 +1,272 @@
import { useParams } from 'react-router-dom';
import { useRecoilState } from 'recoil';
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 { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageFavoriteButton } from '@/ui/layout/page/PageFavoriteButton';
import { PageHeader } from '@/ui/layout/page/PageHeader';
import { ShowPageContainer } from '@/ui/layout/page/ShowPageContainer';
import { ShowPageAddButton } from '@/ui/layout/show-page/components/ShowPageAddButton';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
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 { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FileFolder, useUploadImageMutation } from '~/generated/graphql';
import { getLogoUrlFromDomainName } from '~/utils';
import { useFindOneRecord } from '../hooks/useFindOneRecord';
import { useUpdateOneRecord } from '../hooks/useUpdateOneRecord';
export const RecordShowPage = () => {
const { objectNameSingular, objectRecordId } = useParams<{
objectNameSingular: string;
objectRecordId: string;
}>();
if (!objectNameSingular) {
throw new Error(`Object name is not defined`);
}
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { identifiersMapper } = useRelationPicker();
const { favorites, createFavorite, deleteFavorite } = useFavorites({
objectNamePlural: objectMetadataItem.namePlural,
});
const [, setEntityFields] = useRecoilState(
entityFieldsFamilyState(objectRecordId ?? ''),
);
const { record } = useFindOneRecord({
objectRecordId,
objectNameSingular,
onCompleted: (data) => {
setEntityFields(data);
},
});
const [uploadImage] = useUploadImageMutation();
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular,
});
const objectMetadataType =
objectMetadataItem?.nameSingular === 'company'
? 'Company'
: objectMetadataItem?.nameSingular === 'person'
? 'Person'
: 'Custom';
const useUpdateOneObjectRecordMutation: () => [
(params: any) => any,
any,
] = () => {
const updateEntity = ({
variables,
}: {
variables: {
where: { id: string };
data: {
[fieldName: string]: any;
};
};
}) => {
updateOneRecord?.({
idToUpdate: variables.where.id,
input: variables.data,
});
};
return [updateEntity, { loading: false }];
};
const isFavorite = objectNameSingular
? favorites.some((favorite) => favorite.recordId === record?.id)
: false;
const handleFavoriteButtonClick = async () => {
if (!objectNameSingular || !record) return;
if (isFavorite) deleteFavorite(record?.id);
else {
const additionalData =
objectNameSingular === 'person'
? {
labelIdentifier:
record.name.firstName + ' ' + record.name.lastName,
avatarUrl: record.avatarUrl,
avatarType: 'rounded',
link: `/object/personV2/${record.id}`,
recordId: record.id,
}
: objectNameSingular === 'company'
? {
labelIdentifier: record.name,
avatarUrl: getLogoUrlFromDomainName(record.domainName ?? ''),
avatarType: 'squared',
link: `/object/companyV2/${record.id}`,
recordId: record.id,
}
: {};
createFavorite(record.id, additionalData);
}
};
if (!record) return <></>;
const pageName =
objectNameSingular === 'person'
? record.name.firstName + ' ' + record.name.lastName
: record.name;
const recordIdentifiers = identifiersMapper?.(
record,
objectMetadataItem?.nameSingular ?? '',
);
const onUploadPicture = async (file: File) => {
if (objectNameSingular !== 'person') {
return;
}
const result = await uploadImage({
variables: {
file,
fileFolder: FileFolder.PersonPicture,
},
});
const avatarUrl = result?.data?.uploadImage;
if (!avatarUrl) {
return;
}
if (!updateOneRecord) {
return;
}
await updateOneRecord({
idToUpdate: record?.id,
input: {
avatarUrl,
},
});
};
return (
<PageContainer>
<PageTitle title={pageName} />
<PageHeader
title={pageName ?? ''}
hasBackButton
Icon={IconBuildingSkyscraper}
>
{objectMetadataType !== 'Custom' && (
<>
<PageFavoriteButton
isFavorite={isFavorite}
onClick={handleFavoriteButtonClick}
/>
<ShowPageAddButton
key="add"
entity={{
id: record.id,
type: objectMetadataType,
}}
/>
</>
)}
</PageHeader>
<PageBody>
<RecoilScope CustomRecoilScopeContext={ShowPageRecoilScopeContext}>
<ShowPageContainer>
<ShowPageLeftContainer>
<ShowPageSummaryCard
id={record.id}
logoOrAvatar={recordIdentifiers?.avatarUrl}
title={recordIdentifiers?.name ?? 'No name'}
date={record.createdAt ?? ''}
renderTitleEditComponent={() => <></>}
avatarType={recordIdentifiers?.avatarType ?? 'rounded'}
onUploadPicture={
objectNameSingular === 'person' ? onUploadPicture : undefined
}
/>
<PropertyBox extraPadding={true}>
{objectMetadataItem &&
[...objectMetadataItem.fields]
.sort((a, b) =>
a.name === 'name' ? -1 : a.name.localeCompare(b.name),
)
.filter(filterAvailableFieldMetadataItem)
.map((metadataField, index) => {
return (
<FieldContext.Provider
key={record.id + metadataField.id}
value={{
entityId: record.id,
recoilScopeId: record.id + metadataField.id,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: metadataField,
position: index,
objectMetadataItem,
}),
useUpdateEntityMutation:
useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell />
</FieldContext.Provider>
);
})}
</PropertyBox>
{objectNameSingular === 'company' ? (
<>
<CompanyTeam company={record} />
</>
) : (
<></>
)}
</ShowPageLeftContainer>
<ShowPageRightContainer
entity={{
id: record.id,
// TODO: refacto
type:
objectMetadataItem?.nameSingular === 'company'
? 'Company'
: objectMetadataItem?.nameSingular === 'person'
? 'Person'
: 'Custom',
}}
timeline
tasks
notes
emails
/>
</ShowPageContainer>
</RecoilScope>
</PageBody>
</PageContainer>
);
};
@@ -0,0 +1,124 @@
import styled from '@emotion/styled';
import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
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 { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ViewBar } from '@/views/components/ViewBar';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { RecordTableEffect } from './RecordTableEffect';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
`;
export const RecordTableContainer = ({
objectNamePlural,
createRecord,
}: {
objectNamePlural: string;
createRecord: () => void;
}) => {
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { columnDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular,
});
const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport();
const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport();
const recordTableId = objectNamePlural ?? '';
const viewBarId = objectNamePlural ?? '';
const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({
recordTableScopeId: recordTableId,
});
const updateEntity = ({
variables,
}: {
variables: {
where: { id: string };
data: {
[fieldName: string]: any;
};
};
}) => {
updateOneRecord?.({
idToUpdate: variables.where.id,
input: variables.data,
});
};
const handleImport = () => {
const openImport =
recordTableId === 'companies'
? openCompanySpreadsheetImport
: openPersonSpreadsheetImport;
openImport();
};
return (
<StyledContainer>
<SpreadsheetImportProvider>
<ViewBar
viewBarId={viewBarId}
optionsDropdownButton={
<TableOptionsDropdown
recordTableId={recordTableId}
onImport={
['companies', 'people'].includes(recordTableId)
? handleImport
: undefined
}
/>
}
optionsDropdownScopeId={TableOptionsDropdownId}
onViewFieldsChange={(viewFields) => {
setTableColumns(
mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions),
);
}}
onViewFiltersChange={(viewFilters) => {
setTableFilters(mapViewFiltersToFilters(viewFilters));
}}
onViewSortsChange={(viewSorts) => {
setTableSorts(mapViewSortsToSorts(viewSorts));
}}
/>
</SpreadsheetImportProvider>
<RecordTableEffect recordTableId={recordTableId} viewBarId={viewBarId} />
{
<RecordTable
recordTableId={recordTableId}
viewBarId={viewBarId}
updateRecordMutation={updateEntity}
createRecord={createRecord}
/>
}
</StyledContainer>
);
};
@@ -0,0 +1,111 @@
import { useEffect } from 'react';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
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 { useViewBar } from '@/views/hooks/useViewBar';
import { ViewType } from '@/views/types/ViewType';
export const RecordTableEffect = ({
recordTableId,
viewBarId,
}: {
recordTableId: string;
viewBarId: string;
}) => {
const {
// Todo: do not infer objectNamePlural from recordTableId
scopeId: objectNamePlural,
setAvailableTableColumns,
setOnEntityCountChange,
setObjectMetadataConfig,
} = useRecordTable({ recordTableScopeId: recordTableId });
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const {
objectMetadataItem,
basePathToShowPage,
labelIdentifierFieldMetadataId,
} = useObjectMetadataItem({
objectNameSingular,
});
const { columnDefinitions, filterDefinitions, sortDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const {
setAvailableSortDefinitions,
setAvailableFilterDefinitions,
setAvailableFieldDefinitions,
setViewType,
setViewObjectMetadataId,
setEntityCountInCurrentView,
} = useViewBar({ viewBarId });
useEffect(() => {
if (basePathToShowPage && labelIdentifierFieldMetadataId) {
setObjectMetadataConfig?.({
basePathToShowPage,
labelIdentifierFieldMetadataId,
});
}
}, [
basePathToShowPage,
objectMetadataItem,
labelIdentifierFieldMetadataId,
setObjectMetadataConfig,
]);
useEffect(() => {
if (!objectMetadataItem) {
return;
}
setViewObjectMetadataId?.(objectMetadataItem.id);
setViewType?.(ViewType.Table);
setAvailableSortDefinitions?.(sortDefinitions);
setAvailableFilterDefinitions?.(filterDefinitions);
setAvailableFieldDefinitions?.(columnDefinitions);
const availableTableColumns = columnDefinitions.filter(
filterAvailableTableColumns,
);
setAvailableTableColumns(availableTableColumns);
}, [
setViewObjectMetadataId,
setViewType,
columnDefinitions,
setAvailableSortDefinitions,
setAvailableFilterDefinitions,
setAvailableFieldDefinitions,
objectMetadataItem,
sortDefinitions,
filterDefinitions,
setAvailableTableColumns,
]);
const { setActionBarEntries, setContextMenuEntries } =
useRecordTableContextMenuEntries({
recordTableScopeId: recordTableId,
});
useEffect(() => {
setActionBarEntries?.();
setContextMenuEntries?.();
}, [setActionBarEntries, setContextMenuEntries]);
useEffect(() => {
setOnEntityCountChange(
() => (entityCount: number) => setEntityCountInCurrentView(entityCount),
);
}, [setEntityCountInCurrentView, setOnEntityCountChange]);
return <></>;
};
@@ -0,0 +1,89 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus';
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 { RecordTableContainer } from './RecordTableContainer';
const StyledTableContainer = styled.div`
display: flex;
height: 100%;
width: 100%;
`;
export const RecordTablePage = () => {
const objectNamePlural = useParams().objectNamePlural ?? '';
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const onboardingStatus = useOnboardingStatus();
const navigate = useNavigate();
const { icons } = useLazyLoadIcons();
const { findObjectMetadataItemByNamePlural } =
useObjectMetadataItemForSettings();
useEffect(() => {
if (
!isNonEmptyString(objectNamePlural) &&
onboardingStatus === OnboardingStatus.Completed
) {
navigate('/');
}
}, [objectNamePlural, navigate, onboardingStatus]);
const { createOneRecord: createOneObject } = useCreateOneRecord({
objectNameSingular,
});
const handleAddButtonClick = async () => {
await createOneObject?.({});
};
return (
<PageContainer>
<PageHeader
title={
objectNamePlural.charAt(0).toUpperCase() + objectNamePlural.slice(1)
}
Icon={
icons[
findObjectMetadataItemByNamePlural(objectNamePlural)!.icon ??
'Icon123'
]
}
>
<PageHotkeysEffect onAddButtonClick={handleAddButtonClick} />
<PageAddButton onClick={handleAddButtonClick} />
</PageHeader>
<PageBody>
<StyledTableContainer>
<RecordTableContainer
objectNamePlural={objectNamePlural}
createRecord={handleAddButtonClick}
/>
</StyledTableContainer>
<RecordTableActionBar />
<RecordTableContextMenu />
</PageBody>
</PageContainer>
);
};
@@ -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 />
) : (
<></>
)}
</>
);
};
@@ -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} />
) : (
<></>
)}
</>
);
};
@@ -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,
);
@@ -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;
};
@@ -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;
}
}
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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>
);
};
@@ -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}
/>
);
};
@@ -0,0 +1,9 @@
import { CurrencyDisplay } from '@/ui/field/display/components/CurrencyDisplay';
import { useCurrencyField } from '../../hooks/useCurrencyField';
export const CurrencyFieldDisplay = () => {
const { initialAmount } = useCurrencyField();
return <CurrencyDisplay amount={initialAmount} />;
};
@@ -0,0 +1,9 @@
import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
import { useDateTimeField } from '../../hooks/useDateTimeField';
export const DateFieldDisplay = () => {
const { fieldValue } = useDateTimeField();
return <DateDisplay value={fieldValue} />;
};
@@ -0,0 +1,9 @@
import { EmailDisplay } from '@/ui/field/display/components/EmailDisplay';
import { useEmailField } from '../../hooks/useEmailField';
export const EmailFieldDisplay = () => {
const { fieldValue } = useEmailField();
return <EmailDisplay value={fieldValue} />;
};
@@ -0,0 +1,12 @@
import { useFullNameField } from '@/object-record/field/meta-types/hooks/useFullNameField';
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
export const FullNameFieldDisplay = () => {
const { fieldValue } = useFullNameField();
const content = [fieldValue.firstName, fieldValue.lastName]
.filter(Boolean)
.join(' ');
return <TextDisplay text={content} />;
};
@@ -0,0 +1,9 @@
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
import { useLinkField } from '../../hooks/useLinkField';
export const LinkFieldDisplay = () => {
const { fieldValue } = useLinkField();
return <LinkDisplay value={fieldValue} />;
};
@@ -0,0 +1,9 @@
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
import { useNumberField } from '../../hooks/useNumberField';
export const NumberFieldDisplay = () => {
const { fieldValue } = useNumberField();
return <NumberDisplay value={fieldValue} />;
};
@@ -0,0 +1,9 @@
import { PhoneDisplay } from '@/ui/field/display/components/PhoneDisplay';
import { usePhoneField } from '../../hooks/usePhoneField';
export const PhoneFieldDisplay = () => {
const { fieldValue } = usePhoneField();
return <PhoneDisplay value={fieldValue} />;
};
@@ -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}
/>
);
};
@@ -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} />;
};
@@ -0,0 +1,9 @@
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
import { useTextField } from '../../hooks/useTextField';
export const TextFieldDisplay = () => {
const { fieldValue } = useTextField();
return <TextDisplay text={fieldValue} />;
};
@@ -0,0 +1,8 @@
import { useUuidField } from '@/object-record/field/meta-types/hooks/useUuidField';
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
export const UuidFieldDisplay = () => {
const { fieldValue } = useUuidField();
return <TextDisplay text={fieldValue} />;
};
@@ -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 },
},
};
@@ -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 },
},
};
@@ -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,
},
};
@@ -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 },
},
};
@@ -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 },
},
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -0,0 +1,34 @@
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.Rating, 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,
};
};
@@ -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,
};
};
@@ -0,0 +1,48 @@
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { ThemeColor } from '@/ui/theme/constants/colors';
import { FieldMetadataType } from '~/generated/graphql';
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(FieldMetadataType.Select, 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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -0,0 +1,35 @@
import { BooleanInput } from '@/ui/field/input/components/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}
/>
);
};
@@ -0,0 +1,93 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { useCurrencyField } from '../../hooks/useCurrencyField';
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>
);
};
@@ -0,0 +1,62 @@
import { DateInput } from '@/ui/field/input/components/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}
/>
);
};
@@ -0,0 +1,66 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { usePersistField } from '../../../hooks/usePersistField';
import { useEmailField } from '../../hooks/useEmailField';
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>
);
};
@@ -0,0 +1,74 @@
import { useFullNameField } from '@/object-record/field/meta-types/hooks/useFullNameField';
import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText';
import { DoubleTextInput } from '@/ui/field/input/components/DoubleTextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { usePersistField } from '../../../hooks/usePersistField';
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>
);
};
@@ -0,0 +1,88 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { useLinkField } from '../../hooks/useLinkField';
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>
);
};
@@ -0,0 +1,64 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { useNumberField } from '../../hooks/useNumberField';
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>
);
};
@@ -0,0 +1,64 @@
import { PhoneInput } from '@/ui/field/input/components/PhoneInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { usePhoneField } from '../../hooks/usePhoneField';
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>
);
};
@@ -0,0 +1,28 @@
import { RatingInput } from '@/ui/field/input/components/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} />
);
};
@@ -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>
);
};
@@ -0,0 +1,66 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
import { usePersistField } from '../../../hooks/usePersistField';
import { useTextField } from '../../hooks/useTextField';
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>
);
};
@@ -0,0 +1,99 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
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 = 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);
},
};
@@ -0,0 +1,130 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
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 = fn();
const enterJestFn = fn();
const clickOutsideJestFn = 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);
},
};
@@ -0,0 +1,174 @@
import { useEffect } from 'react';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
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 = fn();
const escapeJestfn = fn();
const clickOutsideJestFn = fn();
const tabJestFn = fn();
const shiftTabJestFn = 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);
});
},
};
@@ -0,0 +1,175 @@
import { useEffect } from 'react';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
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 = fn();
const escapeJestfn = fn();
const clickOutsideJestFn = fn();
const tabJestFn = fn();
const shiftTabJestFn = 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);
});
},
};
@@ -0,0 +1,176 @@
import { useEffect } from 'react';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
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 = fn();
const escapeJestfn = fn();
const clickOutsideJestFn = fn();
const tabJestFn = fn();
const shiftTabJestFn = 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);
});
},
};
@@ -0,0 +1,106 @@
import { useEffect } from 'react';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
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.Rating,
iconName: 'Icon123',
metadata: {
fieldName: 'Rating',
},
}}
entityId={entityId}
>
<RatingFieldValueSetterEffect value={value} />
<RatingFieldInput onSubmit={onSubmit} />
</FieldContextProvider>
);
};
const submitJestFn = 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);
},
};
@@ -0,0 +1,136 @@
import { useEffect } from 'react';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
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 = fn();
const cancelJestFn = 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);
});
},
};
@@ -0,0 +1,174 @@
import { useEffect } from 'react';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
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 = fn();
const escapeJestfn = fn();
const clickOutsideJestFn = fn();
const tabJestFn = fn();
const shiftTabJestFn = 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);
});
},
};
@@ -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],
);
};
@@ -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,
});
@@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const entityFieldsFamilyState = atomFamily<
Record<string, unknown> | null,
string
>({
key: 'entityFieldsFamilyState',
default: null,
});
@@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isFieldEmptyScopedState = atomFamily<boolean, string>({
key: 'isFieldEmptyScopedState',
default: false,
});
@@ -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,
})),
});
@@ -0,0 +1,92 @@
import { selectorFamily } from 'recoil';
import { isFieldFullName } from '@/object-record/field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/field/types/guards/isFieldFullNameValue';
import { isFieldSelect } from '@/object-record/field/types/guards/isFieldSelect';
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) ||
isFieldSelect(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}}`,
);
};
},
});
@@ -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;
};
@@ -0,0 +1,7 @@
import { FieldDefinition } from './FieldDefinition';
import { FieldMetadata } from './FieldMetadata';
export type FieldDefinitionSerializable = Omit<
FieldDefinition<FieldMetadata>,
'Icon'
>;
@@ -0,0 +1,5 @@
import { z } from 'zod';
import { DoubleTextTypeResolver } from './resolvers/DoubleTextTypeResolver';
export type FieldDoubleText = z.infer<typeof DoubleTextTypeResolver>;
@@ -0,0 +1,4 @@
export type FieldInitialValue = {
isEmpty?: boolean;
value?: string;
};
@@ -0,0 +1 @@
export type FieldInputEvent = (persist: () => void) => void;
@@ -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;
@@ -0,0 +1,18 @@
export type FieldType =
| 'BOOLEAN'
| 'CHIP'
| 'CURRENCY'
| 'DATE_TIME'
| 'DOUBLE_TEXT_CHIP'
| 'DOUBLE_TEXT'
| 'EMAIL'
| 'FULL_NAME'
| 'LINK'
| 'NUMBER'
| 'PHONE'
| 'RATING'
| 'RELATION'
| 'SELECT'
| 'TEXT'
| 'URL'
| 'UUID';
@@ -0,0 +1,73 @@
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 'SELECT'
? FieldSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: 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;
}
};
@@ -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';
@@ -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);
@@ -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';
@@ -0,0 +1,13 @@
import { z } from 'zod';
import { FieldCurrencyValue } from '../FieldMetadata';
const currencySchema = z.object({
currencyCode: z.string().nullable(),
amountMicros: z.number().nullable(),
});
export const isFieldCurrencyValue = (
fieldValue: unknown,
): fieldValue is FieldCurrencyValue =>
currencySchema.safeParse(fieldValue).success;
@@ -0,0 +1,7 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldDateTimeMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldDateTime = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldDateTimeMetadata> =>
field.type === 'DATE_TIME';
@@ -0,0 +1,9 @@
import { isString } from '@sniptt/guards';
import { FieldDateTimeValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldDateTimeValue = (
fieldValue: unknown,
): fieldValue is FieldDateTimeValue =>
isString(fieldValue) && !isNaN(Date.parse(fieldValue));
@@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldEmailMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldEmail = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldEmailMetadata> => field.type === 'EMAIL';
@@ -0,0 +1,8 @@
import { isString } from '@sniptt/guards';
import { FieldEmailValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldEmailValue = (
fieldValue: unknown,
): fieldValue is FieldEmailValue => isString(fieldValue);
@@ -0,0 +1,7 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldFullNameMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldFullName = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldFullNameMetadata> =>
field.type === 'FULL_NAME';
@@ -0,0 +1,13 @@
import { z } from 'zod';
import { FieldFullNameValue } from '../FieldMetadata';
const currencySchema = z.object({
firstName: z.string(),
lastName: z.string(),
});
export const isFieldFullNameValue = (
fieldValue: unknown,
): fieldValue is FieldFullNameValue =>
currencySchema.safeParse(fieldValue).success;
@@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldLinkMetadata, FieldMetadata } from '../FieldMetadata';
export const isFieldLink = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldLinkMetadata> => field.type === 'LINK';
@@ -0,0 +1,13 @@
import { z } from 'zod';
import { FieldLinkValue } from '../FieldMetadata';
const linkSchema = z.object({
url: z.string(),
label: z.string(),
});
// TODO: add zod
export const isFieldLinkValue = (
fieldValue: unknown,
): fieldValue is FieldLinkValue => linkSchema.safeParse(fieldValue).success;
@@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldNumberMetadata } from '../FieldMetadata';
export const isFieldNumber = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldNumberMetadata> => field.type === 'NUMBER';
@@ -0,0 +1,8 @@
import { isNull, isNumber } from '@sniptt/guards';
import { FieldNumberValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldNumberValue = (
fieldValue: unknown,
): fieldValue is FieldNumberValue => isNull(fieldValue) || isNumber(fieldValue);
@@ -0,0 +1,9 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldPhoneMetadata } from '../FieldMetadata';
export const isFieldPhone = (
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldPhoneMetadata> =>
field.metadata.objectMetadataNameSingular === 'person' &&
field.metadata.fieldName === 'phone' &&
field.type === 'TEXT';
@@ -0,0 +1,8 @@
import { isString } from '@sniptt/guards';
import { FieldPhoneValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldPhoneValue = (
fieldValue: unknown,
): fieldValue is FieldPhoneValue => isString(fieldValue);
@@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldRatingMetadata } from '../FieldMetadata';
export const isFieldRating = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldRatingMetadata> =>
field.type === FieldMetadataType.Rating;
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { FieldRatingValue } from '../FieldMetadata';
const ratingSchema = z
.string()
.transform((value) => +value)
.pipe(z.number().int().min(1).max(5));
export const isFieldRatingValue = (
fieldValue: unknown,
): fieldValue is FieldRatingValue => ratingSchema.safeParse(fieldValue).success;
@@ -0,0 +1,6 @@
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldRelationMetadata } from '../FieldMetadata';
export const isFieldRelation = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldRelationMetadata> => field.type === 'RELATION';
@@ -0,0 +1,9 @@
import { isNull, isObject, isUndefined } from '@sniptt/guards';
import { FieldRelationValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldRelationValue = (
fieldValue: unknown,
): fieldValue is FieldRelationValue =>
!isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue));
@@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldSelectMetadata } from '../FieldMetadata';
export const isFieldSelect = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldSelectMetadata> =>
field.type === FieldMetadataType.Select;
@@ -0,0 +1,13 @@
import { z } from 'zod';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
const selectValueSchema = z.object({
color: themeColorSchema,
label: z.string(),
});
export const isFieldSelectValue = (
fieldValue: unknown,
): fieldValue is z.infer<typeof selectValueSchema> =>
selectValueSchema.safeParse(fieldValue).success;

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