diff --git a/.dockerignore b/.dockerignore index 285200a8b127..f9fc785f92da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ -server/node_modules/ -server/.env \ No newline at end of file +.git +.env +node_modules +.nx/cache diff --git a/package.json b/package.json index f7e14be3a000..312f4a45e691 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/credential-providers": "^3.363.0", "@blocknote/core": "^0.12.1", - "@blocknote/react": "^0.12.1", + "@blocknote/react": "^0.12.2", "@chakra-ui/accordion": "^2.3.0", "@chakra-ui/system": "^2.6.0", "@codesandbox/sandpack-react": "^2.13.5", diff --git a/packages/twenty-docker/Makefile b/packages/twenty-docker/Makefile index 6e3324e5482b..e4510047ba9a 100644 --- a/packages/twenty-docker/Makefile +++ b/packages/twenty-docker/Makefile @@ -29,6 +29,12 @@ prod-docs-build: prod-docs-run: @docker run -d -p 3000:3000 --name twenty-docs twenty-docs +prod-build: + @cd ../.. && docker build -f ./packages/twenty-docker/prod/twenty/Dockerfile --tag twenty . && cd - + +prod-run: + @docker run -d -p 3000:3000 --name twenty twenty + prod-front-build: @cd ../.. && docker build -f ./packages/twenty-docker/prod/twenty-front/Dockerfile --tag twenty-front . && cd - diff --git a/packages/twenty-docker/prod/.env.example b/packages/twenty-docker/prod/.env.example new file mode 100644 index 000000000000..03628af3f815 --- /dev/null +++ b/packages/twenty-docker/prod/.env.example @@ -0,0 +1,16 @@ +TAG=latest + +POSTGRES_ADMIN_PASSWORD=replace_me_with_a_strong_password + +PG_DATABASE_HOST=db:5432 + +SERVER_URL=http://localhost:3000 +# Uncoment if you are serving your front on another server than the API (eg. bucket) +# FRONT_BASE_URL=http://localhost:3000 + +# Use openssl rand -base64 32 for each secret +# ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access +# LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login +# REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh + +SIGN_IN_PREFILLED=true diff --git a/packages/twenty-docker/prod/docker-compose.yml b/packages/twenty-docker/prod/docker-compose.yml new file mode 100644 index 000000000000..887b820c31d2 --- /dev/null +++ b/packages/twenty-docker/prod/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' +name: twenty + +services: + server: + image: twentycrm/twenty:${TAG} + volumes: + - server-local-data:/app/.local-storage + ports: + - "3000:3000" + environment: + PORT: 3000 + PG_DATABASE_URL: postgres://twenty:twenty@${PG_DATABASE_HOST}/default + SERVER_URL: ${SERVER_URL} + FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL} + + ENABLE_DB_MIGRATIONS: true + + SIGN_IN_PREFILLED: ${SIGN_IN_PREFILLED} + STORAGE_TYPE: local + STORAGE_LOCAL_PATH: .local-storage + ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} + LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} + REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl --silent --fail http://localhost:3000/healthz | jq -e '.status == \"ok\"' > /dev/null || exit 1"] + interval: 5s + timeout: 5s + retries: 10 + restart: always + + db: + image: twentycrm/twenty-postgres:${TAG} + volumes: + - db-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD} + #POSTGRES_USER: ${POSTGRES_USER} + #POSTGRES_DB: ${POSTGRES_DB} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U twenty -d default"] + interval: 5s + timeout: 5s + retries: 10 + restart: always + +volumes: + db-data: + server-local-data: diff --git a/packages/twenty-docker/prod/twenty/Dockerfile b/packages/twenty-docker/prod/twenty/Dockerfile new file mode 100644 index 000000000000..526e578c2df4 --- /dev/null +++ b/packages/twenty-docker/prod/twenty/Dockerfile @@ -0,0 +1,77 @@ +# Base image for common dependencies +FROM node:18.17.1-alpine as common-deps + +WORKDIR /app + +# Copy only the necessary files for dependency resolution +COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/ +COPY ./.yarn/releases /app/.yarn/releases + +COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/ +COPY ./packages/twenty-server/package.json /app/packages/twenty-server/ +COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches +COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/ +COPY ./packages/twenty-front/package.json /app/packages/twenty-front/ + +# Install all dependencies +RUN yarn && yarn cache clean && npx nx reset + + +# Build the back +FROM common-deps as twenty-server-build + +# Copy sourcecode after installing dependences to accelerate subsequents builds +COPY ./packages/twenty-emails /app/packages/twenty-emails +COPY ./packages/twenty-server /app/packages/twenty-server + +RUN npx nx run twenty-server:build && \ + mv /app/packages/twenty-server/dist /app/packages/twenty-server/build && \ + npx nx run twenty-server:build:packageJson && \ + mv /app/packages/twenty-server/dist/package.json /app/packages/twenty-server/package.json && \ + rm -rf /app/packages/twenty-server/dist && \ + mv /app/packages/twenty-server/build /app/packages/twenty-server/dist + +RUN yarn workspaces focus --production twenty-emails twenty-server + + +# Build the front +FROM common-deps as twenty-front-build + +ARG REACT_APP_SERVER_BASE_URL + +COPY ./packages/twenty-front /app/packages/twenty-front +COPY ./packages/twenty-ui /app/packages/twenty-ui +RUN yarn nx build twenty-front + + +# Final stage: Run the application +FROM node:18.17.1-alpine as twenty + +# Used to run healthcheck in docker +RUN apk add --no-cache curl jq + +COPY ./packages/twenty-docker/prod/twenty/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +WORKDIR /app/packages/twenty-server + +ARG REACT_APP_SERVER_BASE_URL +ENV REACT_APP_SERVER_BASE_URL $REACT_APP_SERVER_BASE_URL + +# Copy built applications from previous stages +COPY --chown=1000 --from=twenty-server-build /app /app +COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-server /app/packages/twenty-server +COPY --chown=1000 --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front + +# Set metadata and labels +LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty +LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the backend and frontend, ensuring it deploys faster and runs the same way regardless of the deployment environment." + +RUN mkdir /app/.local-storage +RUN chown -R 1000 /app + +# Use non root user with uid 1000 +USER 1000 + +CMD ["node", "dist/src/main"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/packages/twenty-docker/prod/twenty/entrypoint.sh b/packages/twenty-docker/prod/twenty/entrypoint.sh new file mode 100755 index 000000000000..a6167afcae5b --- /dev/null +++ b/packages/twenty-docker/prod/twenty/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# Check if the initialization has already been done and that we enabled automatic migration +if [ "${ENABLE_DB_MIGRATIONS}" = "true" ] && [ ! -f /app/${STORAGE_LOCAL_PATH}/db_initialized ]; then + echo "Running database setup and migrations..." + + # Run setup and migration scripts + npx ts-node ./scripts/setup-db.ts + yarn database:migrate:prod + + # Mark initialization as done + echo "Successfuly migrated DB!" + touch /app/${STORAGE_LOCAL_PATH}/db_initialized +fi + +# Continue with the original Docker command +exec "$@" diff --git a/packages/twenty-docs/docs/start/local-setup/troubleshooting.mdx b/packages/twenty-docs/docs/start/local-setup/troubleshooting.mdx index 72eccba95ced..fba34447f57d 100644 --- a/packages/twenty-docs/docs/start/local-setup/troubleshooting.mdx +++ b/packages/twenty-docs/docs/start/local-setup/troubleshooting.mdx @@ -41,3 +41,7 @@ This should work out of the box with the eslint extension installed. If this doe "source.fixAll.eslint": "explicit" } ``` + +## Docker container build + +To successfully build Docker images, ensure that your system has a minimum of 2GB of memory available. For users of Docker Desktop, please verify that you've allocated sufficient resources to Docker within the application's settings. diff --git a/packages/twenty-docs/docs/start/self-hosting/docker-compose.mdx b/packages/twenty-docs/docs/start/self-hosting/docker-compose.mdx index 0204e30fb281..a5594f7edf5b 100644 --- a/packages/twenty-docs/docs/start/self-hosting/docker-compose.mdx +++ b/packages/twenty-docs/docs/start/self-hosting/docker-compose.mdx @@ -35,7 +35,7 @@ Complete step three and four with : ## Production docker containers -Prebuilt images for both Postgres, frontend, and back-end can be found on [docker hub](https://hub.docker.com/r/twentycrm/). +Prebuilt images for both Postgres, frontend, and back-end can be found on [docker hub](https://hub.docker.com/r/twentycrm/). Note that the Postgres container will not persist data if your server is not configured to be stateful (for example Heroku). You probably want to configure a special stateful resource for the database. ## Environment Variables diff --git a/packages/twenty-docs/package.json b/packages/twenty-docs/package.json index b89e70bfa1f8..27d018ebc423 100644 --- a/packages/twenty-docs/package.json +++ b/packages/twenty-docs/package.json @@ -1,6 +1,6 @@ { "name": "twenty-docs", - "version": "0.3.2", + "version": "0.3.3", "private": true, "scripts": { "nx": "NX_DEFAULT_PROJECT=twenty-docs node ../../node_modules/nx/bin/nx.js", diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json index 9f22cd6e78ca..07a405c39e01 100644 --- a/packages/twenty-emails/package.json +++ b/packages/twenty-emails/package.json @@ -1,6 +1,6 @@ { "name": "twenty-emails", - "version": "0.3.2", + "version": "0.3.3", "description": "", "author": "", "private": true, diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs index bf92d9cde36d..9ce4ed713711 100644 --- a/packages/twenty-front/nyc.config.cjs +++ b/packages/twenty-front/nyc.config.cjs @@ -6,8 +6,8 @@ const globalCoverage = { }; const modulesCoverage = { - statements: 75, - lines: 75, + statements: 70, + lines: 70, functions: 70, include: ['src/modules/**/*'], exclude: ['src/**/*.ts'], diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 9539629bfedd..219a903b5cdd 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -1,6 +1,6 @@ { "name": "twenty-front", - "version": "0.3.2", + "version": "0.3.3", "private": true, "type": "module", "scripts": { diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 2cf692c3e3b5..820ce959cd60 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -113,11 +113,16 @@ export const PageChangeEffect = () => { ) { navigate(AppPath.CreateProfile); } else if ( - (onboardingStatus === OnboardingStatus.Completed || - onboardingStatus === OnboardingStatus.CompletedWithoutSubscription) && + onboardingStatus === OnboardingStatus.Completed && isMatchingOnboardingRoute ) { navigate(AppPath.Index); + } else if ( + onboardingStatus === OnboardingStatus.CompletedWithoutSubscription && + isMatchingOnboardingRoute && + !isMatchingLocation(AppPath.PlanRequired) + ) { + navigate(AppPath.Index); } else if (isMatchingLocation(AppPath.Invite)) { const inviteHash = matchPath({ path: '/invite/:workspaceInviteHash' }, location.pathname) diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 63d820235287..927fde13800e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -73,6 +73,7 @@ export type Billing = { export type BillingSubscription = { __typename?: 'BillingSubscription'; id: Scalars['ID']['output']; + interval?: Maybe; status: Scalars['String']['output']; }; @@ -263,7 +264,6 @@ export enum FieldMetadataType { DateTime = 'DATE_TIME', Email = 'EMAIL', FullName = 'FULL_NAME', - Json = 'JSON', Link = 'LINK', MultiSelect = 'MULTI_SELECT', Number = 'NUMBER', @@ -272,6 +272,7 @@ export enum FieldMetadataType { Position = 'POSITION', Probability = 'PROBABILITY', Rating = 'RATING', + RawJson = 'RAW_JSON', Relation = 'RELATION', Select = 'SELECT', Text = 'TEXT', @@ -341,6 +342,7 @@ export type Mutation = { renewToken: AuthTokens; signUp: LoginToken; track: Analytics; + updateBillingSubscription: UpdateBillingEntity; updateOneField: Field; updateOneObject: Object; updatePasswordViaResetToken: InvalidatePassword; @@ -545,6 +547,8 @@ export type Query = { fields: FieldConnection; findWorkspaceFromInviteHash: Workspace; getProductPrices: ProductPricesEntity; + getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; + getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; object: Object; @@ -591,6 +595,20 @@ export type QueryGetProductPricesArgs = { }; +export type QueryGetTimelineCalendarEventsFromCompanyIdArgs = { + companyId: Scalars['ID']['input']; + page: Scalars['Int']['input']; + pageSize: Scalars['Int']['input']; +}; + + +export type QueryGetTimelineCalendarEventsFromPersonIdArgs = { + page: Scalars['Int']['input']; + pageSize: Scalars['Int']['input']; + personId: Scalars['ID']['input']; +}; + + export type QueryGetTimelineThreadsFromCompanyIdArgs = { companyId: Scalars['ID']['input']; page: Scalars['Int']['input']; @@ -697,7 +715,7 @@ export type Sentry = { export type SessionEntity = { __typename?: 'SessionEntity'; - url: Scalars['String']['output']; + url?: Maybe; }; /** Sort Directions */ @@ -724,6 +742,45 @@ export type Telemetry = { enabled: Scalars['Boolean']['output']; }; +export type TimelineCalendarEvent = { + __typename?: 'TimelineCalendarEvent'; + attendees: Array; + conferenceSolution: Scalars['String']['output']; + conferenceUri: Scalars['String']['output']; + description: Scalars['String']['output']; + endsAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + isCanceled: Scalars['Boolean']['output']; + isFullDay: Scalars['Boolean']['output']; + location: Scalars['String']['output']; + startsAt: Scalars['DateTime']['output']; + title: Scalars['String']['output']; + visibility: TimelineCalendarEventVisibility; +}; + +export type TimelineCalendarEventAttendee = { + __typename?: 'TimelineCalendarEventAttendee'; + avatarUrl: Scalars['String']['output']; + displayName: Scalars['String']['output']; + firstName: Scalars['String']['output']; + handle: Scalars['String']['output']; + lastName: Scalars['String']['output']; + personId?: Maybe; + workspaceMemberId?: Maybe; +}; + +/** Visibility of the calendar event */ +export enum TimelineCalendarEventVisibility { + Metadata = 'METADATA', + ShareEverything = 'SHARE_EVERYTHING' +} + +export type TimelineCalendarEventsWithTotal = { + __typename?: 'TimelineCalendarEventsWithTotal'; + timelineCalendarEvents: Array; + totalNumberOfCalendarEvents: Scalars['Int']['output']; +}; + export type TimelineThread = { __typename?: 'TimelineThread'; firstParticipant: TimelineThreadParticipant; @@ -760,6 +817,12 @@ export type TransientToken = { transientToken: AuthToken; }; +export type UpdateBillingEntity = { + __typename?: 'UpdateBillingEntity'; + /** Boolean that confirms query was successful */ + success: Scalars['Boolean']['output']; +}; + export type UpdateFieldInput = { defaultValue?: InputMaybe; description?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 1c89af101e9b..3a576f6b2008 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -68,6 +68,7 @@ export type Billing = { export type BillingSubscription = { __typename?: 'BillingSubscription'; id: Scalars['ID']; + interval?: Maybe; status: Scalars['String']; }; @@ -183,7 +184,6 @@ export enum FieldMetadataType { DateTime = 'DATE_TIME', Email = 'EMAIL', FullName = 'FULL_NAME', - Json = 'JSON', Link = 'LINK', MultiSelect = 'MULTI_SELECT', Number = 'NUMBER', @@ -192,6 +192,7 @@ export enum FieldMetadataType { Position = 'POSITION', Probability = 'PROBABILITY', Rating = 'RATING', + RawJson = 'RAW_JSON', Relation = 'RELATION', Select = 'SELECT', Text = 'TEXT', @@ -254,10 +255,10 @@ export type Mutation = { generateJWT: AuthTokens; generateTransientToken: TransientToken; impersonate: Verify; - removeWorkspaceMember: Scalars['String']; renewToken: AuthTokens; signUp: LoginToken; track: Analytics; + updateBillingSubscription: UpdateBillingEntity; updateOneObject: Object; updatePasswordViaResetToken: InvalidatePassword; updateWorkspace: Workspace; @@ -312,11 +313,6 @@ export type MutationImpersonateArgs = { }; -export type MutationRemoveWorkspaceMemberArgs = { - memberId: Scalars['String']; -}; - - export type MutationRenewTokenArgs = { refreshToken: Scalars['String']; }; @@ -660,6 +656,12 @@ export type TransientToken = { transientToken: AuthToken; }; +export type UpdateBillingEntity = { + __typename?: 'UpdateBillingEntity'; + /** Boolean that confirms query was successful */ + success: Scalars['Boolean']; +}; + export type UpdateWorkspaceInput = { allowImpersonation?: InputMaybe; displayName?: InputMaybe; @@ -1045,6 +1047,11 @@ export type GetProductPricesQueryVariables = Exact<{ export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: string, stripePriceId: string, unitAmount: number }> } }; +export type UpdateBillingSubscriptionMutationVariables = Exact<{ [key: string]: never; }>; + + +export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updateBillingSubscription: { __typename?: 'UpdateBillingEntity', success: boolean } }; + export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; @@ -1083,14 +1090,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } }; - -export type RemoveWorkspaceMemberMutationVariables = Exact<{ - memberId: Scalars['String']; -}>; - - -export type RemoveWorkspaceMemberMutation = { __typename?: 'Mutation', removeWorkspaceMember: string }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string, interval?: string | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } }; export type ActivateWorkspaceMutationVariables = Exact<{ input: ActivateWorkspaceInput; @@ -2006,6 +2006,38 @@ export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookO export type GetProductPricesQueryHookResult = ReturnType; export type GetProductPricesLazyQueryHookResult = ReturnType; export type GetProductPricesQueryResult = Apollo.QueryResult; +export const UpdateBillingSubscriptionDocument = gql` + mutation UpdateBillingSubscription { + updateBillingSubscription { + success + } +} + `; +export type UpdateBillingSubscriptionMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateBillingSubscriptionMutation__ + * + * To run a mutation, you first call `useUpdateBillingSubscriptionMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateBillingSubscriptionMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateBillingSubscriptionMutation, { data, loading, error }] = useUpdateBillingSubscriptionMutation({ + * variables: { + * }, + * }); + */ +export function useUpdateBillingSubscriptionMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateBillingSubscriptionDocument, options); + } +export type UpdateBillingSubscriptionMutationHookResult = ReturnType; +export type UpdateBillingSubscriptionMutationResult = Apollo.MutationResult; +export type UpdateBillingSubscriptionMutationOptions = Apollo.BaseMutationOptions; export const GetClientConfigDocument = gql` query GetClientConfig { clientConfig { @@ -2225,6 +2257,7 @@ export const GetCurrentUserDocument = gql` } currentBillingSubscription { status + interval } } workspaces { @@ -2265,37 +2298,6 @@ export function useGetCurrentUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOpt export type GetCurrentUserQueryHookResult = ReturnType; export type GetCurrentUserLazyQueryHookResult = ReturnType; export type GetCurrentUserQueryResult = Apollo.QueryResult; -export const RemoveWorkspaceMemberDocument = gql` - mutation RemoveWorkspaceMember($memberId: String!) { - removeWorkspaceMember(memberId: $memberId) -} - `; -export type RemoveWorkspaceMemberMutationFn = Apollo.MutationFunction; - -/** - * __useRemoveWorkspaceMemberMutation__ - * - * To run a mutation, you first call `useRemoveWorkspaceMemberMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useRemoveWorkspaceMemberMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [removeWorkspaceMemberMutation, { data, loading, error }] = useRemoveWorkspaceMemberMutation({ - * variables: { - * memberId: // value for 'memberId' - * }, - * }); - */ -export function useRemoveWorkspaceMemberMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(RemoveWorkspaceMemberDocument, options); - } -export type RemoveWorkspaceMemberMutationHookResult = ReturnType; -export type RemoveWorkspaceMemberMutationResult = Apollo.MutationResult; -export type RemoveWorkspaceMemberMutationOptions = Apollo.BaseMutationOptions; export const ActivateWorkspaceDocument = gql` mutation ActivateWorkspace($input: ActivateWorkspaceInput!) { activateWorkspace(data: $input) { diff --git a/packages/twenty-front/src/index.tsx b/packages/twenty-front/src/index.tsx index ac59cf8827ea..c23babc438ea 100644 --- a/packages/twenty-front/src/index.tsx +++ b/packages/twenty-front/src/index.tsx @@ -2,7 +2,6 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { HelmetProvider } from 'react-helmet-async'; import { BrowserRouter } from 'react-router-dom'; -import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev'; import { RecoilRoot } from 'recoil'; import { ApolloProvider } from '@/apollo/components/ApolloProvider'; @@ -34,10 +33,6 @@ import 'react-loading-skeleton/dist/skeleton.css'; const root = ReactDOM.createRoot(document.getElementById('root')!); -// Adds messages only in a dev environment -loadDevMessages(); -loadErrorMessages(); - root.render( diff --git a/packages/twenty-front/src/modules/activities/events/components/EventList.tsx b/packages/twenty-front/src/modules/activities/events/components/EventList.tsx new file mode 100644 index 000000000000..158e3ba1215f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventList.tsx @@ -0,0 +1,22 @@ +import { ReactElement } from 'react'; + +import { EventRow } from '@/activities/events/components/EventRow'; +import { Event } from '@/activities/events/types/Event'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +type EventListProps = { + targetableObject: ActivityTargetableObject; + title: string; + events: Event[]; + button?: ReactElement | false; +}; + +export const EventList = ({ events }: EventListProps) => { + return ( + <> + {events && + events.length > 0 && + events.map((event: Event) => )} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx b/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx new file mode 100644 index 000000000000..d7bbc2d7556b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx @@ -0,0 +1,11 @@ +import { Event } from '@/activities/events/types/Event'; + +export const EventRow = ({ event }: { event: Event }) => { + return ( + <> +

