feat: add Relation field form (#2572)

* feat: add useCreateOneRelationMetadata and useRelationMetadata

Closes #2423

* feat: add Relation field form

Closes #2003

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2023-11-17 23:38:39 +01:00
committed by GitHub
parent fea0bbeb2a
commit 18dac1a2b6
34 changed files with 1285 additions and 643 deletions

View File

@ -1014,6 +1014,26 @@ export type UserV2Edge = {
node: UserV2;
};
export type WorkspaceV2 = {
__typename?: 'workspaceV2';
createdAt: Scalars['DateTime']['output'];
deletedAt?: Maybe<Scalars['DateTime']['output']>;
displayName?: Maybe<Scalars['String']['output']>;
domainName?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
inviteHash?: Maybe<Scalars['String']['output']>;
logo?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceV2Edge = {
__typename?: 'workspaceV2Edge';
/** Cursor for this node. */
cursor: Scalars['ConnectionCursor']['output'];
/** The node containing the workspaceV2 */
node: WorkspaceV2;
};
export type CreateOneObjectMetadataItemMutationVariables = Exact<{
input: CreateOneObjectInput;
}>;

View File

@ -1462,6 +1462,7 @@ export type Mutation = {
updateOnePerson?: Maybe<Person>;
updateOnePipelineProgress?: Maybe<PipelineProgress>;
updateOnePipelineStage?: Maybe<PipelineStage>;
updateOneWorkspaceV2: WorkspaceV2;
updateUser: User;
updateWorkspace: Workspace;
uploadAttachment: Scalars['String'];
@ -1668,6 +1669,11 @@ export type MutationUpdateOnePipelineStageArgs = {
};
export type MutationUpdateOneWorkspaceV2Args = {
input: UpdateOneWorkspaceV2Input;
};
export type MutationUpdateUserArgs = {
data: UserUpdateInput;
where: UserWhereUniqueInput;
@ -2486,6 +2492,8 @@ export type Query = {
objects: ObjectConnection;
relation: Relation;
relations: RelationConnection;
workspaceV2: WorkspaceV2;
workspaceV2s: WorkspaceV2Connection;
};
@ -2613,6 +2621,18 @@ export type QueryFindWorkspaceFromInviteHashArgs = {
inviteHash: Scalars['String'];
};
export type QueryWorkspaceV2Args = {
id: Scalars['ID'];
};
export type QueryWorkspaceV2sArgs = {
filter?: WorkspaceV2Filter;
paging?: CursorPaging;
sorting?: Array<WorkspaceV2Sort>;
};
export enum QueryMode {
Default = 'default',
Insensitive = 'insensitive'
@ -2647,6 +2667,18 @@ export enum RelationMetadataType {
OneToOne = 'ONE_TO_ONE'
}
/** Sort Directions */
export enum SortDirection {
Asc = 'ASC',
Desc = 'DESC'
}
/** Sort Nulls Options */
export enum SortNulls {
NullsFirst = 'NULLS_FIRST',
NullsLast = 'NULLS_LAST'
}
export enum SortOrder {
Asc = 'asc',
Desc = 'desc'
@ -2694,6 +2726,20 @@ export type Telemetry = {
enabled: Scalars['Boolean'];
};
export type UpdateOneWorkspaceV2Input = {
/** The id of the record to update */
id: Scalars['ID'];
/** The update to apply. */
update: UpdateWorkspaceInput;
};
export type UpdateWorkspaceInput = {
displayName: Scalars['String'];
domainName: Scalars['String'];
inviteHash: Scalars['String'];
logo: Scalars['String'];
};
export type User = {
__typename?: 'User';
assignedActivities?: Maybe<Array<Activity>>;
@ -3150,6 +3196,16 @@ export type WorkspaceUpdateInput = {
workspaceMember?: InputMaybe<WorkspaceMemberUpdateManyWithoutWorkspaceNestedInput>;
};
export type WorkspaceV2Connection = {
__typename?: 'WorkspaceV2Connection';
/** Array of edges. */
edges: Array<WorkspaceV2Edge>;
/** Paging information */
pageInfo: PageInfo;
/** Fetch total count of records */
totalCount: Scalars['Int'];
};
export type Field = {
__typename?: 'field';
createdAt: Scalars['DateTime'];
@ -3287,6 +3343,42 @@ export type UserV2Edge = {
node: UserV2;
};
export type WorkspaceV2 = {
__typename?: 'workspaceV2';
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>;
domainName?: Maybe<Scalars['String']>;
id: Scalars['ID'];
inviteHash?: Maybe<Scalars['String']>;
logo?: Maybe<Scalars['String']>;
updatedAt: Scalars['DateTime'];
};
export type WorkspaceV2Edge = {
__typename?: 'workspaceV2Edge';
/** Cursor for this node. */
cursor: Scalars['ConnectionCursor'];
/** The node containing the workspaceV2 */
node: WorkspaceV2;
};
export type WorkspaceV2Filter = {
and?: InputMaybe<Array<WorkspaceV2Filter>>;
id?: InputMaybe<IdFilterComparison>;
or?: InputMaybe<Array<WorkspaceV2Filter>>;
};
export type WorkspaceV2Sort = {
direction: SortDirection;
field: WorkspaceV2SortFields;
nulls?: InputMaybe<SortNulls>;
};
export enum WorkspaceV2SortFields {
Id = 'id'
}
export type CreateEventMutationVariables = Exact<{
type: Scalars['String'];
data: Scalars['JSON'];

View File

@ -35,6 +35,11 @@ export const useObjectMetadataItemForSettings = () => {
(objectMetadataItem) => objectMetadataItem.id === id,
);
const findObjectMetadataItemByNamePlural = (namePlural: string) =>
objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.namePlural === namePlural,
);
const { createOneObjectMetadataItem } =
useCreateOneObjectRecordMetadataItem();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
@ -88,6 +93,8 @@ export const useObjectMetadataItemForSettings = () => {
eraseObjectMetadataItem,
findActiveObjectMetadataItemBySlug,
findObjectMetadataItemById,
findObjectMetadataItemByNamePlural,
loading,
objectMetadataItems,
};
};

View File

@ -1,39 +1,51 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldDefinitionRelationType } from '@/ui/object/field/types/FieldDefinition';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const parseFieldRelationType = (
field: FieldMetadataItem | undefined,
): FieldDefinitionRelationType | undefined => {
if (field && field.type === 'RELATION') {
if (
isDefined(field.fromRelationMetadata) &&
field.fromRelationMetadata.relationType === 'ONE_TO_MANY'
) {
return 'FROM_NAMY_OBJECTS';
} else if (
isDefined(field.toRelationMetadata) &&
field.toRelationMetadata.relationType === 'ONE_TO_MANY'
) {
return 'TO_ONE_OBJECT';
} else if (
isDefined(field.fromRelationMetadata) &&
field.fromRelationMetadata.relationType === 'MANY_TO_MANY'
) {
return 'FROM_NAMY_OBJECTS';
} else if (
isDefined(field.toRelationMetadata) &&
field.toRelationMetadata.relationType === 'MANY_TO_MANY'
) {
return 'TO_MANY_OBJECTS';
}
if (!field || field.type !== FieldMetadataType.Relation) return;
throw new Error(
`Cannot determine field relation type for field : ${JSON.stringify(
field,
)}.`,
);
} else {
return undefined;
const config: Record<
RelationMetadataType,
{ from: FieldDefinitionRelationType; to: FieldDefinitionRelationType }
> = {
[RelationMetadataType.ManyToMany]: {
from: 'FROM_MANY_OBJECTS',
to: 'TO_MANY_OBJECTS',
},
[RelationMetadataType.OneToMany]: {
from: 'FROM_MANY_OBJECTS',
to: 'TO_ONE_OBJECT',
},
[RelationMetadataType.OneToOne]: {
from: 'FROM_ONE_OBJECT',
to: 'TO_ONE_OBJECT',
},
};
if (
isDefined(field.fromRelationMetadata) &&
field.fromRelationMetadata.relationType in config
) {
return config[field.fromRelationMetadata.relationType].from;
}
if (
isDefined(field.toRelationMetadata) &&
field.toRelationMetadata.relationType in config
) {
return config[field.toRelationMetadata.relationType].to;
}
throw new Error(
`Cannot determine field relation type for field : ${JSON.stringify(
field,
)}.`,
);
};

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 58 46" width="58" height="46" fill="none" preserveAspectRatio="xMidYMid meet">
<rect width="9" height="9" x=".5" y="18.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="3" y="21" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y=".5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="3" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y="18.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="21" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y="36.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="39" fill="#D6D6D6" rx="1" />
<path fill="#D6D6D6" d="M5.113 22.5h48v1h-48v-1Z" />
<path stroke="#D6D6D6" d="M52.884 41H45.06a7.544 7.544 0 0 1-7.56-7.561V12.56A7.544 7.544 0 0 1 45.06 5h7.793" />
</svg>

After

Width:  |  Height:  |  Size: 870 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="58" height="46" fill="none" viewBox="0 0 58 46">
<rect width="9" height="9" x=".5" y="18.5" stroke="#EBEBEB" rx="2.5"/>
<rect width="4" height="4" x="3" y="21" fill="#D6D6D6" rx="1"/>
<rect width="9" height="9" x="48.5" y="18.5" stroke="#EBEBEB" rx="2.5"/>
<rect width="4" height="4" x="51" y="21" fill="#D6D6D6" rx="1"/>
<path fill="#D6D6D6" d="M5.113 22.5h48v1h-48v-1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@ -1,30 +1,25 @@
import { useEffect } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { parseFieldType } from '@/object-metadata/utils/parseFieldType';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { Tag } from '@/ui/display/tag/components/Tag';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { FieldDisplay } from '@/ui/object/field/components/FieldDisplay';
import { FieldContext } from '@/ui/object/field/contexts/FieldContext';
import { BooleanFieldInput } from '@/ui/object/field/meta-types/input/components/BooleanFieldInput';
import { entityFieldsFamilySelector } from '@/ui/object/field/states/selectors/entityFieldsFamilySelector';
import { FieldMetadataType } from '~/generated/graphql';
import { assertNotNull } from '~/utils/assert';
import { Field } from '~/generated/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { SettingsObjectFieldPreviewValueEffect } from '../components/SettingsObjectFieldPreviewValueEffect';
import { dataTypes } from '../constants/dataTypes';
import { useFieldPreview } from '../hooks/useFieldPreview';
import { useRelationFieldPreview } from '../hooks/useRelationFieldPreview';
export type SettingsObjectFieldPreviewProps = {
fieldIconKey?: string | null;
fieldLabel: string;
fieldName?: string;
fieldType: FieldMetadataType;
isObjectCustom: boolean;
objectIconKey?: string | null;
objectLabelPlural: string;
objectNamePlural: string;
className?: string;
fieldMetadata: Pick<Field, 'icon' | 'label' | 'type'> & { id?: string };
objectMetadataId: string;
relationObjectMetadataId?: string;
shrink?: boolean;
};
const StyledContainer = styled.div`
@ -52,7 +47,7 @@ const StyledObjectName = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledFieldPreview = styled.div`
const StyledFieldPreview = styled.div<{ shrink?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -61,7 +56,8 @@ const StyledFieldPreview = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(8)};
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
padding: 0
${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))};
white-space: nowrap;
`;
@ -73,41 +69,41 @@ const StyledFieldLabel = styled.div`
`;
export const SettingsObjectFieldPreview = ({
fieldIconKey,
fieldLabel,
fieldName,
fieldType,
isObjectCustom,
objectIconKey,
objectLabelPlural,
objectNamePlural,
className,
fieldMetadata,
objectMetadataId,
relationObjectMetadataId,
shrink,
}: SettingsObjectFieldPreviewProps) => {
const theme = useTheme();
const { Icon: ObjectIcon } = useLazyLoadIcon(objectIconKey ?? '');
const { Icon: FieldIcon } = useLazyLoadIcon(fieldIconKey ?? '');
const { objects } = useFindManyObjectRecords({
objectNamePlural,
skip: !fieldName,
const {
entityId,
FieldIcon,
fieldName,
hasValue,
ObjectIcon,
objectMetadataItem,
value,
} = useFieldPreview({
fieldMetadata,
objectMetadataId,
});
const [fieldValue, setFieldValue] = useRecoilState(
entityFieldsFamilySelector({
entityId: objects[0]?.id ?? objectNamePlural,
fieldName: fieldName || 'new-field',
}),
);
const { defaultValue: relationDefaultValue, entityChipDisplayMapper } =
useRelationFieldPreview({
relationObjectMetadataId,
skipDefaultValue:
fieldMetadata.type !== FieldMetadataType.Relation || hasValue,
});
useEffect(() => {
setFieldValue(
fieldName && assertNotNull(objects[0]?.[fieldName])
? objects[0][fieldName]
: dataTypes[fieldType].defaultValue,
);
}, [fieldName, fieldType, fieldValue, objects, setFieldValue]);
const defaultValue =
fieldMetadata.type === FieldMetadataType.Relation
? relationDefaultValue
: dataTypes[fieldMetadata.type].defaultValue;
return (
<StyledContainer>
<StyledContainer className={className}>
<StyledObjectSummary>
<StyledObjectName>
{!!ObjectIcon && (
@ -116,15 +112,20 @@ export const SettingsObjectFieldPreview = ({
stroke={theme.icon.stroke.sm}
/>
)}
{objectLabelPlural}
{objectMetadataItem?.labelPlural}
</StyledObjectName>
{isObjectCustom ? (
{objectMetadataItem?.isCustom ? (
<Tag color="orange" text="Custom" />
) : (
<Tag color="blue" text="Standard" />
)}
</StyledObjectSummary>
<StyledFieldPreview>
<SettingsObjectFieldPreviewValueEffect
entityId={entityId}
fieldName={fieldName}
value={value ?? defaultValue}
/>
<StyledFieldPreview shrink={shrink}>
<StyledFieldLabel>
{!!FieldIcon && (
<FieldIcon
@ -132,22 +133,26 @@ export const SettingsObjectFieldPreview = ({
stroke={theme.icon.stroke.sm}
/>
)}
{fieldLabel}:
{fieldMetadata.label}:
</StyledFieldLabel>
<FieldContext.Provider
value={{
entityId: objects[0]?.id ?? objectNamePlural,
entityId,
fieldDefinition: {
type: parseFieldType(fieldType as FieldMetadataType),
type: parseFieldType(fieldMetadata.type),
Icon: FieldIcon,
fieldMetadataId: '',
label: fieldLabel,
metadata: { fieldName: fieldName || 'new-field' },
fieldMetadataId: fieldMetadata.id || '',
label: fieldMetadata.label,
metadata: { fieldName },
entityChipDisplayMapper:
fieldMetadata.type === FieldMetadataType.Relation
? entityChipDisplayMapper
: undefined,
},
hotkeyScope: 'field-preview',
}}
>
{fieldType === 'BOOLEAN' ? (
{fieldMetadata.type === FieldMetadataType.Boolean ? (
<BooleanFieldInput readonly />
) : (
<FieldDisplay />

View File

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { entityFieldsFamilySelector } from '@/ui/object/field/states/selectors/entityFieldsFamilySelector';
type SettingsObjectFieldPreviewValueEffectProps = {
entityId: string;
fieldName: string;
value: unknown;
};
export const SettingsObjectFieldPreviewValueEffect = ({
entityId,
fieldName,
value,
}: SettingsObjectFieldPreviewValueEffectProps) => {
const [, setFieldValue] = useRecoilState(
entityFieldsFamilySelector({
entityId,
fieldName,
}),
);
useEffect(() => {
setFieldValue(value);
}, [value, setFieldValue]);
return null;
};

View File

@ -0,0 +1,123 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { useLazyLoadIcons } from '@/ui/input/hooks/useLazyLoadIcons';
import { Field } from '~/generated-metadata/graphql';
import { relationTypes } from '../constants/relationTypes';
import { RelationType } from '../types/RelationType';
export type SettingsObjectFieldRelationFormValues = Partial<{
field: Partial<Pick<Field, 'icon' | 'label'>>;
objectMetadataId: string;
type: RelationType;
}>;
type SettingsObjectFieldRelationFormProps = {
disableRelationEdition?: boolean;
onChange: (values: SettingsObjectFieldRelationFormValues) => void;
values?: SettingsObjectFieldRelationFormValues;
};
const StyledSelectsContainer = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
grid-template-columns: 1fr 1fr;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledInputsLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsObjectFieldRelationForm = ({
disableRelationEdition,
onChange,
values,
}: SettingsObjectFieldRelationFormProps) => {
const { icons } = useLazyLoadIcons();
const { objectMetadataItems, findObjectMetadataItemById } =
useObjectMetadataItemForSettings();
const selectedObjectMetadataItem =
(values?.objectMetadataId
? findObjectMetadataItemById(values.objectMetadataId)
: undefined) || objectMetadataItems[0];
return (
<div>
<StyledSelectsContainer>
<Select
label="Relation type"
dropdownScopeId="relation-type-select"
disabled={disableRelationEdition}
value={values?.type}
options={Object.entries(relationTypes).map(
([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}),
)}
onChange={(value) => onChange({ type: value })}
/>
<Select
label="Object destination"
dropdownScopeId="object-destination-select"
disabled={disableRelationEdition}
value={values?.objectMetadataId}
options={objectMetadataItems.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: objectMetadataItem.icon
? icons[objectMetadataItem.icon]
: undefined,
}))}
onChange={(value) => onChange({ objectMetadataId: value })}
/>
</StyledSelectsContainer>
<StyledInputsLabel>
Field on {selectedObjectMetadataItem?.labelPlural}
</StyledInputsLabel>
<StyledInputsContainer>
<IconPicker
dropdownScopeId="field-destination-icon-picker"
selectedIconKey={values?.field?.icon || undefined}
onChange={(value) =>
onChange({
field: { ...values?.field, icon: value.iconKey },
})
}
variant="primary"
/>
<TextInput
placeholder="Field name"
value={values?.field?.label || ''}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange({
field: { ...values?.field, label: value },
});
}
}}
fullWidth
/>
</StyledInputsContainer>
</div>
);
};

View File

@ -27,6 +27,11 @@ const StyledTitle = styled.h3`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledPreviewContent = styled.div`
display: flex;
gap: 6px;
`;
const StyledFormContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -46,7 +51,7 @@ export const SettingsObjectFieldTypeCard = ({
<div className={className}>
<StyledPreviewContainer>
<StyledTitle>Preview</StyledTitle>
{preview}
<StyledPreviewContent>{preview}</StyledPreviewContent>
</StyledPreviewContainer>
{!!form && <StyledFormContainer>{form}</StyledFormContainer>}
</div>

View File

@ -3,82 +3,142 @@ import styled from '@emotion/styled';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { Section } from '@/ui/layout/section/components/Section';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { dataTypes } from '../constants/dataTypes';
import { relationTypes } from '../constants/relationTypes';
import {
SettingsObjectFieldPreview,
SettingsObjectFieldPreviewProps,
} from './SettingsObjectFieldPreview';
import {
SettingsObjectFieldRelationForm,
SettingsObjectFieldRelationFormValues,
} from './SettingsObjectFieldRelationForm';
import { SettingsObjectFieldTypeCard } from './SettingsObjectFieldTypeCard';
export type SettingsObjectFieldTypeSelectSectionFormValues = Partial<{
type: FieldMetadataType;
relation: SettingsObjectFieldRelationFormValues;
}>;
type SettingsObjectFieldTypeSelectSectionProps = {
disabled?: boolean;
onChange?: (value: FieldMetadataType) => void;
} & Pick<
SettingsObjectFieldPreviewProps,
| 'fieldIconKey'
| 'fieldLabel'
| 'fieldName'
| 'fieldType'
| 'isObjectCustom'
| 'objectIconKey'
| 'objectLabelPlural'
| 'objectNamePlural'
>;
fieldMetadata: Pick<Field, 'icon' | 'label'> & { id?: string };
relationFieldMetadataId?: string;
onChange: (values: SettingsObjectFieldTypeSelectSectionFormValues) => void;
values?: SettingsObjectFieldTypeSelectSectionFormValues;
} & Pick<SettingsObjectFieldPreviewProps, 'objectMetadataId'>;
const StyledSettingsObjectFieldTypeCard = styled(SettingsObjectFieldTypeCard)`
margin-top: ${({ theme }) => theme.spacing(4)};
`;
// TODO: remove "enum" and "relation" types for now, add them back when the backend is ready.
const { ENUM: _ENUM, RELATION: _RELATION, ...allowedDataTypes } = dataTypes;
const StyledSettingsObjectFieldPreview = styled(SettingsObjectFieldPreview)`
display: grid;
flex: 1 1 100%;
`;
const StyledRelationImage = styled.img<{ flip?: boolean }>`
transform: ${({ flip }) => (flip ? 'scaleX(-1)' : 'none')};
width: 54px;
`;
export const SettingsObjectFieldTypeSelectSection = ({
disabled,
fieldIconKey,
fieldLabel,
fieldName,
fieldType,
isObjectCustom,
objectIconKey,
objectLabelPlural,
objectNamePlural,
fieldMetadata,
relationFieldMetadataId,
objectMetadataId,
onChange,
}: SettingsObjectFieldTypeSelectSectionProps) => (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<Select
disabled={disabled}
dropdownScopeId="object-field-type-select"
value={fieldType}
onChange={onChange}
options={Object.entries(allowedDataTypes).map(([key, dataType]) => ({
value: key as FieldMetadataType,
...dataType,
}))}
/>
{['BOOLEAN', 'DATE', 'MONEY', 'NUMBER', 'TEXT', 'URL'].includes(
fieldType,
) && (
<StyledSettingsObjectFieldTypeCard
preview={
<SettingsObjectFieldPreview
fieldIconKey={fieldIconKey}
fieldLabel={fieldLabel}
fieldName={fieldName}
fieldType={fieldType}
isObjectCustom={isObjectCustom}
objectIconKey={objectIconKey}
objectLabelPlural={objectLabelPlural}
objectNamePlural={objectNamePlural}
/>
}
values,
}: SettingsObjectFieldTypeSelectSectionProps) => {
const relationFormConfig = values?.relation;
const allowedFieldTypes = Object.entries(dataTypes).filter(
([key]) => key !== FieldMetadataType.Relation,
);
return (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
)}
</Section>
);
<Select
disabled={!!fieldMetadata.id}
dropdownScopeId="object-field-type-select"
value={values?.type}
onChange={(value) => onChange({ type: value })}
options={allowedFieldTypes.map(([key, dataType]) => ({
value: key as FieldMetadataType,
...dataType,
}))}
/>
{!!values?.type &&
[
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.Date,
FieldMetadataType.Link,
FieldMetadataType.Number,
FieldMetadataType.Relation,
FieldMetadataType.Text,
].includes(values.type) && (
<StyledSettingsObjectFieldTypeCard
preview={
<>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...fieldMetadata,
type: values.type,
}}
shrink={values.type === FieldMetadataType.Relation}
objectMetadataId={objectMetadataId}
relationObjectMetadataId={
relationFormConfig?.objectMetadataId
}
/>
{values.type === FieldMetadataType.Relation &&
!!relationFormConfig?.type &&
!!relationFormConfig.objectMetadataId && (
<>
<StyledRelationImage
src={relationTypes[relationFormConfig.type].imageSrc}
flip={
relationTypes[relationFormConfig.type].isImageFlipped
}
alt={relationTypes[relationFormConfig.type].label}
/>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...relationFormConfig.field,
label:
relationFormConfig.field?.label || 'Field name',
type: FieldMetadataType.Relation,
id: relationFieldMetadataId,
}}
shrink
objectMetadataId={relationFormConfig.objectMetadataId}
relationObjectMetadataId={objectMetadataId}
/>
</>
)}
</>
}
form={
values.type === FieldMetadataType.Relation && (
<SettingsObjectFieldRelationForm
disableRelationEdition={!!relationFieldMetadataId}
values={relationFormConfig}
onChange={(nextValues) =>
onChange({
relation: { ...relationFormConfig, ...nextValues },
})
}
/>
)
}
/>
)}
</Section>
);
};

