New field type: DATE (#4876)
### Description New field type: DATE ### Refs https://github.com/twentyhq/twenty/issues/4377 ### Demo https://jam.dev/c/d0b59883-593c-4ca3-966b-c12d5d2e1c32 Fixes #4377 --------- Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com> Co-authored-by: v1b3m <vibenjamin6@gmail.com> Co-authored-by: Toledodev <rafael.toledo@engenharia.ufjf.br> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
committed by
GitHub
parent
ca9cc86742
commit
7211730570
@ -1391,6 +1391,7 @@ export type FieldDeleteResponse = {
|
||||
export enum FieldMetadataType {
|
||||
Boolean = 'BOOLEAN',
|
||||
Currency = 'CURRENCY',
|
||||
Date = 'DATE',
|
||||
DateTime = 'DATE_TIME',
|
||||
Email = 'EMAIL',
|
||||
FullName = 'FULL_NAME',
|
||||
|
||||
@ -284,6 +284,7 @@ export enum FieldMetadataType {
|
||||
Address = 'ADDRESS',
|
||||
Boolean = 'BOOLEAN',
|
||||
Currency = 'CURRENCY',
|
||||
Date = 'DATE',
|
||||
DateTime = 'DATE_TIME',
|
||||
Email = 'EMAIL',
|
||||
FullName = 'FULL_NAME',
|
||||
|
||||
@ -194,6 +194,7 @@ export enum FieldMetadataType {
|
||||
Address = 'ADDRESS',
|
||||
Boolean = 'BOOLEAN',
|
||||
Currency = 'CURRENCY',
|
||||
Date = 'DATE',
|
||||
DateTime = 'DATE_TIME',
|
||||
Email = 'EMAIL',
|
||||
FullName = 'FULL_NAME',
|
||||
|
||||
@ -61,6 +61,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.DateTime:
|
||||
return 'DATE_TIME';
|
||||
case FieldMetadataType.Date:
|
||||
return 'DATE';
|
||||
case FieldMetadataType.Link:
|
||||
return 'LINK';
|
||||
case FieldMetadataType.FullName:
|
||||
|
||||
@ -12,6 +12,7 @@ export const formatFieldMetadataItemsAsSortDefinitions = ({
|
||||
if (
|
||||
![
|
||||
FieldMetadataType.DateTime,
|
||||
FieldMetadataType.Date,
|
||||
FieldMetadataType.Number,
|
||||
FieldMetadataType.Text,
|
||||
FieldMetadataType.Boolean,
|
||||
|
||||
@ -31,6 +31,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
|
||||
'TEXT',
|
||||
'PHONE',
|
||||
'DATE_TIME',
|
||||
'DATE',
|
||||
'EMAIL',
|
||||
'NUMBER',
|
||||
'BOOLEAN',
|
||||
|
||||
@ -3,6 +3,7 @@ export type FilterType =
|
||||
| 'PHONE'
|
||||
| 'EMAIL'
|
||||
| 'DATE_TIME'
|
||||
| 'DATE'
|
||||
| 'NUMBER'
|
||||
| 'CURRENCY'
|
||||
| 'FULL_NAME'
|
||||
|
||||
@ -15,6 +15,7 @@ export const getOperandsForFilterType = (
|
||||
case 'CURRENCY':
|
||||
case 'NUMBER':
|
||||
case 'DATE_TIME':
|
||||
case 'DATE':
|
||||
return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan];
|
||||
case 'RELATION':
|
||||
case 'SELECT':
|
||||
|
||||
@ -5,6 +5,7 @@ import { AddressFieldDisplay } from '../meta-types/display/components/AddressFie
|
||||
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 { DateTimeFieldDisplay } from '../meta-types/display/components/DateTimeFieldDisplay';
|
||||
import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay';
|
||||
import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay';
|
||||
import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay';
|
||||
@ -18,6 +19,7 @@ import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisp
|
||||
import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay';
|
||||
import { isFieldAddress } from '../types/guards/isFieldAddress';
|
||||
import { isFieldCurrency } from '../types/guards/isFieldCurrency';
|
||||
import { isFieldDate } from '../types/guards/isFieldDate';
|
||||
import { isFieldDateTime } from '../types/guards/isFieldDateTime';
|
||||
import { isFieldEmail } from '../types/guards/isFieldEmail';
|
||||
import { isFieldFullName } from '../types/guards/isFieldFullName';
|
||||
@ -53,6 +55,8 @@ export const FieldDisplay = () => {
|
||||
) : isFieldEmail(fieldDefinition) ? (
|
||||
<EmailFieldDisplay />
|
||||
) : isFieldDateTime(fieldDefinition) ? (
|
||||
<DateTimeFieldDisplay />
|
||||
) : isFieldDate(fieldDefinition) ? (
|
||||
<DateFieldDisplay />
|
||||
) : isFieldNumber(fieldDefinition) ? (
|
||||
<NumberFieldDisplay />
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
|
||||
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
|
||||
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
|
||||
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
|
||||
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
|
||||
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
|
||||
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
|
||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
@ -15,7 +17,7 @@ import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/get
|
||||
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 { DateTimeFieldInput } from '../meta-types/input/components/DateTimeFieldInput';
|
||||
import { EmailFieldInput } from '../meta-types/input/components/EmailFieldInput';
|
||||
import { LinkFieldInput } from '../meta-types/input/components/LinkFieldInput';
|
||||
import { NumberFieldInput } from '../meta-types/input/components/NumberFieldInput';
|
||||
@ -98,6 +100,12 @@ export const FieldInput = ({
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
) : isFieldDateTime(fieldDefinition) ? (
|
||||
<DateTimeFieldInput
|
||||
onEnter={onEnter}
|
||||
onEscape={onEscape}
|
||||
onClickOutside={onClickOutside}
|
||||
/>
|
||||
) : isFieldDate(fieldDefinition) ? (
|
||||
<DateFieldInput
|
||||
onEnter={onEnter}
|
||||
onEscape={onEscape}
|
||||
|
||||
@ -3,6 +3,8 @@ import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
|
||||
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
|
||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
|
||||
@ -61,6 +63,9 @@ export const usePersistField = () => {
|
||||
isFieldDateTime(fieldDefinition) &&
|
||||
isFieldDateTimeValue(valueToPersist);
|
||||
|
||||
const fieldIsDate =
|
||||
isFieldDate(fieldDefinition) && isFieldDateValue(valueToPersist);
|
||||
|
||||
const fieldIsLink =
|
||||
isFieldLink(fieldDefinition) && isFieldLinkValue(valueToPersist);
|
||||
|
||||
@ -108,6 +113,7 @@ export const usePersistField = () => {
|
||||
fieldIsProbability ||
|
||||
fieldIsNumber ||
|
||||
fieldIsDateTime ||
|
||||
fieldIsDate ||
|
||||
fieldIsPhone ||
|
||||
fieldIsLink ||
|
||||
fieldIsCurrency ||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
|
||||
|
||||
import { useDateTimeField } from '../../hooks/useDateTimeField';
|
||||
import { useDateField } from '../../hooks/useDateField';
|
||||
|
||||
export const DateFieldDisplay = () => {
|
||||
const { fieldValue } = useDateTimeField();
|
||||
const { fieldValue } = useDateField();
|
||||
|
||||
return <DateDisplay value={fieldValue} />;
|
||||
};
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
|
||||
|
||||
import { useDateTimeField } from '../../hooks/useDateTimeField';
|
||||
|
||||
export const DateTimeFieldDisplay = () => {
|
||||
const { fieldValue } = useDateTimeField();
|
||||
|
||||
return <DateDisplay value={fieldValue} />;
|
||||
};
|
||||
@ -6,7 +6,7 @@ import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { FieldContext } from '../../../../contexts/FieldContext';
|
||||
import { useDateTimeField } from '../../../hooks/useDateTimeField';
|
||||
import { DateFieldDisplay } from '../DateFieldDisplay';
|
||||
import { DateTimeFieldDisplay } from '../DateTimeFieldDisplay';
|
||||
|
||||
const formattedDate = new Date('2023-04-01');
|
||||
|
||||
@ -47,7 +47,7 @@ const meta: Meta = {
|
||||
),
|
||||
ComponentDecorator,
|
||||
],
|
||||
component: DateFieldDisplay,
|
||||
component: DateTimeFieldDisplay,
|
||||
argTypes: { value: { control: 'date' } },
|
||||
args: {
|
||||
value: formattedDate,
|
||||
@ -56,7 +56,7 @@ const meta: Meta = {
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DateFieldDisplay>;
|
||||
type Story = StoryObj<typeof DateTimeFieldDisplay>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
|
||||
import { FieldDateValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
import { FieldContext } from '../../contexts/FieldContext';
|
||||
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
|
||||
|
||||
export const useDateField = () => {
|
||||
const { entityId, fieldDefinition, hotkeyScope, clearable } =
|
||||
useContext(FieldContext);
|
||||
|
||||
assertFieldMetadata(FieldMetadataType.Date, isFieldDate, fieldDefinition);
|
||||
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
|
||||
const [fieldValue, setFieldValue] = useRecoilState<string>(
|
||||
recordStoreFamilySelector({
|
||||
recordId: entityId,
|
||||
fieldName: fieldName,
|
||||
}),
|
||||
);
|
||||
|
||||
const { setDraftValue } = useRecordFieldInput<FieldDateValue>(
|
||||
`${entityId}-${fieldName}`,
|
||||
);
|
||||
|
||||
return {
|
||||
fieldDefinition,
|
||||
fieldValue,
|
||||
setDraftValue,
|
||||
setFieldValue,
|
||||
hotkeyScope,
|
||||
clearable,
|
||||
};
|
||||
};
|
||||
@ -3,7 +3,7 @@ import { BooleanInput } from '@/ui/field/input/components/BooleanInput';
|
||||
import { usePersistField } from '../../../hooks/usePersistField';
|
||||
import { useBooleanField } from '../../hooks/useBooleanField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type BooleanFieldInputProps = {
|
||||
onSubmit?: FieldInputEvent;
|
||||
|
||||
@ -5,7 +5,7 @@ import { CurrencyInput } from '@/ui/field/input/components/CurrencyInput';
|
||||
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
|
||||
import { useCurrencyField } from '../../hooks/useCurrencyField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type CurrencyFieldInputProps = {
|
||||
onClickOutside?: FieldInputEvent;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useDateField } from '@/object-record/record-field/meta-types/hooks/useDateField';
|
||||
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;
|
||||
|
||||
@ -17,8 +17,7 @@ export const DateFieldInput = ({
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
}: DateFieldInputProps) => {
|
||||
const { fieldValue, hotkeyScope, clearable, setDraftValue } =
|
||||
useDateTimeField();
|
||||
const { fieldValue, hotkeyScope, setDraftValue } = useDateField();
|
||||
|
||||
const persistField = usePersistField();
|
||||
|
||||
@ -33,6 +32,7 @@ export const DateFieldInput = ({
|
||||
};
|
||||
|
||||
const handleEnter = (newDate: Nullable<Date>) => {
|
||||
console.log('newDate enter', newDate);
|
||||
onEnter?.(() => persistDate(newDate));
|
||||
};
|
||||
|
||||
@ -60,7 +60,7 @@ export const DateFieldInput = ({
|
||||
onEnter={handleEnter}
|
||||
onEscape={handleEscape}
|
||||
value={dateValue}
|
||||
clearable={clearable}
|
||||
clearable
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
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 DateTimeFieldInputProps = {
|
||||
onClickOutside?: FieldInputEvent;
|
||||
onEnter?: FieldInputEvent;
|
||||
onEscape?: FieldInputEvent;
|
||||
};
|
||||
|
||||
export const DateTimeFieldInput = ({
|
||||
onEnter,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
}: DateTimeFieldInputProps) => {
|
||||
const { fieldValue, hotkeyScope, clearable, setDraftValue } =
|
||||
useDateTimeField();
|
||||
|
||||
const persistField = usePersistField();
|
||||
|
||||
const persistDate = (newDate: Nullable<Date>) => {
|
||||
if (!newDate) {
|
||||
persistField(null);
|
||||
} 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 handleChange = (newDate: Nullable<Date>) => {
|
||||
setDraftValue(newDate?.toDateString() ?? '');
|
||||
};
|
||||
|
||||
const dateValue = fieldValue ? new Date(fieldValue) : null;
|
||||
|
||||
return (
|
||||
<DateInput
|
||||
hotkeyScope={hotkeyScope}
|
||||
onClickOutside={handleClickOutside}
|
||||
onEnter={handleEnter}
|
||||
onEscape={handleEscape}
|
||||
value={dateValue}
|
||||
clearable={clearable}
|
||||
onChange={handleChange}
|
||||
isDateTimeInput
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -4,7 +4,7 @@ import { FieldInputOverlay } from '../../../../../ui/field/input/components/Fiel
|
||||
import { usePersistField } from '../../../hooks/usePersistField';
|
||||
import { useEmailField } from '../../hooks/useEmailField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type EmailFieldInputProps = {
|
||||
onClickOutside?: FieldInputEvent;
|
||||
|
||||
@ -5,7 +5,7 @@ import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay
|
||||
|
||||
import { usePersistField } from '../../../hooks/usePersistField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
const FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS =
|
||||
'First name';
|
||||
|
||||
@ -3,7 +3,7 @@ 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';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type LinkFieldInputProps = {
|
||||
onClickOutside?: FieldInputEvent;
|
||||
|
||||
@ -3,7 +3,7 @@ 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';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type PhoneFieldInputProps = {
|
||||
onClickOutside?: FieldInputEvent;
|
||||
|
||||
@ -4,7 +4,7 @@ import { RatingInput } from '@/ui/field/input/components/RatingInput';
|
||||
import { usePersistField } from '../../../hooks/usePersistField';
|
||||
import { useRatingField } from '../../hooks/useRatingField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type RatingFieldInputProps = {
|
||||
onSubmit?: FieldInputEvent;
|
||||
|
||||
@ -6,7 +6,7 @@ import { EntityForSelect } from '@/object-record/relation-picker/types/EntityFor
|
||||
import { usePersistField } from '../../../hooks/usePersistField';
|
||||
import { useRelationField } from '../../hooks/useRelationField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
const StyledRelationPickerContainer = styled.div`
|
||||
left: -1px;
|
||||
|
||||
@ -4,7 +4,7 @@ import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput';
|
||||
import { usePersistField } from '../../../hooks/usePersistField';
|
||||
import { useTextField } from '../../hooks/useTextField';
|
||||
|
||||
import { FieldInputEvent } from './DateFieldInput';
|
||||
import { FieldInputEvent } from './DateTimeFieldInput';
|
||||
|
||||
export type TextFieldInputProps = {
|
||||
onClickOutside?: FieldInputEvent;
|
||||
|
||||
@ -7,7 +7,10 @@ import { FieldMetadataType } from '~/generated/graphql';
|
||||
|
||||
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
|
||||
import { useDateTimeField } from '../../../hooks/useDateTimeField';
|
||||
import { DateFieldInput, DateFieldInputProps } from '../DateFieldInput';
|
||||
import {
|
||||
DateTimeFieldInput,
|
||||
DateTimeFieldInputProps,
|
||||
} from '../DateTimeFieldInput';
|
||||
|
||||
const formattedDate = new Date(2022, 1, 1);
|
||||
|
||||
@ -21,7 +24,7 @@ const DateFieldValueSetterEffect = ({ value }: { value: Date }) => {
|
||||
return <></>;
|
||||
};
|
||||
|
||||
type DateFieldInputWithContextProps = DateFieldInputProps & {
|
||||
type DateFieldInputWithContextProps = DateTimeFieldInputProps & {
|
||||
value: Date;
|
||||
entityId?: string;
|
||||
};
|
||||
@ -55,7 +58,7 @@ const DateFieldInputWithContext = ({
|
||||
entityId={entityId}
|
||||
>
|
||||
<DateFieldValueSetterEffect value={value} />
|
||||
<DateFieldInput
|
||||
<DateTimeFieldInput
|
||||
onEscape={onEscape}
|
||||
onEnter={onEnter}
|
||||
onClickOutside={onClickOutside}
|
||||
@ -26,6 +26,12 @@ export type FieldDateTimeMetadata = {
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export type FieldDateMetadata = {
|
||||
objectMetadataNameSingular?: string;
|
||||
placeHolder: string;
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
export type FieldNumberMetadata = {
|
||||
objectMetadataNameSingular?: string;
|
||||
fieldName: string;
|
||||
@ -113,6 +119,7 @@ export type FieldMetadata =
|
||||
| FieldBooleanMetadata
|
||||
| FieldCurrencyMetadata
|
||||
| FieldDateTimeMetadata
|
||||
| FieldDateMetadata
|
||||
| FieldEmailMetadata
|
||||
| FieldFullNameMetadata
|
||||
| FieldLinkMetadata
|
||||
@ -129,6 +136,7 @@ export type FieldMetadata =
|
||||
export type FieldTextValue = string;
|
||||
export type FieldUUidValue = string;
|
||||
export type FieldDateTimeValue = string | null;
|
||||
export type FieldDateValue = string | null;
|
||||
export type FieldNumberValue = number | null;
|
||||
export type FieldBooleanValue = boolean;
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
FieldAddressMetadata,
|
||||
FieldBooleanMetadata,
|
||||
FieldCurrencyMetadata,
|
||||
FieldDateMetadata,
|
||||
FieldDateTimeMetadata,
|
||||
FieldEmailMetadata,
|
||||
FieldFullNameMetadata,
|
||||
@ -31,33 +32,35 @@ type AssertFieldMetadataFunction = <
|
||||
? FieldFullNameMetadata
|
||||
: E extends 'DATE_TIME'
|
||||
? FieldDateTimeMetadata
|
||||
: E extends 'EMAIL'
|
||||
? FieldEmailMetadata
|
||||
: E extends 'SELECT'
|
||||
? FieldSelectMetadata
|
||||
: E extends 'MULTI_SELECT'
|
||||
? FieldMultiSelectMetadata
|
||||
: 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
|
||||
: E extends 'ADDRESS'
|
||||
? FieldAddressMetadata
|
||||
: E extends 'RAW_JSON'
|
||||
? FieldRawJsonMetadata
|
||||
: never,
|
||||
: E extends 'DATE'
|
||||
? FieldDateMetadata
|
||||
: E extends 'EMAIL'
|
||||
? FieldEmailMetadata
|
||||
: E extends 'SELECT'
|
||||
? FieldSelectMetadata
|
||||
: E extends 'MULTI_SELECT'
|
||||
? FieldMultiSelectMetadata
|
||||
: 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
|
||||
: E extends 'ADDRESS'
|
||||
? FieldAddressMetadata
|
||||
: E extends 'RAW_JSON'
|
||||
? FieldRawJsonMetadata
|
||||
: never,
|
||||
>(
|
||||
fieldType: E,
|
||||
fieldTypeGuard: (
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { FieldDefinition } from '../FieldDefinition';
|
||||
import { FieldDateMetadata, FieldMetadata } from '../FieldMetadata';
|
||||
|
||||
export const isFieldDate = (
|
||||
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
|
||||
): field is FieldDefinition<FieldDateMetadata> => field.type === 'DATE';
|
||||
@ -0,0 +1,10 @@
|
||||
import { isNull, isString } from '@sniptt/guards';
|
||||
|
||||
import { FieldDateValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
|
||||
// TODO: add zod
|
||||
export const isFieldDateValue = (
|
||||
fieldValue: unknown,
|
||||
): fieldValue is FieldDateValue =>
|
||||
(isString(fieldValue) && !isNaN(Date.parse(fieldValue))) ||
|
||||
isNull(fieldValue);
|
||||
@ -5,6 +5,7 @@ import { isFieldAddressValue } from '@/object-record/record-field/types/guards/i
|
||||
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
|
||||
import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency';
|
||||
import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue';
|
||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||
import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime';
|
||||
import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldEmail';
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
@ -38,6 +39,7 @@ export const isFieldValueEmpty = ({
|
||||
isFieldUuid(fieldDefinition) ||
|
||||
isFieldText(fieldDefinition) ||
|
||||
isFieldDateTime(fieldDefinition) ||
|
||||
isFieldDate(fieldDefinition) ||
|
||||
isFieldNumber(fieldDefinition) ||
|
||||
isFieldRating(fieldDefinition) ||
|
||||
isFieldEmail(fieldDefinition) ||
|
||||
|
||||
@ -39,6 +39,9 @@ export const generateEmptyFieldValue = (
|
||||
case FieldMetadataType.DateTime: {
|
||||
return null;
|
||||
}
|
||||
case FieldMetadataType.Date: {
|
||||
return null;
|
||||
}
|
||||
case FieldMetadataType.Number:
|
||||
case FieldMetadataType.Rating:
|
||||
case FieldMetadataType.Position:
|
||||
|
||||
@ -71,6 +71,11 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
|
||||
Icon: IconCalendarEvent,
|
||||
defaultValue: DEFAULT_DATE_VALUE.toISOString(),
|
||||
},
|
||||
[FieldMetadataType.Date]: {
|
||||
label: 'Date',
|
||||
Icon: IconCalendarEvent,
|
||||
defaultValue: DEFAULT_DATE_VALUE.toISOString(),
|
||||
},
|
||||
[FieldMetadataType.Select]: {
|
||||
label: 'Select',
|
||||
Icon: IconTag,
|
||||
|
||||
@ -63,6 +63,7 @@ const previewableTypes = [
|
||||
FieldMetadataType.Boolean,
|
||||
FieldMetadataType.Currency,
|
||||
FieldMetadataType.DateTime,
|
||||
FieldMetadataType.Date,
|
||||
FieldMetadataType.Select,
|
||||
FieldMetadataType.MultiSelect,
|
||||
FieldMetadataType.Link,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { flip, offset, useFloating } from '@floating-ui/react';
|
||||
@ -14,8 +14,6 @@ const StyledCalendarContainer = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
|
||||
margin-top: 1px;
|
||||
|
||||
position: absolute;
|
||||
|
||||
z-index: 1;
|
||||
@ -38,6 +36,7 @@ export type DateInputProps = {
|
||||
hotkeyScope: string;
|
||||
clearable?: boolean;
|
||||
onChange?: (newDate: Nullable<Date>) => void;
|
||||
isDateTimeInput?: boolean;
|
||||
};
|
||||
|
||||
export const DateInput = ({
|
||||
@ -48,6 +47,7 @@ export const DateInput = ({
|
||||
onClickOutside,
|
||||
clearable,
|
||||
onChange,
|
||||
isDateTimeInput,
|
||||
}: DateInputProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@ -60,7 +60,7 @@ export const DateInput = ({
|
||||
middleware: [
|
||||
flip(),
|
||||
offset({
|
||||
mainAxis: theme.spacingMultiplicator * 2,
|
||||
mainAxis: theme.spacingMultiplicator * -6,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -70,10 +70,6 @@ export const DateInput = ({
|
||||
onChange?.(newDate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
inputValue: internalValue,
|
||||
@ -99,6 +95,7 @@ export const DateInput = ({
|
||||
onEnter(newDate);
|
||||
}}
|
||||
clearable={clearable ? clearable : false}
|
||||
isDateTimeInput={isDateTimeInput}
|
||||
/>
|
||||
</StyledCalendarContainer>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import ReactDatePicker from 'react-datepicker';
|
||||
import styled from '@emotion/styled';
|
||||
import { DateTime } from 'luxon';
|
||||
import { IconCalendarX } from 'twenty-ui';
|
||||
|
||||
import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/components/MenuItemLeftContent';
|
||||
@ -235,7 +236,7 @@ const StyledButtonContainer = styled(StyledHoverableMenuItemBase)`
|
||||
`;
|
||||
|
||||
const StyledButton = styled(MenuItemLeftContent)`
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
`;
|
||||
|
||||
export type InternalDatePickerProps = {
|
||||
@ -243,24 +244,100 @@ export type InternalDatePickerProps = {
|
||||
onMouseSelect?: (date: Date | null) => void;
|
||||
onChange?: (date: Date) => void;
|
||||
clearable?: boolean;
|
||||
isDateTimeInput?: boolean;
|
||||
};
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
background: ${({ theme }) => theme.background.secondary};
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
font-weight: 500;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const PICKER_DATE_FORMAT = 'MM/dd/yyyy';
|
||||
|
||||
export const InternalDatePicker = ({
|
||||
date,
|
||||
onChange,
|
||||
onMouseSelect,
|
||||
clearable = true,
|
||||
isDateTimeInput,
|
||||
}: InternalDatePickerProps) => {
|
||||
const handleClear = () => {
|
||||
onMouseSelect?.(null);
|
||||
};
|
||||
|
||||
const initialDate = date
|
||||
? DateTime.fromJSDate(date).toFormat(PICKER_DATE_FORMAT)
|
||||
: DateTime.now().toFormat(PICKER_DATE_FORMAT);
|
||||
|
||||
const [dateValue, setDateValue] = useState(initialDate);
|
||||
|
||||
const dateValueAsJSDate = DateTime.fromFormat(dateValue, PICKER_DATE_FORMAT)
|
||||
.isValid
|
||||
? DateTime.fromFormat(dateValue, PICKER_DATE_FORMAT).toJSDate()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<div className={clearable ? 'clearable ' : ''}>
|
||||
<StyledInputContainer>
|
||||
<StyledInput
|
||||
type="text"
|
||||
placeholder={`Type date${
|
||||
isDateTimeInput ? ' and time' : ' (mm/dd/yyyy)'
|
||||
}`}
|
||||
inputMode="numeric"
|
||||
value={dateValue}
|
||||
onChange={(e) => {
|
||||
const inputValue = e.target.value;
|
||||
setDateValue(inputValue);
|
||||
|
||||
if (!isDateTimeInput) {
|
||||
const parsedInputDate = DateTime.fromFormat(
|
||||
inputValue,
|
||||
PICKER_DATE_FORMAT,
|
||||
{ zone: 'utc' },
|
||||
);
|
||||
|
||||
const isValid = parsedInputDate.isValid;
|
||||
|
||||
if (isValid) {
|
||||
onChange?.(parsedInputDate.toJSDate());
|
||||
}
|
||||
} else {
|
||||
// TODO: implement time also
|
||||
const parsedInputDate = DateTime.fromFormat(
|
||||
inputValue,
|
||||
PICKER_DATE_FORMAT,
|
||||
{ zone: 'utc' },
|
||||
);
|
||||
|
||||
const isValid = parsedInputDate.isValid;
|
||||
|
||||
if (isValid) {
|
||||
onChange?.(parsedInputDate.toJSDate());
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={date}
|
||||
selected={dateValueAsJSDate}
|
||||
value={dateValue}
|
||||
showMonthDropdown
|
||||
showYearDropdown
|
||||
onChange={() => {
|
||||
@ -268,10 +345,18 @@ export const InternalDatePicker = ({
|
||||
}}
|
||||
customInput={<></>}
|
||||
onSelect={(date: Date, event) => {
|
||||
// Setting the time to midnight might sometimes return the previous day
|
||||
// We set to 21:00 to avoid any timezone issues
|
||||
const dateForDateField = new Date(date.setHours(21, 0, 0, 0));
|
||||
|
||||
setDateValue(
|
||||
DateTime.fromJSDate(date).toFormat(PICKER_DATE_FORMAT),
|
||||
);
|
||||
|
||||
if (event?.type === 'click') {
|
||||
onMouseSelect?.(date);
|
||||
onMouseSelect?.(isDateTimeInput ? date : dateForDateField);
|
||||
} else {
|
||||
onChange?.(date);
|
||||
onChange?.(isDateTimeInput ? date : dateForDateField);
|
||||
}
|
||||
}}
|
||||
></ReactDatePicker>
|
||||
|
||||
@ -65,6 +65,7 @@ export class TypeMapperService {
|
||||
[FieldMetadataType.PHONE, GraphQLString],
|
||||
[FieldMetadataType.EMAIL, GraphQLString],
|
||||
[FieldMetadataType.DATE_TIME, dateScalar],
|
||||
[FieldMetadataType.DATE, dateScalar],
|
||||
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
|
||||
[FieldMetadataType.NUMBER, numberScalar],
|
||||
[FieldMetadataType.NUMERIC, BigFloatScalarType],
|
||||
@ -96,6 +97,7 @@ export class TypeMapperService {
|
||||
[FieldMetadataType.PHONE, StringFilterType],
|
||||
[FieldMetadataType.EMAIL, StringFilterType],
|
||||
[FieldMetadataType.DATE_TIME, dateFilter],
|
||||
[FieldMetadataType.DATE, DateFilterType],
|
||||
[FieldMetadataType.BOOLEAN, BooleanFilterType],
|
||||
[FieldMetadataType.NUMBER, numberScalar],
|
||||
[FieldMetadataType.NUMERIC, BigFloatFilterType],
|
||||
@ -117,6 +119,7 @@ export class TypeMapperService {
|
||||
[FieldMetadataType.PHONE, OrderByDirectionType],
|
||||
[FieldMetadataType.EMAIL, OrderByDirectionType],
|
||||
[FieldMetadataType.DATE_TIME, OrderByDirectionType],
|
||||
[FieldMetadataType.DATE, OrderByDirectionType],
|
||||
[FieldMetadataType.BOOLEAN, OrderByDirectionType],
|
||||
[FieldMetadataType.NUMBER, OrderByDirectionType],
|
||||
[FieldMetadataType.NUMERIC, OrderByDirectionType],
|
||||
|
||||
@ -20,6 +20,7 @@ export const mapFieldMetadataToGraphqlQuery = (
|
||||
FieldMetadataType.TEXT,
|
||||
FieldMetadataType.PHONE,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
FieldMetadataType.DATE,
|
||||
FieldMetadataType.EMAIL,
|
||||
FieldMetadataType.NUMBER,
|
||||
FieldMetadataType.SELECT,
|
||||
|
||||
@ -31,6 +31,7 @@ const getSchemaComponentsProperties = (
|
||||
case FieldMetadataType.PHONE:
|
||||
case FieldMetadataType.EMAIL:
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
case FieldMetadataType.DATE:
|
||||
itemProperty.type = 'string';
|
||||
break;
|
||||
case FieldMetadataType.NUMBER:
|
||||
|
||||
@ -58,6 +58,12 @@ export class FieldMetadataDefaultValueDateTime {
|
||||
value: Date | null;
|
||||
}
|
||||
|
||||
export class FieldMetadataDefaultValueDate {
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
@IsDate()
|
||||
value: Date | null;
|
||||
}
|
||||
|
||||
export class FieldMetadataDefaultValueLink {
|
||||
@ValidateIf((object, value) => value !== null)
|
||||
@IsQuotedString()
|
||||
|
||||
@ -23,6 +23,7 @@ export enum FieldMetadataType {
|
||||
PHONE = 'PHONE',
|
||||
EMAIL = 'EMAIL',
|
||||
DATE_TIME = 'DATE_TIME',
|
||||
DATE = 'DATE',
|
||||
BOOLEAN = 'BOOLEAN',
|
||||
NUMBER = 'NUMBER',
|
||||
NUMERIC = 'NUMERIC',
|
||||
|
||||
@ -27,6 +27,9 @@ type FieldMetadataDefaultValueMapping = {
|
||||
[FieldMetadataType.DATE_TIME]:
|
||||
| FieldMetadataDefaultValueDateTime
|
||||
| FieldMetadataDefaultValueNowFunction;
|
||||
[FieldMetadataType.DATE]:
|
||||
| FieldMetadataDefaultValueDateTime
|
||||
| FieldMetadataDefaultValueNowFunction;
|
||||
[FieldMetadataType.BOOLEAN]: FieldMetadataDefaultValueBoolean;
|
||||
[FieldMetadataType.NUMBER]: FieldMetadataDefaultValueNumber;
|
||||
[FieldMetadataType.POSITION]: FieldMetadataDefaultValueNumber;
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
FieldMetadataDefaultValueStringArray,
|
||||
FieldMetadataDefaultValueNowFunction,
|
||||
FieldMetadataDefaultValueUuidFunction,
|
||||
FieldMetadataDefaultValueDate,
|
||||
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
|
||||
@ -35,6 +36,7 @@ export const defaultValueValidatorsMap = {
|
||||
FieldMetadataDefaultValueDateTime,
|
||||
FieldMetadataDefaultValueNowFunction,
|
||||
],
|
||||
[FieldMetadataType.DATE]: [FieldMetadataDefaultValueDate],
|
||||
[FieldMetadataType.BOOLEAN]: [FieldMetadataDefaultValueBoolean],
|
||||
[FieldMetadataType.NUMBER]: [FieldMetadataDefaultValueNumber],
|
||||
[FieldMetadataType.NUMERIC]: [FieldMetadataDefaultValueString],
|
||||
|
||||
@ -25,6 +25,7 @@ export type BasicFieldMetadataType =
|
||||
| FieldMetadataType.BOOLEAN
|
||||
| FieldMetadataType.POSITION
|
||||
| FieldMetadataType.DATE_TIME
|
||||
| FieldMetadataType.DATE
|
||||
| FieldMetadataType.POSITION;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -25,6 +25,8 @@ export const fieldMetadataTypeToColumnType = <Type extends FieldMetadataType>(
|
||||
return 'boolean';
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
return 'timestamptz';
|
||||
case FieldMetadataType.DATE:
|
||||
return 'date';
|
||||
case FieldMetadataType.RATING:
|
||||
case FieldMetadataType.SELECT:
|
||||
case FieldMetadataType.MULTI_SELECT:
|
||||
|
||||
@ -74,6 +74,7 @@ export class WorkspaceMigrationFactory {
|
||||
],
|
||||
[FieldMetadataType.BOOLEAN, { factory: this.basicColumnActionFactory }],
|
||||
[FieldMetadataType.DATE_TIME, { factory: this.basicColumnActionFactory }],
|
||||
[FieldMetadataType.DATE, { factory: this.basicColumnActionFactory }],
|
||||
[FieldMetadataType.RATING, { factory: this.enumColumnActionFactory }],
|
||||
[FieldMetadataType.SELECT, { factory: this.enumColumnActionFactory }],
|
||||
[
|
||||
|
||||
@ -18,6 +18,8 @@ const getTypeFromFieldMetadataType = (
|
||||
return 'string';
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
return 'datetime';
|
||||
case FieldMetadataType.DATE:
|
||||
return 'date';
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
return 'boolean';
|
||||
case FieldMetadataType.NUMBER:
|
||||
@ -177,6 +179,7 @@ export const computeInputFields = (
|
||||
case FieldMetadataType.PHONE:
|
||||
case FieldMetadataType.EMAIL:
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
case FieldMetadataType.DATE:
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.NUMERIC:
|
||||
|
||||
@ -36,6 +36,7 @@ export enum FieldMetadataType {
|
||||
PHONE = 'PHONE',
|
||||
EMAIL = 'EMAIL',
|
||||
DATE_TIME = 'DATE_TIME',
|
||||
DATE = 'DATE',
|
||||
BOOLEAN = 'BOOLEAN',
|
||||
NUMBER = 'NUMBER',
|
||||
NUMERIC = 'NUMERIC',
|
||||
|
||||
Reference in New Issue
Block a user