+ {event.name}:

{event.properties}
+

+ + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/Events.tsx b/packages/twenty-front/src/modules/activities/events/components/Events.tsx new file mode 100644 index 000000000000..11536d79ac9d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/Events.tsx @@ -0,0 +1,25 @@ +import { isNonEmptyArray } from '@sniptt/guards'; + +import { EventList } from '@/activities/events/components/EventList'; +import { useEvents } from '@/activities/events/hooks/useEvents'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +export const Events = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const { events } = useEvents(targetableObject); + + if (!isNonEmptyArray(events)) { + return
No log yet
; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts b/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts new file mode 100644 index 000000000000..25c6d5bf1209 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react'; + +import { useEvents } from '@/activities/events/hooks/useEvents'; + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + useFindManyRecords: jest.fn(), +})); + +describe('useEvent', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('fetches events correctly for a given targetableObject', () => { + const mockEvents = [ + { + __typename: 'Event', + id: '166ec73f-26b1-4934-bb3b-c86c8513b99b', + opportunityId: null, + opportunity: null, + personId: null, + person: null, + company: { + __typename: 'Company', + address: 'Paris', + linkedinLink: { + __typename: 'Link', + label: '', + url: '', + }, + xLink: { + __typename: 'Link', + label: '', + url: '', + }, + position: 4, + domainName: 'microsoft.com', + employees: null, + createdAt: '2024-03-21T16:01:41.809Z', + annualRecurringRevenue: { + __typename: 'Currency', + amountMicros: 100000000, + currencyCode: 'USD', + }, + idealCustomerProfile: false, + accountOwnerId: null, + updatedAt: '2024-03-22T08:28:44.812Z', + name: 'Microsoft', + id: '460b6fb1-ed89-413a-b31a-962986e67bb4', + }, + workspaceMember: { + __typename: 'WorkspaceMember', + locale: 'en', + avatarUrl: '', + updatedAt: '2024-03-21T16:01:41.839Z', + name: { + __typename: 'FullName', + firstName: 'Tim', + lastName: 'Apple', + }, + id: '20202020-0687-4c41-b707-ed1bfca972a7', + userEmail: 'tim@apple.dev', + colorScheme: 'Light', + createdAt: '2024-03-21T16:01:41.839Z', + userId: '20202020-9e3b-46d4-a556-88b9ddc2b034', + }, + workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7', + createdAt: '2024-03-22T08:28:44.830Z', + name: 'updated.company', + companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4', + properties: '{"diff": {"address": {"after": "Paris", "before": ""}}}', + updatedAt: '2024-03-22T08:28:44.830Z', + }, + ]; + const mockTargetableObject = { + id: '1', + targetObjectNameSingular: 'Opportunity', + }; + + const useFindManyRecordsMock = jest.requireMock( + '@/object-record/hooks/useFindManyRecords', + ); + useFindManyRecordsMock.useFindManyRecords.mockReturnValue({ + records: mockEvents, + }); + + const { result } = renderHook(() => useEvents(mockTargetableObject)); + + expect(result.current.events).toEqual(mockEvents); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx b/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx new file mode 100644 index 000000000000..8e37947a6566 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx @@ -0,0 +1,28 @@ +import { Event } from '@/activities/events/types/Event'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; + +// do we need to test this? +export const useEvents = (targetableObject: ActivityTargetableObject) => { + const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + const { records: events } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Event, + filter: { + [targetableObjectFieldIdName]: { + eq: targetableObject.id, + }, + }, + orderBy: { + createdAt: 'DescNullsFirst', + }, + }); + + return { + events: events as Event[], + }; +}; diff --git a/packages/twenty-front/src/modules/activities/events/types/Event.ts b/packages/twenty-front/src/modules/activities/events/types/Event.ts new file mode 100644 index 000000000000..1752b8367c2a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/types/Event.ts @@ -0,0 +1,12 @@ +export type Event = { + id: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + opportunityId: string | null; + companyId: string; + personId: string; + workspaceMemberId: string; + properties: any; + name: string; +}; diff --git a/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts new file mode 100644 index 000000000000..2f75c7610b37 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_BILLING_SUBSCRIPTION = gql` + mutation UpdateBillingSubscription { + updateBillingSubscription { + success + } + } +`; diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx index 43f5210bdff2..750d3ab418ac 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx @@ -6,6 +6,7 @@ import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { GraphQLView } from '@/views/types/GraphQLView'; +import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews'; export const ObjectMetadataNavItems = () => { const { activeObjectMetadataItems } = useObjectMetadataItemForSettings(); @@ -13,7 +14,9 @@ export const ObjectMetadataNavItems = () => { const { getIcon } = useIcons(); const currentPath = useLocation().pathname; - const { records } = usePrefetchedData(PrefetchKey.AllViews); + const { records: views } = usePrefetchedData( + PrefetchKey.AllViews, + ); return ( <> @@ -45,9 +48,11 @@ export const ObjectMetadataNavItems = () => { : -1; }), ].map((objectMetadataItem) => { - const viewId = records?.find( - (view: any) => view?.objectMetadataId === objectMetadataItem.id, - )?.id; + const objectMetadataViews = getObjectMetadataItemViews( + objectMetadataItem.id, + views, + ); + const viewId = objectMetadataViews[0]?.id; const navigationPath = `/objects/${objectMetadataItem.namePlural}${ viewId ? `?view=${viewId}` : '' diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts index 263745e388b1..61df6cf56869 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts @@ -21,6 +21,31 @@ export const query = gql` } `; +export const findManyViewsQuery = gql` + query FindManyViews($filter: ViewFilterInput, $orderBy: ViewOrderByInput, $lastCursor: String, $limit: Float) { + views(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor) { + edges { + node { + __typename + id + objectMetadataId + type + createdAt + name + updatedAt + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } +`; + export const variables = { input: { object: { diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx index 067eeff19cda..150db7f88da6 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx @@ -7,6 +7,7 @@ import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreat import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; import { + findManyViewsQuery, query, responseData, variables, @@ -24,6 +25,27 @@ const mocks = [ }, })), }, + { + request: { + query: findManyViewsQuery, + variables: {}, + }, + result: jest.fn(() => ({ + data: { + views: { + __typename: 'ViewConnection', + totalCount: 0, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + startCursor: '', + endCursor: '', + }, + edges: [], + }, + }, + })), + }, ]; const Wrapper = ({ children }: { children: ReactNode }) => ( diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts index 624cc4066fd8..7e4f54f65203 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts @@ -1,6 +1,8 @@ -import { ApolloClient, useMutation } from '@apollo/client'; +import { ApolloClient, useApolloClient, useMutation } from '@apollo/client'; import { getOperationName } from '@apollo/client/utilities'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CreateObjectInput, CreateOneObjectMetadataItemMutation, @@ -14,6 +16,10 @@ import { useApolloMetadataClient } from './useApolloMetadataClient'; export const useCreateOneObjectMetadataItem = () => { const apolloMetadataClient = useApolloMetadataClient(); + const apolloClient = useApolloClient(); + const { findManyRecordsQuery } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.View, + }); const [mutate] = useMutation< CreateOneObjectMetadataItemMutation, @@ -23,16 +29,20 @@ export const useCreateOneObjectMetadataItem = () => { }); const createOneObjectMetadataItem = async (input: CreateObjectInput) => { - return await mutate({ + const createdObjectMetadata = await mutate({ variables: { input: { object: input }, }, awaitRefetchQueries: true, - refetchQueries: [ - getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? '', - 'FindManyRecordsMultipleMetadataItems', - ], + refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''], + }); + + await apolloClient.query({ + query: findManyRecordsQuery, + fetchPolicy: 'network-only', }); + + return createdObjectMetadata; }; return { 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 13591a07d002..9886f09b722e 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -8,6 +8,7 @@ export enum CoreObjectNameSingular { Comment = 'comment', Company = 'company', ConnectedAccount = 'connectedAccount', + Event = 'event', Favorite = 'favorite', Message = 'message', MessageChannel = 'messageChannel', @@ -15,7 +16,6 @@ export enum CoreObjectNameSingular { MessageThread = 'messageThread', Opportunity = 'opportunity', Person = 'person', - PipelineStep = 'pipelineStep', View = 'view', ViewField = 'viewField', ViewFilter = 'viewFilter', diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx index 4ca474d31f13..c71702c94238 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx @@ -150,7 +150,6 @@ personId pointOfContactId updatedAt companyId -pipelineStepId probability closeDate amount diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx index 7473906175f9..a1d34e9b5b75 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx @@ -78,7 +78,6 @@ personId pointOfContactId updatedAt companyId -pipelineStepId probability closeDate amount @@ -106,7 +105,6 @@ personId pointOfContactId updatedAt companyId -pipelineStepId probability closeDate amount diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts index f14d5607e409..e9549cf70904 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts @@ -401,23 +401,6 @@ export const getObjectMetadataItemsMock = () => { fromRelationMetadata: null, toRelationMetadata: null, }, - { - __typename: 'field', - id: '20202020-0a2e-4676-8011-3fdb2c30d7f8', - type: 'UUID', - name: 'pipelineStepId', - label: 'Pipeline Step ID (foreign key)', - description: 'Foreign key for pipeline step', - icon: null, - isCustom: false, - isActive: true, - isSystem: true, - isNullable: true, - createdAt: '2023-11-30T11:13:15.308Z', - updatedAt: '2023-11-30T11:13:15.308Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, { __typename: 'field', id: '20202020-3b9c-4e58-a3d2-c617d3b596b1', @@ -436,32 +419,63 @@ export const getObjectMetadataItemsMock = () => { toRelationMetadata: null, }, { - __typename: 'field', - id: '20202020-0a2e-4676-8011-3fdb2c30c258', - type: 'RELATION', - name: 'pipelineStep', - label: 'Pipeline Step', - description: 'Opportunity pipeline step', - icon: 'IconKanban', - isCustom: false, - isActive: true, - isSystem: true, - isNullable: true, - createdAt: '2023-11-30T11:13:15.308Z', - updatedAt: '2023-11-30T11:13:15.308Z', - fromRelationMetadata: null, - toRelationMetadata: { - __typename: 'relation', - id: 'dfb44970-3e09-49f2-9f1d-51c8c451b8f5', - relationType: 'ONE_TO_MANY', - fromObjectMetadata: { - __typename: 'object', - id: '20202020-1029-4661-9e91-83bad932bdcd', - dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', - nameSingular: 'pipelineStep', - namePlural: 'pipelineSteps', + __typename: 'fieldEdge', + node: { + __typename: 'field', + id: '20202020-46cc-42bb-90d5-c724921a012d', + type: 'SELECT', + name: 'stage', + label: 'Stage', + description: 'Opportunity stage', + icon: 'IconProgressCheck', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: false, + createdAt: '2024-03-21T16:48:40.384Z', + updatedAt: '2024-03-21T16:48:40.384Z', + defaultValue: { + value: 'NEW', }, - fromFieldMetadataId: '20202020-22c4-443a-b114-43c97dda5867', + options: [ + { + id: '20202020-aa3b-4c0b-bd90-9d071e3b9bf2', + color: 'red', + label: 'New', + value: 'NEW', + position: 0, + }, + { + id: '20202020-8f9b-4bc3-b0a0-ce6a5085c1cf', + color: 'purple', + label: 'Screening', + value: 'SCREENING', + position: 1, + }, + { + id: '20202020-9797-448d-81e4-49b055a1d19b', + color: 'sky', + label: 'Meeting', + value: 'MEETING', + position: 2, + }, + { + id: '20202020-d542-479c-bc88-3c6d4ee78d09', + color: 'turquoise', + label: 'Proposal', + value: 'PROPOSAL', + position: 3, + }, + { + id: '20202020-b69a-4c9c-ac16-adcba0ec972d', + color: 'yellow', + label: 'Customer', + value: 'CUSTOMER', + position: 4, + }, + ], + fromRelationMetadata: null, + toRelationMetadata: null, }, }, { @@ -3537,155 +3551,6 @@ export const getObjectMetadataItemsMock = () => { }, ], }, - { - __typename: 'object', - id: '20202020-1029-4661-9e91-83bad932bdcd', - dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', - nameSingular: 'pipelineStep', - namePlural: 'pipelineSteps', - labelSingular: 'Pipeline Step', - labelPlural: 'Pipeline Steps', - description: 'A pipeline step', - icon: 'IconLayoutKanban', - isCustom: false, - isActive: true, - isSystem: true, - createdAt: '2023-11-30T11:13:15.206Z', - updatedAt: '2023-11-30T11:13:15.206Z', - fields: [ - { - __typename: 'field', - id: '20202020-f294-430e-b800-3a411fc05ad3', - type: 'TEXT', - name: 'name', - label: 'Name', - description: 'Pipeline Step name', - icon: 'IconCurrencyDollar', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-039a-4fbd-b4c1-66dfa9e4bd3f', - type: 'UUID', - name: 'id', - label: 'Id', - description: null, - icon: null, - isCustom: false, - isActive: true, - isSystem: true, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-816f-4861-9b36-4a2f8ae2791c', - type: 'DATE_TIME', - name: 'createdAt', - label: 'Creation date', - description: null, - icon: 'IconCalendar', - isCustom: false, - isActive: true, - isSystem: true, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-22c4-443a-b114-43c97dda5867', - type: 'RELATION', - name: 'opportunities', - label: 'Opportunities', - description: 'Opportunities linked to the step.', - icon: 'IconTargetArrow', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: true, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: { - __typename: 'relation', - id: 'dfb44970-3e09-49f2-9f1d-51c8c451b8f5', - relationType: 'ONE_TO_MANY', - toObjectMetadata: { - __typename: 'object', - id: '20202020-cae9-4ff4-9579-f7d9fe44c937', - dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - }, - toFieldMetadataId: '20202020-0a2e-4676-8011-3fdb2c30c258', - }, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-6296-4cab-aafb-121ef5822b13', - type: 'NUMBER', - name: 'position', - label: 'Position', - description: 'Pipeline Step position', - icon: 'IconHierarchy2', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-5b93-4b28-8c45-7988ea68f91b', - type: 'TEXT', - name: 'color', - label: 'Color', - description: 'Pipeline Step color', - icon: 'IconColorSwatch', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-2d73-4829-b774-522c2f5627d7', - type: 'DATE_TIME', - name: 'updatedAt', - label: 'Update date', - description: null, - icon: 'IconCalendar', - isCustom: false, - isActive: true, - isSystem: true, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - ], - }, ]; // Todo fix typing here (the backend is not in sync with the frontend) 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 4603ac0cefc0..e6dc29a1c31e 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -34,6 +34,7 @@ export const mapFieldMetadataToGraphQLQuery = ({ 'RATING', 'SELECT', 'POSITION', + 'RAW_JSON', ] as FieldMetadataType[] ).includes(fieldType); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts index 84eb553d97e9..7a756cac76ef 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts @@ -26,7 +26,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { @@ -52,7 +52,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts index 1c8f8a1849bd..7758ca97fae1 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts @@ -8,6 +8,7 @@ import { onRecordBoardFetchMoreVisibilityChangeComponentState } from '@/object-r import { recordBoardColumnIdsComponentState } from '@/object-record/record-board/states/recordBoardColumnIdsComponentState'; import { recordBoardFieldDefinitionsComponentState } from '@/object-record/record-board/states/recordBoardFieldDefinitionsComponentState'; import { recordBoardFiltersComponentState } from '@/object-record/record-board/states/recordBoardFiltersComponentState'; +import { recordBoardKanbanFieldMetadataNameComponentState } from '@/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState'; import { recordBoardObjectSingularNameComponentState } from '@/object-record/record-board/states/recordBoardObjectSingularNameComponentState'; import { recordBoardRecordIdsByColumnIdComponentFamilyState } from '@/object-record/record-board/states/recordBoardRecordIdsByColumnIdComponentFamilyState'; import { recordBoardSortsComponentState } from '@/object-record/record-board/states/recordBoardSortsComponentState'; @@ -32,6 +33,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => { recordBoardObjectSingularNameComponentState, scopeId, ), + kanbanFieldMetadataNameState: extractComponentState( + recordBoardKanbanFieldMetadataNameComponentState, + scopeId, + ), isFetchingRecordState: extractComponentState( isRecordBoardFetchingRecordsComponentState, scopeId, diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts index 90f5a1ad93fd..8ead970305d8 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts @@ -10,6 +10,7 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { recordIdsByColumnIdFamilyState, columnsFamilySelector, columnIdsState, + kanbanFieldMetadataNameState, } = useRecordBoardStates(recordBoardId); const setRecordIds = useRecoilCallback( @@ -26,8 +27,18 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { .getLoadable(recordIdsByColumnIdFamilyState(columnId)) .getValue(); + const kanbanFieldMetadataName = snapshot + .getLoadable(kanbanFieldMetadataNameState) + .getValue(); + + if (!kanbanFieldMetadataName) { + return; + } + const columnRecordIds = records - .filter((record) => record.stage === column?.value) + .filter( + (record) => record[kanbanFieldMetadataName] === column?.value, + ) .sort(sortRecordsByPosition) .map((record) => record.id); @@ -36,7 +47,12 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { } }); }, - [columnsFamilySelector, columnIdsState, recordIdsByColumnIdFamilyState], + [ + columnIdsState, + columnsFamilySelector, + recordIdsByColumnIdFamilyState, + kanbanFieldMetadataNameState, + ], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts index f6a266d4746b..25a358e7ac91 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts @@ -12,12 +12,16 @@ export const useRecordBoard = (recordBoardId?: string) => { selectedRecordIdsSelector, isCompactModeActiveState, onFetchMoreVisibilityChangeState, + kanbanFieldMetadataNameState, } = useRecordBoardStates(recordBoardId); const { setColumns } = useSetRecordBoardColumns(recordBoardId); const { setRecordIds } = useSetRecordBoardRecordIds(recordBoardId); const setFieldDefinitions = useSetRecoilState(fieldDefinitionsState); const setObjectSingularName = useSetRecoilState(objectSingularNameState); + const setKanbanFieldMetadataName = useSetRecoilState( + kanbanFieldMetadataNameState, + ); return { scopeId, @@ -25,6 +29,7 @@ export const useRecordBoard = (recordBoardId?: string) => { setRecordIds, setFieldDefinitions, setObjectSingularName, + setKanbanFieldMetadataName, selectedRecordIdsSelector, isCompactModeActiveState, onFetchMoreVisibilityChangeState, diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState.ts new file mode 100644 index 000000000000..26490c9298b3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardKanbanFieldMetadataNameComponentState = + createComponentState({ + key: 'recordBoardKanbanFieldMetadataNameComponentState', + defaultValue: undefined, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx index 77fa108e15ed..7756c1ef678b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx @@ -66,6 +66,10 @@ export const FullNameFieldInput = ({ setDraftValue(convertToFullName(newDoubleText)); }; + const handlePaste = (newDoubleText: FieldDoubleText) => { + setDraftValue(convertToFullName(newDoubleText)); + }; + return ( 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 74f8bbe88461..7f228198369d 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 @@ -69,6 +69,11 @@ export type FieldRatingMetadata = { fieldName: string; }; +export type FieldRawJsonMetadata = { + objectMetadataNameSingular?: string; + fieldName: string; +}; + export type FieldDefinitionRelationType = | 'FROM_MANY_OBJECTS' | 'FROM_ONE_OBJECT' diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts index e09af18f81cb..d59a6bb84d67 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts @@ -17,4 +17,5 @@ export type FieldType = | 'URL' | 'UUID' | 'MULTI_SELECT' - | 'NUMERIC'; + | 'NUMERIC' + | 'RAW_JSON'; 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 86f1922cbeea..0cf44a152c3e 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 @@ -10,6 +10,7 @@ import { FieldNumberMetadata, FieldPhoneMetadata, FieldRatingMetadata, + FieldRawJsonMetadata, FieldRelationMetadata, FieldSelectMetadata, FieldTextMetadata, @@ -47,7 +48,9 @@ type AssertFieldMetadataFunction = < ? FieldTextMetadata : E extends 'UUID' ? FieldUuidMetadata - : never, + : E extends 'RAW_JSON' + ? FieldRawJsonMetadata + : never, >( fieldType: E, fieldTypeGuard: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJson.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJson.ts new file mode 100644 index 000000000000..3decadfb864a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJson.ts @@ -0,0 +1,6 @@ +import { FieldDefinition } from '../FieldDefinition'; +import { FieldMetadata, FieldRawJsonMetadata } from '../FieldMetadata'; + +export const isFieldRawJson = ( + field: Pick, 'type'>, +): field is FieldDefinition => field.type === 'RAW_JSON'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx index 3e7fff5bd0e7..df517ccbe8f4 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx @@ -8,7 +8,10 @@ import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoar import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { useLoadRecordIndexBoard } from '@/object-record/record-index/hooks/useLoadRecordIndexBoard'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; +import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; type RecordIndexBoardContainerEffectProps = { objectNameSingular: string; @@ -31,6 +34,7 @@ export const RecordIndexBoardContainerEffect = ({ selectedRecordIdsSelector, setFieldDefinitions, onFetchMoreVisibilityChangeState, + setKanbanFieldMetadataName, } = useRecordBoard(recordBoardId); const { fetchMoreRecords, loading } = useLoadRecordIndexBoard({ @@ -43,6 +47,10 @@ export const RecordIndexBoardContainerEffect = ({ onFetchMoreVisibilityChangeState, ); + const recordIndexKanbanFieldMetadataId = useRecoilValue( + recordIndexKanbanFieldMetadataIdState, + ); + useEffect(() => { setOnFetchMoreVisibilityChange(() => () => { if (!loading) { @@ -67,6 +75,7 @@ export const RecordIndexBoardContainerEffect = ({ setColumns( computeRecordBoardColumnDefinitionsFromObjectMetadata( objectMetadataItem, + recordIndexKanbanFieldMetadataId ?? '', navigateToSelectSettings, ), ); @@ -74,6 +83,7 @@ export const RecordIndexBoardContainerEffect = ({ navigateToSelectSettings, objectMetadataItem, objectNameSingular, + recordIndexKanbanFieldMetadataId, setColumns, ]); @@ -85,6 +95,24 @@ export const RecordIndexBoardContainerEffect = ({ setFieldDefinitions(recordIndexFieldDefinitions); }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); + useEffect(() => { + if (isDefined(recordIndexKanbanFieldMetadataId)) { + const kanbanFieldMetadataName = objectMetadataItem?.fields.find( + (field) => + field.type === FieldMetadataType.Select && + field.id === recordIndexKanbanFieldMetadataId, + )?.name; + + if (isDefined(kanbanFieldMetadataName)) { + setKanbanFieldMetadataName(kanbanFieldMetadataName); + } + } + }, [ + objectMetadataItem, + recordIndexKanbanFieldMetadataId, + setKanbanFieldMetadataName, + ]); + const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 1d9e89b250d0..3fceb3f2cce3 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -10,10 +10,10 @@ import { RecordIndexTableContainer } from '@/object-record/record-index/componen import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; +import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; @@ -65,6 +65,9 @@ export const RecordIndexContainer = ({ const setRecordIndexIsCompactModeActive = useSetRecoilState( recordIndexIsCompactModeActiveState, ); + const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState( + recordIndexKanbanFieldMetadataIdState, + ); const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({ recordTableId: recordIndexId, @@ -129,9 +132,11 @@ export const RecordIndexContainer = ({ mapViewSortsToSorts(view.viewSorts, sortDefinitions), ); setRecordIndexViewType(view.type); + setRecordIndexViewKanbanFieldMetadataIdState( + view.kanbanFieldMetadataId, + ); setRecordIndexIsCompactModeActive(view.isCompact); }} - optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} /> { - const { setViewEditMode } = useViewBarEditMode(recordIndexId); - return ( } - onClickOutside={() => setViewEditMode('none')} /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index 19f1e2594736..9d48f624c2cb 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -1,6 +1,5 @@ -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { Key } from 'ts-key-enum'; -import { v4 } from 'uuid'; import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; @@ -14,7 +13,6 @@ import { IconTag, } from '@/ui/display/icon'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; @@ -23,8 +21,6 @@ import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemTog import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; -import { useHandleViews } from '@/views/hooks/useHandleViews'; -import { useViewBarEditMode } from '@/views/hooks/useViewBarEditMode'; import { ViewType } from '@/views/types/ViewType'; type RecordIndexOptionsMenu = 'fields'; @@ -40,9 +36,6 @@ export const RecordIndexOptionsDropdownContent = ({ recordIndexId, objectNameSingular, }: RecordIndexOptionsDropdownContentProps) => { - const { updateCurrentView, createEmptyView, selectView } = - useHandleViews(recordIndexId); - const { viewEditMode, setViewEditMode } = useViewBarEditMode(recordIndexId); const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); @@ -53,8 +46,6 @@ export const RecordIndexOptionsDropdownContent = ({ const resetMenu = () => setCurrentMenu(undefined); - const viewEditInputRef = useRef(null); - const handleSelectMenu = (option: RecordIndexOptionsMenu) => { setCurrentMenu(option); }; @@ -67,25 +58,6 @@ export const RecordIndexOptionsDropdownContent = ({ TableOptionsHotkeyScope.Dropdown, ); - useScopedHotkeys( - Key.Enter, - async () => { - const name = viewEditInputRef.current?.value; - if (viewEditMode === 'create') { - const id = v4(); - await createEmptyView(id, name ?? ''); - selectView(id); - } else { - updateCurrentView({ name }); - } - - resetMenu(); - setViewEditMode('none'); - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - const { handleColumnVisibilityChange, handleReorderColumns, @@ -128,37 +100,18 @@ export const RecordIndexOptionsDropdownContent = ({ return ( <> {!currentMenu && ( - <> - + handleSelectMenu('fields')} + LeftIcon={IconTag} + text="Fields" /> - - - handleSelectMenu('fields')} - LeftIcon={IconTag} - text="Fields" - /> - openRecordSpreadsheetImport()} - LeftIcon={IconFileImport} - text="Import" - /> - - + openRecordSpreadsheetImport()} + LeftIcon={IconFileImport} + text="Import" + /> + )} {currentMenu === 'fields' && ( <> diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState.ts new file mode 100644 index 000000000000..5ff8ba4feb72 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState.ts @@ -0,0 +1,8 @@ +import { createState } from '@/ui/utilities/state/utils/createState'; + +export const recordIndexKanbanFieldMetadataIdState = createState( + { + key: 'recordIndexKanbanFieldMetadataIdState', + defaultValue: null, + }, +); diff --git a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts index 5af6540aecbb..8e70a4e5c0cb 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts @@ -5,10 +5,13 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export const computeRecordBoardColumnDefinitionsFromObjectMetadata = ( objectMetadataItem: ObjectMetadataItem, + kanbanFieldMetadataId: string, navigateToSelectSettings: () => void, ): RecordBoardColumnDefinition[] => { const selectFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.type === FieldMetadataType.Select, + (field) => + field.id === kanbanFieldMetadataId && + field.type === FieldMetadataType.Select, ); if (!selectFieldMetadataItem) { diff --git a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts index 176ccbad2867..bb7cc3578b3d 100644 --- a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts +++ b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts @@ -16,12 +16,5 @@ export const filterAvailableTableColumns = ( return false; } - if ( - isFieldRelation(columnDefinition) && - columnDefinition.metadata?.fieldName === 'pipelineStep' - ) { - return false; - } - return true; }; diff --git a/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts b/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts index 39201e66a8fc..cb1daf98147c 100644 --- a/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts @@ -24,7 +24,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { @@ -49,7 +49,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx index 447ca98aa402..699b82af7bff 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx @@ -1,7 +1,6 @@ import styled from '@emotion/styled'; import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; import { SignInBackgroundMockContainerEffect } from '@/sign-in-background-mock/components/SignInBackgroundMockContainerEffect'; import { ViewBar } from '@/views/components/ViewBar'; @@ -32,7 +31,6 @@ export const SignInBackgroundMockContainer = () => { viewType={ViewType.Table} /> } - optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} /> void; onChange?: (newDoubleTextValue: FieldDoubleText) => void; + onPaste?: (newDoubleTextValue: FieldDoubleText) => void; }; export const DoubleTextInput = ({ @@ -53,6 +60,7 @@ export const DoubleTextInput = ({ onShiftTab, onTab, onChange, + onPaste, }: DoubleTextInputProps) => { const [firstInternalValue, setFirstInternalValue] = useState(firstValue); const [secondInternalValue, setSecondInternalValue] = useState(secondValue); @@ -150,6 +158,20 @@ export const DoubleTextInput = ({ enabled: isDefined(onClickOutside), }); + const handleOnPaste = (event: ClipboardEvent) => { + if (firstInternalValue.length > 0 || secondInternalValue.length > 0) { + return; + } + + event.preventDefault(); + + const name = event.clipboardData.getData('Text'); + + const splittedName = name.split(' '); + + onPaste?.({ firstValue: splittedName[0], secondValue: splittedName[1] }); + }; + return ( ) => { handleChange(event.target.value, secondInternalValue); }} + onPaste={(event: ClipboardEvent) => + handleOnPaste(event) + } /> ) => void; @@ -28,7 +29,13 @@ export type ButtonProps = { const StyledButton = styled.button< Pick< ButtonProps, - 'fullWidth' | 'variant' | 'size' | 'position' | 'accent' | 'focus' + | 'fullWidth' + | 'variant' + | 'size' + | 'position' + | 'accent' + | 'focus' + | 'justify' > >` align-items: center; @@ -177,9 +184,7 @@ const StyledButton = styled.button< `; case 'danger': return css` - background: ${!disabled - ? theme.background.transparent.primary - : 'transparent'}; + background: transparent; border-color: ${variant === 'secondary' ? focus ? theme.color.red @@ -236,6 +241,7 @@ const StyledButton = styled.button< font-weight: 500; gap: ${({ theme }) => theme.spacing(1)}; height: ${({ size }) => (size === 'small' ? '24px' : '32px')}; + justify-content: ${({ justify }) => justify}; padding: ${({ theme }) => { return `0 ${theme.spacing(2)}`; }}; @@ -266,6 +272,7 @@ export const Button = ({ position = 'standalone', soon = false, disabled = false, + justify = 'flex-start', focus = false, onClick, }: ButtonProps) => { @@ -279,6 +286,7 @@ export const Button = ({ position={position} disabled={soon || disabled} focus={focus} + justify={justify} accent={accent} className={className} onClick={onClick} diff --git a/packages/twenty-front/src/modules/ui/input/button/components/LightIconButtonGroup.tsx b/packages/twenty-front/src/modules/ui/input/button/components/LightIconButtonGroup.tsx index ff548e5df72c..adb20d64bcac 100644 --- a/packages/twenty-front/src/modules/ui/input/button/components/LightIconButtonGroup.tsx +++ b/packages/twenty-front/src/modules/ui/input/button/components/LightIconButtonGroup.tsx @@ -31,6 +31,7 @@ export const LightIconButtonGroup = ({ diff --git a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx index 2c4c195164e7..ebc209d63022 100644 --- a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx @@ -30,6 +30,7 @@ type IconPickerProps = { onOpen?: () => void; variant?: IconButtonVariant; className?: string; + disableBlur?: boolean; }; const StyledMenuIconItemsContainer = styled.div` @@ -86,6 +87,7 @@ export const IconPicker = ({ onClose, onOpen, variant = 'secondary', + disableBlur = false, className, }: IconPickerProps) => { const [searchString, setSearchString] = useState(''); @@ -148,6 +150,7 @@ export const IconPicker = ({ /> } dropdownMenuWidth={176} + disableBlur={disableBlur} dropdownComponents={ = { export type SelectProps = { className?: string; disabled?: boolean; + disableBlur?: boolean; dropdownId: string; dropdownWidth?: `${string}px` | 'auto' | number; emptyOption?: SelectOption; @@ -75,6 +76,7 @@ const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>` export const Select = ({ className, disabled: disabledFromProps, + disableBlur = false, dropdownId, dropdownWidth = 176, emptyOption, @@ -141,6 +143,7 @@ export const Select = ({ dropdownMenuWidth={dropdownWidth} dropdownPlacement="bottom-start" clickableComponent={selectControl} + disableBlur={disableBlur} dropdownComponents={ <> {!!withSearchInput && ( diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx index 6d303a54cc31..88d8479be24c 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/Dropdown.tsx @@ -35,6 +35,7 @@ type DropdownProps = { dropdownPlacement?: Placement; dropdownMenuWidth?: `${string}px` | `${number}%` | 'auto' | number; dropdownOffset?: { x?: number; y?: number }; + disableBlur?: boolean; onClickOutside?: () => void; onClose?: () => void; onOpen?: () => void; @@ -50,6 +51,7 @@ export const Dropdown = ({ dropdownHotkeyScope, dropdownPlacement = 'bottom-end', dropdownOffset = { x: 0, y: 0 }, + disableBlur = false, onClickOutside, onClose, onOpen, @@ -109,7 +111,10 @@ export const Dropdown = ({ {clickableComponent && (
{ + toggleDropdown(); + onClickOutside?.(); + }} className={className} > {clickableComponent} @@ -123,6 +128,7 @@ export const Dropdown = ({ )} {isDropdownOpen && ( theme.background.transparent.forBackdropFilter}; + background: ${({ theme, disableBlur }) => + disableBlur + ? theme.background.primary + : theme.background.transparent.secondary}; border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.md}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader.tsx index 68446f9a945b..3c652c86e902 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuHeader.tsx @@ -11,14 +11,11 @@ const StyledHeader = styled.li` display: flex; font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; padding: ${({ theme }) => theme.spacing(1)}; user-select: none; - - &:hover { - background: ${({ theme }) => theme.background.transparent.light}; - } `; const StyledChildrenWrapper = styled.span` @@ -46,9 +43,10 @@ export const DropdownMenuHeader = ({ testId, }: DropdownMenuHeaderProps) => { return ( - + {StartIcon && ( {children} {EndIcon && ( ->(({ autoFocus, defaultValue, placeholder, onChange }, ref) => { +>(({ autoFocus, value, placeholder, onChange }, ref) => { return ( - + {optionsMock.map(({ name }) => ( diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenuInput.stories.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenuInput.stories.tsx index 21e5d765399a..42838fca3b4d 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenuInput.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/__stories__/DropdownMenuInput.stories.tsx @@ -8,7 +8,7 @@ const meta: Meta = { title: 'UI/Layout/Dropdown/DropdownMenuInput', component: DropdownMenuInput, decorators: [ComponentDecorator], - args: { defaultValue: 'Lorem ipsum' }, + args: { value: 'Lorem ipsum' }, }; export default meta; diff --git a/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx b/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx index e71955d62bad..515a27bfe0b1 100644 --- a/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx +++ b/packages/twenty-front/src/modules/ui/layout/modal/components/ConfirmationModal.tsx @@ -7,7 +7,7 @@ import { H1Title, H1TitleFontColor, } from '@/ui/display/typography/components/H1Title'; -import { Button } from '@/ui/input/button/components/Button'; +import { Button, ButtonAccent } from '@/ui/input/button/components/Button'; import { TextInput } from '@/ui/input/components/TextInput'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { @@ -25,6 +25,7 @@ export type ConfirmationModalProps = { deleteButtonText?: string; confirmationPlaceholder?: string; confirmationValue?: string; + confirmButtonAccent?: ButtonAccent; }; const StyledConfirmationModal = styled(Modal)` @@ -66,6 +67,7 @@ export const ConfirmationModal = ({ deleteButtonText = 'Delete', confirmationValue, confirmationPlaceholder, + confirmButtonAccent = 'danger', }: ConfirmationModalProps) => { const [inputConfirmationValue, setInputConfirmationValue] = useState(''); @@ -127,7 +129,7 @@ export const ConfirmationModal = ({ setIsOpen(false); }} variant="secondary" - accent="danger" + accent={confirmButtonAccent} title={deleteButtonText} disabled={!isValidValue} fullWidth 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 390e5e9e1020..001904181cf3 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 @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { Calendar } from '@/activities/calendar/components/Calendar'; import { EmailThreads } from '@/activities/emails/components/EmailThreads'; +import { Events } from '@/activities/events/components/Events'; import { Attachments } from '@/activities/files/components/Attachments'; import { Notes } from '@/activities/notes/components/Notes'; import { ObjectTasks } from '@/activities/tasks/components/ObjectTasks'; @@ -65,6 +66,8 @@ export const ShowPageRightContainer = ({ const activeTabId = useRecoilValue(activeTabIdState); const shouldDisplayCalendarTab = useIsFeatureEnabled('IS_CALENDAR_ENABLED'); + const shouldDisplayLogTab = useIsFeatureEnabled('IS_EVENT_OBJECT_ENABLED'); + const shouldDisplayEmailsTab = (emails && targetableObject.targetObjectNameSingular === @@ -101,7 +104,6 @@ export const ShowPageRightContainer = ({ title: 'Emails', Icon: IconMail, hide: !shouldDisplayEmailsTab, - hasBetaPill: true, }, { id: 'calendar', @@ -109,6 +111,13 @@ export const ShowPageRightContainer = ({ Icon: IconCalendarEvent, hide: !shouldDisplayCalendarTab, }, + { + id: 'logs', + title: 'Logs', + Icon: IconTimelineEvent, + hide: !shouldDisplayLogTab, + hasBetaPill: true, + }, ]; return ( @@ -131,6 +140,7 @@ export const ShowPageRightContainer = ({ )} {activeTabId === 'emails' && } {activeTabId === 'calendar' && } + {activeTabId === 'logs' && } ); }; diff --git a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts index 5568ae49c464..03a6a06a4e61 100644 --- a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts +++ b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts @@ -37,6 +37,7 @@ export const GET_CURRENT_USER = gql` } currentBillingSubscription { status + interval } } workspaces { diff --git a/packages/twenty-front/src/modules/views/components/FilterQueryParamsEffect.tsx b/packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx similarity index 60% rename from packages/twenty-front/src/modules/views/components/FilterQueryParamsEffect.tsx rename to packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx index ad9d60800555..25278a77c9c4 100644 --- a/packages/twenty-front/src/modules/views/components/FilterQueryParamsEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx @@ -1,33 +1,24 @@ import { useEffect } from 'react'; -import { isUndefined } from '@sniptt/guards'; import { useSetRecoilState } from 'recoil'; import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; import { useViewStates } from '@/views/hooks/internal/useViewStates'; import { useResetCurrentView } from '@/views/hooks/useResetCurrentView'; -export const FilterQueryParamsEffect = () => { - const { hasFiltersQueryParams, getFiltersFromQueryParams, viewIdQueryParam } = +export const QueryParamsFiltersEffect = () => { + const { hasFiltersQueryParams, getFiltersFromQueryParams } = useViewFromQueryParams(); - const { unsavedToUpsertViewFiltersState, currentViewIdState } = - useViewStates(); + const { unsavedToUpsertViewFiltersState } = useViewStates(); const setUnsavedViewFilter = useSetRecoilState( unsavedToUpsertViewFiltersState, ); - const setCurrentViewId = useSetRecoilState(currentViewIdState); const { resetCurrentView } = useResetCurrentView(); useEffect(() => { - if (isUndefined(viewIdQueryParam) || !viewIdQueryParam) { + if (!hasFiltersQueryParams) { return; } - setCurrentViewId(viewIdQueryParam); - }, [getFiltersFromQueryParams, setCurrentViewId, viewIdQueryParam]); - - useEffect(() => { - if (!hasFiltersQueryParams) return; - getFiltersFromQueryParams().then((filtersFromParams) => { if (Array.isArray(filtersFromParams)) { setUnsavedViewFilter(filtersFromParams); @@ -44,5 +35,5 @@ export const FilterQueryParamsEffect = () => { setUnsavedViewFilter, ]); - return null; + return <>; }; diff --git a/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx b/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx new file mode 100644 index 000000000000..a7130fb3bf17 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { isUndefined } from '@sniptt/guards'; +import { useRecoilState } from 'recoil'; + +import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; +import { useViewStates } from '@/views/hooks/internal/useViewStates'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; +import { isDefined } from '~/utils/isDefined'; + +export const QueryParamsViewIdEffect = () => { + const { getFiltersFromQueryParams, viewIdQueryParam } = + useViewFromQueryParams(); + const { currentViewIdState } = useViewStates(); + + const [currentViewId, setCurrentViewId] = useRecoilState(currentViewIdState); + const { viewsOnCurrentObject } = useGetCurrentView(); + + useEffect(() => { + const indexView = viewsOnCurrentObject.find((view) => view.key === 'INDEX'); + + if (isUndefined(viewIdQueryParam) && isDefined(indexView)) { + setCurrentViewId(indexView.id); + return; + } + + if (isDefined(viewIdQueryParam)) { + setCurrentViewId(viewIdQueryParam); + } + }, [ + currentViewId, + getFiltersFromQueryParams, + setCurrentViewId, + viewIdQueryParam, + viewsOnCurrentObject, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index d4dac54f45a4..0bc6c43164f4 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -7,14 +7,18 @@ import { Button } from '@/ui/input/button/components/Button'; import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; -import { UPDATE_VIEW_DROPDOWN_ID } from '@/views/constants/UpdateViewDropdownId'; +import { UPDATE_VIEW_BUTTON_DROPDOWN_ID } from '@/views/constants/UpdateViewButtonDropdownId'; import { useViewStates } from '@/views/hooks/internal/useViewStates'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { useSaveCurrentViewFiltersAndSorts } from '@/views/hooks/useSaveCurrentViewFiltersAndSorts'; +import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId'; +import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; +import { useViewPickerStates } from '@/views/view-picker/hooks/useViewPickerStates'; const StyledContainer = styled.div` - background: ${({ theme }) => theme.color.blue}; border-radius: ${({ theme }) => theme.border.radius.md}; display: inline-flex; margin-right: ${({ theme }) => theme.spacing(2)}; @@ -23,25 +27,50 @@ const StyledContainer = styled.div` export type UpdateViewButtonGroupProps = { hotkeyScope: HotkeyScope; - onViewEditModeChange?: () => void; }; export const UpdateViewButtonGroup = ({ hotkeyScope, - onViewEditModeChange, }: UpdateViewButtonGroupProps) => { - const { canPersistViewSelector, viewEditModeState } = useViewStates(); + const { canPersistViewSelector, currentViewIdState } = useViewStates(); const { saveCurrentViewFilterAndSorts } = useSaveCurrentViewFiltersAndSorts(); - const setViewEditMode = useSetRecoilState(viewEditModeState); + const { setViewPickerMode } = useViewPickerMode(); + const { viewPickerReferenceViewIdState } = useViewPickerStates(); const canPersistView = useRecoilValue(canPersistViewSelector()); - const handleCreateViewButtonClick = useCallback(() => { - setViewEditMode('create'); - onViewEditModeChange?.(); - }, [setViewEditMode, onViewEditModeChange]); + const { closeDropdown: closeUpdateViewButtonDropdown } = useDropdown( + UPDATE_VIEW_BUTTON_DROPDOWN_ID, + ); + const { openDropdown: openViewPickerDropdown } = useDropdown( + VIEW_PICKER_DROPDOWN_ID, + ); + const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + + const currentViewId = useRecoilValue(currentViewIdState); + + const setViewPickerReferenceViewId = useSetRecoilState( + viewPickerReferenceViewIdState, + ); + + const handleViewCreate = useCallback(() => { + if (!currentViewId) { + return; + } + openViewPickerDropdown(); + setViewPickerReferenceViewId(currentViewId); + setViewPickerMode('create'); + + closeUpdateViewButtonDropdown(); + }, [ + closeUpdateViewButtonDropdown, + currentViewId, + openViewPickerDropdown, + setViewPickerMode, + setViewPickerReferenceViewId, + ]); - const handleViewSubmit = async () => { + const handleViewUpdate = async () => { await saveCurrentViewFilterAndSorts(); }; @@ -51,27 +80,42 @@ export const UpdateViewButtonGroup = ({ return ( - -