View File

@ -1,91 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldPreview } from '../SettingsObjectFieldPreview';
const meta: Meta<typeof SettingsObjectFieldPreview> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview',
component: SettingsObjectFieldPreview,
decorators: [ComponentDecorator],
args: {
fieldIconKey: 'IconNotes',
fieldLabel: 'Description',
fieldType: FieldMetadataType.Text,
isObjectCustom: false,
objectIconKey: 'IconBuildingSkyscraper',
objectLabelPlural: 'Companies',
objectNamePlural: 'companies',
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldPreview>;
export const Text: Story = {};
export const Boolean: Story = {
args: {
fieldIconKey: 'IconHeadphones',
fieldLabel: 'Priority Support',
fieldType: FieldMetadataType.Boolean,
},
};
export const Currency: Story = {
args: {
fieldIconKey: 'IconCurrencyDollar',
fieldLabel: 'Amount',
fieldType: FieldMetadataType.Currency,
},
};
export const Date: Story = {
args: {
fieldIconKey: 'IconCalendarEvent',
fieldLabel: 'Registration Date',
fieldType: FieldMetadataType.Date,
},
};
export const Link: Story = {
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
fieldIconKey: 'IconWorldWww',
fieldLabel: 'Website',
fieldType: FieldMetadataType.Link,
},
};
export const Number: Story = {
args: {
fieldIconKey: 'IconUsers',
fieldLabel: 'Employees',
fieldType: FieldMetadataType.Number,
},
};
export const Select: Story = {
args: {
fieldIconKey: 'IconBuildingFactory2',
fieldLabel: 'Industry',
fieldType: FieldMetadataType.Enum,
},
};
export const CustomObject: Story = {
args: {
isObjectCustom: true,
objectIconKey: 'IconApps',
objectLabelPlural: 'Workspaces',
objectNamePlural: 'workspaces',
},
};

