diff --git a/package.json b/package.json index 0b146a6bb6a8..25515163d152 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,9 @@ "@nestjs/serve-static": "^4.0.1", "@nestjs/terminus": "^9.2.2", "@nestjs/typeorm": "^10.0.0", + "@nivo/bar": "^0.87.0", "@nivo/calendar": "^0.84.0", - "@nivo/core": "^0.84.0", + "@nivo/core": "^0.87.0", "@nx/eslint-plugin": "^17.2.8", "@octokit/graphql": "^7.0.2", "@ptc-org/nestjs-query-core": "^4.2.0", diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f5e61e07afd5..0a4d6180d175 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -154,6 +154,11 @@ export enum CaptchaDriverType { Turnstile = 'Turnstile' } +export type ChartResult = { + __typename?: 'ChartResult'; + chartResult: Scalars['String']['output']; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; api: ApiConfig; @@ -351,6 +356,7 @@ export enum FieldMetadataType { Date = 'DATE', DateTime = 'DATE_TIME', Email = 'EMAIL', + DataExplorerQuery = 'DATA_EXPLORER_QUERY', FullName = 'FULL_NAME', Link = 'LINK', Links = 'LINKS', @@ -775,6 +781,7 @@ export type ProductPricesEntity = { export type Query = { __typename?: 'Query'; billingPortalSession: SessionEntity; + chartData: ChartResult; checkUserExists: UserExists; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -808,6 +815,11 @@ export type QueryBillingPortalSessionArgs = { }; +export type QueryChartDataArgs = { + chartId: Scalars['String']['input']; +}; + + export type QueryCheckUserExistsArgs = { captchaToken?: InputMaybe; email: Scalars['String']['input']; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index cb973a45cf52..30c043236555 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; +import { gql } from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -147,6 +147,11 @@ export enum CaptchaDriverType { Turnstile = 'Turnstile' } +export type ChartResult = { + __typename?: 'ChartResult'; + chartResult?: Maybe; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; api: ApiConfig; @@ -162,6 +167,28 @@ export type ClientConfig = { telemetry: Telemetry; }; +export type CreateFieldInput = { + defaultValue?: InputMaybe; + description?: InputMaybe; + icon?: InputMaybe; + isActive?: InputMaybe; + isCustom?: InputMaybe; + isNullable?: InputMaybe; + isRemoteCreation?: InputMaybe; + isSystem?: InputMaybe; + label: Scalars['String']; + name: Scalars['String']; + objectMetadataId: Scalars['String']; + options?: InputMaybe; + settings?: InputMaybe; + type: FieldMetadataType; +}; + +export type CreateOneFieldMetadataInput = { + /** The record to create */ + field: CreateFieldInput; +}; + export type CreateServerlessFunctionFromFileInput = { description?: InputMaybe; name: Scalars['String']; @@ -184,11 +211,21 @@ export type CursorPaging = { last?: InputMaybe; }; +export type DeleteOneFieldInput = { + /** The id of the field to delete. */ + id: Scalars['UUID']; +}; + export type DeleteOneObjectInput = { /** The id of the record to delete. */ id: Scalars['UUID']; }; +export type DeleteOneRelationInput = { + /** The id of the relation to delete. */ + id: Scalars['UUID']; +}; + export type DeleteServerlessFunctionInput = { /** The id of the function. */ id: Scalars['ID']; @@ -256,6 +293,7 @@ export enum FieldMetadataType { Date = 'DATE', DateTime = 'DATE_TIME', Email = 'EMAIL', + DataExplorerQuery = 'DATA_EXPLORER_QUERY', FullName = 'FULL_NAME', Link = 'LINK', Links = 'LINKS', @@ -326,11 +364,15 @@ export type Mutation = { challenge: LoginToken; checkoutSession: SessionEntity; createOneAppToken: AppToken; + createOneField: Field; createOneObject: Object; + createOneRelation: Relation; createOneServerlessFunction: ServerlessFunction; createOneServerlessFunctionFromFile: ServerlessFunction; deleteCurrentWorkspace: Workspace; + deleteOneField: Field; deleteOneObject: Object; + deleteOneRelation: Relation; deleteOneServerlessFunction: ServerlessFunction; deleteUser: User; disablePostgresProxy: PostgresCredentials; @@ -350,6 +392,7 @@ export type Mutation = { skipSyncEmailOnboardingStep: OnboardingStepSuccess; track: Analytics; updateBillingSubscription: UpdateBillingEntity; + updateOneField: Field; updateOneObject: Object; updateOneServerlessFunction: ServerlessFunction; updatePasswordViaResetToken: InvalidatePassword; @@ -392,6 +435,11 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationCreateOneFieldArgs = { + input: CreateOneFieldMetadataInput; +}; + + export type MutationCreateOneServerlessFunctionArgs = { input: CreateServerlessFunctionInput; }; @@ -403,11 +451,21 @@ export type MutationCreateOneServerlessFunctionFromFileArgs = { }; +export type MutationDeleteOneFieldArgs = { + input: DeleteOneFieldInput; +}; + + export type MutationDeleteOneObjectArgs = { input: DeleteOneObjectInput; }; +export type MutationDeleteOneRelationArgs = { + input: DeleteOneRelationInput; +}; + + export type MutationDeleteOneServerlessFunctionArgs = { input: DeleteServerlessFunctionInput; }; @@ -597,11 +655,14 @@ export type ProductPricesEntity = { export type Query = { __typename?: 'Query'; billingPortalSession: SessionEntity; + chartData: ChartResult; checkUserExists: UserExists; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; currentUser: User; currentWorkspace: Workspace; + field: Field; + fields: FieldConnection; findWorkspaceFromInviteHash: Workspace; getAISQLQuery: AisqlQueryResult; getPostgresCredentials?: Maybe; @@ -612,6 +673,8 @@ export type Query = { getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; object: Object; objects: ObjectConnection; + relation: Relation; + relations: RelationConnection; serverlessFunction: ServerlessFunction; serverlessFunctions: ServerlessFunctionConnection; validatePasswordResetToken: ValidatePasswordResetToken; @@ -623,6 +686,11 @@ export type QueryBillingPortalSessionArgs = { }; +export type QueryChartDataArgs = { + chartId: Scalars['String']; +}; + + export type QueryCheckUserExistsArgs = { captchaToken?: InputMaybe; email: Scalars['String']; @@ -954,6 +1022,20 @@ export type UpdateBillingEntity = { success: Scalars['Boolean']; }; +export type UpdateFieldInput = { + defaultValue?: InputMaybe; + description?: InputMaybe; + icon?: InputMaybe; + isActive?: InputMaybe; + isCustom?: InputMaybe; + isNullable?: InputMaybe; + isSystem?: InputMaybe; + label?: InputMaybe; + name?: InputMaybe; + options?: InputMaybe; + settings?: InputMaybe; +}; + export type UpdateObjectPayload = { description?: InputMaybe; icon?: InputMaybe; @@ -966,6 +1048,13 @@ export type UpdateObjectPayload = { nameSingular?: InputMaybe; }; +export type UpdateOneFieldMetadataInput = { + /** The id of the record to update */ + id: Scalars['UUID']; + /** The record to update */ + update: UpdateFieldInput; +}; + export type UpdateOneObjectInput = { /** The id of the object to update */ id: Scalars['UUID']; @@ -1282,6 +1371,13 @@ export type GetTimelineThreadsFromPersonIdQueryVariables = Exact<{ export type GetTimelineThreadsFromPersonIdQuery = { __typename?: 'Query', getTimelineThreadsFromPersonId: { __typename?: 'TimelineThreadsWithTotal', totalNumberOfThreads: number, timelineThreads: Array<{ __typename?: 'TimelineThread', id: any, read: boolean, visibility: MessageChannelVisibility, lastMessageReceivedAt: string, lastMessageBody: string, subject: string, numberOfMessagesInThread: number, participantCount: number, firstParticipant: { __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }, lastTwoParticipants: Array<{ __typename?: 'TimelineThreadParticipant', personId?: any | null, workspaceMemberId?: any | null, firstName: string, lastName: string, displayName: string, avatarUrl: string, handle: string }> }> } }; +export type ChartDataQueryVariables = Exact<{ + chartId: Scalars['String']; +}>; + + +export type ChartDataQuery = { __typename?: 'Query', chartData: { __typename?: 'ChartResult', chartResult?: string | null } }; + export type TrackMutationVariables = Exact<{ type: Scalars['String']; data: Scalars['JSON']; @@ -1835,6 +1931,41 @@ export function useGetTimelineThreadsFromPersonIdLazyQuery(baseOptions?: Apollo. export type GetTimelineThreadsFromPersonIdQueryHookResult = ReturnType; export type GetTimelineThreadsFromPersonIdLazyQueryHookResult = ReturnType; export type GetTimelineThreadsFromPersonIdQueryResult = Apollo.QueryResult; +export const ChartDataDocument = gql` + query ChartData($chartId: String!) { + chartData(chartId: $chartId) { + chartResult + } +} + `; + +/** + * __useChartDataQuery__ + * + * To run a query within a React component, call `useChartDataQuery` and pass it any options that fit your needs. + * When your component renders, `useChartDataQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useChartDataQuery({ + * variables: { + * chartId: // value for 'chartId' + * }, + * }); + */ +export function useChartDataQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ChartDataDocument, options); + } +export function useChartDataLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ChartDataDocument, options); + } +export type ChartDataQueryHookResult = ReturnType; +export type ChartDataLazyQueryHookResult = ReturnType; +export type ChartDataQueryResult = Apollo.QueryResult; export const TrackDocument = gql` mutation Track($type: String!, $data: JSON!) { track(type: $type, data: $data) { diff --git a/packages/twenty-front/src/modules/activities/charts/components/Chart.tsx b/packages/twenty-front/src/modules/activities/charts/components/Chart.tsx new file mode 100644 index 000000000000..c55d5005f67c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/charts/components/Chart.tsx @@ -0,0 +1,71 @@ +import styled from '@emotion/styled'; +import { ResponsiveBar } from '@nivo/bar'; + +import { Chart as ChartType } from '@/activities/charts/types/Chart'; +import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { useTheme } from '@emotion/react'; +import { useChartDataQuery } from '~/generated/graphql'; + +const StyledChartContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + overflow: hidden; +`; + +interface ChartProps { + targetableObject: ActivityTargetableObject; +} + +export const Chart = (props: ChartProps) => { + const theme = useTheme(); + + const { record: chart, loading: chartLoading } = useFindOneRecord({ + objectRecordId: props.targetableObject.id, + objectNameSingular: props.targetableObject.targetObjectNameSingular, + }); + + const { data: chartDataResponse, loading: chartDataLoading } = + useChartDataQuery({ + variables: { + chartId: props.targetableObject.id, + }, + }); + const chartResult = + chartDataResponse?.chartData.chartResult && + JSON.parse(chartDataResponse.chartData.chartResult); + + const loading: boolean = chartLoading || chartDataLoading; + + if (loading) return ; + + if (!chart) throw new Error('Could not load chart'); + + /* if (!chart?.groupBy) { + return
{chartResult?.[0].measure}
; + } */ + + if (!chartResult) return; + + const margin = theme.spacingMultiplicator * 12; + + const indexBy = Object.keys(chartResult[0]).find((key) => key !== 'measure'); + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/charts/constants/ReportGroupTimeSpans.ts b/packages/twenty-front/src/modules/activities/charts/constants/ReportGroupTimeSpans.ts new file mode 100644 index 000000000000..ff9630f0719c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/charts/constants/ReportGroupTimeSpans.ts @@ -0,0 +1,43 @@ +export interface ReportGroupTimeSpan { + title: string; + minSinceMs: number; +} + +export const REPORT_GROUP_TIME_SPANS: ReportGroupTimeSpan[] = [ + { + title: 'today', + minSinceMs: 0, + }, + { + title: 'one day ago', + minSinceMs: 1000 * 60 * 60 * 24, + }, + { + title: 'one week ago', + minSinceMs: 1000 * 60 * 60 * 24 * 7, + }, + { + title: 'one month ago', + minSinceMs: 1000 * 60 * 60 * 24 * 30, + }, + { + title: 'one year ago', + minSinceMs: 1000 * 60 * 60 * 24 * 365, + }, + ...[ + 'two', + 'three', + 'four', + 'five', + 'six', + 'sever', + 'eight', + 'nine', + 'ten', + ].map((yearCount, i) => { + return { + title: `${yearCount} years ago`, + minSinceMs: 1000 * 60 * 60 * 24 * 365 * (i + 2), + }; + }), +]; diff --git a/packages/twenty-front/src/modules/activities/charts/types/Chart.ts b/packages/twenty-front/src/modules/activities/charts/types/Chart.ts new file mode 100644 index 000000000000..d58d01ba2472 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/charts/types/Chart.ts @@ -0,0 +1,7 @@ +export interface Chart { + id: string; + name: string; + description: string; + query: any; + __typename: string; +} diff --git a/packages/twenty-front/src/modules/activities/charts/types/ChartFilter.ts b/packages/twenty-front/src/modules/activities/charts/types/ChartFilter.ts new file mode 100644 index 000000000000..aa194bfecd58 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/charts/types/ChartFilter.ts @@ -0,0 +1,30 @@ +// TODO - Should charts have share filters with the rest of the app? + +export interface ChartFilter { + id: string; + chartId: string; + field: string; + operator: ChartFilterOperator; + value: string; + __typename: string; +} + +type NumberOperator = + | 'is' + | 'is not' + | 'less than' + | 'greater than' + | 'empty' + | 'not empty'; + +type StringOperator = + | 'contains' + | 'not contains' + | 'starts with' + | 'end with' + | 'is' + | 'is not' + | 'empty' + | 'not empty'; + +export type ChartFilterOperator = NumberOperator | StringOperator; diff --git a/packages/twenty-front/src/modules/analytics-query/graphql/queries/chartData.ts b/packages/twenty-front/src/modules/analytics-query/graphql/queries/chartData.ts new file mode 100644 index 000000000000..bf5c6bbccd48 --- /dev/null +++ b/packages/twenty-front/src/modules/analytics-query/graphql/queries/chartData.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const CHART_DATA = gql` + query ChartData($chartId: String!) { + chartData(chartId: $chartId) { + chartResult + } + } +`; diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index f0b76f473928..71310f47aa6a 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -6,6 +6,7 @@ export enum CoreObjectNameSingular { Blocklist = 'blocklist', CalendarChannel = 'calendarChannel', CalendarEvent = 'calendarEvent', + Chart = 'chart', Comment = 'comment', Company = 'company', ConnectedAccount = 'connectedAccount', diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index 7f46e887d6e2..14e140fbe451 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -40,6 +40,7 @@ export const mapFieldMetadataToGraphQLQuery = ({ FieldMetadataType.MultiSelect, FieldMetadataType.Position, FieldMetadataType.RawJson, + FieldMetadataType.DataExplorerQuery, FieldMetadataType.RichText, ].includes(fieldType); diff --git a/packages/twenty-front/src/modules/object-record/data-explorer-query-builder/components/DataExplorerQueryBuilder.tsx b/packages/twenty-front/src/modules/object-record/data-explorer-query-builder/components/DataExplorerQueryBuilder.tsx new file mode 100644 index 000000000000..b5ca207e1e01 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/data-explorer-query-builder/components/DataExplorerQueryBuilder.tsx @@ -0,0 +1,36 @@ +import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; +import { FieldDataExplorerQueryValue } from '@/object-record/record-field/types/FieldMetadata'; +import { useRef } from 'react'; + +interface DataExplorerQueryBuilderProps { + value?: FieldDataExplorerQueryValue | undefined; + hotkeyScope: string; + onClickOutside: ( + event: MouseEvent | TouchEvent, + newValue: FieldDataExplorerQueryValue, + ) => void; + onEnter: (newValue: FieldDataExplorerQueryValue) => void; + onEscape: (newValue: FieldDataExplorerQueryValue) => void; + onTab?: (newValue: FieldDataExplorerQueryValue) => void; + onShiftTab?: (newValue: FieldDataExplorerQueryValue) => void; + onChange: (newValue: FieldDataExplorerQueryValue) => void; +} + +export const DataExplorerQueryBuilder = ( + props: DataExplorerQueryBuilderProps, +) => { + const wrapperRef = useRef(null); + + useRegisterInputEvents({ + inputRef: wrapperRef, + onClickOutside: props.onClickOutside, + onEnter: props.onEnter, + onEscape: props.onEscape, + onTab: props.onTab, + onShiftTab: props.onShiftTab, + hotkeyScope: props.hotkeyScope, + inputValue: props.value ?? {}, + }); + + return
DataExplorerQueryBuilder
; +}; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/components/CountRecordsItem.tsx b/packages/twenty-front/src/modules/object-record/field-path-picker/components/CountRecordsItem.tsx new file mode 100644 index 000000000000..57158c71b570 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/components/CountRecordsItem.tsx @@ -0,0 +1,33 @@ +import { COUNT_RECORDS_ITEM_KEY } from '@/object-record/field-path-picker/constants/CountRecordsItemKey'; +import { FIELD_PATH_PICKER_SELECTABLE_LIST_ID } from '@/object-record/field-path-picker/constants/FieldPathPickerSelectableListId'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/components/MenuItemLeftContent'; +import { StyledMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase'; +import { useRecoilValue } from 'recoil'; +import { IconTallymarks } from 'twenty-ui'; + +interface CountRecordsItemProps { + onSelect: () => void; + objectLabelPlural?: string; +} + +export const CountRecordsItem = (props: CountRecordsItemProps) => { + const { isSelectedItemIdSelector } = useSelectableList( + FIELD_PATH_PICKER_SELECTABLE_LIST_ID, + ); + const isSelectedByKeyboard = useRecoilValue( + isSelectedItemIdSelector(COUNT_RECORDS_ITEM_KEY), + ); + + return ( + props.onSelect()} + isKeySelected={isSelectedByKeyboard} + > + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/components/FieldPathPicker.tsx b/packages/twenty-front/src/modules/object-record/field-path-picker/components/FieldPathPicker.tsx new file mode 100644 index 000000000000..fed861a01766 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/components/FieldPathPicker.tsx @@ -0,0 +1,130 @@ +/* import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { CountRecordsItem } from '@/object-record/field-path-picker/components/CountRecordsItem'; +import { FieldSelectItem } from '@/object-record/field-path-picker/components/FieldSelectItem'; +import { COUNT_RECORDS_ITEM_KEY } from '@/object-record/field-path-picker/constants/CountRecordsItemKey'; +import { FIELD_PATH_PICKER_SELECTABLE_LIST_ID } from '@/object-record/field-path-picker/constants/FieldPathPickerSelectableListId'; +import { getViewObjectMetadata } from '@/object-record/field-path-picker/utils/getViewObjectMetadata'; +import { isSelectableFieldPathPart } from '@/object-record/field-path-picker/utils/isSelectableFieldPathPart'; +import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; +import { FieldDataExplorerQueryValue } from '@/object-record/record-field/types/FieldMetadata'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { useRef } from 'react'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; */ + +interface FieldPathPickerEffectProps { + value?: any /* FieldDataExplorerQueryValue */ | undefined; + hotkeyScope: string; + sourceObjectNameSingular?: string; + onClickOutside: ( + event: MouseEvent | TouchEvent, + newFieldPath: string[], + ) => void; + onEnter: (newFieldPath: string[]) => void; + onEscape: (newFieldPath: string[]) => void; + onTab?: (newFieldPath: string[]) => void; + onShiftTab?: (newFieldPath: string[]) => void; + onChange: (newFieldPath: string[]) => void; + maxDepth: number; +} + +export const FieldPathPickerEffect = (props: FieldPathPickerEffectProps) => { + return null; + /* const { objectMetadataItems } = useFilteredObjectMetadataItems(); + + const wrapperRef = useRef(null); + + useRegisterInputEvents({ + inputRef: wrapperRef, + onClickOutside: props.onClickOutside, + onEnter: props.onEnter, + onEscape: props.onEscape, + onTab: props.onTab, + onShiftTab: props.onShiftTab, + hotkeyScope: props.hotkeyScope, + inputValue: props.value ?? [], + }); + + const noResult = false; + + const onSearchQueryChange = () => {}; // TODO + + const sourceObjectMetadata = objectMetadataItems.find( + (objectMetadata) => + objectMetadata.nameSingular === props.sourceObjectNameSingular, + ); + + if (!sourceObjectMetadata) return
No source object selected
; + + const viewObjectMetadata = getViewObjectMetadata( + objectMetadataItems, + sourceObjectMetadata, + props.value, + ); + + const selectableFieldMetadataItems = + viewObjectMetadata?.fields.filter(isSelectableFieldPathPart) ?? []; + + const selectableItemIds = [ + COUNT_RECORDS_ITEM_KEY, + ...selectableFieldMetadataItems.map( + (fieldMetadata) => fieldMetadata.id as string, + ), + ]; + + return ( +
+ + + + + + <> + { + props.onEnter(props.value ?? []); + }} + objectLabelPlural={viewObjectMetadata?.labelPlural} + /> + {selectableFieldMetadataItems.length > 0 && ( + + )} + + {noResult ? ( + + ) : ( + selectableFieldMetadataItems?.map((fieldMetadata) => ( + { + const newFieldPath = [ + ...(props.value ?? []), + fieldMetadata.id, + ]; + const shouldClose = + fieldMetadata.type !== FieldMetadataType.Relation || + newFieldPath.length >= props.maxDepth; + if (shouldClose) { + return props.onEnter(newFieldPath); + } + props.onChange(newFieldPath); + }} + /> + )) + )} + + + +
+ ); */ +}; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/components/FieldSelectItem.tsx b/packages/twenty-front/src/modules/object-record/field-path-picker/components/FieldSelectItem.tsx new file mode 100644 index 000000000000..51a30d42e1a7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/components/FieldSelectItem.tsx @@ -0,0 +1,36 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FIELD_PATH_PICKER_SELECTABLE_LIST_ID } from '@/object-record/field-path-picker/constants/FieldPathPickerSelectableListId'; +import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/components/MenuItemLeftContent'; +import { StyledMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase'; +import { useRecoilValue } from 'recoil'; +import { useIcons } from 'twenty-ui'; + +interface FieldSelectItemProps { + fieldMetadata: FieldMetadataItem; + onSelect: (fieldMetadataId: string) => void; +} + +export const FieldSelectItem = (props: FieldSelectItemProps) => { + const { isSelectedItemIdSelector } = useSelectableList( + FIELD_PATH_PICKER_SELECTABLE_LIST_ID, + ); + const isSelectedByKeyboard = useRecoilValue( + isSelectedItemIdSelector(props.fieldMetadata.id), + ); + + const { getIcon } = useIcons(); + const IconComponent = getIcon(props.fieldMetadata.icon); + + return ( + props.onSelect(props.fieldMetadata.id)} + isKeySelected={isSelectedByKeyboard} + > + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/constants/CountRecordsItemKey.ts b/packages/twenty-front/src/modules/object-record/field-path-picker/constants/CountRecordsItemKey.ts new file mode 100644 index 000000000000..dbb6849e09ed --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/constants/CountRecordsItemKey.ts @@ -0,0 +1 @@ +export const COUNT_RECORDS_ITEM_KEY = 'count-records-item-key'; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/constants/FieldPathPickerSelectableListId.ts b/packages/twenty-front/src/modules/object-record/field-path-picker/constants/FieldPathPickerSelectableListId.ts new file mode 100644 index 000000000000..be11bdb80bc6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/constants/FieldPathPickerSelectableListId.ts @@ -0,0 +1,2 @@ +export const FIELD_PATH_PICKER_SELECTABLE_LIST_ID = + 'field-path-picker-selectable-list-id'; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/utils/getViewObjectMetadata.ts b/packages/twenty-front/src/modules/object-record/field-path-picker/utils/getViewObjectMetadata.ts new file mode 100644 index 000000000000..bbafd28b0ad3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/utils/getViewObjectMetadata.ts @@ -0,0 +1,41 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isSelectableFieldPathPart } from '@/object-record/field-path-picker/utils/isSelectableFieldPathPart'; + +export const getViewObjectMetadata = ( + objectMetadataItems: ObjectMetadataItem[], + sourceObjectMetadata: ObjectMetadataItem, + fieldPath: string[] | undefined | null, +) => { + const fieldPathFieldMetadataIds = fieldPath ?? []; + + const allFieldMetadataItems = objectMetadataItems + .flatMap((objectMetadata) => objectMetadata.fields) + .filter(isSelectableFieldPathPart); + + let viewObjectMetadata = sourceObjectMetadata; + + for (const fieldPathFieldMetadataId of fieldPathFieldMetadataIds) { + const fieldPathFieldMetadata = allFieldMetadataItems.find( + (fieldMetadata) => fieldMetadata.id === fieldPathFieldMetadataId, + ); + if (!fieldPathFieldMetadata) + throw new Error( + `Could not resolve field metadata id '${fieldPathFieldMetadataId}' in field path.`, + ); + const { relationDefinition } = fieldPathFieldMetadata; + if (!relationDefinition) return; // TODO: Throw error? + + const nextObjectMetadataId = + relationDefinition.sourceFieldMetadata.id === fieldPathFieldMetadataId + ? relationDefinition.targetObjectMetadata.id + : relationDefinition.sourceObjectMetadata.id; + const nextObjectMetadata = objectMetadataItems.find( + (objectMetadata) => objectMetadata.id === nextObjectMetadataId, + ); + if (!nextObjectMetadata) throw new Error(); + + viewObjectMetadata = nextObjectMetadata; + } + + return viewObjectMetadata; +}; diff --git a/packages/twenty-front/src/modules/object-record/field-path-picker/utils/isSelectableFieldPathPart.ts b/packages/twenty-front/src/modules/object-record/field-path-picker/utils/isSelectableFieldPathPart.ts new file mode 100644 index 000000000000..752fec4a8c90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/field-path-picker/utils/isSelectableFieldPathPart.ts @@ -0,0 +1,26 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; +import { FieldTextMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +const numericFieldMetadataTypes = new Set([ + FieldMetadataType.Currency, + FieldMetadataType.Number, +]); + +export const isSelectableFieldPathPart = ( + field: Pick< + FieldMetadataItem, + | 'isActive' + | 'isSystem' + | 'type' + | 'toRelationMetadata' + | 'fromRelationMetadata' + >, +): field is FieldDefinition => + (field.isActive && + !field.isSystem && + numericFieldMetadataTypes.has(field.type)) || + (field.type === FieldMetadataType.Relation && + !field.toRelationMetadata?.fromObjectMetadata.isSystem && + !field.fromRelationMetadata?.toObjectMetadata.isSystem); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx index 5f57a990d365..465575584eba 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import { useTheme } from '@emotion/react'; +import React from 'react'; import { useRecoilValue } from 'recoil'; import { IconChevronDown } from 'twenty-ui'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 6f648f145c77..0adc445277d9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -5,10 +5,13 @@ import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/dis import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay'; import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; + +import { DataExplorerQueryFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DataExplorerQueryFieldDisplay'; import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay'; import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldIdentifierDisplay'; import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; +import { isFieldDataExplorerQuery } from '@/object-record/record-field/types/guards/isFieldDataExplorerQuery'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; @@ -96,6 +99,8 @@ export const FieldDisplay = () => { ) : isFieldRating(fieldDefinition) ? ( + ) : isFieldDataExplorerQuery(fieldDefinition) ? ( + ) : isFieldRichText(fieldDefinition) ? ( ) : isFieldActor(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index bea16402ae2a..701d80ed0126 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -20,7 +20,9 @@ import { isFieldRelationToOneObject } from '@/object-record/record-field/types/g import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { FieldDataExplorerQueryInput } from '@/object-record/record-field/meta-types/input/components/FieldPathFieldInput'; import { RichTextFieldInput } from '@/object-record/record-field/meta-types/input/components/RichTextFieldInput'; +import { isFieldDataExplorerQuery } from '@/object-record/record-field/types/guards/isFieldDataExplorerQuery'; import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; import { FieldContext } from '../contexts/FieldContext'; import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput'; @@ -177,6 +179,14 @@ export const FieldInput = ({ onTab={onTab} onShiftTab={onShiftTab} /> + ) : isFieldDataExplorerQuery(fieldDefinition) ? ( + ) : isFieldRichText(fieldDefinition) ? ( ) : ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts index 670fa1b0cd2f..02b51b8fef4c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts @@ -22,6 +22,8 @@ import { isFieldSelectValue } from '@/object-record/record-field/types/guards/is import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { isFieldDataExplorerQuery } from '@/object-record/record-field/types/guards/isFieldDataExplorerQuery'; +import { isFieldDataExplorerQueryValue } from '@/object-record/record-field/types/guards/isFieldDataExplorerQueryValue'; import { FieldContext } from '../contexts/FieldContext'; import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue'; @@ -114,6 +116,10 @@ export const usePersistField = () => { isFieldRawJson(fieldDefinition) && isFieldRawJsonValue(valueToPersist); + const fieldIsDataExplorerQuery = + isFieldDataExplorerQuery(fieldDefinition) && + isFieldDataExplorerQueryValue(valueToPersist); + const isValuePersistable = fieldIsRelationToOneObject || fieldIsText || @@ -131,7 +137,8 @@ export const usePersistField = () => { fieldIsSelect || fieldIsMultiSelect || fieldIsAddress || - fieldIsRawJson; + fieldIsRawJson || + fieldIsDataExplorerQuery; if (isValuePersistable) { const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DataExplorerQueryFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DataExplorerQueryFieldDisplay.tsx new file mode 100644 index 000000000000..3a165e703326 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DataExplorerQueryFieldDisplay.tsx @@ -0,0 +1,33 @@ +import { useDataExplorerQueryFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useDataExplorerQueryFieldDisplay'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { GRAY_SCALE, IconCaretRightFilled } from 'twenty-ui'; + +const StyledContainer = styled.div` + align-items: center; + display: flex; +`; + +const StyledIconCaretRightFilled = styled(IconCaretRightFilled)` + color: ${({ theme }) => theme.color.gray40}; +`; + +const StyledEmptyValue = styled.div` + color: ${GRAY_SCALE.gray35}; +`; + +export const DataExplorerQueryFieldDisplay = () => { + const theme = useTheme(); + + const { fieldValue, fieldDefinition } = useDataExplorerQueryFieldDisplay(); + + const containerId = `field-path-display-${fieldDefinition.fieldMetadataId}`; + + return ( + <> + + DataExplorerQueryFieldDisplay + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDataExplorerQueryField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDataExplorerQueryField.ts new file mode 100644 index 000000000000..89c01031f183 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDataExplorerQueryField.ts @@ -0,0 +1,63 @@ +import { useContext } from 'react'; +import { SetterOrUpdater, useRecoilState, useRecoilValue } from 'recoil'; + +import { + DataExplorerQuery, + FieldDataExplorerQueryValue, + FieldTextValue, +} from '@/object-record/record-field/types/FieldMetadata'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { isFieldDataExplorerQuery } from '@/object-record/record-field/types/guards/isFieldDataExplorerQuery'; +import { FieldContext } from '../../contexts/FieldContext'; +import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; + +export const useDataExplorerQueryField = () => { + const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.DataExplorerQuery, + isFieldDataExplorerQuery, + fieldDefinition, + ); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = + useRecoilState( + recordStoreFamilySelector({ + recordId: recordId, + fieldName: fieldName, + }), + ); + + const sourceObjectNameSingular = useRecoilValue( + recordStoreFamilySelector({ + recordId: recordId, + fieldName: 'sourceObjectNameSingular', + }), + ); + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput( + `${recordId}-${fieldName}`, + ); + + const draftValue = useRecoilValue(getDraftValueSelector()) as + | FieldDataExplorerQueryValue + | undefined; + + return { + draftValue, + setDraftValue: setDraftValue as SetterOrUpdater< + DataExplorerQuery | undefined + >, + fieldDefinition, + fieldValue, + setFieldValue, + hotkeyScope, + sourceObjectNameSingular, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDataExplorerQueryFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDataExplorerQueryFieldDisplay.ts new file mode 100644 index 000000000000..5d73f95568b3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDataExplorerQueryFieldDisplay.ts @@ -0,0 +1,26 @@ +import { useContext } from 'react'; + +import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; +import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; + +import { FieldContext } from '../../contexts/FieldContext'; +import { + FieldDataExplorerQueryMetadata, + FieldDataExplorerQueryValue, +} from '../../types/FieldMetadata'; + +export const useDataExplorerQueryFieldDisplay = () => { + const { recordId, fieldDefinition } = useContext(FieldContext); + + const { fieldName } = fieldDefinition.metadata; + + const fieldValue = useRecordFieldValue< + FieldDataExplorerQueryValue | undefined + >(recordId, fieldName); + + return { + fieldDefinition: + fieldDefinition as FieldDefinition, + fieldValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FieldPathFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FieldPathFieldInput.tsx new file mode 100644 index 000000000000..b2d809c10045 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FieldPathFieldInput.tsx @@ -0,0 +1,73 @@ +import { DataExplorerQueryBuilder } from '@/object-record/data-explorer-query-builder/components/DataExplorerQueryBuilder'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { useDataExplorerQueryField } from '@/object-record/record-field/meta-types/hooks/useDataExplorerQueryField'; +import { FieldDataExplorerQueryValue } from '@/object-record/record-field/types/FieldMetadata'; +import { FieldInputEvent } from './DateTimeFieldInput'; + +export type FieldDataExplorerQueryInputProps = { + onClickOutside?: FieldInputEvent; + onEnter?: FieldInputEvent; + onEscape?: FieldInputEvent; + onTab?: FieldInputEvent; + onShiftTab?: FieldInputEvent; +}; + +export const FieldDataExplorerQueryInput = ({ + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, +}: FieldDataExplorerQueryInputProps) => { + const { + draftValue, + setDraftValue, + /* fieldDefinition, + fieldValue, + setFieldValue, */ + hotkeyScope, + sourceObjectNameSingular, + } = useDataExplorerQueryField(); + + const persistField = usePersistField(); + + const handleEnter = (newValue: FieldDataExplorerQueryValue) => { + onEnter?.(() => persistField(newValue)); + }; + + const handleEscape = (newValue: FieldDataExplorerQueryValue) => { + onEscape?.(() => persistField(newValue)); + }; + + const handleClickOutside = ( + event: MouseEvent | TouchEvent, + newValue: FieldDataExplorerQueryValue, + ) => { + onClickOutside?.(() => persistField(newValue)); // TODO: Implement string array saving in persistField + }; + + const handleTab = (newValue?: FieldDataExplorerQueryValue) => { + onTab?.(() => persistField(newValue)); + }; + + const handleShiftTab = (newValue: FieldDataExplorerQueryValue) => { + onShiftTab?.(() => persistField(newValue)); + }; + + const handleChange = (newValue: FieldDataExplorerQueryValue) => { + setDraftValue(newValue ?? undefined); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 55a25af1c4c9..dc27850fb234 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -1,9 +1,11 @@ import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; import { + DataExplorerQuery, FieldActorValue, FieldAddressValue, FieldBooleanValue, FieldCurrencyValue, + FieldDataExplorerQueryValue, FieldDateTimeValue, FieldEmailValue, FieldFullNameValue, @@ -52,6 +54,7 @@ export type FieldAddressDraftValue = { addressLng: number | null; }; export type FieldJsonDraftValue = string; +export type FieldDataExplorerQueryDraftValue = DataExplorerQuery; export type FieldActorDraftValue = { source: string; workspaceMemberId?: string; @@ -94,6 +97,8 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldAddressDraftValue : FieldValue extends FieldJsonValue ? FieldJsonDraftValue - : FieldValue extends FieldActorValue - ? FieldActorDraftValue - : never; + : FieldValue extends FieldDataExplorerQueryValue + ? FieldDataExplorerQueryDraftValue + : FieldValue extends FieldActorValue + ? FieldActorDraftValue + : never; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 7777c10c091d..c8653e1f88fe 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -95,6 +95,12 @@ export type FieldRawJsonMetadata = { placeHolder: string; }; +export type FieldDataExplorerQueryMetadata = { + objectMetadataNameSingular?: string; + fieldName: string; + placeHolder: string; +}; + export type FieldRichTextMetadata = { objectMetadataNameSingular?: string; fieldName: string; @@ -169,6 +175,7 @@ export type FieldMetadata = | FieldTextMetadata | FieldUuidMetadata | FieldAddressMetadata + | FieldDataExplorerQueryMetadata | FieldActorMetadata; export type FieldTextValue = string; @@ -216,6 +223,45 @@ export type FieldRelationValue< export type Json = ZodHelperLiteral | { [key: string]: Json } | Json[]; export type FieldJsonValue = Record | Json[] | null; +interface DataExplorerQueryChildJoin { + type: 'join'; + children: DataExplorerQueryChild; + fieldMetadataId?: string; + measure?: 'COUNT'; +} + +interface DataExplorerQueryChildSelect { + type: 'select'; + children: DataExplorerQueryChild; + fieldMetadataId?: string; + measure?: 'AVG' | 'MAX' | 'MIN' | 'SUM'; +} + +interface DataExplorerQueryGroupBy { + type: 'groupBy'; + groupBy?: boolean; + groups?: { upperLimit: number; lowerLimit: number }[]; + includeNulls?: boolean; +} + +interface DataExplorerQuerySort { + type: 'sort'; + sortBy?: 'ASC' | 'DESC'; +} + +type DataExplorerQueryChild = + | DataExplorerQueryChildJoin + | DataExplorerQueryChildSelect + | DataExplorerQueryGroupBy + | DataExplorerQuerySort; + +export interface DataExplorerQuery { + sourceObjectMetadataId?: string; + children?: DataExplorerQueryChild[]; +} + +export type FieldDataExplorerQueryValue = DataExplorerQuery | null; + export type FieldActorValue = { source: string; workspaceMemberId?: string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts index 8445a468e028..f45be42d95c4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts @@ -6,6 +6,7 @@ import { FieldAddressMetadata, FieldBooleanMetadata, FieldCurrencyMetadata, + FieldDataExplorerQueryMetadata, FieldDateMetadata, FieldDateTimeMetadata, FieldEmailMetadata, @@ -62,11 +63,13 @@ type AssertFieldMetadataFunction = < ? FieldAddressMetadata : E extends 'RAW_JSON' ? FieldRawJsonMetadata - : E extends 'RICH_TEXT' - ? FieldTextMetadata - : E extends 'ACTOR' - ? FieldActorMetadata - : never, + : E extends 'DATA_EXPLORER_QUERY' + ? FieldDataExplorerQueryMetadata + : E extends 'RICH_TEXT' + ? FieldTextMetadata + : E extends 'ACTOR' + ? FieldActorMetadata + : never, >( fieldType: E, fieldTypeGuard: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDataExplorerQuery.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDataExplorerQuery.ts new file mode 100644 index 000000000000..ba92ace4b24d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDataExplorerQuery.ts @@ -0,0 +1,12 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldDefinition } from '../FieldDefinition'; +import { + FieldDataExplorerQueryMetadata, + FieldMetadata, +} from '../FieldMetadata'; + +export const isFieldDataExplorerQuery = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.DataExplorerQuery; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDataExplorerQueryValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDataExplorerQueryValue.ts new file mode 100644 index 000000000000..fce97a3cf077 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDataExplorerQueryValue.ts @@ -0,0 +1,79 @@ +import { FieldJsonValue } from '@/object-record/record-field/types/FieldMetadata'; +import { z } from 'zod'; + +interface DataExplorerQueryChildJoin { + type: 'join'; + children: DataExplorerQueryChild[]; + fieldMetadataId?: string; + measure?: 'COUNT'; +} + +interface DataExplorerQueryChildSelect { + type: 'select'; + children?: DataExplorerQueryChild[]; + fieldMetadataId?: string; + measure?: 'AVG' | 'MAX' | 'MIN' | 'SUM'; +} + +const dataExplorerQueryGroupBySchema = z.object({ + type: z.literal('groupBy'), + groupBy: z.boolean().optional(), + groups: z + .array( + z.object({ + upperLimit: z.number(), + lowerLimit: z.number(), + }), + ) + .optional(), + includeNulls: z.boolean().optional(), +}); + +const dataExplorerQuerySortSchema = z.object({ + type: z.literal('sort'), + sortBy: z.enum(['ASC', 'DESC']).optional(), +}); + +type DataExplorerQueryChild = + | DataExplorerQueryChildJoin + | DataExplorerQueryChildSelect + | z.infer + | z.infer; + +const dataExplorerQueryChildSchema: z.ZodType = z.lazy( + () => + z.union([ + dataExplorerQueryChildJoinSchema, + dataExplorerQueryChildSelectSchema, + dataExplorerQueryGroupBySchema, + dataExplorerQuerySortSchema, + ]), +); + +const dataExplorerQueryChildJoinSchema: z.ZodType = + z.object({ + type: z.literal('join'), + children: z.array(dataExplorerQueryChildSchema), + fieldMetadataId: z.string().optional(), + measure: z.literal('COUNT').optional(), + }); + +const dataExplorerQueryChildSelectSchema: z.ZodType = + z.object({ + type: z.literal('select'), + children: z.array(dataExplorerQueryChildSchema).optional(), + fieldMetadataId: z.string().optional(), + measure: z.enum(['AVG', 'MAX', 'MIN', 'SUM']).optional(), + }); + +export const dataExplorerQuerySchema = z.object({ + sourceObjectMetadataId: z.string().optional(), + children: z.array(dataExplorerQueryChildSchema).optional(), +}); + +export type DataExplorerQuery = z.infer; + +export const isFieldDataExplorerQueryValue = ( + fieldValue: unknown, +): fieldValue is FieldJsonValue => + dataExplorerQuerySchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullNameValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullNameValue.ts index 15a21d4d7d7f..1062814a301b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullNameValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullNameValue.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { FieldFullNameValue } from '../FieldMetadata'; -const fullnameSchema = z.object({ +const fullNameSchema = z.object({ firstName: z.string(), lastName: z.string(), }); @@ -10,4 +10,4 @@ const fullnameSchema = z.object({ export const isFieldFullNameValue = ( fieldValue: unknown, ): fieldValue is FieldFullNameValue => - fullnameSchema.safeParse(fieldValue).success; + fullNameSchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 1cb5296c40ad..2d560bc53bc6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -9,6 +9,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 { isFieldDataExplorerQuery } from '@/object-record/record-field/types/guards/isFieldDataExplorerQuery'; 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'; @@ -58,6 +59,7 @@ export const isFieldValueEmpty = ({ isFieldBoolean(fieldDefinition) || isFieldRelation(fieldDefinition) || isFieldRawJson(fieldDefinition) || + isFieldDataExplorerQuery(fieldDefinition) || isFieldRichText(fieldDefinition) || isFieldPhone(fieldDefinition) || isFieldPosition(fieldDefinition) diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx index 505dfae807c5..90203fc230a4 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx @@ -19,6 +19,7 @@ import { RecordInlineCell } from '@/object-record/record-inline-cell/components/ import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; +import { RecordDetailDataExplorerQuerySection } from '@/object-record/record-show/record-detail-section/components/RecordDetailDataExplorerQuerySection'; import { RecordDetailDuplicatesSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection'; import { RecordDetailRelationSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSection'; import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState'; @@ -126,7 +127,11 @@ export const RecordShowContainer = ({ fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), ); - const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy( + const { + inlineFieldMetadataItems, + relationFieldMetadataItems, + dataExplorerQueryFieldMetadataItems, + } = groupBy( availableFieldMetadataItems.filter( (fieldMetadataItem) => fieldMetadataItem.name !== 'createdAt' && @@ -135,7 +140,9 @@ export const RecordShowContainer = ({ (fieldMetadataItem) => fieldMetadataItem.type === FieldMetadataType.Relation ? 'relationFieldMetadataItems' - : 'inlineFieldMetadataItems', + : fieldMetadataItem.type === FieldMetadataType.DataExplorerQuery + ? 'dataExplorerQueryFieldMetadataItems' + : 'inlineFieldMetadataItems', ); const inlineRelationFieldMetadataItems = relationFieldMetadataItems?.filter( @@ -300,6 +307,29 @@ export const RecordShowContainer = ({ /> ))} + {dataExplorerQueryFieldMetadataItems.map( + (fieldMetadataItem, index) => ( + + + + ), + )} )} diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDataExplorerQuerySection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDataExplorerQuerySection.tsx new file mode 100644 index 000000000000..b36f42ebf5b5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailDataExplorerQuerySection.tsx @@ -0,0 +1,52 @@ +import { useContext } from 'react'; + +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { useDataExplorerQueryField } from '@/object-record/record-field/meta-types/hooks/useDataExplorerQueryField'; +import { RecordDetailSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailSection'; +import { RecordDetailSectionHeader } from '@/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader'; + +type RecordDetailDataExplorerQuerySectionProps = { + loading: boolean; +}; + +export const RecordDetailDataExplorerQuerySection = ({ + loading, +}: RecordDetailDataExplorerQuerySectionProps) => { + const { recordId } = useContext(FieldContext); + + const { + draftValue, + setDraftValue, + fieldDefinition, + fieldValue, + setFieldValue, + hotkeyScope, + sourceObjectNameSingular, + } = useDataExplorerQueryField(); + + const persistField = usePersistField(); + + const testValue = { sourceObjectMetadataId: 'test' }; + + return ( + + + fieldValue: {JSON.stringify(fieldValue)} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 95747a055586..0afcd1a20121 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -84,6 +84,9 @@ export const generateEmptyFieldValue = ( case FieldMetadataType.RawJson: { return null; } + case FieldMetadataType.DataExplorerQuery: { + return null; + } case FieldMetadataType.RichText: { return null; } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index d0e6604d143b..3e28d99b3160 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -96,6 +96,7 @@ const previewableTypes = [ FieldMetadataType.Relation, FieldMetadataType.Select, FieldMetadataType.Text, + FieldMetadataType.DataExplorerQuery, ]; export const SettingsDataModelFieldSettingsFormCard = ({ diff --git a/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts b/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts index 0149601685e3..1d91a44da2d2 100644 --- a/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts +++ b/packages/twenty-front/src/modules/settings/data-model/types/SettingsSupportedFieldType.ts @@ -2,5 +2,5 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export type SettingsSupportedFieldType = Exclude< FieldMetadataType, - FieldMetadataType.Position + FieldMetadataType.Position | FieldMetadataType.DataExplorerQuery >; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx index 5022ffecb6dc..88549a29e48d 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx @@ -1,6 +1,6 @@ -import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react'; import { RGBA, TEXT_INPUT_STYLE } from 'twenty-ui'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx index 96a129c122e5..367f9d077081 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx @@ -11,6 +11,7 @@ import { } from 'twenty-ui'; import { Calendar } from '@/activities/calendar/components/Calendar'; +import { Chart } from '@/activities/charts/components/Chart'; import { EmailThreads } from '@/activities/emails/components/EmailThreads'; import { Attachments } from '@/activities/files/components/Attachments'; import { Notes } from '@/activities/notes/components/Notes'; @@ -95,6 +96,8 @@ export const ShowPageRightContainer = ({ CoreObjectNameSingular.Person, ].includes(targetObjectNameSingular); + const isChart = targetObjectNameSingular === CoreObjectNameSingular.Chart; + const shouldDisplayCalendarTab = isCompanyOrPerson; const shouldDisplayEmailsTab = emails && isCompanyOrPerson; @@ -122,7 +125,7 @@ export const ShowPageRightContainer = ({ id: 'timeline', title: 'Timeline', Icon: IconTimelineEvent, - hide: !timeline || isInRightDrawer, + hide: !timeline || isInRightDrawer || isChart, }, { id: 'tasks', @@ -133,7 +136,8 @@ export const ShowPageRightContainer = ({ targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Note || targetableObject.targetObjectNameSingular === - CoreObjectNameSingular.Task, + CoreObjectNameSingular.Task || + isChart, }, { id: 'notes', @@ -144,25 +148,32 @@ export const ShowPageRightContainer = ({ targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Note || targetableObject.targetObjectNameSingular === - CoreObjectNameSingular.Task, + CoreObjectNameSingular.Task || + isChart, }, { id: 'files', title: 'Files', Icon: IconPaperclip, - hide: !notes, + hide: !notes || isChart, }, { id: 'emails', title: 'Emails', Icon: IconMail, - hide: !shouldDisplayEmailsTab, + hide: !shouldDisplayEmailsTab || isChart, }, { id: 'calendar', title: 'Calendar', Icon: IconCalendarEvent, - hide: !shouldDisplayCalendarTab, + hide: !shouldDisplayCalendarTab || isChart, + }, + { + id: 'chart', + title: 'Chart', + Icon: IconCalendarEvent, + hide: !isChart, }, ]; const renderActiveTabContent = () => { @@ -202,6 +213,8 @@ export const ShowPageRightContainer = ({ return ; case 'calendar': return ; + case 'chart': + return ; default: return <>; } diff --git a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts index 2152c787807a..1573aa178b45 100644 --- a/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts +++ b/packages/twenty-server/src/database/commands/data-seed-dev-workspace.command.ts @@ -100,7 +100,7 @@ export class DataSeedWorkspaceCommand extends CommandRunner { }); } } catch (error) { - this.logger.error(error); + this.logger.error(error.message, error.stack); return; } @@ -221,7 +221,7 @@ export class DataSeedWorkspaceCommand extends CommandRunner { }, ); } catch (error) { - this.logger.error(error); + this.logger.error(error, error.stack); } await this.typeORMService.disconnectFromDataSource(dataSourceMetadata.id); diff --git a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts index dd0be3fa81b7..3457c3b7b7da 100644 --- a/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts +++ b/packages/twenty-server/src/database/typeorm-seeds/core/feature-flags.ts @@ -65,6 +65,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IsChartsEnabled, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts index 37ee2af4e7e4..a47cac575f44 100644 --- a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts +++ b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts @@ -190,6 +190,13 @@ const fieldRawJsonMock = { defaultValue: null, }; +const fieldDataExplorerQueryMock = { + name: 'fieldDataExplorerQuery', + type: FieldMetadataType.DATA_EXPLORER_QUERY, + isNullable: true, + defaultValue: null, +}; + const fieldRichTextMock = { name: 'fieldRichText', type: FieldMetadataType.RICH_TEXT, @@ -228,6 +235,7 @@ export const fields = [ fieldPositionMock, fieldAddressMock, fieldRawJsonMock, + fieldDataExplorerQueryMock, fieldRichTextMock, fieldActorMock, ]; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/index.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/index.ts index 071e99b1dba3..36819a7a24e6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/index.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/index.ts @@ -1,18 +1,18 @@ -import { RawJSONScalar } from './raw-json.scalar'; -import { PositionScalarType } from './position.scalar'; -import { CursorScalarType } from './cursor.scalar'; import { BigFloatScalarType } from './big-float.scalar'; import { BigIntScalarType } from './big-int.scalar'; -import { DateScalarType } from './date.scalar'; +import { CursorScalarType } from './cursor.scalar'; import { DateTimeScalarType } from './date-time.scalar'; +import { DateScalarType } from './date.scalar'; +import { PositionScalarType } from './position.scalar'; +import { RawJSONScalar } from './raw-json.scalar'; import { TimeScalarType } from './time.scalar'; import { UUIDScalarType } from './uuid.scalar'; export * from './big-float.scalar'; export * from './big-int.scalar'; export * from './cursor.scalar'; -export * from './date.scalar'; export * from './date-time.scalar'; +export * from './date.scalar'; export * from './time.scalar'; export * from './uuid.scalar'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 9656430d9509..c3cb1c91f35d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -74,6 +74,7 @@ export class TypeMapperService { [FieldMetadataType.NUMERIC, BigFloatScalarType], [FieldMetadataType.POSITION, PositionScalarType], [FieldMetadataType.RAW_JSON, RawJSONScalar], + [FieldMetadataType.DATA_EXPLORER_QUERY, RawJSONScalar], [FieldMetadataType.RICH_TEXT, GraphQLString], ]); @@ -110,6 +111,7 @@ export class TypeMapperService { [FieldMetadataType.NUMERIC, BigFloatFilterType], [FieldMetadataType.POSITION, FloatFilterType], [FieldMetadataType.RAW_JSON, RawJsonFilterType], + [FieldMetadataType.DATA_EXPLORER_QUERY, RawJsonFilterType], [FieldMetadataType.RICH_TEXT, StringFilterType], ]); @@ -134,6 +136,7 @@ export class TypeMapperService { [FieldMetadataType.MULTI_SELECT, OrderByDirectionType], [FieldMetadataType.POSITION, OrderByDirectionType], [FieldMetadataType.RAW_JSON, OrderByDirectionType], + [FieldMetadataType.DATA_EXPLORER_QUERY, OrderByDirectionType], [FieldMetadataType.RICH_TEXT, OrderByDirectionType], ]); diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts index b82e5b05c6c6..a744ba327470 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -30,6 +30,7 @@ export const mapFieldMetadataToGraphqlQuery = ( FieldMetadataType.MULTI_SELECT, FieldMetadataType.POSITION, FieldMetadataType.RAW_JSON, + FieldMetadataType.DATA_EXPLORER_QUERY, FieldMetadataType.RICH_TEXT, ].includes(fieldType); diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts index 010effe4d7b5..20235edbc65d 100644 --- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts @@ -1,21 +1,21 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { BeforeCreateOne, IDField } from '@ptc-org/nestjs-query-graphql'; import { - Entity, Column, - PrimaryGeneratedColumn, - ManyToOne, - JoinColumn, CreateDateColumn, - UpdateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, Relation, + UpdateDateColumn, } from 'typeorm'; -import { BeforeCreateOne, IDField } from '@ptc-org/nestjs-query-graphql'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { BeforeCreateOneAppToken } from 'src/engine/core-modules/app-token/hooks/before-create-one-app-token.hook'; import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; export enum AppTokenType { RefreshToken = 'REFRESH_TOKEN', CodeChallenge = 'CODE_CHALLENGE', diff --git a/packages/twenty-server/src/engine/core-modules/chart/chart.module.ts b/packages/twenty-server/src/engine/core-modules/chart/chart.module.ts new file mode 100644 index 000000000000..bdeb5c45c202 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/chart.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; + +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; + +import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module'; +import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module'; +import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { ChartResolver } from 'src/engine/core-modules/chart/chart.resolver'; +import { ChartService } from 'src/engine/core-modules/chart/chart.service'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { RelationMetadataModule } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.module'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; + +@Module({ + imports: [ + WorkspaceDataSourceModule, + WorkspaceQueryRunnerModule, + ObjectMetadataModule, + FieldMetadataModule, + RelationMetadataModule, + NestjsQueryTypeOrmModule.forFeature( + [RelationMetadataEntity, FieldMetadataEntity], + 'metadata', + ), + TwentyORMModule, + ], + exports: [], + providers: [ChartResolver, ChartService], +}) +export class ChartModule {} diff --git a/packages/twenty-server/src/engine/core-modules/chart/chart.resolver.ts b/packages/twenty-server/src/engine/core-modules/chart/chart.resolver.ts new file mode 100644 index 000000000000..fa8fe4f4f9d3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/chart.resolver.ts @@ -0,0 +1,31 @@ +import { Args, Resolver, ArgsType, Field, Query } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; + +import { User } from 'src/engine/core-modules/user/user.entity'; +import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { ChartService } from 'src/engine/core-modules/chart/chart.service'; +import { ChartResult } from 'src/engine/core-modules/chart/dtos/chart-result.dto'; + +@ArgsType() +class ChartDataArgs { + @Field(() => String) + chartId: string; +} + +@UseGuards(JwtAuthGuard) +@Resolver() +export class ChartResolver { + constructor(private readonly chartService: ChartService) {} + + @Query(() => ChartResult) + async chartData( + @AuthWorkspace() { id: workspaceId }: Workspace, + @AuthUser() user: User, + @Args() { chartId }: ChartDataArgs, + ) { + return await this.chartService.run(workspaceId, chartId); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/chart/chart.service.ts b/packages/twenty-server/src/engine/core-modules/chart/chart.service.ts new file mode 100644 index 000000000000..3de6e5ce2f1f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/chart.service.ts @@ -0,0 +1,572 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ChartResult } from 'src/engine/core-modules/chart/dtos/chart-result.dto'; +import { AliasPrefix } from 'src/engine/core-modules/chart/types/alias-prefix.type'; +import { CommonTableExpressionDefinition } from 'src/engine/core-modules/chart/types/common-table-expression-definition.type'; +import { QueryRelation } from 'src/engine/core-modules/chart/types/query-relation.type'; +import { + FieldMetadataEntity, + FieldMetadataType, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; +import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +import { + RelationMetadataEntity, + RelationMetadataType, +} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; +import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; +import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; +import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service'; +import { ChartWorkspaceEntity } from 'src/modules/charts/standard-objects/chart.workspace-entity'; + +@Injectable() +export class ChartService { + constructor( + private readonly workspaceDataSourceService: WorkspaceDataSourceService, + private readonly objectMetadataService: ObjectMetadataService, + @InjectRepository(RelationMetadataEntity, 'metadata') + private readonly relationMetadataRepository: Repository, + private readonly fieldMetadataService: FieldMetadataService, + private readonly twentyORMManager: TwentyORMManager, + ) {} + + private async getRelationMetadata( + workspaceId: string, + fieldMetadataId?: string, + ) { + if (!fieldMetadataId) return; + const [relationMetadata] = await this.relationMetadataRepository.find({ + where: [ + { + fromFieldMetadataId: fieldMetadataId, + }, + { + toFieldMetadataId: fieldMetadataId, + }, + ], + relations: [ + 'fromObjectMetadata', + 'toObjectMetadata', + 'fromFieldMetadata', + 'toFieldMetadata', + 'fromObjectMetadata.fields', + 'toObjectMetadata.fields', + ], + }); + + if (relationMetadata instanceof NotFoundException) throw relationMetadata; + + return relationMetadata; + } + + private async getOppositeObjectMetadata( + relationMetadata: RelationMetadataEntity, + objectMetadata: ObjectMetadataEntity, + ) { + const oppositeObjectMetadata = + relationMetadata?.fromObjectMetadataId === objectMetadata.id + ? relationMetadata?.toObjectMetadata + : relationMetadata?.fromObjectMetadata; + + if (!oppositeObjectMetadata) throw new Error(); + + return oppositeObjectMetadata; + } + + private computeJoinTableAlias(aliasPrefix: AliasPrefix, i: number) { + return `table_${aliasPrefix}_${i}`; + } + + private async getQueryRelation( + workspaceId: string, + dataSourceSchemaName: string, + objectMetadata: ObjectMetadataEntity, + index: number, + aliasPrefix: AliasPrefix, + oppositeObjectMetadata: ObjectMetadataEntity, + relationMetadata: RelationMetadataEntity, + joinTargetQueryRelation: QueryRelation, + isLastRelationField?: boolean, + measureFieldMetadata?: FieldMetadataEntity, + ): Promise { + const fromIsExistingTable = + relationMetadata?.fromObjectMetadataId === objectMetadata.id; + const toJoinFieldName = computeColumnName( + relationMetadata.toFieldMetadata.name, + { + isForeignKey: true, + }, + ); + const fromJoinFieldName = 'id'; + + const baseTableName = computeObjectTargetTable(oppositeObjectMetadata); + + const commonTableExpressionDefinition = isLastRelationField + ? await this.getCommonTableExpressionDefinition( + workspaceId, + dataSourceSchemaName, + baseTableName, + measureFieldMetadata, + ) + : undefined; + + const rightTableName = + commonTableExpressionDefinition?.resultSetName ?? baseTableName; + + switch (relationMetadata?.relationType) { + case RelationMetadataType.ONE_TO_MANY: { + return { + tableName: rightTableName, + tableAlias: this.computeJoinTableAlias(aliasPrefix, index), + fieldName: fromIsExistingTable ? toJoinFieldName : fromJoinFieldName, + joinTarget: { + tableAlias: + index === 0 + ? joinTargetQueryRelation.tableAlias + : this.computeJoinTableAlias(aliasPrefix, index - 1), + fieldName: fromIsExistingTable + ? fromJoinFieldName + : toJoinFieldName, + }, + withQueries: commonTableExpressionDefinition + ? [commonTableExpressionDefinition.withQuery] + : undefined, + }; + } + default: + throw new Error( + `Chart query construction is not implemented for relation type '${relationMetadata?.relationType}'`, + ); + } + } + + private async getQueryRelations( + dataSourceSchemaName: string, + workspaceId: string, + sourceObjectMetadata: ObjectMetadataEntity, + aliasPrefix: AliasPrefix, + sourceQueryRelation: QueryRelation, + relationFieldMetadataIds?: string[], + measureFieldMetadata?: FieldMetadataEntity, + ) { + if (!relationFieldMetadataIds || relationFieldMetadataIds.length === 0) + return []; + let objectMetadata = sourceObjectMetadata; + const queryRelations: QueryRelation[] = []; + + for (let i = 0; i < relationFieldMetadataIds.length; i++) { + const fieldMetadataId = relationFieldMetadataIds[i]; + + const relationMetadata = await this.getRelationMetadata( + workspaceId, + fieldMetadataId, + ); + + if (!relationMetadata) break; + + const oppositeObjectMetadata = await this.getOppositeObjectMetadata( + relationMetadata, + objectMetadata, + ); + + const joinTargetQueryRelation = + queryRelations[i - 1] ?? sourceQueryRelation; + + const isLastRelationField = i === relationFieldMetadataIds.length - 1; + + const queryRelation = await this.getQueryRelation( + workspaceId, + dataSourceSchemaName, + objectMetadata, + i, + aliasPrefix, + oppositeObjectMetadata, + relationMetadata, + joinTargetQueryRelation, + isLastRelationField, + measureFieldMetadata, + ); + + if (!queryRelation) break; + + queryRelations.push(queryRelation); + objectMetadata = oppositeObjectMetadata; + } + + return queryRelations; + } + + private getJoinClauses( + dataSourceSchema: string, + chartQueryRelations: QueryRelation[], + ): string[] { + return chartQueryRelations.map((queryRelation, i) => { + if (!queryRelation.joinTarget) { + throw new Error('Missing join target'); + } + + return `JOIN ${queryRelation.withQueries && queryRelation.withQueries.length > 0 ? '' : `"${dataSourceSchema}".`}"${queryRelation.tableName}" "${queryRelation.tableAlias}" ON "${queryRelation.joinTarget.tableAlias}"."${queryRelation.joinTarget.fieldName}" = "${queryRelation.tableAlias}"."${ + queryRelation.fieldName + }"`; + }); + } + + /* private getTargetSelectColumn( + chartQueryMeasure?: ChartQueryMeasure, + qualifiedColumn?: string, + ) { + if ( + !chartQueryMeasure || + (!qualifiedColumn && chartQueryMeasure !== ChartQueryMeasure.COUNT) + ) { + return; + } + + switch (chartQueryMeasure) { + case ChartQueryMeasure.COUNT: + return 'COUNT(*) as measure'; + case ChartQueryMeasure.AVERAGE: + return `AVG(${qualifiedColumn}) as measure`; + case ChartQueryMeasure.MIN: + return `MIN(${qualifiedColumn}) as measure`; + case ChartQueryMeasure.MAX: + return `MAX(${qualifiedColumn}) as measure`; + case ChartQueryMeasure.SUM: + return `SUM(${qualifiedColumn}) as measure`; + } + } */ + + private async getFieldMetadata(workspaceId, fieldMetadataId) { + if (!fieldMetadataId) return; + + return ( + (await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, { + where: { + id: fieldMetadataId, + }, + })) ?? undefined + ); + } + + private async getQualifiedColumn( + workspaceId: string, + targetQueryRelations: QueryRelation[], + sourceTableName: string, + relationFieldMetadataIds?: string[], + measureFieldMetadata?: FieldMetadataEntity, + ) { + const lastTargetRelationFieldMetadataId = + relationFieldMetadataIds?.[relationFieldMetadataIds?.length - 1]; + + const lastTargetRelationFieldMetadata = await this.getFieldMetadata( + workspaceId, + lastTargetRelationFieldMetadataId, + ); + + const columnName = + measureFieldMetadata?.name ?? lastTargetRelationFieldMetadata?.name; + + const lastQueryRelation: QueryRelation | undefined = + targetQueryRelations[targetQueryRelations.length - 1]; + const tableAlias = lastQueryRelation?.tableAlias ?? sourceTableName; + + return `"${tableAlias}"."${columnName}"`; + } + + private async getCommonTableExpressionDefinition( + workspaceId: string, + dataSourceSchemaName: string, + baseTableName: string, + measureFieldMetadata?: FieldMetadataEntity, + ): Promise { + if (!measureFieldMetadata) return; + + const resultSetName = `${baseTableName}_cte`; // TODO: Unique identifier + + switch (measureFieldMetadata.type) { + case FieldMetadataType.CURRENCY: + return { + resultSetName, + withQuery: ` + WITH "${resultSetName}" AS ( + SELECT + *, + "${measureFieldMetadata.name}AmountMicros" / 1000000.0 * + CASE "${measureFieldMetadata.name}CurrencyCode" + WHEN 'EUR' THEN 1.10 + WHEN 'GBP' THEN 1.29 + WHEN 'USD' THEN 1.00 + -- TODO: Get rates from external API and cache them + ELSE 1.0 + END AS "${measureFieldMetadata.name}" + FROM + "${dataSourceSchemaName}"."${baseTableName}" + ) + `, + }; + } + } + + private async getSourceQueryRelation( + dataSourceSchemaName: string, + workspaceId: string, + sourceObjectMetadata: ObjectMetadataEntity, + targetRelationFieldMetadataIds?: string[], + targetMeasureFieldMetadata?: FieldMetadataEntity, + groupByRelationFieldMetadataIds?: string[], + groupByMeasureFieldMetadata?: FieldMetadataEntity, + ): Promise { + const baseTableName = computeObjectTargetTable(sourceObjectMetadata); + + const targetCommonTableExpressionDefinition = + !targetRelationFieldMetadataIds || + targetRelationFieldMetadataIds?.length === 0 + ? await this.getCommonTableExpressionDefinition( + workspaceId, + dataSourceSchemaName, + baseTableName, + targetMeasureFieldMetadata, + ) + : undefined; + + const groupByCommonTableExpressionDefinition = + !groupByRelationFieldMetadataIds || + groupByRelationFieldMetadataIds?.length === 0 + ? await this.getCommonTableExpressionDefinition( + workspaceId, + dataSourceSchemaName, + targetCommonTableExpressionDefinition?.resultSetName ?? + baseTableName, + groupByMeasureFieldMetadata, + ) + : undefined; + + const tableName = + groupByCommonTableExpressionDefinition?.resultSetName ?? + targetCommonTableExpressionDefinition?.resultSetName ?? + baseTableName; + + const withQueries = [ + targetCommonTableExpressionDefinition?.withQuery, + groupByCommonTableExpressionDefinition?.withQuery, + ].filter((withQuery): withQuery is string => withQuery !== undefined); + + return { + tableName, + tableAlias: tableName, + withQueries: withQueries, + }; + } + + private async getQuery(workspaceId: string, chartId: string) { + const repository = + await this.twentyORMManager.getRepository(ChartWorkspaceEntity); + + const chart = await repository.findOneByOrFail({ id: chartId }); + + /* const sourceObjectMetadata = + await this.objectMetadataService.findOneOrFailWithinWorkspace( + workspaceId, + { + where: { nameSingular: chart?.sourceObjectNameSingular }, + }, + ); + + const lastTargetFieldMetadataId = chart.target?.[chart.target?.length - 1]; + + const lastTargetFieldMetadata = await this.getFieldMetadata( + workspaceId, + lastTargetFieldMetadataId, + ); + + const targetMeasureFieldMetadata = + (lastTargetFieldMetadata?.type !== FieldMetadataType.RELATION && + lastTargetFieldMetadata) || + undefined; + + const lastGroupByFieldMetadataId = + chart.groupBy?.[chart.groupBy?.length - 1]; + const lastGroupByFieldMetadata = await this.getFieldMetadata( + workspaceId, + lastGroupByFieldMetadataId, + ); + + const groupByMeasureFieldMetadata = + (lastGroupByFieldMetadata?.type !== FieldMetadataType.RELATION && + lastGroupByFieldMetadata) || + undefined; */ + + return chart.query; + } + + async run(workspaceId: string, chartId: string): Promise { + const query = await this.getQuery(workspaceId, chartId); + + console.log('query', query); + + return { chartResult: JSON.stringify([{ measure: 3 }]) }; + /* + const dataSourceSchemaName = + this.workspaceDataSourceService.getSchemaName(workspaceId); + + const sourceObjectMetadata = + await this.objectMetadataService.findOneOrFailWithinWorkspace( + workspaceId, + { + where: { id: chartQuery.sourceObjectMetadataId }, + }, + ); + + const targetMeasureFieldMetadata = await this.getFieldMetadata( + workspaceId, + chartQuery.target?.measureFieldMetadataId, + ); + + const groupByMeasureFieldMetadata = await this.getFieldMetadata( + workspaceId, + chartQuery.groupBy?.measureFieldMetadataId, + ); + + const sourceQueryRelation = await this.getSourceQueryRelation( + dataSourceSchemaName, + workspaceId, + sourceObjectMetadata, + chartQuery.target?.relationFieldMetadataIds, + targetMeasureFieldMetadata, + chartQuery.groupBy?.relationFieldMetadataIds, + groupByMeasureFieldMetadata, + ); + + const targetQueryRelations = await this.getQueryRelations( + dataSourceSchemaName, + workspaceId, + sourceObjectMetadata, + 'target', + sourceQueryRelation, + chartQuery.target?.relationFieldMetadataIds, + targetMeasureFieldMetadata, + ); + + const targetJoinClauses = this.getJoinClauses( + dataSourceSchemaName, + targetQueryRelations, + ); + + const targetQualifiedColumn = await this.getQualifiedColumn( + workspaceId, + targetQueryRelations, + sourceQueryRelation.tableName, + chartQuery.target?.relationFieldMetadataIds, + targetMeasureFieldMetadata, + ); + + const groupByQueryRelations = await this.getQueryRelations( + dataSourceSchemaName, + workspaceId, + sourceObjectMetadata, + 'group_by', + sourceQueryRelation, + chartQuery.groupBy?.relationFieldMetadataIds, + groupByMeasureFieldMetadata, + ); + + const groupByJoinClauses = this.getJoinClauses( + dataSourceSchemaName, + groupByQueryRelations, + ); + + // TODO: Refactor conditions + const groupByQualifiedColumn = + chartQuery.groupBy?.measureFieldMetadataId || + (chartQuery.groupBy?.relationFieldMetadataIds && + chartQuery.groupBy?.relationFieldMetadataIds?.length > 0) + ? await this.getQualifiedColumn( + workspaceId, + groupByQueryRelations, + sourceQueryRelation.tableName, + chartQuery.groupBy?.relationFieldMetadataIds, + groupByMeasureFieldMetadata, + ) + : undefined; + + const groupByClauseString = + chartQuery.groupBy?.measureFieldMetadataId || + (chartQuery.groupBy?.relationFieldMetadataIds && + chartQuery.groupBy?.relationFieldMetadataIds?.length > 0) + ? `GROUP BY ${groupByQualifiedColumn}` + : ''; + + const allQueryRelations = [ + sourceQueryRelation, + targetQueryRelations, + groupByQueryRelations, + ].flat(); + + const commonTableExpressions = allQueryRelations + .flatMap((queryRelation) => queryRelation.withQueries) + .join('\n'); + + console.log('commonTableExpressions', commonTableExpressions); + + if (chartQuery.target?.measure === undefined) { + throw new Error('Measure is currently required'); + } + + const targetSelectColumn = this.getTargetSelectColumn( + chartQuery.target?.measure, + targetQualifiedColumn, + ); + + const selectColumns = [targetSelectColumn, groupByQualifiedColumn].filter( + (col) => !!col, + ); + + const joinClausesString = [targetJoinClauses, groupByJoinClauses] + .flat() + .filter((col) => col) + .join('\n'); + + const groupByExcludeNullsWhereClause = + (chartQuery.groupBy?.measureFieldMetadataId || + (chartQuery.groupBy?.relationFieldMetadataIds && + chartQuery.groupBy?.relationFieldMetadataIds.length > 0)) && + !chartQuery.groupBy?.includeNulls + ? `${groupByQualifiedColumn} IS NOT NULL` + : undefined; + + const whereClauses = [groupByExcludeNullsWhereClause].filter( + (whereClause) => whereClause !== undefined, + ); + + const whereClausesString = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + + const sqlQuery = ` + ${commonTableExpressions} + SELECT ${selectColumns.join(', ')} + FROM ${sourceQueryRelation.withQueries && sourceQueryRelation.withQueries.length > 0 ? '' : `"${dataSourceSchemaName}".`}"${sourceQueryRelation.tableName}" + ${joinClausesString} + ${whereClausesString} + ${groupByClauseString}; + `; + + console.log('sqlQuery\n', sqlQuery); + + const result = await this.workspaceDataSourceService.executeRawQuery( + sqlQuery, + [], + workspaceId, + ); + + console.log('result', JSON.stringify(result, undefined, 2)); + + return { chartResult: JSON.stringify(result) }; */ + } +} + +// TODO: only allow counting source object records? diff --git a/packages/twenty-server/src/engine/core-modules/chart/dtos/chart-result.dto.ts b/packages/twenty-server/src/engine/core-modules/chart/dtos/chart-result.dto.ts new file mode 100644 index 000000000000..71a74d9731df --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/dtos/chart-result.dto.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsOptional } from 'class-validator'; + +@ObjectType('ChartResult') +export class ChartResult { + @Field(() => String, { nullable: true }) + @IsOptional() + chartResult?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/chart/types/alias-prefix.type.ts b/packages/twenty-server/src/engine/core-modules/chart/types/alias-prefix.type.ts new file mode 100644 index 000000000000..aee6dbcfbdb8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/types/alias-prefix.type.ts @@ -0,0 +1 @@ +export type AliasPrefix = 'target' | 'group_by'; diff --git a/packages/twenty-server/src/engine/core-modules/chart/types/chart-query.ts b/packages/twenty-server/src/engine/core-modules/chart/types/chart-query.ts new file mode 100644 index 000000000000..076e8627fccc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/types/chart-query.ts @@ -0,0 +1,36 @@ +interface DataExplorerQueryChildJoin { + type: 'join'; + children: DataExplorerQueryChild[]; + fieldMetadataId?: string; + measure?: 'COUNT'; +} + +interface DataExplorerQueryChildSelect { + type: 'select'; + children?: DataExplorerQueryChild[]; + fieldMetadataId?: string; + measure?: 'AVG' | 'MAX' | 'MIN' | 'SUM'; +} + +interface DataExplorerQueryGroupBy { + type: 'groupBy'; + groupBy?: boolean; + groups?: { upperLimit: number; lowerLimit: number }[]; + includeNulls?: boolean; +} + +interface DataExplorerQuerySort { + type: 'sort'; + sortBy?: 'ASC' | 'DESC'; +} + +type DataExplorerQueryChild = + | DataExplorerQueryChildJoin + | DataExplorerQueryChildSelect + | DataExplorerQueryGroupBy + | DataExplorerQuerySort; + +export interface DataExplorerQuery { + sourceObjectMetadataId?: string; + children?: DataExplorerQueryChild[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/chart/types/common-table-expression-definition.type.ts b/packages/twenty-server/src/engine/core-modules/chart/types/common-table-expression-definition.type.ts new file mode 100644 index 000000000000..c221a7439850 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/types/common-table-expression-definition.type.ts @@ -0,0 +1,4 @@ +export interface CommonTableExpressionDefinition { + resultSetName: string; + withQuery: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/chart/types/join-target.type.ts b/packages/twenty-server/src/engine/core-modules/chart/types/join-target.type.ts new file mode 100644 index 000000000000..3cca006a1cb2 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/types/join-target.type.ts @@ -0,0 +1,4 @@ +export interface JoinTarget { + tableAlias: string; + fieldName: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/chart/types/query-relation.type.ts b/packages/twenty-server/src/engine/core-modules/chart/types/query-relation.type.ts new file mode 100644 index 000000000000..924f9fd14e70 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/chart/types/query-relation.type.ts @@ -0,0 +1,11 @@ +import { JoinTarget } from 'src/engine/core-modules/chart/types/join-target.type'; + +export interface QueryRelation { + tableName: string; + tableAlias: string; + fieldName?: string; + + joinTarget?: JoinTarget; + + withQueries?: string[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 11e07c91da4d..f29866fde82f 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -5,6 +5,7 @@ import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.modu import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module'; +import { ChartModule } from 'src/engine/core-modules/chart/chart.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { HealthModule } from 'src/engine/core-modules/health/health.module'; import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timeline-messaging.module'; @@ -35,6 +36,7 @@ import { FileModule } from './file/file.module'; WorkspaceModule, AISQLQueryModule, PostgresCredentialsModule, + ChartModule, WorkflowTriggerCoreModule, ], exports: [ diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index fd30ef5bdc7f..f831b0344b4d 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -11,4 +11,5 @@ export enum FeatureFlagKey { IsFunctionSettingsEnabled = 'IS_FUNCTION_SETTINGS_ENABLED', IsWorkflowEnabled = 'IS_WORKFLOW_ENABLED', IsMessageThreadSubscriberEnabled = 'IS_MESSAGE_THREAD_SUBSCRIBER_ENABLED', + IsChartsEnabled = 'IS_CHARTS_ENABLED', } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index ea26a3118349..55337ba2e1b7 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -43,6 +43,8 @@ const getFieldProperties = (type: FieldMetadataType): Property => { return { type: 'boolean' }; case FieldMetadataType.RAW_JSON: return { type: 'object' }; + case FieldMetadataType.DATA_EXPLORER_QUERY: + return { type: 'object' }; default: return { type: 'string' }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 09f1fad3314a..944a09f3a3d2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -42,6 +42,7 @@ export enum FieldMetadataType { POSITION = 'POSITION', ADDRESS = 'ADDRESS', RAW_JSON = 'RAW_JSON', + DATA_EXPLORER_QUERY = 'DATA_EXPLORER_QUERY', RICH_TEXT = 'RICH_TEXT', ACTOR = 'ACTOR', } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts index 82fd946614e4..33280cd2767b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts @@ -46,6 +46,7 @@ type FieldMetadataDefaultValueMapping = { [FieldMetadataType.SELECT]: FieldMetadataDefaultValueString; [FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueString; [FieldMetadataType.RAW_JSON]: FieldMetadataDefaultValueRawJson; + [FieldMetadataType.DATA_EXPLORER_QUERY]: FieldMetadataDefaultValueRawJson; [FieldMetadataType.RICH_TEXT]: FieldMetadataDefaultValueRichText; [FieldMetadataType.ACTOR]: FieldMetadataDefaultActor; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts index 7087cfb5c70e..1ba574596205 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts @@ -52,6 +52,7 @@ export const defaultValueValidatorsMap = { [FieldMetadataType.RICH_TEXT]: [FieldMetadataDefaultValueString], [FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson], [FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks], + [FieldMetadataType.DATA_EXPLORER_QUERY]: [FieldMetadataDefaultValueRawJson], [FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor], }; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts index b1de619efb4c..9aca0e98c494 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util.ts @@ -37,6 +37,8 @@ export const fieldMetadataTypeToColumnType = ( return 'enum'; case FieldMetadataType.RAW_JSON: return 'jsonb'; + case FieldMetadataType.DATA_EXPLORER_QUERY: + return 'jsonb'; default: throw new WorkspaceMigrationException( `Cannot convert ${fieldMetadataType} to column type.`, diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts index 8c16ab64cb0c..c5aa928b2f0f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts @@ -72,6 +72,10 @@ export class WorkspaceMigrationFactory { [FieldMetadataType.NUMBER, { factory: this.basicColumnActionFactory }], [FieldMetadataType.POSITION, { factory: this.basicColumnActionFactory }], [FieldMetadataType.RAW_JSON, { factory: this.basicColumnActionFactory }], + [ + FieldMetadataType.DATA_EXPLORER_QUERY, + { factory: this.basicColumnActionFactory }, + ], [FieldMetadataType.RICH_TEXT, { factory: this.basicColumnActionFactory }], [FieldMetadataType.BOOLEAN, { factory: this.basicColumnActionFactory }], [FieldMetadataType.DATE_TIME, { factory: this.basicColumnActionFactory }], diff --git a/packages/twenty-server/src/engine/workspace-manager/demo-objects-prefill-data/demo-objects-prefill-data.ts b/packages/twenty-server/src/engine/workspace-manager/demo-objects-prefill-data/demo-objects-prefill-data.ts index 1b4b4ec8b908..1a9796a03e11 100644 --- a/packages/twenty-server/src/engine/workspace-manager/demo-objects-prefill-data/demo-objects-prefill-data.ts +++ b/packages/twenty-server/src/engine/workspace-manager/demo-objects-prefill-data/demo-objects-prefill-data.ts @@ -1,16 +1,18 @@ import { DataSource, EntityManager } from 'typeorm'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { workspaceMemberPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/workspace-member'; -import { viewPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/view'; import { companyPrefillDemoData } from 'src/engine/workspace-manager/demo-objects-prefill-data/company'; -import { personPrefillDemoData } from 'src/engine/workspace-manager/demo-objects-prefill-data/person'; import { opportunityPrefillDemoData } from 'src/engine/workspace-manager/demo-objects-prefill-data/opportunity'; +import { personPrefillDemoData } from 'src/engine/workspace-manager/demo-objects-prefill-data/person'; +import { workspaceMemberPrefillData } from 'src/engine/workspace-manager/demo-objects-prefill-data/workspace-member'; +import { viewPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/view'; export const demoObjectsPrefillData = async ( workspaceDataSource: DataSource, schemaName: string, objectMetadata: ObjectMetadataEntity[], + featureFlags?: FeatureFlagEntity[], ) => { const objectMetadataMap = objectMetadata.reduce((acc, object) => { acc[object.standardId ?? ''] = { @@ -31,7 +33,7 @@ export const demoObjectsPrefillData = async ( await personPrefillDemoData(entityManager, schemaName); await opportunityPrefillDemoData(entityManager, schemaName); - await viewPrefillData(entityManager, schemaName, objectMetadataMap); + await viewPrefillData(entityManager, schemaName, objectMetadataMap, featureFlags); await workspaceMemberPrefillData(entityManager, schemaName); }, diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts index 19ab7a0554e6..7138ff8e04e9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts @@ -1,5 +1,6 @@ import { DataSource, EntityManager } from 'typeorm'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { companyPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/company'; import { personPrefillData } from 'src/engine/workspace-manager/standard-objects-prefill-data/person'; @@ -9,6 +10,7 @@ export const standardObjectsPrefillData = async ( workspaceDataSource: DataSource, schemaName: string, objectMetadata: ObjectMetadataEntity[], + featureFlags?: FeatureFlagEntity[], ) => { const objectMetadataMap = objectMetadata.reduce((acc, object) => { if (!object.standardId) { @@ -34,6 +36,11 @@ export const standardObjectsPrefillData = async ( workspaceDataSource.transaction(async (entityManager: EntityManager) => { await companyPrefillData(entityManager, schemaName); await personPrefillData(entityManager, schemaName); - await viewPrefillData(entityManager, schemaName, objectMetadataMap); + await viewPrefillData( + entityManager, + schemaName, + objectMetadataMap, + featureFlags, + ); }); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view-chart-fields.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view-chart-fields.ts new file mode 100644 index 000000000000..3c46d01e683c --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view-chart-fields.ts @@ -0,0 +1,21 @@ +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { CHART_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; + +export const viewChartFields = ( + viewId: string, + objectMetadataMap: Record, +) => { + return [ + { + fieldMetadataId: + objectMetadataMap[STANDARD_OBJECT_IDS.chart].fields[ + CHART_STANDARD_FIELD_IDS.name + ], + viewId: viewId, + position: 0, + isVisible: true, + size: 180, + }, + ]; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts index 05b269746a76..6d876dffabe5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/view.ts @@ -5,6 +5,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { activitiesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/activities-all.view'; +import { chartsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/charts-all.view'; import { companiesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/companies-all.view'; import { notesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/notes-all.view'; import { opportunitiesAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/opportunities-all.view'; @@ -20,6 +21,11 @@ export const viewPrefillData = async ( objectMetadataMap: Record, featureFlags?: FeatureFlagEntity[], ) => { + const isChartsEnabled = featureFlags?.some( + (featureFlag) => + featureFlag.key === FeatureFlagKey.IsChartsEnabled && + featureFlag.value === true, + ); const isWorkflowEnabled = featureFlags?.find( (featureFlag) => featureFlag.key === FeatureFlagKey.IsWorkflowEnabled, @@ -34,6 +40,7 @@ export const viewPrefillData = async ( await notesAllView(objectMetadataMap), await tasksAllView(objectMetadataMap), await tasksByStatusView(objectMetadataMap), + ...(isChartsEnabled ? [await chartsAllView(objectMetadataMap)] : []), ...(isWorkflowEnabled ? [await workflowsAllView(objectMetadataMap)] : []), ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/charts-all.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/charts-all.view.ts new file mode 100644 index 000000000000..4d4b7e6041e6 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/charts-all.view.ts @@ -0,0 +1,29 @@ +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { CHART_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; + +export const chartsAllView = async ( + objectMetadataMap: Record, +) => { + return { + name: 'All', + objectMetadataId: objectMetadataMap[STANDARD_OBJECT_IDS.chart].id, + type: 'table', + key: 'INDEX', + position: 0, + icon: 'IconList', + kanbanFieldMetadataId: '', + filters: [], + fields: [ + { + fieldMetadataId: + objectMetadataMap[STANDARD_OBJECT_IDS.chart].fields[ + CHART_STANDARD_FIELD_IDS.name + ], + position: 0, + isVisible: true, + size: 180, + }, + ], + }; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 2d4f95b259bf..03805fea22d8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; @@ -133,10 +134,16 @@ export class WorkspaceManagerService { const createdObjectMetadata = await this.objectMetadataService.findManyWithinWorkspace(workspaceId); + const featureFlagRepository = + workspaceDataSource.getRepository('featureFlag'); + + const featureFlags = await featureFlagRepository.find({}); + await standardObjectsPrefillData( workspaceDataSource, dataSourceMetadata.schema, createdObjectMetadata, + featureFlags, ); } @@ -163,10 +170,16 @@ export class WorkspaceManagerService { const createdObjectMetadata = await this.objectMetadataService.findManyWithinWorkspace(workspaceId); + const featureFlagRepository = + workspaceDataSource.getRepository('featureFlag'); + + const featureFlags = await featureFlagRepository.find({}); + await demoObjectsPrefillData( workspaceDataSource, dataSourceMetadata.schema, createdObjectMetadata, + featureFlags, ); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts index 2c7eb944a278..1dff9cf2ea75 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command.ts @@ -68,7 +68,9 @@ export class SyncWorkspaceMetadataCommand extends CommandRunner { if (issues.length > 0) { if (!options.force) { this.logger.error( - `Workspace contains ${issues.length} issues, aborting.`, + `Workspace contains ${ + issues.length + } issues, aborting. ${JSON.stringify(issues, undefined, 2)}`, ); this.logger.log( diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index ff51c318ea17..f1609459cf2b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -61,6 +61,13 @@ export const BLOCKLIST_STANDARD_FIELD_IDS = { workspaceMember: '20202020-548d-4084-a947-fa20a39f7c06', }; +export const CHART_STANDARD_FIELD_IDS = { + name: '20202020-e5aa-45b1-aec0-431420660570', + description: '20202020-71b2-4df1-8cb3-120d55272b12', + query: '20202020-b301-49fc-a26e-55f1a482a4c8', + position: '20202020-b014-4ead-b2f4-5cbb9c67cd05', +}; + export const CALENDAR_CHANNEL_EVENT_ASSOCIATION_STANDARD_FIELD_IDS = { calendarChannel: '20202020-93ee-4da4-8d58-0282c4a9cb7d', calendarEvent: '20202020-5aa5-437e-bb86-f42d457783e3', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts index a13e78836b57..98f823088b99 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids.ts @@ -16,6 +16,7 @@ export const STANDARD_OBJECT_IDS = { calendarChannel: '20202020-e8f2-40e1-a39c-c0e0039c5034', calendarEventParticipant: '20202020-a1c3-47a6-9732-27e5b1e8436d', calendarEvent: '20202020-8f1d-4eef-9f85-0d1965e27221', + chart: '20202020-79fa-42d8-87f0-430e97cdc74d', comment: '20202020-435f-4de9-89b5-97e32233bf5f', company: '20202020-b374-4779-a561-80086cb2e17f', connectedAccount: '20202020-977e-46b2-890b-c3002ddfd5c5', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts index d510e4f2d2e7..30f51756a467 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-objects/index.ts @@ -8,6 +8,7 @@ import { CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/cale import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity'; +import { ChartWorkspaceEntity } from 'src/modules/charts/standard-objects/chart.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; @@ -50,6 +51,7 @@ export const standardObjectMetadataDefinitions = [ CalendarChannelWorkspaceEntity, CalendarChannelEventAssociationWorkspaceEntity, CalendarEventParticipantWorkspaceEntity, + ChartWorkspaceEntity, CommentWorkspaceEntity, CompanyWorkspaceEntity, ConnectedAccountWorkspaceEntity, diff --git a/packages/twenty-server/src/modules/charts/standard-objects/chart.workspace-entity.ts b/packages/twenty-server/src/modules/charts/standard-objects/chart.workspace-entity.ts new file mode 100644 index 000000000000..210f6573c76d --- /dev/null +++ b/packages/twenty-server/src/modules/charts/standard-objects/chart.workspace-entity.ts @@ -0,0 +1,66 @@ +import { DataExplorerQuery } from 'src/engine/core-modules/chart/types/chart-query'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; +import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; +import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; +import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; +import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { CHART_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; +import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; + +@WorkspaceEntity({ + standardId: STANDARD_OBJECT_IDS.chart, + namePlural: 'charts', + labelSingular: 'Chart', + labelPlural: 'Charts', + description: 'A chart for data visualization', + icon: 'IconChartBar', + labelIdentifierStandardId: CHART_STANDARD_FIELD_IDS.name, + softDelete: true, +}) +@WorkspaceGate({ + featureFlag: FeatureFlagKey.IsChartsEnabled, +}) +export class ChartWorkspaceEntity extends BaseWorkspaceEntity { + @WorkspaceField({ + standardId: CHART_STANDARD_FIELD_IDS.name, + type: FieldMetadataType.TEXT, + label: 'Name', + description: 'Chart name', + icon: 'IconNotes', + }) + name: string; + + @WorkspaceField({ + standardId: CHART_STANDARD_FIELD_IDS.description, + type: FieldMetadataType.TEXT, + label: 'Description', + description: 'Chart description', + icon: 'IconNotes', + }) + @WorkspaceIsNullable() + description: string; + + @WorkspaceField({ + standardId: CHART_STANDARD_FIELD_IDS.query, + type: FieldMetadataType.DATA_EXPLORER_QUERY, + label: 'Query', + description: 'Query', + icon: 'IconForms', + }) + @WorkspaceIsNullable() + query: DataExplorerQuery; + + @WorkspaceField({ + standardId: CHART_STANDARD_FIELD_IDS.position, + type: FieldMetadataType.POSITION, + label: 'Position', + description: 'Chart record position', + icon: 'IconHierarchy2', + }) + @WorkspaceIsSystem() + @WorkspaceIsNullable() + position: number | null; +} diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 722507d11c96..4848198a5934 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -33,6 +33,7 @@ export { IconCalendarEvent, IconCalendarTime, IconCalendarX, + IconCaretRightFilled, IconChartCandle, IconCheck, IconCheckbox, @@ -69,6 +70,7 @@ export { IconCurrencyYen, IconCurrencyYuan, IconDatabase, + IconDatabaseSearch, IconDeviceFloppy, IconDoorEnter, IconDotsVertical, @@ -89,7 +91,9 @@ export { IconFilterOff, IconFocusCentered, IconForbid, + IconForms, IconFunction, + IconGraph, IconGripVertical, IconH1, IconH2, @@ -120,6 +124,7 @@ export { IconMail, IconMailCog, IconMap, + IconMathFunction, IconMaximize, IconMessage, IconMinus, @@ -146,9 +151,11 @@ export { IconRelationOneToOne, IconReload, IconRepeat, + IconReportAnalytics, IconRestore, IconRocket, IconRotate, + IconRulerMeasure, IconSearch, IconSend, IconSettings, @@ -156,9 +163,11 @@ export { IconSparkles, IconSql, IconSquareRoundedCheck, + IconStack2, IconTable, IconTag, IconTags, + IconTallymarks, IconTarget, IconTargetArrow, IconTestPipe, diff --git a/yarn.lock b/yarn.lock index bac1580e3bc0..d07e1e8de1a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8319,6 +8319,60 @@ __metadata: languageName: node linkType: hard +"@nivo/annotations@npm:0.87.0": + version: 0.87.0 + resolution: "@nivo/annotations@npm:0.87.0" + dependencies: + "@nivo/colors": "npm:0.87.0" + "@nivo/core": "npm:0.87.0" + "@react-spring/web": "npm:9.4.5 || ^9.7.2" + lodash: "npm:^4.17.21" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/603599c5e697b987ccd360fc835fd4152b43e9facf5542369669faad9352e56a093c6b9dff8c3b4114d25267144478c89ad834fe4929b0ec223c7600795b276b + languageName: node + linkType: hard + +"@nivo/axes@npm:0.87.0": + version: 0.87.0 + resolution: "@nivo/axes@npm:0.87.0" + dependencies: + "@nivo/core": "npm:0.87.0" + "@nivo/scales": "npm:0.87.0" + "@react-spring/web": "npm:9.4.5 || ^9.7.2" + "@types/d3-format": "npm:^1.4.1" + "@types/d3-time-format": "npm:^2.3.1" + d3-format: "npm:^1.4.4" + d3-time-format: "npm:^3.0.0" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/7af5f6ea3101ab957b171713276ff2acb1dee608fe96876a3afac8ede5327856ddfcd3046d084fbe417ef032080caa87736187d3b77d2f6f79f65c7d70fa5bb2 + languageName: node + linkType: hard + +"@nivo/bar@npm:^0.87.0": + version: 0.87.0 + resolution: "@nivo/bar@npm:0.87.0" + dependencies: + "@nivo/annotations": "npm:0.87.0" + "@nivo/axes": "npm:0.87.0" + "@nivo/colors": "npm:0.87.0" + "@nivo/core": "npm:0.87.0" + "@nivo/legends": "npm:0.87.0" + "@nivo/scales": "npm:0.87.0" + "@nivo/tooltip": "npm:0.87.0" + "@react-spring/web": "npm:9.4.5 || ^9.7.2" + "@types/d3-scale": "npm:^4.0.8" + "@types/d3-shape": "npm:^3.1.6" + d3-scale: "npm:^4.0.2" + d3-shape: "npm:^3.2.0" + lodash: "npm:^4.17.21" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/e8385cdd4efabb4bcdcfa9c12967622e4602e25bebd544e9405afcaf3540a168af2a4a98665e3edb14fa64f979e7e372f166977ed4c329fea5ac17ea2a6b0e3c + languageName: node + linkType: hard + "@nivo/calendar@npm:^0.84.0": version: 0.84.0 resolution: "@nivo/calendar@npm:0.84.0" @@ -8359,7 +8413,27 @@ __metadata: languageName: node linkType: hard -"@nivo/core@npm:0.84.0, @nivo/core@npm:^0.84.0": +"@nivo/colors@npm:0.87.0": + version: 0.87.0 + resolution: "@nivo/colors@npm:0.87.0" + dependencies: + "@nivo/core": "npm:0.87.0" + "@types/d3-color": "npm:^3.0.0" + "@types/d3-scale": "npm:^4.0.8" + "@types/d3-scale-chromatic": "npm:^3.0.0" + "@types/prop-types": "npm:^15.7.2" + d3-color: "npm:^3.1.0" + d3-scale: "npm:^4.0.2" + d3-scale-chromatic: "npm:^3.0.0" + lodash: "npm:^4.17.21" + prop-types: "npm:^15.7.2" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/872f4e2d8392f89633531250e0474a365e0456dff146d2e8ba8576226effd1eda7e721ce1dd15a792b05d06caa28a11510a5ca8e55fb84aedb8ba3d964f357f5 + languageName: node + linkType: hard + +"@nivo/core@npm:0.84.0": version: 0.84.0 resolution: "@nivo/core@npm:0.84.0" dependencies: @@ -8382,6 +8456,28 @@ __metadata: languageName: node linkType: hard +"@nivo/core@npm:0.87.0, @nivo/core@npm:^0.87.0": + version: 0.87.0 + resolution: "@nivo/core@npm:0.87.0" + dependencies: + "@nivo/tooltip": "npm:0.87.0" + "@react-spring/web": "npm:9.4.5 || ^9.7.2" + "@types/d3-shape": "npm:^3.1.6" + d3-color: "npm:^3.1.0" + d3-format: "npm:^1.4.4" + d3-interpolate: "npm:^3.0.1" + d3-scale: "npm:^4.0.2" + d3-scale-chromatic: "npm:^3.0.0" + d3-shape: "npm:^3.2.0" + d3-time-format: "npm:^3.0.0" + lodash: "npm:^4.17.21" + prop-types: "npm:^15.7.2" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/75bb5e48cf57c8e31fbd9b9f33121fb4bffb98d47a967e5135527f8fde51537b68da3d894dc7231bf3c969523985eac6a5fc6d50827b958d51e6907a6391ed84 + languageName: node + linkType: hard + "@nivo/legends@npm:0.84.0": version: 0.84.0 resolution: "@nivo/legends@npm:0.84.0" @@ -8398,6 +8494,20 @@ __metadata: languageName: node linkType: hard +"@nivo/legends@npm:0.87.0": + version: 0.87.0 + resolution: "@nivo/legends@npm:0.87.0" + dependencies: + "@nivo/colors": "npm:0.87.0" + "@nivo/core": "npm:0.87.0" + "@types/d3-scale": "npm:^4.0.8" + d3-scale: "npm:^4.0.2" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/1501bf698cefa2695d1124c38da5fc7712a5ce9911683c6694cbea1a481b9daef8ca7b0fc49eb85530c50347c571ea5b686caff3d6a81174b66bc38318bd317d + languageName: node + linkType: hard + "@nivo/recompose@npm:0.84.0": version: 0.84.0 resolution: "@nivo/recompose@npm:0.84.0" @@ -8412,6 +8522,21 @@ __metadata: languageName: node linkType: hard +"@nivo/scales@npm:0.87.0": + version: 0.87.0 + resolution: "@nivo/scales@npm:0.87.0" + dependencies: + "@types/d3-scale": "npm:^4.0.8" + "@types/d3-time": "npm:^1.1.1" + "@types/d3-time-format": "npm:^3.0.0" + d3-scale: "npm:^4.0.2" + d3-time: "npm:^1.0.11" + d3-time-format: "npm:^3.0.0" + lodash: "npm:^4.17.21" + checksum: 10c0/7df01cd72fecbd82791c8aeb99439aa23ff54229ad9044e111fc02d51d0a2f7e8d67a75ed39854d829a7ab274580ca2e252931be5062eff3d08c48ab1580c7bd + languageName: node + linkType: hard + "@nivo/tooltip@npm:0.84.0": version: 0.84.0 resolution: "@nivo/tooltip@npm:0.84.0" @@ -8422,6 +8547,18 @@ __metadata: languageName: node linkType: hard +"@nivo/tooltip@npm:0.87.0": + version: 0.87.0 + resolution: "@nivo/tooltip@npm:0.87.0" + dependencies: + "@nivo/core": "npm:0.87.0" + "@react-spring/web": "npm:9.4.5 || ^9.7.2" + peerDependencies: + react: ">= 16.14.0 < 19.0.0" + checksum: 10c0/ac6b1b0bb0a09017c0e5055432e4c5ee771301615db3ee3d34abb55c900f765af78830eb2b18c8d94ff49ddeaa19895e907c793fb431943ade11cc2d7369b7e4 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -15744,7 +15881,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-color@npm:*": +"@types/d3-color@npm:*, @types/d3-color@npm:^3.0.0": version: 3.1.3 resolution: "@types/d3-color@npm:3.1.3" checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae @@ -15828,6 +15965,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-format@npm:^1.4.1": + version: 1.4.5 + resolution: "@types/d3-format@npm:1.4.5" + checksum: 10c0/d4dbfff22afdf1ad60db7115e877b891864fac380537534dbacf9b5f87cdcd0a418e8d83d4947c59ed8715befa7d018aecd8445f05ae3a5b0796dd495508c082 + languageName: node + linkType: hard + "@types/d3-geo@npm:*": version: 3.1.0 resolution: "@types/d3-geo@npm:3.1.0" @@ -15888,7 +16032,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-scale-chromatic@npm:*": +"@types/d3-scale-chromatic@npm:*, @types/d3-scale-chromatic@npm:^3.0.0": version: 3.0.3 resolution: "@types/d3-scale-chromatic@npm:3.0.3" checksum: 10c0/2f48c6f370edba485b57b73573884ded71914222a4580140ff87ee96e1d55ccd05b1d457f726e234a31269b803270ac95d5554229ab6c43c7e4a9894e20dd490 @@ -15902,7 +16046,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-scale@npm:*": +"@types/d3-scale@npm:*, @types/d3-scale@npm:^4.0.8": version: 4.0.8 resolution: "@types/d3-scale@npm:4.0.8" dependencies: @@ -15927,7 +16071,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-shape@npm:*": +"@types/d3-shape@npm:*, @types/d3-shape@npm:^3.1.6": version: 3.1.6 resolution: "@types/d3-shape@npm:3.1.6" dependencies: @@ -15952,6 +16096,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-time-format@npm:^2.3.1": + version: 2.3.4 + resolution: "@types/d3-time-format@npm:2.3.4" + checksum: 10c0/37b447f7338ab99d1591c7c2e55dde3b35916904132040046de4ad68a5691580bc29f23d04d6ce262454bc2713f1fbeaac912b5b44efcd8b733adc30b08ce28a + languageName: node + linkType: hard + "@types/d3-time-format@npm:^3.0.0": version: 3.0.4 resolution: "@types/d3-time-format@npm:3.0.4" @@ -15966,7 +16117,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-time@npm:^1.0.10": +"@types/d3-time@npm:^1.0.10, @types/d3-time@npm:^1.1.1": version: 1.1.4 resolution: "@types/d3-time@npm:1.1.4" checksum: 10c0/d1dafa4605c10739de216bdf3dfe9c3953e583e849dc5586216525897c96bbbae8972c50e9c11a4c54e700c089914cf7a9764e9806d316a84838ecf9e5c52722 @@ -23956,6 +24107,15 @@ __metadata: languageName: node linkType: hard +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3": + version: 3.2.4 + resolution: "d3-array@npm:3.2.4" + dependencies: + internmap: "npm:1 - 2" + checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50 + languageName: node + linkType: hard + "d3-array@npm:2, d3-array@npm:^2.3.0": version: 2.12.1 resolution: "d3-array@npm:2.12.1" @@ -24010,6 +24170,13 @@ __metadata: languageName: node linkType: hard +"d3-format@npm:1 - 3": + version: 3.1.0 + resolution: "d3-format@npm:3.1.0" + checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75 + languageName: node + linkType: hard + "d3-format@npm:^1.4.4": version: 1.4.5 resolution: "d3-format@npm:1.4.5" @@ -24026,7 +24193,7 @@ __metadata: languageName: node linkType: hard -"d3-interpolate@npm:1 - 3, d3-interpolate@npm:^3.0.1": +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "d3-interpolate@npm:3.0.1" dependencies: @@ -24042,6 +24209,13 @@ __metadata: languageName: node linkType: hard +"d3-path@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-path@npm:3.1.0" + checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da + languageName: node + linkType: hard + "d3-scale-chromatic@npm:^2.0.0": version: 2.0.0 resolution: "d3-scale-chromatic@npm:2.0.0" @@ -24075,6 +24249,19 @@ __metadata: languageName: node linkType: hard +"d3-scale@npm:^4.0.2": + version: 4.0.2 + resolution: "d3-scale@npm:4.0.2" + dependencies: + d3-array: "npm:2.10.0 - 3" + d3-format: "npm:1 - 3" + d3-interpolate: "npm:1.2.0 - 3" + d3-time: "npm:2.1.1 - 3" + d3-time-format: "npm:2 - 4" + checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1 + languageName: node + linkType: hard + "d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0": version: 3.0.0 resolution: "d3-selection@npm:3.0.0" @@ -24091,6 +24278,15 @@ __metadata: languageName: node linkType: hard +"d3-shape@npm:^3.2.0": + version: 3.2.0 + resolution: "d3-shape@npm:3.2.0" + dependencies: + d3-path: "npm:^3.1.0" + checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132 + languageName: node + linkType: hard + "d3-time-format@npm:2 - 3, d3-time-format@npm:^3.0.0": version: 3.0.0 resolution: "d3-time-format@npm:3.0.0" @@ -24100,6 +24296,15 @@ __metadata: languageName: node linkType: hard +"d3-time-format@npm:2 - 4": + version: 4.1.0 + resolution: "d3-time-format@npm:4.1.0" + dependencies: + d3-time: "npm:1 - 3" + checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206 + languageName: node + linkType: hard + "d3-time@npm:1 - 2, d3-time@npm:^2.1.1": version: 2.1.1 resolution: "d3-time@npm:2.1.1" @@ -24109,7 +24314,16 @@ __metadata: languageName: node linkType: hard -"d3-time@npm:^1.0.10": +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3": + version: 3.1.0 + resolution: "d3-time@npm:3.1.0" + dependencies: + d3-array: "npm:2 - 3" + checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1 + languageName: node + linkType: hard + +"d3-time@npm:^1.0.10, d3-time@npm:^1.0.11": version: 1.1.0 resolution: "d3-time@npm:1.1.0" checksum: 10c0/69ab137adff5b22d0fa148ea514a207bd9cd7d2c042ccf34a268f2ef73720b404f0be6e7b56c95650c53caf52080b5254e2a27f0a676f41d1dd22ef8872c8335 @@ -31119,6 +31333,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:1 - 2": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed + languageName: node + linkType: hard + "internmap@npm:^1.0.0": version: 1.0.1 resolution: "internmap@npm:1.0.1" @@ -47244,8 +47465,9 @@ __metadata: "@nestjs/testing": "npm:^9.0.0" "@nestjs/typeorm": "npm:^10.0.0" "@next/eslint-plugin-next": "npm:^14.1.4" + "@nivo/bar": "npm:^0.87.0" "@nivo/calendar": "npm:^0.84.0" - "@nivo/core": "npm:^0.84.0" + "@nivo/core": "npm:^0.87.0" "@nx/eslint": "npm:18.3.3" "@nx/eslint-plugin": "npm:18.3.3" "@nx/jest": "npm:18.3.3"