View File

@ -1,36 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { TextInput } from '@/ui/input/components/TextInput';
import { FieldMetadataType } from '~/generated/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldPreview } from '../SettingsObjectFieldPreview';
import { SettingsObjectFieldTypeCard } from '../SettingsObjectFieldTypeCard';
const meta: Meta<typeof SettingsObjectFieldTypeCard> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeCard',
component: SettingsObjectFieldTypeCard,
decorators: [ComponentDecorator],
args: {
preview: (
<SettingsObjectFieldPreview
fieldIconKey="IconNotes"
fieldLabel="Description"
fieldType={FieldMetadataType.Text}
isObjectCustom={false}
objectIconKey="IconUser"
objectLabelPlural="People"
objectNamePlural="people"
/>
),
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldTypeCard>;
export const Default: Story = {};
export const WithForm: Story = {
args: { form: <TextInput label="Lorem ipsum" placeholder="Lorem ipsum" /> },
};

View File

@ -1,7 +1,6 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldTypeSelectSection } from '../SettingsObjectFieldTypeSelectSection';
@ -10,16 +9,7 @@ const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection,
decorators: [ComponentDecorator],
args: {
fieldType: FieldMetadataType.Number,
fieldIconKey: 'IconUsers',
fieldLabel: 'Employees',
fieldName: 'employees',
isObjectCustom: false,
objectIconKey: 'IconUser',
objectLabelPlural: 'People',
objectNamePlural: 'people',
},
args: {},
};
export default meta;
@ -28,7 +18,7 @@ type Story = StoryObj<typeof SettingsObjectFieldTypeSelectSection>;
export const Default: Story = {};
export const Disabled: Story = {
args: { disabled: true },
args: {},
};
export const WithOpenSelect: Story = {

View File

@ -7,7 +7,7 @@ import {
IconMail,
IconNumbers,
IconPhone,
IconPlug,
IconRelationManyToMany,
IconTag,
IconTextSize,
IconUser,
@ -61,9 +61,12 @@ export const dataTypes: Record<
[FieldMetadataType.Currency]: {
label: 'Currency',
Icon: IconCoins,
defaultValue: { amount: 2000, currency: CurrencyCode.Usd },
defaultValue: { amountMicros: 2000000000, currencyCode: CurrencyCode.Usd },
},
[FieldMetadataType.Relation]: {
label: 'Relation',
Icon: IconRelationManyToMany,
},
[FieldMetadataType.Relation]: { label: 'Relation', Icon: IconPlug },
[FieldMetadataType.Email]: { label: 'Email', Icon: IconMail },
[FieldMetadataType.Phone]: { label: 'Phone', Icon: IconPhone },
[FieldMetadataType.Probability]: {

View File

@ -0,0 +1,34 @@
import { IconRelationOneToMany, IconRelationOneToOne } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { RelationMetadataType } from '~/generated-metadata/graphql';
import OneToManySvg from '../assets/OneToMany.svg';
import OneToOneSvg from '../assets/OneToOne.svg';
import { RelationType } from '../types/RelationType';
export const relationTypes: Record<
RelationType,
{
label: string;
Icon: IconComponent;
imageSrc: string;
isImageFlipped?: boolean;
}
> = {
[RelationMetadataType.OneToMany]: {
label: 'Has many',
Icon: IconRelationOneToMany,
imageSrc: OneToManySvg,
},
[RelationMetadataType.OneToOne]: {
label: 'Has one',
Icon: IconRelationOneToOne,
imageSrc: OneToOneSvg,
},
MANY_TO_ONE: {
label: 'Belongs to one',
Icon: IconRelationOneToMany,
imageSrc: OneToManySvg,
isImageFlipped: true,
},
};

View File

@ -0,0 +1,142 @@
import { useState } from 'react';
import { DeepPartial } from 'react-hook-form';
import { z } from 'zod';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsObjectFieldTypeSelectSectionFormValues } from '../components/SettingsObjectFieldTypeSelectSection';
type FormValues = {
description?: string;
icon: string;
label: string;
type: FieldMetadataType;
relation: SettingsObjectFieldTypeSelectSectionFormValues['relation'];
};
const defaultValues: FormValues = {
icon: 'IconUsers',
label: '',
type: FieldMetadataType.Text,
relation: {
type: RelationMetadataType.OneToMany,
},
};
const fieldSchema = z.object({
description: z.string().optional(),
icon: z.string().startsWith('Icon'),
label: z.string().min(1),
});
const relationSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Relation),
relation: z.object({
field: fieldSchema,
objectMetadataId: z.string().uuid(),
type: z.enum([
RelationMetadataType.OneToMany,
RelationMetadataType.OneToOne,
'MANY_TO_ONE',
]),
}),
}),
);
const { Relation: _, ...otherFieldTypes } = FieldMetadataType;
const otherFieldTypesSchema = fieldSchema.merge(
z.object({
type: z.enum(
Object.values(otherFieldTypes) as [
Exclude<FieldMetadataType, FieldMetadataType.Relation>,
...Exclude<FieldMetadataType, FieldMetadataType.Relation>[],
],
),
}),
);
const schema = z.discriminatedUnion('type', [
relationSchema,
otherFieldTypesSchema,
]);
export const useFieldMetadataForm = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [initialFormValues, setInitialFormValues] =
useState<FormValues>(defaultValues);
const [formValues, setFormValues] = useState<FormValues>(defaultValues);
const [hasFieldFormChanged, setHasFieldFormChanged] = useState(false);
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
const [validationResult, setValidationResult] = useState(
schema.safeParse(formValues),
);
const mergePartialValues = (
previousValues: FormValues,
nextValues: DeepPartial<FormValues>,
) => ({
...previousValues,
...nextValues,
relation: {
...previousValues.relation,
...nextValues.relation,
field: {
...previousValues.relation?.field,
...nextValues.relation?.field,
},
},
});
const initForm = (lazyInitialFormValues: DeepPartial<FormValues>) => {
if (isInitialized) return;
const mergedFormValues = mergePartialValues(
initialFormValues,
lazyInitialFormValues,
);
setInitialFormValues(mergedFormValues);
setFormValues(mergedFormValues);
setValidationResult(schema.safeParse(mergedFormValues));
setIsInitialized(true);
};
const handleFormChange = (values: DeepPartial<FormValues>) => {
const nextFormValues = mergePartialValues(formValues, values);
setFormValues(nextFormValues);
setValidationResult(schema.safeParse(nextFormValues));
const { relation: initialRelationFormValues, ...initialFieldFormValues } =
initialFormValues;
const { relation: nextRelationFormValues, ...nextFieldFormValues } =
nextFormValues;
setHasFieldFormChanged(
!isDeeplyEqual(initialFieldFormValues, nextFieldFormValues),
);
setHasRelationFormChanged(
nextFieldFormValues.type === FieldMetadataType.Relation &&
!isDeeplyEqual(initialRelationFormValues, nextRelationFormValues),
);
};
return {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged: hasFieldFormChanged || hasRelationFormChanged,
hasRelationFormChanged,
initForm,
isValid: validationResult.success,
validatedFormValues: validationResult.success
? validationResult.data
: undefined,
};
};

View File

@ -0,0 +1,40 @@
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { Field } from '~/generated-metadata/graphql';
import { assertNotNull } from '~/utils/assert';
export const useFieldPreview = ({
fieldMetadata,
objectMetadataId,
}: {
fieldMetadata: Partial<Pick<Field, 'icon' | 'id' | 'type'>>;
objectMetadataId: string;
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const objectMetadataItem = findObjectMetadataItemById(objectMetadataId);
const { objects } = useFindManyObjectRecords({
objectNamePlural: objectMetadataItem?.namePlural,
skip: !objectMetadataItem || !fieldMetadata.id,
});
const { Icon: ObjectIcon } = useLazyLoadIcon(objectMetadataItem?.icon ?? '');
const { Icon: FieldIcon } = useLazyLoadIcon(fieldMetadata.icon ?? '');
const [firstRecord] = objects;
const fieldName = fieldMetadata.id
? objectMetadataItem?.fields.find(({ id }) => id === fieldMetadata.id)?.name
: undefined;
const value = fieldName ? firstRecord?.[fieldName] : undefined;
return {
entityId: firstRecord?.id || `${objectMetadataId}-no-records`,
FieldIcon,
fieldName: fieldName || `${fieldMetadata.type}-new-field`,
hasValue: assertNotNull(value),
ObjectIcon,
objectMetadataItem,
value,
};
};

View File

@ -0,0 +1,29 @@
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
export const useRelationFieldPreview = ({
relationObjectMetadataId,
skipDefaultValue,
}: {
relationObjectMetadataId?: string;
skipDefaultValue: boolean;
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const relationObjectMetadataItem = relationObjectMetadataId
? findObjectMetadataItemById(relationObjectMetadataId)
: undefined;
const { objects: relationObjects } = useFindManyObjectRecords({
objectNamePlural: relationObjectMetadataItem?.namePlural,
skip: skipDefaultValue || !relationObjectMetadataItem,
});
return {
defaultValue: relationObjects?.[0],
entityChipDisplayMapper: (fieldValue?: { id: string }) => ({
name: fieldValue?.id || relationObjectMetadataItem?.labelSingular || '',
avatarType: 'squared' as const,
}),
};
};

View File

@ -16,7 +16,7 @@ const StyledDataType = styled.div<{ value: FieldMetadataType }>`
padding: 0 ${({ theme }) => theme.spacing(2)};
${({ theme, value }) =>
value === 'RELATION'
value === FieldMetadataType.Relation
? css`
border-color: ${theme.color.purple20};
color: ${theme.color.purple};

View File

@ -6,7 +6,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dataTypes } from '../../constants/dataTypes';
@ -31,9 +30,6 @@ const StyledIconTableCell = styled(TableCell)`
padding-right: ${({ theme }) => theme.spacing(1)};
`;
// TODO: remove "relation" type for now, add it back when the backend is ready.
const { RELATION: _, ...dataTypesWithoutRelation } = dataTypes;
export const SettingsObjectFieldItemTableRow = ({
ActionIcon,
fieldItem,
@ -42,13 +38,11 @@ export const SettingsObjectFieldItemTableRow = ({
const { Icon } = useLazyLoadIcon(fieldItem.icon ?? '');
// TODO: parse with zod and merge types with FieldType (create a subset of FieldType for example)
const fieldDataTypeIsSupported = Object.keys(
dataTypesWithoutRelation,
).includes(fieldItem.type);
const fieldDataTypeIsSupported = Object.keys(dataTypes).includes(
fieldItem.type,
);
if (!fieldDataTypeIsSupported) {
return null;
}
if (!fieldDataTypeIsSupported) return null;
return (
<StyledObjectFieldTableRow>
@ -58,9 +52,7 @@ export const SettingsObjectFieldItemTableRow = ({
</StyledNameTableCell>
<TableCell>{fieldItem.isCustom ? 'Custom' : 'Standard'}</TableCell>
<TableCell>
<SettingsObjectFieldDataType
value={fieldItem.type as FieldMetadataType}
/>
<SettingsObjectFieldDataType value={fieldItem.type} />
</TableCell>
<StyledIconTableCell>{ActionIcon}</StyledIconTableCell>
</StyledObjectFieldTableRow>

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import { MouseEvent, ReactNode } from 'react';
import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '../../tooltip/OverflowingTextWithTooltip';
@ -28,9 +28,10 @@ type ChipProps = {
maxWidth?: string;
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: React.ReactNode;
rightComponent?: React.ReactNode;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
className?: string;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const StyledContainer = styled.div<Partial<ChipProps>>`
@ -125,6 +126,7 @@ export const Chip = ({
accent = ChipAccent.TextPrimary,
maxWidth,
className,
onClick,
}: ChipProps) => (
<StyledContainer
data-testid="chip"
@ -135,6 +137,7 @@ export const Chip = ({
disabled={disabled}
className={className}
maxWidth={maxWidth}
onClick={onClick}
>
{leftComponent}
<StyledLabel>

View File

@ -45,31 +45,31 @@ export const EntityChip = ({
};
return isNonEmptyString(name) ? (
<div onClick={handleLinkClick}>
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={pictureUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
/>
</div>
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={pictureUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
clickable={!!linkToEntity}
onClick={handleLinkClick}
/>
) : (
<></>
);

View File

@ -69,6 +69,9 @@ export {
IconPlug,
IconPlus,
IconProgressCheck,
IconRelationManyToMany,
IconRelationOneToMany,
IconRelationOneToOne,
IconRepeat,
IconRobot,
IconSearch,

View File

@ -19,6 +19,7 @@ import { IconPickerHotkeyScope } from '../types/IconPickerHotkeyScope';
type IconPickerProps = {
disabled?: boolean;
dropdownScopeId?: string;
onChange: (params: { iconKey: string; Icon: IconComponent }) => void;
selectedIconKey?: string;
onClickOutside?: () => void;
@ -44,6 +45,7 @@ const convertIconKeyToLabel = (iconKey: string) =>
export const IconPicker = ({
disabled,
dropdownScopeId = 'icon-picker',
onChange,
selectedIconKey,
onClickOutside,
@ -53,7 +55,7 @@ export const IconPicker = ({
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
const { closeDropdown } = useDropdown({ dropdownScopeId: 'icon-picker' });
const { closeDropdown } = useDropdown({ dropdownScopeId });
const { icons, isLoadingIcons: isLoading } = useLazyLoadIcons();
@ -75,7 +77,7 @@ export const IconPicker = ({
}, [icons, searchString, selectedIconKey]);
return (
<DropdownScope dropdownScopeId="icon-picker">
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownHotkeyScope={{ scope: IconPickerHotkeyScope.IconPicker }}
clickableComponent={

View File

@ -12,14 +12,16 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string | number | null> = {
className?: string;
disabled?: boolean;
dropdownScopeId: string;
label?: string;
onChange?: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
value?: Value;
};
const StyledContainer = styled.div<{ disabled?: boolean }>`
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -34,7 +36,16 @@ const StyledContainer = styled.div<{ disabled?: boolean }>`
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledLabel = styled.div`
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
@ -46,8 +57,10 @@ const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
`;
export const Select = <Value extends string | number | null>({
className,
disabled,
dropdownScopeId,
label,
onChange,
options,
value,
@ -59,46 +72,49 @@ export const Select = <Value extends string | number | null>({
const { closeDropdown } = useDropdown({ dropdownScopeId });
const selectControl = (
<StyledContainer disabled={disabled}>
<StyledLabel>
{!!selectedOption.Icon && (
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
color={disabled ? theme.font.color.light : theme.font.color.primary}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{selectedOption.label}
</StyledLabel>
{selectedOption?.label}
</StyledControlLabel>
<StyledIconChevronDown disabled={disabled} size={theme.icon.size.md} />
</StyledContainer>
</StyledControlContainer>
);
return disabled ? (
selectControl
) : (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
<div className={className}>
{!!label && <StyledLabel>{label}</StyledLabel>}
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</div>
</DropdownScope>
);
};

View File

@ -5,8 +5,6 @@ const StyledPanel = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: row;
height: 100%;
overflow: auto;
width: 100%;

View File

@ -7,9 +7,10 @@ export const getEntityChipFromFieldMetadata = (
fieldDefinition: FieldDefinition<FieldRelationMetadata>,
fieldValue: any,
) => {
const { entityChipDisplayMapper } = fieldDefinition;
const { fieldName } = fieldDefinition.metadata;
const chipValue: Pick<
const defaultChipValue: Pick<
EntityChipProps,
'name' | 'pictureUrl' | 'avatarType' | 'entityId'
> = {
@ -19,15 +20,23 @@ export const getEntityChipFromFieldMetadata = (
entityId: fieldValue?.id,
};
// TODO: use every
if (fieldName === 'accountOwner' && fieldValue) {
chipValue.name = fieldValue.name.firstName + ' ' + fieldValue.name.lastName;
} else if (fieldName === 'company' && fieldValue) {
chipValue.name = fieldValue.name;
chipValue.pictureUrl = getLogoUrlFromDomainName(fieldValue.domainName);
} else if (fieldName === 'person' && fieldValue) {
chipValue.name = fieldValue.name.firstName + ' ' + fieldValue.name.lastName;
if (['accountOwner', 'person'].includes(fieldName) && fieldValue) {
return {
...defaultChipValue,
name: `${fieldValue.firstName} ${fieldValue.lastName}`,
};
}
return chipValue;
if (fieldName === 'company' && fieldValue) {
return {
...defaultChipValue,
name: fieldValue.name,
pictureUrl: getLogoUrlFromDomainName(fieldValue.domainName),
};
}
return {
...defaultChipValue,
...entityChipDisplayMapper?.(fieldValue),
};
};

View File

@ -5,9 +5,10 @@ import { FieldMetadata } from './FieldMetadata';
import { FieldType } from './FieldType';
export type FieldDefinitionRelationType =
| 'TO_ONE_OBJECT'
| 'FROM_NAMY_OBJECTS'
| 'TO_MANY_OBJECTS';
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type FieldDefinition<T extends FieldMetadata> = {
fieldMetadataId: string;

View File

@ -1,8 +1,20 @@
import { isNull, isString } from '@sniptt/guards';
import { formatToHumanReadableDate } from '~/utils';
import { FieldDateValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldDateValue = (
fieldValue: unknown,
): fieldValue is FieldDateValue => isNull(fieldValue) || isString(fieldValue);
): fieldValue is FieldDateValue => {
try {
if (isNull(fieldValue)) return true;
if (isString(fieldValue)) {
formatToHumanReadableDate(fieldValue);
return true;
}
} catch {}
return false;
};

View File

@ -1,14 +1,16 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { useFieldMetadataForm } from '@/settings/data-model/hooks/useFieldMetadataForm';
import { AppPath } from '@/types/AppPath';
import { IconArchive, IconSettings } from '@/ui/display/icon';
import { H2Title } from '@/ui/display/typography/components/H2Title';
@ -34,13 +36,22 @@ export const SettingsObjectFieldEdit = () => {
metadataField.isActive && getFieldSlug(metadataField) === fieldSlug,
);
const [formValues, setFormValues] = useState<
Partial<{
icon: string;
label: string;
description: string;
}>
>({});
const {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
} = useRelationMetadata({ fieldMetadataItem: activeMetadataField });
const {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged,
hasRelationFormChanged,
initForm,
isValid,
validatedFormValues,
} = useFieldMetadataForm();
useEffect(() => {
if (loading) return;
@ -50,36 +61,59 @@ export const SettingsObjectFieldEdit = () => {
return;
}
if (!Object.keys(formValues).length) {
setFormValues({
icon: activeMetadataField.icon ?? undefined,
label: activeMetadataField.label,
description: activeMetadataField.description ?? undefined,
});
}
initForm({
icon: activeMetadataField.icon ?? undefined,
label: activeMetadataField.label,
description: activeMetadataField.description ?? undefined,
type: activeMetadataField.type,
relation: {
field: {
icon: relationFieldMetadataItem?.icon,
label: relationFieldMetadataItem?.label,
},
objectMetadataId: relationObjectMetadataItem?.id,
type: relationType,
},
});
}, [
activeMetadataField,
activeObjectMetadataItem,
formValues,
initForm,
loading,
navigate,
relationFieldMetadataItem?.icon,
relationFieldMetadataItem?.label,
relationObjectMetadataItem?.id,
relationType,
]);
if (!activeObjectMetadataItem || !activeMetadataField) return null;
const areRequiredFieldsFilled = !!formValues.label;
const hasChanges =
formValues.description !== activeMetadataField.description ||
formValues.icon !== activeMetadataField.icon ||
formValues.label !== activeMetadataField.label;
const canSave = areRequiredFieldsFilled && hasChanges;
const canSave = isValid && hasFormChanged;
const handleSave = async () => {
const editedField = { ...activeMetadataField, ...formValues };
if (!validatedFormValues) return;
await editMetadataField(editedField);
if (
validatedFormValues.type === FieldMetadataType.Relation &&
relationFieldMetadataItem?.id &&
hasRelationFormChanged
) {
await editMetadataField({
icon: validatedFormValues.relation.field.icon,
id: relationFieldMetadataItem.id,
label: validatedFormValues.relation.field.label,
});
}
if (hasFieldFormChanged) {
await editMetadataField({
description: validatedFormValues.description,
icon: validatedFormValues.icon,
id: activeMetadataField.id,
label: validatedFormValues.label,
});
}
navigate(`/settings/objects/${objectSlug}`);
};
@ -116,23 +150,21 @@ export const SettingsObjectFieldEdit = () => {
name={formValues.label}
description={formValues.description}
iconKey={formValues.icon}
onChange={(values) =>
setFormValues((previousFormValues) => ({
...previousFormValues,
...values,
}))
}
onChange={handleFormChange}
/>
<SettingsObjectFieldTypeSelectSection
disabled
fieldIconKey={formValues.icon}
fieldLabel={formValues.label || 'Employees'}
fieldName={activeMetadataField.name}
fieldType={activeMetadataField.type as FieldMetadataType}
isObjectCustom={activeObjectMetadataItem.isCustom}
objectIconKey={activeObjectMetadataItem.icon}
objectLabelPlural={activeObjectMetadataItem.labelPlural}
objectNamePlural={activeObjectMetadataItem.namePlural}
fieldMetadata={{
icon: formValues.icon,
label: formValues.label || 'Employees',
id: activeMetadataField.id,
}}
objectMetadataId={activeObjectMetadataItem.id}
onChange={handleFormChange}
relationFieldMetadataId={relationFieldMetadataItem?.id}
values={{
type: formValues.type,
relation: formValues.relation,
}}
/>
<Section>
<H2Title title="Danger zone" description="Disable this field" />

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useCreateOneRelationMetadata } from '@/object-metadata/hooks/useCreateOneRelationMetadata';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useCreateOneObjectRecord } from '@/object-record/hooks/useCreateOneObjectRecord';
@ -11,6 +12,7 @@ import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderCon
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsObjectFieldFormSection } from '@/settings/data-model/components/SettingsObjectFieldFormSection';
import { SettingsObjectFieldTypeSelectSection } from '@/settings/data-model/components/SettingsObjectFieldTypeSelectSection';
import { useFieldMetadataForm } from '@/settings/data-model/hooks/useFieldMetadataForm';
import { AppPath } from '@/types/AppPath';
import { IconSettings } from '@/ui/display/icon';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
@ -23,26 +25,48 @@ export const SettingsObjectNewFieldStep2 = () => {
const navigate = useNavigate();
const { objectSlug = '' } = useParams();
const { findActiveObjectMetadataItemBySlug, loading } =
useObjectMetadataItemForSettings();
const {
findActiveObjectMetadataItemBySlug,
findObjectMetadataItemById,
findObjectMetadataItemByNamePlural,
loading,
} = useObjectMetadataItemForSettings();
const activeObjectMetadataItem =
findActiveObjectMetadataItemBySlug(objectSlug);
const { createMetadataField } = useFieldMetadataItem();
const {
formValues,
handleFormChange,
initForm,
isValid: canSave,
validatedFormValues,
} = useFieldMetadataForm();
useEffect(() => {
if (loading) return;
if (!activeObjectMetadataItem) navigate(AppPath.NotFound);
}, [activeObjectMetadataItem, loading, navigate]);
if (!activeObjectMetadataItem) {
navigate(AppPath.NotFound);
return;
}
const [formValues, setFormValues] = useState<{
description?: string;
icon: string;
label: string;
type: FieldMetadataType;
}>({ icon: 'IconUsers', label: '', type: FieldMetadataType.Number });
initForm({
relation: {
field: { icon: activeObjectMetadataItem.icon },
objectMetadataId: findObjectMetadataItemByNamePlural('peopleV2')?.id,
},
});
}, [
activeObjectMetadataItem,
findObjectMetadataItemByNamePlural,
initForm,
loading,
navigate,
]);
const [objectViews, setObjectViews] = useState<View[]>([]);
const [relationObjectViews, setRelationObjectViews] = useState<View[]>([]);
const { createOneObject: createOneViewField } = useCreateOneObjectRecord({
objectNameSingular: 'viewFieldV2',
@ -57,32 +81,100 @@ export const SettingsObjectNewFieldStep2 = () => {
onCompleted: async (data: PaginatedObjectTypeResults<View>) => {
const views = data.edges;
if (!views) {
return;
}
if (!views) return;
setObjectViews(data.edges.map(({ node }) => node));
},
});
useFindManyObjectRecords({
objectNamePlural: 'viewsV2',
skip: !formValues.relation?.objectMetadataId,
filter: {
type: { eq: ViewType.Table },
objectMetadataId: { eq: formValues.relation?.objectMetadataId },
},
onCompleted: async (data: PaginatedObjectTypeResults<View>) => {
const views = data.edges;
if (!views) return;
setRelationObjectViews(data.edges.map(({ node }) => node));
},
});
const { createOneRelationMetadata } = useCreateOneRelationMetadata();
if (!activeObjectMetadataItem) return null;
const canSave = !!formValues.label;
const handleSave = async () => {
const createdField = await createMetadataField({
...formValues,
objectMetadataId: activeObjectMetadataItem.id,
});
objectViews.forEach(async (view) => {
await createOneViewField?.({
view: view.id,
fieldMetadataId: createdField.data?.createOneField.id,
position: activeObjectMetadataItem.fields.length,
isVisible: true,
size: 100,
if (!validatedFormValues) return;
if (validatedFormValues.type === FieldMetadataType.Relation) {
const createdRelation = await createOneRelationMetadata({
relationType: validatedFormValues.relation.type,
field: {
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label,
},
objectMetadataId: activeObjectMetadataItem.id,
connect: {
field: {
icon: validatedFormValues.relation.field.icon,
label: validatedFormValues.relation.field.label,
},
objectMetadataId: validatedFormValues.relation.objectMetadataId,
},
});
});
const relationObjectMetadataItem = findObjectMetadataItemById(
validatedFormValues.relation.objectMetadataId,
);
objectViews.forEach(async (view) => {
await createOneViewField?.({
view: view.id,
fieldMetadataId:
validatedFormValues.relation.type === 'MANY_TO_ONE'
? createdRelation.data?.createOneRelation.toFieldMetadataId
: createdRelation.data?.createOneRelation.fromFieldMetadataId,
position: activeObjectMetadataItem.fields.length,
isVisible: true,
size: 100,
});
});
relationObjectViews.forEach(async (view) => {
await createOneViewField?.({
view: view.id,
fieldMetadataId:
validatedFormValues.relation.type === 'MANY_TO_ONE'
? createdRelation.data?.createOneRelation.fromFieldMetadataId
: createdRelation.data?.createOneRelation.toFieldMetadataId,
position: relationObjectMetadataItem?.fields.length,
isVisible: true,
size: 100,
});
});
} else {
const createdField = await createMetadataField({
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label,
objectMetadataId: activeObjectMetadataItem.id,
type: validatedFormValues.type,
});
objectViews.forEach(async (view) => {
await createOneViewField?.({
view: view.id,
fieldMetadataId: createdField.data?.createOneField.id,
position: activeObjectMetadataItem.fields.length,
isVisible: true,
size: 100,
});
});
}
navigate(`/settings/objects/${objectSlug}`);
};
@ -110,24 +202,19 @@ export const SettingsObjectNewFieldStep2 = () => {
iconKey={formValues.icon}
name={formValues.label}
description={formValues.description}
onChange={(values) =>
setFormValues((previousValues) => ({
...previousValues,
...values,
}))
}
onChange={handleFormChange}
/>
<SettingsObjectFieldTypeSelectSection
fieldIconKey={formValues.icon}
fieldLabel={formValues.label || 'Employees'}
fieldType={formValues.type}
isObjectCustom={activeObjectMetadataItem.isCustom}
objectIconKey={activeObjectMetadataItem.icon}
objectLabelPlural={activeObjectMetadataItem.labelPlural}
objectNamePlural={activeObjectMetadataItem.namePlural}
onChange={(type) =>
setFormValues((previousValues) => ({ ...previousValues, type }))
}
fieldMetadata={{
icon: formValues.icon,
label: formValues.label || 'Employees',
}}
objectMetadataId={activeObjectMetadataItem.id}
onChange={handleFormChange}
values={{
type: formValues.type,
relation: formValues.relation,
}}
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>

View File

@ -1,3 +1,187 @@
export const mockedCompaniesMetadata = {
node: {
id: 'a3195559-cc20-4749-9565-572a2f506581',
dataSourceId: '',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: null,
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
createdAt: '',
updatedAt: '',
fields: {
edges: [
{
node: {
id: '397eabc0-c5a1-4550-8e68-839c878a8d0e',
type: 'TEXT',
name: 'name',
label: 'Name',
description: 'The company name.',
placeholder: null,
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
isNullable: false,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: '7ad234c7-f3b9-4efc-813c-43dc97070b07',
type: 'URL',
name: 'URL',
label: 'URL',
description:
'The company website URL. We use this url to fetch the company icon.',
placeholder: null,
icon: 'IconLink',
isCustom: false,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: 'a578ffb2-13db-483c-ace7-5c30a13dff2d',
type: 'RELATION',
name: 'accountOwner',
label: 'Account Owner',
description:
'Your team member responsible for managing the company account.',
placeholder: null,
icon: 'IconUserCircle',
isCustom: false,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: 'b7fd622d-7d8b-4f5a-b148-a7e9fd2c4660',
type: 'NUMBER',
name: 'employees',
label: 'Employees',
description: 'Number of employees in the company.',
placeholder: null,
icon: 'IconUsers',
isCustom: true,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: '60ab27ed-a959-471e-b583-887387f7accd',
type: 'URL',
name: 'linkedin',
label: 'Linkedin',
description: null,
placeholder: null,
icon: 'IconBrandLinkedin',
isCustom: false,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: '6daadb98-83ca-4c85-bca5-7792a7d958ad',
type: 'BOOLEAN',
name: 'prioritySupport',
label: 'Priority Support',
description: 'Whether the company has priority support.',
placeholder: null,
icon: 'IconHeadphones',
isCustom: true,
isActive: false,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 6,
},
},
};
export const mockedWorkspacesMetadata = {
node: {
id: 'c973efa3-436e-47ea-8dbc-983ed869c04d',
dataSourceId: '',
nameSingular: 'workspace',
namePlural: 'workspaces',
labelSingular: 'Workspace',
labelPlural: 'Workspaces',
description: null,
icon: 'IconApps',
isCustom: true,
isActive: true,
createdAt: '',
updatedAt: '',
fields: {
edges: [
{
node: {
id: 'f955402c-9e8f-4b91-a82c-95f6de392b99',
type: 'TEXT',
name: 'slug',
label: 'Slug',
description: null,
placeholder: null,
icon: 'IconSlash',
isCustom: true,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 1,
},
},
};
export const mockedObjectMetadataItems = {
edges: [
{
@ -198,188 +382,8 @@ export const mockedObjectMetadataItems = {
},
},
},
{
node: {
id: 'a3195559-cc20-4749-9565-572a2f506581',
dataSourceId: '',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
description: null,
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
createdAt: '',
updatedAt: '',
fields: {
edges: [
{
node: {
id: '397eabc0-c5a1-4550-8e68-839c878a8d0e',
type: 'TEXT',
name: 'name',
label: 'Name',
description: 'The company name.',
placeholder: null,
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
isNullable: false,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: '7ad234c7-f3b9-4efc-813c-43dc97070b07',
type: 'URL',
name: 'URL',
label: 'URL',
description:
'The company website URL. We use this url to fetch the company icon.',
placeholder: null,
icon: 'IconLink',
isCustom: false,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: 'a578ffb2-13db-483c-ace7-5c30a13dff2d',
type: 'RELATION',
name: 'accountOwner',
label: 'Account Owner',
description:
'Your team member responsible for managing the company account.',
placeholder: null,
icon: 'IconUserCircle',
isCustom: false,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: 'b7fd622d-7d8b-4f5a-b148-a7e9fd2c4660',
type: 'NUMBER',
name: 'employees',
label: 'Employees',
description: 'Number of employees in the company.',
placeholder: null,
icon: 'IconUsers',
isCustom: true,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: '60ab27ed-a959-471e-b583-887387f7accd',
type: 'URL',
name: 'linkedin',
label: 'Linkedin',
description: null,
placeholder: null,
icon: 'IconBrandLinkedin',
isCustom: false,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
{
node: {
id: '6daadb98-83ca-4c85-bca5-7792a7d958ad',
type: 'BOOLEAN',
name: 'prioritySupport',
label: 'Priority Support',
description: 'Whether the company has priority support.',
placeholder: null,
icon: 'IconHeadphones',
isCustom: true,
isActive: false,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 6,
},
},
},
{
node: {
id: 'c973efa3-436e-47ea-8dbc-983ed869c04d',
dataSourceId: '',
nameSingular: 'workspace',
namePlural: 'workspaces',
labelSingular: 'Workspace',
labelPlural: 'Workspaces',
description: null,
icon: 'IconApps',
isCustom: true,
isActive: true,
createdAt: '',
updatedAt: '',
fields: {
edges: [
{
node: {
id: 'f955402c-9e8f-4b91-a82c-95f6de392b99',
type: 'TEXT',
name: 'slug',
label: 'Slug',
description: null,
placeholder: null,
icon: 'IconSlash',
isCustom: true,
isActive: true,
isNullable: true,
createdAt: '',
updatedAt: '',
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
totalCount: 1,
},
},
},
mockedCompaniesMetadata,
mockedWorkspacesMetadata,
],
pageInfo: {
hasNextPage: false,

View File

@ -9,8 +9,8 @@ import {
UpdateDateColumn,
} from 'typeorm';
@Entity('workspaces')
@ObjectType('workspace')
@Entity('workspaceV2')
@ObjectType('workspaceV2')
export class Workspace {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')