Compare commits

...
18 Commits
Author SHA1 Message Date
Weiko 218cb798e6 translate = no 2025-02-26 18:03:51 +01:00
Weiko a364692925 Bump version 2025-02-26 18:02:35 +01:00
Weiko 1991f0d3c0 Fix REST API not using metadata cache (#10521) 2025-02-26 18:00:07 +01:00
Charles Bochet ce428985aa Bump version 2025-02-21 18:37:03 +01:00
Charles BochetandGitHub 2039986684 Fix error messages on sign up (#10399)
In this PR:
- adding logs to track workspace creation performance
- refactor useIsWorkspaceSuspended to be more generic
- only fetch favorites and views if workspace is Active to avoid error
messages on sign up (workspace is not created yet)
2025-02-21 18:34:40 +01:00
MarieandGitHub 1ed9de2300 [permission] (fix ) use SettingsPermissions and not SettingsFeatures (#10398)
oops
2025-02-21 17:29:01 +01:00
MarieandGitHub ee2810281e [permissions] Add permission gates on workspace-invitations (#10394)
Adding permission gates on all workspace-invitations endpoints:
sendInvitation, resendInvitation, deleteWorkspaceInvitation,
findWorkspaceInvitations (the latter being from my understanding only
used to list the invitations to then re-send them or detee them).

+ tests on Api & webhooks permission gates
2025-02-21 17:26:01 +01:00
MarieandGitHub 50bd91262f [permissions] Rename enum SettingsFeatures --> SettingsPermissions (#10389) 2025-02-21 16:04:30 +00:00
bf92860d19 feat(twenty-server): add trusted domain - backend crud (#10290)
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
2025-02-21 16:02:48 +00:00
Lucas BordeauandGitHub 22203bfd3c Removed sort definitions (#10368)
This PR focuses on complete removal of sort definitions.
2025-02-21 16:59:38 +01:00
Charles BochetandGitHub d747366bf3 Provide a wrapper to execute command on workspace with easier devXP (#10391)
Proposal:
- Add a method in ActiveWorkspaceCommand to loop over workspace safely
(add counter, add try / catch, provide datasource with fresh cache,
destroy datasource => as we do always do it)

Also in this PR:
- make sure we clear all dataSources (and not only the one on metadata
version in RAM)
2025-02-21 16:40:33 +01:00
Raphaël BosiandGitHub 7a3e92fe0b Fix command menu selectable items ordering (#10392)
The order of the selectableItems was not the same as the command groups
so the navigation with arrow keys inside the command menu was broken.

Before:


https://github.com/user-attachments/assets/a3487b58-9c26-4522-9e7b-584d30396a21


After:


https://github.com/user-attachments/assets/bd60263b-b4e2-4d41-bad1-eef9115caec6
2025-02-21 16:05:31 +01:00
Félix MalfaitandGitHub ba51c091f0 Improve i18n CI (#10390) 2025-02-21 15:42:01 +01:00
Raphaël BosiandGitHub f269f8b905 Fix autogrow input whitespace bug (#10388)
Also fixed chip alignment.

Before:


https://github.com/user-attachments/assets/7bfe7c73-676a-4dee-b5df-fed15b663267

After:


https://github.com/user-attachments/assets/9060b3cc-051b-41f7-8149-418ed1c223cb
2025-02-21 15:37:07 +01:00
MarieandGitHub 6fb81e757b [permissions] Add conditional permission gate on billing's checkoutSession (#10387)
Following a conversation with @etiennejouan and @martmull, we are adding
a permission gate on billing resolver's checkoutSession, which should
only be accessible to entitled users or at workspace creation (when
there are no roles yet), when the subscription is incomplete
2025-02-21 14:01:36 +00:00
Raphaël BosiandGitHub 0571eb2cf6 Only display record actions inside the RecordIndexActionMenuDropdown (#10384)
In the action menu dropdown, we only want to display the actions for the
selected records, so we need to filter available actions according to
their type and scope.
Note: the `Search record` action was appearing twice because one is a
standard action and one is a fallback action and both are registered in
the available actions. We don't want to display this action because it
isn't related to the selected records, it is a global action.

Before:
<img width="226" alt="Capture d’écran 2025-02-21 à 12 12 53"
src="https://github.com/user-attachments/assets/0a4f9b8c-4e30-4697-9861-559e76dc288f"
/>

After:
<img width="241" alt="Capture d’écran 2025-02-21 à 12 10 34"
src="https://github.com/user-attachments/assets/0a98c9d5-432d-4018-a4d3-bf3a6d0c49be"
/>
2025-02-21 12:29:41 +01:00
EtienneandGitHub 5863c45d4b cleaning workspace job - fix on soft delete condition (#10380)
Context
If the command runs multiple times, soft deleted workspaces are soft
deleted again (+ email spamming)

Solution
Check for soft deletion before entering soft delete condition
2025-02-21 12:12:08 +01:00
WeikoandGitHub 29d079babc [FE] Improve Read-only field behavior (#10382)
Fixes https://github.com/twentyhq/core-team-issues/issues/235
2025-02-21 12:09:52 +01:00
189 changed files with 3218 additions and 2160 deletions
+8 -13
View File
@@ -1,7 +1,7 @@
# Pull down translations from Crowdin every two hours or when triggered manually.
# When force_pull input is true, translations will be pulled regardless of compilation status.
name: 'Pull translations'
name: 'Pull translations from Crowdin'
on:
schedule:
@@ -53,7 +53,7 @@ jobs:
# Strict mode fails if there are missing translations.
- name: Compile translations
id: compile_translations
id: compile_translations_strict
run: |
npx nx run twenty-server:lingui:compile --strict
npx nx run twenty-emails:lingui:compile --strict
@@ -68,7 +68,7 @@ jobs:
git stash
- name: Pull translations from Crowdin
if: inputs.force_pull || steps.compile_translations.outcome == 'failure'
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@v2
with:
upload_sources: false
@@ -98,18 +98,13 @@ jobs:
run: sudo chown -R runner:docker .
- name: Compile translations
if: inputs.force_pull || steps.compile_translations.outcome == 'failure'
id: compile_translations
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
run: |
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
- name: Debug git status
run: git status
- name: Check and commit compiled files
id: check_changes
run: |
git status
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add .
@@ -121,11 +116,11 @@ jobs:
fi
- name: Push changes
if: steps.check_changes.outputs.changes_detected == 'true'
if: steps.compile_translations.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n
- name: Create pull request
if: steps.check_changes.outputs.changes_detected == 'true'
if: steps.compile_translations.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n --title 'i18n - translations' --body 'Created by Github action' || true
+2 -1
View File
@@ -1,4 +1,4 @@
name: 'Extract translations when there is a push to main, and upload them to Crowdin'
name: 'Push translations to Crowdin'
on:
workflow_dispatch:
@@ -16,6 +16,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-e2e-testing",
"version": "0.42.0-canary",
"version": "0.42.16",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.42.0-canary",
"version": "0.42.16",
"description": "",
"author": "",
"private": true,
@@ -0,0 +1,67 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Img } from '@react-email/components';
import { emailTheme } from 'src/common-style';
import { BaseEmail } from 'src/components/BaseEmail';
import { CallToAction } from 'src/components/CallToAction';
import { HighlightedContainer } from 'src/components/HighlightedContainer';
import { HighlightedText } from 'src/components/HighlightedText';
import { Link } from 'src/components/Link';
import { MainText } from 'src/components/MainText';
import { Title } from 'src/components/Title';
import { WhatIsTwenty } from 'src/components/WhatIsTwenty';
import { capitalize } from 'src/utils/capitalize';
import { APP_LOCALES, getImageAbsoluteURI } from 'twenty-shared';
type SendApprovedAccessDomainValidationProps = {
link: string;
domain: string;
workspace: { name: string | undefined; logo: string | undefined };
sender: {
email: string;
firstName: string;
lastName: string;
};
serverUrl: string;
locale: keyof typeof APP_LOCALES;
};
export const SendApprovedAccessDomainValidation = ({
link,
domain,
workspace,
sender,
serverUrl,
locale,
}: SendApprovedAccessDomainValidationProps) => {
const workspaceLogo = workspace.logo
? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl })
: null;
return (
<BaseEmail width={333} locale={locale}>
<Title value={t`Validate domain`} />
<MainText>
{capitalize(sender.firstName)} (
<Link
href={`mailto:${sender.email}`}
value={sender.email}
color={emailTheme.font.colors.blue}
/>
)
<Trans>
Please validate this domain to allow users with <b>@{domain}</b> email
addresses to join your workspace without requiring an invitation.
</Trans>
<br />
</MainText>
<HighlightedContainer>
{workspaceLogo && <Img src={workspaceLogo} width={40} height={40} />}
{workspace.name && <HighlightedText value={workspace.name} />}
<CallToAction href={link} value={t`Validate domain`} />
</HighlightedContainer>
<WhatIsTwenty />
</BaseEmail>
);
};
+1
View File
@@ -4,3 +4,4 @@ export * from './emails/password-update-notify.email';
export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email';
export * from './emails/warn-suspended-workspace.email';
export * from './emails/validate-approved-access-domain.email';
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.42.0-canary",
"version": "0.42.16",
"private": true,
"type": "module",
"scripts": {
@@ -102,6 +102,14 @@ export type AppTokenEdge = {
node: AppToken;
};
export type ApprovedAccessDomain = {
__typename?: 'ApprovedAccessDomain';
createdAt: Scalars['DateTime']['output'];
domain: Scalars['String']['output'];
id: Scalars['UUID']['output'];
isValidated: Scalars['Boolean']['output'];
};
export type AuthProviders = {
__typename?: 'AuthProviders';
google: Scalars['Boolean']['output'];
@@ -300,6 +308,11 @@ export type CreateAppTokenInput = {
expiresAt: Scalars['DateTime']['input'];
};
export type CreateApprovedAccessDomainInput = {
domain: Scalars['String']['input'];
email: Scalars['String']['input'];
};
export type CreateDraftFromWorkflowVersionInput = {
/** Workflow ID */
workflowId: Scalars['String']['input'];
@@ -423,6 +436,10 @@ export type CustomDomainValidRecords = {
records: Array<CustomDomainRecord>;
};
export type DeleteApprovedAccessDomainInput = {
id: Scalars['String']['input'];
};
export type DeleteOneFieldInput = {
/** The id of the field to delete. */
id: Scalars['UUID']['input'];
@@ -545,6 +562,7 @@ export enum FeatureFlagKey {
IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled',
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
IsBillingPlansEnabled = 'IsBillingPlansEnabled',
IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled',
IsCopilotEnabled = 'IsCopilotEnabled',
@@ -833,6 +851,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON']['output'];
createApprovedAccessDomain: ApprovedAccessDomain;
createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken;
@@ -844,6 +863,7 @@ export type Mutation = {
createSAMLIdentityProvider: SetupSsoOutput;
createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean']['output'];
deleteApprovedAccessDomain: Scalars['Boolean']['output'];
deleteCurrentWorkspace: Workspace;
deleteOneField: Field;
deleteOneObject: Object;
@@ -894,6 +914,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String']['output'];
uploadWorkspaceLogo: Scalars['String']['output'];
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
};
@@ -927,6 +948,11 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateApprovedAccessDomainArgs = {
input: CreateApprovedAccessDomainInput;
};
export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput;
};
@@ -982,6 +1008,11 @@ export type MutationDeactivateWorkflowVersionArgs = {
};
export type MutationDeleteApprovedAccessDomainArgs = {
input: DeleteApprovedAccessDomainInput;
};
export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput;
};
@@ -1214,6 +1245,11 @@ export type MutationUserLookupAdminPanelArgs = {
userIdentifier: Scalars['String']['input'];
};
export type MutationValidateApprovedAccessDomainArgs = {
input: ValidateApprovedAccessDomainInput;
};
export type Object = {
__typename?: 'Object';
createdAt: Scalars['DateTime']['output'];
@@ -1382,6 +1418,7 @@ export type Query = {
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON']['output'];
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
@@ -1767,7 +1804,7 @@ export enum ServerlessFunctionSyncStatus {
READY = 'READY'
}
export enum SettingsFeatures {
export enum SettingsPermissions {
ADMIN_PANEL = 'ADMIN_PANEL',
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
DATA_MODEL = 'DATA_MODEL',
@@ -2093,7 +2130,7 @@ export type UserWorkspace = {
deletedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['UUID']['output'];
objectRecordsPermissions?: Maybe<Array<PermissionsOnAllObjectRecords>>;
settingsPermissions?: Maybe<Array<SettingsFeatures>>;
settingsPermissions?: Maybe<Array<SettingsPermissions>>;
updatedAt: Scalars['DateTime']['output'];
user: User;
userId: Scalars['String']['output'];
@@ -2101,6 +2138,11 @@ export type UserWorkspace = {
workspaceId: Scalars['String']['output'];
};
export type ValidateApprovedAccessDomainInput = {
approvedAccessDomainId: Scalars['String']['input'];
validationToken: Scalars['String']['input'];
};
export type ValidatePasswordResetToken = {
__typename?: 'ValidatePasswordResetToken';
email: Scalars['String']['output'];
+212 -4
View File
@@ -92,6 +92,14 @@ export type AppTokenEdge = {
node: AppToken;
};
export type ApprovedAccessDomain = {
__typename?: 'ApprovedAccessDomain';
createdAt: Scalars['DateTime'];
domain: Scalars['String'];
id: Scalars['UUID'];
isValidated: Scalars['Boolean'];
};
export type AuthProviders = {
__typename?: 'AuthProviders';
google: Scalars['Boolean'];
@@ -286,6 +294,11 @@ export type ComputeStepOutputSchemaInput = {
step: Scalars['JSON'];
};
export type CreateApprovedAccessDomainInput = {
domain: Scalars['String'];
email: Scalars['String'];
};
export type CreateDraftFromWorkflowVersionInput = {
/** Workflow ID */
workflowId: Scalars['String'];
@@ -357,6 +370,10 @@ export type CustomDomainValidRecords = {
records: Array<CustomDomainRecord>;
};
export type DeleteApprovedAccessDomainInput = {
id: Scalars['String'];
};
export type DeleteOneFieldInput = {
/** The id of the field to delete. */
id: Scalars['UUID'];
@@ -474,6 +491,7 @@ export enum FeatureFlagKey {
IsAdvancedFiltersEnabled = 'IsAdvancedFiltersEnabled',
IsAirtableIntegrationEnabled = 'IsAirtableIntegrationEnabled',
IsAnalyticsV2Enabled = 'IsAnalyticsV2Enabled',
IsApprovedAccessDomainsEnabled = 'IsApprovedAccessDomainsEnabled',
IsBillingPlansEnabled = 'IsBillingPlansEnabled',
IsCommandMenuV2Enabled = 'IsCommandMenuV2Enabled',
IsCopilotEnabled = 'IsCopilotEnabled',
@@ -762,6 +780,7 @@ export type Mutation = {
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createApprovedAccessDomain: ApprovedAccessDomain;
createDraftFromWorkflowVersion: WorkflowVersion;
createOIDCIdentityProvider: SetupSsoOutput;
createOneAppToken: AppToken;
@@ -771,6 +790,7 @@ export type Mutation = {
createSAMLIdentityProvider: SetupSsoOutput;
createWorkflowVersionStep: WorkflowAction;
deactivateWorkflowVersion: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCurrentWorkspace: Workspace;
deleteOneField: Field;
deleteOneObject: Object;
@@ -815,6 +835,7 @@ export type Mutation = {
uploadProfilePicture: Scalars['String'];
uploadWorkspaceLogo: Scalars['String'];
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
};
@@ -848,6 +869,11 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateApprovedAccessDomainArgs = {
input: CreateApprovedAccessDomainInput;
};
export type MutationCreateDraftFromWorkflowVersionArgs = {
input: CreateDraftFromWorkflowVersionInput;
};
@@ -883,6 +909,11 @@ export type MutationDeactivateWorkflowVersionArgs = {
};
export type MutationDeleteApprovedAccessDomainArgs = {
input: DeleteApprovedAccessDomainInput;
};
export type MutationDeleteOneFieldArgs = {
input: DeleteOneFieldInput;
};
@@ -1085,6 +1116,11 @@ export type MutationUserLookupAdminPanelArgs = {
userIdentifier: Scalars['String'];
};
export type MutationValidateApprovedAccessDomainArgs = {
input: ValidateApprovedAccessDomainInput;
};
export type Object = {
__typename?: 'Object';
createdAt: Scalars['DateTime'];
@@ -1250,6 +1286,7 @@ export type Query = {
findOneServerlessFunction: ServerlessFunction;
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAvailablePackages: Scalars['JSON'];
getEnvironmentVariablesGrouped: EnvironmentVariablesOutput;
getIndicatorHealthStatus: AdminPanelHealthServiceData;
@@ -1567,7 +1604,7 @@ export enum ServerlessFunctionSyncStatus {
READY = 'READY'
}
export enum SettingsFeatures {
export enum SettingsPermissions {
ADMIN_PANEL = 'ADMIN_PANEL',
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
DATA_MODEL = 'DATA_MODEL',
@@ -1879,7 +1916,7 @@ export type UserWorkspace = {
deletedAt?: Maybe<Scalars['DateTime']>;
id: Scalars['UUID'];
objectRecordsPermissions?: Maybe<Array<PermissionsOnAllObjectRecords>>;
settingsPermissions?: Maybe<Array<SettingsFeatures>>;
settingsPermissions?: Maybe<Array<SettingsPermissions>>;
updatedAt: Scalars['DateTime'];
user: User;
userId: Scalars['String'];
@@ -1887,6 +1924,11 @@ export type UserWorkspace = {
workspaceId: Scalars['String'];
};
export type ValidateApprovedAccessDomainInput = {
approvedAccessDomainId: Scalars['String'];
validationToken: Scalars['String'];
};
export type ValidatePasswordResetToken = {
__typename?: 'ValidatePasswordResetToken';
email: Scalars['String'];
@@ -2333,6 +2375,13 @@ export type GetRolesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, canUpdateAllSettings: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> }> };
export type CreateApprovedAccessDomainMutationVariables = Exact<{
input: CreateApprovedAccessDomainInput;
}>;
export type CreateApprovedAccessDomainMutation = { __typename?: 'Mutation', createApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, domain: string, isValidated: boolean, createdAt: string } };
export type CreateOidcIdentityProviderMutationVariables = Exact<{
input: SetupOidcSsoInput;
}>;
@@ -2347,6 +2396,13 @@ export type CreateSamlIdentityProviderMutationVariables = Exact<{
export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
export type DeleteApprovedAccessDomainMutationVariables = Exact<{
input: DeleteApprovedAccessDomainInput;
}>;
export type DeleteApprovedAccessDomainMutation = { __typename?: 'Mutation', deleteApprovedAccessDomain: boolean };
export type DeleteSsoIdentityProviderMutationVariables = Exact<{
input: DeleteSsoInput;
}>;
@@ -2361,12 +2417,24 @@ export type EditSsoIdentityProviderMutationVariables = Exact<{
export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdentityProviderType, issuer: string, name: string, status: SsoIdentityProviderStatus } };
export type ValidateApprovedAccessDomainMutationVariables = Exact<{
input: ValidateApprovedAccessDomainInput;
}>;
export type ValidateApprovedAccessDomainMutation = { __typename?: 'Mutation', validateApprovedAccessDomain: { __typename?: 'ApprovedAccessDomain', id: any, isValidated: boolean, domain: string, createdAt: string } };
export type GetApprovedAccessDomainsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetApprovedAccessDomainsQuery = { __typename?: 'Query', getApprovedAccessDomains: Array<{ __typename?: 'ApprovedAccessDomain', id: any, createdAt: string, domain: string, isValidated: boolean }> };
export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsFeatures> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsPermissions> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@@ -2383,7 +2451,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsFeatures> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', settingsPermissions?: Array<SettingsPermissions> | null, objectRecordsPermissions?: Array<PermissionsOnAllObjectRecords> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string, customDomain?: string | null, workspaceUrls: { __typename?: 'workspaceUrls', subdomainUrl: string, customUrl?: string | null } } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@@ -4230,6 +4298,42 @@ export function useGetRolesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<G
export type GetRolesQueryHookResult = ReturnType<typeof useGetRolesQuery>;
export type GetRolesLazyQueryHookResult = ReturnType<typeof useGetRolesLazyQuery>;
export type GetRolesQueryResult = Apollo.QueryResult<GetRolesQuery, GetRolesQueryVariables>;
export const CreateApprovedAccessDomainDocument = gql`
mutation CreateApprovedAccessDomain($input: CreateApprovedAccessDomainInput!) {
createApprovedAccessDomain(input: $input) {
id
domain
isValidated
createdAt
}
}
`;
export type CreateApprovedAccessDomainMutationFn = Apollo.MutationFunction<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>;
/**
* __useCreateApprovedAccessDomainMutation__
*
* To run a mutation, you first call `useCreateApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateApprovedAccessDomainMutation` 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 [createApprovedAccessDomainMutation, { data, loading, error }] = useCreateApprovedAccessDomainMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useCreateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>(CreateApprovedAccessDomainDocument, options);
}
export type CreateApprovedAccessDomainMutationHookResult = ReturnType<typeof useCreateApprovedAccessDomainMutation>;
export type CreateApprovedAccessDomainMutationResult = Apollo.MutationResult<CreateApprovedAccessDomainMutation>;
export type CreateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<CreateApprovedAccessDomainMutation, CreateApprovedAccessDomainMutationVariables>;
export const CreateOidcIdentityProviderDocument = gql`
mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) {
createOIDCIdentityProvider(input: $input) {
@@ -4304,6 +4408,37 @@ export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.Mutat
export type CreateSamlIdentityProviderMutationHookResult = ReturnType<typeof useCreateSamlIdentityProviderMutation>;
export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult<CreateSamlIdentityProviderMutation>;
export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions<CreateSamlIdentityProviderMutation, CreateSamlIdentityProviderMutationVariables>;
export const DeleteApprovedAccessDomainDocument = gql`
mutation DeleteApprovedAccessDomain($input: DeleteApprovedAccessDomainInput!) {
deleteApprovedAccessDomain(input: $input)
}
`;
export type DeleteApprovedAccessDomainMutationFn = Apollo.MutationFunction<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>;
/**
* __useDeleteApprovedAccessDomainMutation__
*
* To run a mutation, you first call `useDeleteApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteApprovedAccessDomainMutation` 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 [deleteApprovedAccessDomainMutation, { data, loading, error }] = useDeleteApprovedAccessDomainMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useDeleteApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>(DeleteApprovedAccessDomainDocument, options);
}
export type DeleteApprovedAccessDomainMutationHookResult = ReturnType<typeof useDeleteApprovedAccessDomainMutation>;
export type DeleteApprovedAccessDomainMutationResult = Apollo.MutationResult<DeleteApprovedAccessDomainMutation>;
export type DeleteApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<DeleteApprovedAccessDomainMutation, DeleteApprovedAccessDomainMutationVariables>;
export const DeleteSsoIdentityProviderDocument = gql`
mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) {
deleteSSOIdentityProvider(input: $input) {
@@ -4374,6 +4509,79 @@ export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.Mutation
export type EditSsoIdentityProviderMutationHookResult = ReturnType<typeof useEditSsoIdentityProviderMutation>;
export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult<EditSsoIdentityProviderMutation>;
export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions<EditSsoIdentityProviderMutation, EditSsoIdentityProviderMutationVariables>;
export const ValidateApprovedAccessDomainDocument = gql`
mutation ValidateApprovedAccessDomain($input: ValidateApprovedAccessDomainInput!) {
validateApprovedAccessDomain(input: $input) {
id
isValidated
domain
createdAt
}
}
`;
export type ValidateApprovedAccessDomainMutationFn = Apollo.MutationFunction<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>;
/**
* __useValidateApprovedAccessDomainMutation__
*
* To run a mutation, you first call `useValidateApprovedAccessDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useValidateApprovedAccessDomainMutation` 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 [validateApprovedAccessDomainMutation, { data, loading, error }] = useValidateApprovedAccessDomainMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useValidateApprovedAccessDomainMutation(baseOptions?: Apollo.MutationHookOptions<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>(ValidateApprovedAccessDomainDocument, options);
}
export type ValidateApprovedAccessDomainMutationHookResult = ReturnType<typeof useValidateApprovedAccessDomainMutation>;
export type ValidateApprovedAccessDomainMutationResult = Apollo.MutationResult<ValidateApprovedAccessDomainMutation>;
export type ValidateApprovedAccessDomainMutationOptions = Apollo.BaseMutationOptions<ValidateApprovedAccessDomainMutation, ValidateApprovedAccessDomainMutationVariables>;
export const GetApprovedAccessDomainsDocument = gql`
query GetApprovedAccessDomains {
getApprovedAccessDomains {
id
createdAt
domain
isValidated
}
}
`;
/**
* __useGetApprovedAccessDomainsQuery__
*
* To run a query within a React component, call `useGetApprovedAccessDomainsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetApprovedAccessDomainsQuery` 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 } = useGetApprovedAccessDomainsQuery({
* variables: {
* },
* });
*/
export function useGetApprovedAccessDomainsQuery(baseOptions?: Apollo.QueryHookOptions<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>(GetApprovedAccessDomainsDocument, options);
}
export function useGetApprovedAccessDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>(GetApprovedAccessDomainsDocument, options);
}
export type GetApprovedAccessDomainsQueryHookResult = ReturnType<typeof useGetApprovedAccessDomainsQuery>;
export type GetApprovedAccessDomainsLazyQueryHookResult = ReturnType<typeof useGetApprovedAccessDomainsLazyQuery>;
export type GetApprovedAccessDomainsQueryResult = Apollo.QueryResult<GetApprovedAccessDomainsQuery, GetApprovedAccessDomainsQueryVariables>;
export const GetSsoIdentityProvidersDocument = gql`
query GetSSOIdentityProviders {
getSSOIdentityProviders {
@@ -2,7 +2,7 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { AppPath } from '@/types/AppPath';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
@@ -19,12 +19,12 @@ const setupMockOnboardingStatus = (
jest.mocked(useOnboardingStatus).mockReturnValueOnce(onboardingStatus);
};
jest.mock('@/workspace/hooks/useIsWorkspaceActivationStatusSuspended');
const setupMockIsWorkspaceActivationStatusSuspended = (
jest.mock('@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo');
const setupMockIsWorkspaceActivationStatusEqualsTo = (
isWorkspaceSuspended: boolean,
) => {
jest
.mocked(useIsWorkspaceActivationStatusSuspended)
.mocked(useIsWorkspaceActivationStatusEqualsTo)
.mockReturnValueOnce(isWorkspaceSuspended);
};
@@ -270,7 +270,7 @@ describe('usePageChangeEffectNavigateLocation', () => {
it(`with location ${testCase.loc} and onboardingStatus ${testCase.onboardingStatus} and isWorkspaceSuspended ${testCase.isWorkspaceSuspended} should return ${testCase.res}`, () => {
setupMockIsMatchingLocation(testCase.loc);
setupMockOnboardingStatus(testCase.onboardingStatus);
setupMockIsWorkspaceActivationStatusSuspended(
setupMockIsWorkspaceActivationStatusEqualsTo(
testCase.isWorkspaceSuspended,
);
setupMockIsLogged(testCase.isLoggedIn);
@@ -4,10 +4,10 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
import { WorkspaceActivationStatus, isDefined } from 'twenty-shared';
import { OnboardingStatus } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
@@ -15,7 +15,9 @@ export const usePageChangeEffectNavigateLocation = () => {
const { isMatchingLocation } = useIsMatchingLocation();
const isLoggedIn = useIsLogged();
const onboardingStatus = useOnboardingStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
);
const { defaultHomePagePath } = useDefaultHomePagePath();
const isMatchingOpenRoute =
@@ -2,6 +2,10 @@ import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionM
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -29,6 +33,12 @@ export const RecordIndexActionMenuDropdown = () => {
actionMenuEntriesComponentSelector,
);
const recordIndexActions = actionMenuEntries.filter(
(actionMenuEntry) =>
actionMenuEntry.type === ActionMenuEntryType.Standard &&
actionMenuEntry.scope === ActionMenuEntryScope.RecordSelection,
);
const actionMenuId = useAvailableComponentInstanceIdOrThrow(
ActionMenuComponentInstanceContext,
);
@@ -44,7 +54,7 @@ export const RecordIndexActionMenuDropdown = () => {
);
//TODO: remove this
const width = actionMenuEntries.some(
const width = recordIndexActions.some(
(actionMenuEntry) =>
i18n._(actionMenuEntry.label) === 'Remove from favorites',
)
@@ -68,7 +78,7 @@ export const RecordIndexActionMenuDropdown = () => {
dropdownComponents={
<StyledDropdownMenuContainer className="action-menu-dropdown">
<DropdownMenuItemsContainer>
{actionMenuEntries.map((item) => (
{recordIndexActions.map((item) => (
<MenuItem
key={item.key}
LeftIcon={item.Icon}
@@ -81,17 +81,19 @@ export const Notes = ({
title="All"
notes={notes}
button={
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add note"
onClick={() =>
openCreateActivity({
targetableObjects: [targetableObject],
})
}
></Button>
!hasObjectReadOnlyPermission && (
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add note"
onClick={() =>
openCreateActivity({
targetableObjects: [targetableObject],
})
}
/>
)
}
/>
</StyledNotesContainer>
@@ -4,6 +4,7 @@ import { Button, IconPlus } from 'twenty-ui';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
export const AddTaskButton = ({
activityTargetableObjects,
@@ -14,8 +15,13 @@ export const AddTaskButton = ({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
if (!isNonEmptyArray(activityTargetableObjects)) {
return <></>;
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
if (
!isNonEmptyArray(activityTargetableObjects) ||
hasObjectReadOnlyPermission
) {
return null;
}
return (
@@ -29,6 +35,6 @@ export const AddTaskButton = ({
targetableObjects: activityTargetableObjects,
})
}
></Button>
/>
);
};
@@ -4,7 +4,7 @@ import { Route, Routes } from 'react-router-dom';
import { SettingsProtectedRouteWrapper } from '@/settings/components/SettingsProtectedRouteWrapper';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsFeatures } from 'twenty-shared';
import { SettingsPermissions } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const SettingsAccountsCalendars = lazy(() =>
@@ -234,6 +234,14 @@ const SettingsSecuritySSOIdentifyProvider = lazy(() =>
),
);
const SettingsSecurityApprovedAccessDomain = lazy(() =>
import('~/pages/settings/security/SettingsSecurityApprovedAccessDomain').then(
(module) => ({
default: module.SettingsSecurityApprovedAccessDomain,
}),
),
);
const SettingsAdmin = lazy(() =>
import('~/pages/settings/admin-panel/SettingsAdmin').then((module) => ({
default: module.SettingsAdmin,
@@ -300,7 +308,7 @@ export const SettingsRoutes = ({
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.WORKSPACE}
settingsPermission={SettingsPermissions.WORKSPACE}
/>
}
>
@@ -315,7 +323,7 @@ export const SettingsRoutes = ({
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.DATA_MODEL}
settingsPermission={SettingsPermissions.DATA_MODEL}
/>
}
>
@@ -333,7 +341,7 @@ export const SettingsRoutes = ({
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.ROLES}
settingsPermission={SettingsPermissions.ROLES}
requiredFeatureFlag={FeatureFlagKey.IsPermissionsEnabled}
/>
}
@@ -408,6 +416,11 @@ export const SettingsRoutes = ({
path={SettingsPath.NewSSOIdentityProvider}
element={<SettingsSecuritySSOIdentifyProvider />}
/>
<Route
path={SettingsPath.NewApprovedAccessDomain}
element={<SettingsSecurityApprovedAccessDomain />}
/>
{isAdminPageEnabled && (
<>
<Route path={SettingsPath.AdminPanel} element={<SettingsAdmin />} />
@@ -424,7 +437,7 @@ export const SettingsRoutes = ({
<Route
element={
<SettingsProtectedRouteWrapper
settingsPermission={SettingsFeatures.WORKSPACE}
settingsPermission={SettingsPermissions.WORKSPACE}
/>
}
>
@@ -36,18 +36,6 @@ export const CommandMenu = () => {
commandMenuSearch,
});
const selectableItems: Command[] = copilotCommands
.concat(
matchingStandardActionRecordSelectionCommands,
matchingStandardActionObjectCommands,
matchingWorkflowRunRecordSelectionCommands,
matchingStandardActionGlobalCommands,
matchingWorkflowRunGlobalCommands,
matchingNavigateCommands,
fallbackCommands,
)
.filter(isDefined);
const previousContextStoreCurrentObjectMetadataItem =
useRecoilComponentValueV2(
contextStoreCurrentObjectMetadataItemComponentState,
@@ -58,12 +46,6 @@ export const CommandMenu = () => {
contextStoreCurrentObjectMetadataItemComponentState,
);
const selectableItemIds = selectableItems.map((item) => item.id);
if (isDefined(previousContextStoreCurrentObjectMetadataItem)) {
selectableItemIds.unshift(RESET_CONTEXT_TO_SELECTION);
}
const commandGroups: CommandGroupConfig[] = [
{
heading: t`Copilot`,
@@ -91,6 +73,16 @@ export const CommandMenu = () => {
},
];
const selectableItems: Command[] = commandGroups.flatMap(
(group) => group.items ?? [],
);
const selectableItemIds = selectableItems.map((item) => item.id);
if (isDefined(previousContextStoreCurrentObjectMetadataItem)) {
selectableItemIds.unshift(RESET_CONTEXT_TO_SELECTION);
}
return (
<CommandMenuList
commandGroups={commandGroups}
@@ -8,6 +8,7 @@ const StyledChip = styled.button<{
onClick?: () => void;
}>`
align-items: center;
justify-content: center;
background: ${({ theme }) => theme.background.transparent.light};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
@@ -3,10 +3,10 @@ import { InformationBannerFailPaymentInfo } from '@/information-banner/component
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { WorkspaceActivationStatus, isDefined } from 'twenty-shared';
import { SubscriptionStatus } from '~/generated-metadata/graphql';
const StyledInformationBannerWrapper = styled.div`
@@ -20,7 +20,9 @@ const StyledInformationBannerWrapper = styled.div`
export const InformationBannerWrapper = () => {
const subscriptionStatus = useSubscriptionStatus();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
);
const displayBillingSubscriptionPausedBanner =
isWorkspaceSuspended && subscriptionStatus === SubscriptionStatus.Paused;
@@ -63,9 +63,8 @@ describe('useColumnDefinitionsFromFieldMetadata', () => {
},
);
const { columnDefinitions, sortDefinitions } = result.current;
const { columnDefinitions } = result.current;
expect(columnDefinitions.length).toBe(21);
expect(sortDefinitions.length).toBe(14);
});
});
@@ -4,9 +4,9 @@ import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefin
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { useRecoilValue } from 'recoil';
import { formatFieldMetadataItemAsColumnDefinition } from '../utils/formatFieldMetadataItemAsColumnDefinition';
import { formatFieldMetadataItemsAsSortDefinitions } from '../utils/formatFieldMetadataItemsAsSortDefinitions';
export const useColumnDefinitionsFromFieldMetadata = (
objectMetadataItem: ObjectMetadataItem,
@@ -21,9 +21,11 @@ export const useColumnDefinitionsFromFieldMetadata = (
}),
);
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: activeFieldMetadataItems,
});
const sortableFieldMetadataItems = useRecoilValue(
availableFieldMetadataItemsForSortFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
const columnDefinitions: ColumnDefinition<FieldMetadata>[] =
activeFieldMetadataItems
@@ -40,8 +42,10 @@ export const useColumnDefinitionsFromFieldMetadata = (
(fieldMetadataItem) =>
fieldMetadataItem.id === column.fieldMetadataId,
);
const existsInSortDefinitions = sortDefinitions.some(
(sort) => sort.fieldMetadataId === column.fieldMetadataId,
const existsInSortDefinitions = sortableFieldMetadataItems.some(
(fieldMetadataItem) =>
fieldMetadataItem.id === column.fieldMetadataId,
);
return {
...column,
@@ -52,6 +56,5 @@ export const useColumnDefinitionsFromFieldMetadata = (
return {
columnDefinitions,
sortDefinitions,
};
};
@@ -1,24 +0,0 @@
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
import { SORTABLE_FIELD_METADATA_TYPES } from '@/object-metadata/constants/SortableFieldMetadataTypes';
import { ObjectMetadataItem } from '../types/ObjectMetadataItem';
export const formatFieldMetadataItemsAsSortDefinitions = ({
fields,
}: {
fields: Array<ObjectMetadataItem['fields'][0]>;
}): SortDefinition[] =>
fields.reduce((acc, field) => {
if (!SORTABLE_FIELD_METADATA_TYPES.includes(field.type)) {
return acc;
}
return [
...acc,
{
fieldMetadataId: field.id,
label: field.label,
iconName: field.icon ?? 'Icon123',
},
];
}, [] as SortDefinition[]);
@@ -1,6 +1,8 @@
import styled from '@emotion/styled';
import { IconChevronDown, MenuItem, useIcons } from 'twenty-ui';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { OBJECT_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ObjectSortDropdownId';
import { useCloseSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useCloseSortDropdown';
import { useResetRecordSortDropdownSearchInput } from '@/object-record/object-sort-dropdown/hooks/useResetRecordSortDropdownSearchInput';
@@ -10,7 +12,6 @@ import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/objec
import { objectSortDropdownSearchInputComponentState } from '@/object-record/object-sort-dropdown/states/objectSortDropdownSearchInputComponentState';
import { onSortSelectComponentState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState';
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import {
RECORD_SORT_DIRECTIONS,
@@ -28,8 +29,8 @@ import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
export const StyledInput = styled.input`
@@ -89,14 +90,16 @@ export const ObjectSortDropdownButton = ({
const { resetSortDropdown } = useResetSortDropdown();
const { recordIndexId } = useRecordIndexContextOrThrow();
const { recordIndexId, objectMetadataItem } = useRecordIndexContextOrThrow();
const objectSortDropdownSearchInput = useRecoilComponentValueV2(
objectSortDropdownSearchInputComponentState,
);
const availableSortDefinitions = useRecoilComponentValueV2(
availableSortDefinitionsComponentState,
const sortableFieldMetadataItems = useRecoilValue(
availableFieldMetadataItemsForSortFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
const { getIcon } = useIcons();
@@ -105,40 +108,45 @@ export const ObjectSortDropdownButton = ({
visibleTableColumnsComponentSelector,
recordIndexId,
);
const visibleColumnsIds = visibleTableColumns.map(
const visibleColumnsFieldMetadataIds = visibleTableColumns.map(
(column) => column.fieldMetadataId,
);
const hiddenTableColumns = useRecoilComponentValueV2(
hiddenTableColumnsComponentSelector,
recordIndexId,
);
const hiddenColumnIds = hiddenTableColumns.map(
const hiddenColumnFieldMetadataIds = hiddenTableColumns.map(
(column) => column.fieldMetadataId,
);
const filteredSearchInputSortDefinitions = availableSortDefinitions.filter(
(item) =>
const filteredSearchInputFieldMetadataItems =
sortableFieldMetadataItems.filter((item) =>
item.label
.toLocaleLowerCase()
.includes(objectSortDropdownSearchInput.toLocaleLowerCase()),
);
);
const visibleColumnsSortDefinitions = filteredSearchInputSortDefinitions
.sort((a, b) => {
const visibleFieldMetadataItems = filteredSearchInputFieldMetadataItems
.sort((fieldMetadataItemA, fieldMetadataItemB) => {
return (
visibleColumnsIds.indexOf(a.fieldMetadataId) -
visibleColumnsIds.indexOf(b.fieldMetadataId)
visibleColumnsFieldMetadataIds.indexOf(fieldMetadataItemA.id) -
visibleColumnsFieldMetadataIds.indexOf(fieldMetadataItemB.id)
);
})
.filter((item) => visibleColumnsIds.includes(item.fieldMetadataId));
.filter((fieldMetadataItem) =>
visibleColumnsFieldMetadataIds.includes(fieldMetadataItem.id),
);
const hiddenColumnsSortDefinitions = filteredSearchInputSortDefinitions
.sort((a, b) => a.label.localeCompare(b.label))
.filter((item) => hiddenColumnIds.includes(item.fieldMetadataId));
const hiddenFieldMetadataItems = filteredSearchInputFieldMetadataItems
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.label.localeCompare(fieldMetadataItemB.label),
)
.filter((fieldMetadataItem) =>
hiddenColumnFieldMetadataIds.includes(fieldMetadataItem.id),
);
const shoudShowSeparator =
visibleColumnsSortDefinitions.length > 0 &&
hiddenColumnsSortDefinitions.length > 0;
const shouldShowSeparator =
visibleFieldMetadataItems.length > 0 && hiddenFieldMetadataItems.length > 0;
const handleButtonClick = () => {
toggleSortDropdown();
@@ -153,14 +161,13 @@ export const ObjectSortDropdownButton = ({
const onSortSelect = useRecoilComponentValueV2(onSortSelectComponentState);
const handleAddSort = (sortDefinition: SortDefinition) => {
const handleAddSort = (fieldMetadataItem: FieldMetadataItem) => {
setObjectSortDropdownSearchInput('');
closeSortDropdown();
onSortSelect?.({
id: v4(),
fieldMetadataId: sortDefinition.fieldMetadataId,
fieldMetadataId: fieldMetadataItem.id,
direction: selectedRecordSortDirection,
definition: sortDefinition,
});
};
@@ -231,25 +238,25 @@ export const ObjectSortDropdownButton = ({
}
/>
<DropdownMenuItemsContainer>
{visibleColumnsSortDefinitions.map(
(visibleSortDefinition, index) => (
{visibleFieldMetadataItems.map(
(visibleFieldMetadataItem, index) => (
<MenuItem
testId={`visible-select-sort-${index}`}
key={index}
onClick={() => handleAddSort(visibleSortDefinition)}
LeftIcon={getIcon(visibleSortDefinition.iconName)}
text={visibleSortDefinition.label}
onClick={() => handleAddSort(visibleFieldMetadataItem)}
LeftIcon={getIcon(visibleFieldMetadataItem.icon)}
text={visibleFieldMetadataItem.label}
/>
),
)}
{shoudShowSeparator && <DropdownMenuSeparator />}
{hiddenColumnsSortDefinitions.map((hiddenSortDefinition, index) => (
{shouldShowSeparator && <DropdownMenuSeparator />}
{hiddenFieldMetadataItems.map((hiddenFieldMetadataItem, index) => (
<MenuItem
testId={`hidden-select-sort-${index}`}
key={index}
onClick={() => handleAddSort(hiddenSortDefinition)}
LeftIcon={getIcon(hiddenSortDefinition.iconName)}
text={hiddenSortDefinition.label}
onClick={() => handleAddSort(hiddenFieldMetadataItem)}
LeftIcon={getIcon(hiddenFieldMetadataItem.icon)}
text={hiddenFieldMetadataItem.label}
/>
))}
</DropdownMenuItemsContainer>
@@ -1,11 +0,0 @@
import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { SortDefinition } from '../types/SortDefinition';
export const availableSortDefinitionsComponentState = createComponentStateV2<
SortDefinition[]
>({
key: 'availableSortDefinitionsComponentState',
defaultValue: [],
componentInstanceContext: ObjectSortDropdownComponentInstanceContext,
});
@@ -1,5 +0,0 @@
export type SortDefinition = {
fieldMetadataId: string;
label: string;
iconName: string;
};
@@ -1,15 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
const sortDefinition: SortDefinition = {
fieldMetadataId: 'id',
label: 'definition label',
iconName: 'icon',
};
const objectMetadataItem: ObjectMetadataItem = {
id: 'object1',
fields: [],
@@ -47,7 +40,6 @@ describe('turnSortsIntoOrderBy', () => {
id: 'id',
fieldMetadataId: 'field1',
direction: 'asc',
definition: sortDefinition,
},
];
const fields = [{ id: 'field1', name: 'field1' }] as FieldMetadataItem[];
@@ -62,13 +54,11 @@ describe('turnSortsIntoOrderBy', () => {
id: 'id',
fieldMetadataId: 'field1',
direction: 'asc',
definition: sortDefinition,
},
{
id: 'id',
fieldMetadataId: 'field2',
direction: 'desc',
definition: sortDefinition,
},
];
const fields = [
@@ -90,7 +80,6 @@ describe('turnSortsIntoOrderBy', () => {
id: 'id',
fieldMetadataId: 'invalidField',
direction: 'asc',
definition: sortDefinition,
},
];
expect(turnSortsIntoOrderBy(objectMetadataItem, sorts)).toEqual([
@@ -104,7 +93,6 @@ describe('turnSortsIntoOrderBy', () => {
id: 'id',
fieldMetadataId: 'invalidField',
direction: 'asc',
definition: sortDefinition,
},
];
expect(
@@ -4,7 +4,6 @@ import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/s
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sortRecordsByPosition } from '@/object-record/utils/sortRecordsByPosition';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { isDefined } from 'twenty-shared';
@@ -49,7 +48,6 @@ export const useSetRecordIdsForColumn = (recordBoardId?: string) => {
(record) =>
record[recordGroupFieldMetadata.name] === recordGroup?.value,
)
.sort(sortRecordsByPosition)
.map((record) => record.id);
if (!isDeeplyEqual(existingRecordGroupRowIds, recordGroupRowIds)) {
@@ -12,7 +12,7 @@ import { ViewType } from '@/views/types/ViewType';
import { useCallback, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { isDefined, SettingsFeatures } from 'twenty-shared';
import { isDefined, SettingsPermissions } from 'twenty-shared';
import { IconEyeOff, IconSettings } from 'twenty-ui';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -71,7 +71,7 @@ export const useRecordGroupActions = ({
]);
const hasAccessToDataModelSettings = useHasSettingsPermission(
SettingsFeatures.DATA_MODEL,
SettingsPermissions.DATA_MODEL,
);
const recordGroupActions: RecordGroupAction[] = [];
@@ -23,27 +23,21 @@ export const RecordIndexViewBarEffect = ({
objectNameSingular,
});
const { columnDefinitions, sortDefinitions } =
const { columnDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const {
setViewObjectMetadataId,
setAvailableSortDefinitions,
setAvailableFieldDefinitions,
} = useInitViewBar(viewBarId);
const { setViewObjectMetadataId, setAvailableFieldDefinitions } =
useInitViewBar(viewBarId);
useEffect(() => {
if (isUndefinedOrNull(objectMetadataItem)) {
return;
}
setViewObjectMetadataId?.(objectMetadataItem.id);
setAvailableSortDefinitions?.(sortDefinitions);
setAvailableFieldDefinitions?.(columnDefinitions);
}, [
setViewObjectMetadataId,
objectMetadataItem,
setAvailableSortDefinitions,
sortDefinitions,
setAvailableFieldDefinitions,
columnDefinitions,
]);
@@ -8,7 +8,6 @@ import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useReco
import { tableViewFilterGroupsComponentState } from '@/object-record/record-table/states/tableViewFilterGroupsComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
export const useFindManyRecordIndexTableParams = (
@@ -33,14 +32,9 @@ export const useFindManyRecordIndexTableParams = (
const { currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView(recordTableId);
const availableSortDefinitions = useRecoilComponentValueV2(
availableSortDefinitionsComponentState,
recordTableId,
);
const viewSorts = currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [];
const sorts = mapViewSortsToSorts(viewSorts, availableSortDefinitions);
const sorts = mapViewSortsToSorts(viewSorts);
const currentRecordFilters = useRecoilComponentValueV2(
currentRecordFiltersComponentState,
@@ -40,11 +40,6 @@ export const useHandleToggleColumnSort = ({
const newSort: RecordSort = {
id: v4(),
fieldMetadataId,
definition: {
fieldMetadataId,
label: correspondingColumnDefinition.label,
iconName: correspondingColumnDefinition.iconName,
},
direction: 'asc',
};
@@ -14,7 +14,6 @@ import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/s
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { isDefined } from 'twenty-shared';
@@ -52,11 +51,7 @@ export const useLoadRecordIndexBoardColumn = ({
const viewsorts = currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [];
const sortDefinitions = useRecoilComponentValueV2(
availableSortDefinitionsComponentState,
);
const sorts = mapViewSortsToSorts(viewsorts, sortDefinitions);
const sorts = mapViewSortsToSorts(viewsorts);
const { filterValueDependencies } = useFilterValueDependencies();
@@ -1,8 +1,8 @@
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { useSetRecordGroup } from '@/object-record/record-group/hooks/useSetRecordGroup';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
@@ -81,9 +81,13 @@ export const useLoadRecordIndexStates = () => {
)
.getValue();
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: activeFieldMetadataItems,
});
const sortableFieldMetadataItems = snapshot
.getLoadable(
availableFieldMetadataItemsForSortFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
)
.getValue();
const columnDefinitions: ColumnDefinition<FieldMetadata>[] =
activeFieldMetadataItems
@@ -101,9 +105,12 @@ export const useLoadRecordIndexStates = () => {
(fieldMetadataItem) =>
fieldMetadataItem.id === column.fieldMetadataId,
);
const existsInSortDefinitions = sortDefinitions.some(
(sort) => sort.fieldMetadataId === column.fieldMetadataId,
const existsInSortDefinitions = sortableFieldMetadataItems.some(
(fieldMetadataItem) =>
fieldMetadataItem.id === column.fieldMetadataId,
);
return {
...column,
isFilterable: existsInFilterDefinitions,
@@ -228,23 +235,13 @@ export const useLoadRecordIndexStates = () => {
),
}));
const activeFieldMetadataItems = objectMetadataItem.fields.filter(
({ isActive, isSystem }) => isActive && !isSystem,
);
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: activeFieldMetadataItems,
});
set(
tableSortsComponentState.atomFamily({
instanceId: recordIndexId,
}),
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
mapViewSortsToSorts(view.viewSorts),
);
setRecordIndexSorts(mapViewSortsToSorts(view.viewSorts));
setRecordIndexViewType(view.type);
setRecordIndexOpenRecordIn(view.openRecordIn);
setRecordIndexViewKanbanFieldMetadataIdState(
@@ -1,15 +0,0 @@
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecoilValue } from 'recoil';
export const useSortableFieldMetadataItemsInRecordIndexContext = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const sortableFieldMetadataItems = useRecoilValue(
availableFieldMetadataItemsForSortFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
return { sortableFieldMetadataItems };
};
@@ -1,9 +1,7 @@
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
import { RecordSortDirection } from '@/object-record/record-sort/types/RecordSortDirection';
export type RecordSort = {
id: string;
fieldMetadataId: string;
direction: RecordSortDirection;
definition: SortDefinition;
};
@@ -4,6 +4,7 @@ import { BORDER_COMMON, ThemeContext } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useIsFieldValueReadOnly } from '@/object-record/record-field/hooks/useIsFieldValueReadOnly';
import { CellHotkeyScopeContext } from '@/object-record/record-table/contexts/CellHotkeyScopeContext';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
@@ -15,11 +16,13 @@ import {
const StyledBaseContainer = styled.div<{
hasSoftFocus: boolean;
fontColorExtraLight: string;
fontColorMedium: string;
backgroundColorTransparentSecondary: string;
isReadOnly: boolean;
}>`
align-items: center;
box-sizing: border-box;
cursor: pointer;
cursor: ${({ isReadOnly }) => (isReadOnly ? 'default' : 'pointer')};
display: flex;
height: 32px;
position: relative;
@@ -28,11 +31,20 @@ const StyledBaseContainer = styled.div<{
background: ${({ hasSoftFocus, backgroundColorTransparentSecondary }) =>
hasSoftFocus ? backgroundColorTransparentSecondary : 'none'};
border-radius: ${({ hasSoftFocus }) =>
hasSoftFocus ? BORDER_COMMON.radius.sm : 'none'};
border-radius: ${({ hasSoftFocus, isReadOnly }) =>
hasSoftFocus && !isReadOnly ? BORDER_COMMON.radius.sm : 'none'};
outline: ${({ hasSoftFocus, fontColorExtraLight }) =>
hasSoftFocus ? `1px solid ${fontColorExtraLight}` : 'none'};
outline: ${({
hasSoftFocus,
fontColorExtraLight,
fontColorMedium,
isReadOnly,
}) =>
hasSoftFocus
? isReadOnly
? `1px solid ${fontColorMedium}`
: `1px solid ${fontColorExtraLight}`
: 'none'};
`;
export const RecordTableCellBaseContainer = ({
@@ -44,6 +56,7 @@ export const RecordTableCellBaseContainer = ({
const { openTableCell } = useOpenRecordTableCellFromCell();
const { theme } = useContext(ThemeContext);
const isReadOnly = useIsFieldValueReadOnly();
const { hasSoftFocus, cellPosition } = useContext(RecordTableCellContext);
const { onMoveSoftFocusToCell, onCellMouseEnter } =
@@ -83,7 +96,9 @@ export const RecordTableCellBaseContainer = ({
theme.background.transparent.secondary
}
fontColorExtraLight={theme.font.color.extraLight}
fontColorMedium={theme.border.color.medium}
hasSoftFocus={hasSoftFocus}
isReadOnly={isReadOnly}
>
{children}
</StyledBaseContainer>
@@ -1,9 +1,11 @@
import { useRecoilValue } from 'recoil';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingStatus } from '~/generated/graphql';
export const useOnboardingStatus = (): OnboardingStatus | null | undefined => {
const currentUser = useRecoilValue(currentUserState);
return currentUser?.onboardingStatus;
const isLoggedIn = useIsLogged();
return isLoggedIn ? currentUser?.onboardingStatus : undefined;
};
@@ -13,14 +13,16 @@ import { prefetchFavoriteFoldersState } from '@/prefetch/states/prefetchFavorite
import { prefetchFavoritesState } from '@/prefetch/states/prefetchFavoritesState';
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { isDefined } from 'twenty-shared';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { WorkspaceActivationStatus, isDefined } from 'twenty-shared';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const PrefetchRunFavoriteQueriesEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isWorkspaceActive = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.ACTIVE,
);
const { objectMetadataItems } = useObjectMetadataItems();
@@ -50,14 +52,14 @@ export const PrefetchRunFavoriteQueriesEffect = () => {
objectNameSingular: CoreObjectNameSingular.Favorite,
filter: findAllFavoritesOperationSignature.variables.filter,
recordGqlFields: findAllFavoritesOperationSignature.fields,
skip: !currentUser || isWorkspaceSuspended,
skip: !currentUser || !isWorkspaceActive,
});
const { records: favoriteFolders } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.FavoriteFolder,
filter: findAllFavoriteFoldersOperationSignature.variables.filter,
recordGqlFields: findAllFavoriteFoldersOperationSignature.fields,
skip: !currentUser || isWorkspaceSuspended,
skip: !currentUser || !isWorkspaceActive,
});
const setPrefetchFavoritesState = useRecoilCallback(
@@ -9,14 +9,16 @@ import { findAllViewsOperationSignatureFactory } from '@/prefetch/graphql/operat
import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState';
import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState';
import { View } from '@/views/types/View';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { isDefined } from 'twenty-shared';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { WorkspaceActivationStatus, isDefined } from 'twenty-shared';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const PrefetchRunViewQueryEffect = () => {
const currentUser = useRecoilValue(currentUserState);
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isWorkspaceActive = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.ACTIVE,
);
const { objectMetadataItems } = useObjectMetadataItems();
@@ -30,7 +32,7 @@ export const PrefetchRunViewQueryEffect = () => {
objectNameSingular: CoreObjectNameSingular.View,
filter: findAllViewsOperationSignature.variables.filter,
recordGqlFields: findAllViewsOperationSignature.fields,
skip: !currentUser || isWorkspaceSuspended,
skip: !currentUser || !isWorkspaceActive,
});
const setPrefetchViewsState = useRecoilCallback(
@@ -1,10 +1,13 @@
import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { useRecoilValue } from 'recoil';
import { WorkspaceActivationStatus } from 'twenty-shared';
export const useIsPrefetchLoading = () => {
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isWorkspaceActive = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.ACTIVE,
);
const isFavoriteFoldersPrefetched = useRecoilValue(
prefetchIsLoadedFamilyState(PrefetchKey.AllFavoritesFolders),
);
@@ -14,7 +17,7 @@ export const useIsPrefetchLoading = () => {
);
return (
!isWorkspaceSuspended &&
isWorkspaceActive &&
(!areFavoritesPrefetched || !isFavoriteFoldersPrefetched)
);
};
@@ -3,12 +3,12 @@ import { SettingsPath } from '@/types/SettingsPath';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { ReactNode } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { FeatureFlagKey, SettingsFeatures } from '~/generated/graphql';
import { FeatureFlagKey, SettingsPermissions } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type SettingsProtectedRouteWrapperProps = {
children?: ReactNode;
settingsPermission?: SettingsFeatures;
settingsPermission?: SettingsPermissions;
requiredFeatureFlag?: FeatureFlagKey;
};
@@ -4,7 +4,7 @@ import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MutableSnapshot, RecoilRoot } from 'recoil';
import { SettingsFeatures } from 'twenty-shared';
import { SettingsPermissions } from 'twenty-shared';
import { Billing, FeatureFlagKey, OnboardingStatus } from '~/generated/graphql';
import { currentUserState } from '@/auth/states/currentUserState';
@@ -56,12 +56,12 @@ jest.mock('@/workspace/hooks/useFeatureFlagsMap', () => ({
describe('useSettingsNavigationItems', () => {
it('should hide workspace settings when no permissions', () => {
(useSettingsPermissionMap as jest.Mock).mockImplementation(() => ({
[SettingsFeatures.WORKSPACE]: false,
[SettingsFeatures.WORKSPACE_USERS]: false,
[SettingsFeatures.DATA_MODEL]: false,
[SettingsFeatures.API_KEYS_AND_WEBHOOKS]: false,
[SettingsFeatures.ROLES]: false,
[SettingsFeatures.SECURITY]: false,
[SettingsPermissions.WORKSPACE]: false,
[SettingsPermissions.WORKSPACE_USERS]: false,
[SettingsPermissions.DATA_MODEL]: false,
[SettingsPermissions.API_KEYS_AND_WEBHOOKS]: false,
[SettingsPermissions.ROLES]: false,
[SettingsPermissions.SECURITY]: false,
}));
const { result } = renderHook(() => useSettingsNavigationItems(), {
@@ -77,12 +77,12 @@ describe('useSettingsNavigationItems', () => {
it('should show workspace settings when has permissions', () => {
(useSettingsPermissionMap as jest.Mock).mockImplementation(() => ({
[SettingsFeatures.WORKSPACE]: true,
[SettingsFeatures.WORKSPACE_USERS]: true,
[SettingsFeatures.DATA_MODEL]: true,
[SettingsFeatures.API_KEYS_AND_WEBHOOKS]: true,
[SettingsFeatures.ROLES]: true,
[SettingsFeatures.SECURITY]: true,
[SettingsPermissions.WORKSPACE]: true,
[SettingsPermissions.WORKSPACE_USERS]: true,
[SettingsPermissions.DATA_MODEL]: true,
[SettingsPermissions.API_KEYS_AND_WEBHOOKS]: true,
[SettingsPermissions.ROLES]: true,
[SettingsPermissions.SECURITY]: true,
}));
const { result } = renderHook(() => useSettingsNavigationItems(), {
@@ -20,7 +20,7 @@ import {
} from 'twenty-ui';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsFeatures } from 'twenty-shared';
import { SettingsPermissions } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { currentUserState } from '@/auth/states/currentUserState';
@@ -105,20 +105,20 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
label: t`General`,
path: SettingsPath.Workspace,
Icon: IconSettings,
isHidden: !permissionMap[SettingsFeatures.WORKSPACE],
isHidden: !permissionMap[SettingsPermissions.WORKSPACE],
},
{
label: t`Members`,
path: SettingsPath.WorkspaceMembersPage,
Icon: IconUsers,
isHidden: !permissionMap[SettingsFeatures.WORKSPACE_USERS],
isHidden: !permissionMap[SettingsPermissions.WORKSPACE_USERS],
},
{
label: t`Billing`,
path: SettingsPath.Billing,
Icon: IconCurrencyDollar,
isHidden:
!isBillingEnabled || !permissionMap[SettingsFeatures.WORKSPACE],
!isBillingEnabled || !permissionMap[SettingsPermissions.WORKSPACE],
},
{
label: t`Roles`,
@@ -126,26 +126,26 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconLock,
isHidden:
!featureFlags[FeatureFlagKey.IsPermissionsEnabled] ||
!permissionMap[SettingsFeatures.ROLES],
!permissionMap[SettingsPermissions.ROLES],
},
{
label: t`Data model`,
path: SettingsPath.Objects,
Icon: IconHierarchy2,
isHidden: !permissionMap[SettingsFeatures.DATA_MODEL],
isHidden: !permissionMap[SettingsPermissions.DATA_MODEL],
},
{
label: t`Integrations`,
path: SettingsPath.Integrations,
Icon: IconApps,
isHidden: !permissionMap[SettingsFeatures.API_KEYS_AND_WEBHOOKS],
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
},
{
label: t`Security`,
path: SettingsPath.Security,
Icon: IconKey,
isAdvanced: true,
isHidden: !permissionMap[SettingsFeatures.SECURITY],
isHidden: !permissionMap[SettingsPermissions.SECURITY],
},
],
},
@@ -158,7 +158,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
path: SettingsPath.Developers,
Icon: IconCode,
isAdvanced: true,
isHidden: !permissionMap[SettingsFeatures.API_KEYS_AND_WEBHOOKS],
isHidden: !permissionMap[SettingsPermissions.API_KEYS_AND_WEBHOOKS],
},
{
label: t`Functions`,
@@ -184,7 +184,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconFlask,
isHidden:
!labPublicFeatureFlags.length ||
!permissionMap[SettingsFeatures.WORKSPACE],
!permissionMap[SettingsPermissions.WORKSPACE],
},
{
label: t`Releases`,
@@ -15,6 +15,10 @@ export const useHasObjectReadOnlyPermission = () => {
}
if (!isDefined(currentUserWorkspace?.objectRecordsPermissions)) {
return false;
}
if (currentUserWorkspace?.objectRecordsPermissions.length === 0) {
return true;
}
@@ -1,9 +1,9 @@
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { useRecoilValue } from 'recoil';
import { SettingsFeatures } from 'twenty-shared';
import { SettingsPermissions } from 'twenty-shared';
export const useHasSettingsPermission = (
settingsPermission?: SettingsFeatures,
settingsPermission?: SettingsPermissions,
) => {
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
@@ -1,12 +1,12 @@
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { SettingsFeatures } from 'twenty-shared';
import { SettingsPermissions } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
export const useSettingsPermissionMap = (): Record<
SettingsFeatures,
SettingsPermissions,
boolean
> => {
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
@@ -19,7 +19,7 @@ export const useSettingsPermissionMap = (): Record<
currentUserWorkspace?.settingsPermissions;
const initialPermissions = buildRecordFromKeysWithSameValue(
Object.values(SettingsFeatures),
Object.values(SettingsPermissions),
!isPermissionEnabled,
);
@@ -2,8 +2,8 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer';
import { SettingsSSOOIDCForm } from '@/settings/security/components/SettingsSSOOIDCForm';
import { SettingsSSOSAMLForm } from '@/settings/security/components/SettingsSSOSAMLForm';
import { SettingsSSOOIDCForm } from '@/settings/security/components/SSO/SettingsSSOOIDCForm';
import { SettingsSSOSAMLForm } from '@/settings/security/components/SSO/SettingsSSOSAMLForm';
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { TextInput } from '@/ui/input/components/TextInput';
import styled from '@emotion/styled';
@@ -6,7 +6,7 @@ import { SettingsPath } from '@/types/SettingsPath';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCardWrapper';
import { SettingsSSOIdentitiesProvidersListCardWrapper } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCardWrapper';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -1,7 +1,7 @@
/* @license Enterprise */
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SettingsSSOIdentityProviderRowRightContainer';
import { SettingsSSOIdentityProviderRowRightContainer } from '@/settings/security/components/SSO/SettingsSSOIdentityProviderRowRightContainer';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { guessSSOIdentityProviderIconByUrl } from '@/settings/security/utils/guessSSOIdentityProviderIconByUrl';
import { SettingsPath } from '@/types/SettingsPath';
@@ -1,6 +1,6 @@
/* @license Enterprise */
import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SettingsSecuritySSORowDropdownMenu';
import { SettingsSecuritySSORowDropdownMenu } from '@/settings/security/components/SSO/SettingsSecuritySSORowDropdownMenu';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { getColorBySSOIdentityProviderStatus } from '@/settings/security/utils/getColorBySSOIdentityProviderStatus';
import { Status } from 'twenty-ui';
@@ -24,7 +24,7 @@ const StyledSettingsSecurityOptionsList = styled.div`
gap: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsSecurityOptionsList = () => {
export const SettingsSecurityAuthProvidersOptionsList = () => {
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
@@ -0,0 +1,73 @@
import { Link, useNavigate } from 'react-router-dom';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { IconAt, IconMailCog } from 'twenty-ui';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState';
import { SettingsSecurityApprovedAccessDomainRowDropdownMenu } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainRowDropdownMenu';
import { SettingsSecurityApprovedAccessDomainValidationEffect } from '@/settings/security/components/approvedAccessDomains/SettingsSecurityApprovedAccessDomainValidationEffect';
import { useGetApprovedAccessDomainsQuery } from '~/generated/graphql';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsApprovedAccessDomainsListCard = () => {
const { enqueueSnackBar } = useSnackBar();
const navigate = useNavigate();
const { t } = useLingui();
const [approvedAccessDomains, setApprovedAccessDomains] = useRecoilState(
approvedAccessDomainsState,
);
const { loading } = useGetApprovedAccessDomainsQuery({
fetchPolicy: 'network-only',
onCompleted: (data) => {
setApprovedAccessDomains(data?.getApprovedAccessDomains ?? []);
},
onError: (error: Error) => {
enqueueSnackBar(error.message, {
variant: SnackBarVariant.Error,
});
},
});
return loading || !approvedAccessDomains.length ? (
<StyledLink to={getSettingsPath(SettingsPath.NewApprovedAccessDomain)}>
<SettingsCard
title={t`Add Approved Access Domain`}
Icon={<IconMailCog />}
/>
</StyledLink>
) : (
<>
<SettingsSecurityApprovedAccessDomainValidationEffect />
<SettingsListCard
items={approvedAccessDomains}
getItemLabel={(approvedAccessDomain) =>
`${approvedAccessDomain.domain} - ${approvedAccessDomain.createdAt}`
}
RowIcon={IconAt}
RowRightComponent={({ item: approvedAccessDomain }) => (
<SettingsSecurityApprovedAccessDomainRowDropdownMenu
approvedAccessDomain={approvedAccessDomain}
/>
)}
hasFooter
footerButtonLabel="Add Approved Access Domain"
onFooterButtonClick={() =>
navigate(getSettingsPath(SettingsPath.NewApprovedAccessDomain))
}
/>
</>
);
};
@@ -0,0 +1,84 @@
import {
IconDotsVertical,
IconTrash,
LightIconButton,
MenuItem,
} from 'twenty-ui';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
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 { UnwrapRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useDeleteApprovedAccessDomainMutation } from '~/generated/graphql';
import { approvedAccessDomainsState } from '@/settings/security/states/ApprovedAccessDomainsState';
type SettingsSecurityApprovedAccessDomainRowDropdownMenuProps = {
approvedAccessDomain: UnwrapRecoilValue<typeof approvedAccessDomainsState>[0];
};
export const SettingsSecurityApprovedAccessDomainRowDropdownMenu = ({
approvedAccessDomain,
}: SettingsSecurityApprovedAccessDomainRowDropdownMenuProps) => {
const dropdownId = `settings-approved-access-domain-row-${approvedAccessDomain.id}`;
const setApprovedAccessDomains = useSetRecoilState(
approvedAccessDomainsState,
);
const { enqueueSnackBar } = useSnackBar();
const { closeDropdown } = useDropdown(dropdownId);
const [deleteApprovedAccessDomain] = useDeleteApprovedAccessDomainMutation();
const handleDeleteApprovedAccessDomain = async () => {
const result = await deleteApprovedAccessDomain({
variables: {
input: {
id: approvedAccessDomain.id,
},
},
onCompleted: () => {
setApprovedAccessDomains((approvedAccessDomains) => {
return approvedAccessDomains.filter(
({ id }) => id !== approvedAccessDomain.id,
);
});
},
});
if (isDefined(result.errors)) {
enqueueSnackBar('Error deleting approved access domain', {
variant: SnackBarVariant.Error,
duration: 2000,
});
}
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
dropdownHotkeyScope={{ scope: dropdownId }}
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownMenuWidth={160}
dropdownComponents={
<DropdownMenuItemsContainer>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Delete"
onClick={() => {
handleDeleteApprovedAccessDomain();
closeDropdown();
}}
/>
</DropdownMenuItemsContainer>
}
/>
);
};
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared';
import { useValidateApprovedAccessDomainMutation } from '~/generated/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSearchParams } from 'react-router-dom';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
export const SettingsSecurityApprovedAccessDomainValidationEffect = () => {
const [validateApprovedAccessDomainMutation] =
useValidateApprovedAccessDomainMutation();
const { enqueueSnackBar } = useSnackBar();
const [searchParams] = useSearchParams();
const approvedAccessDomainId = searchParams.get('wtdId');
const validationToken = searchParams.get('validationToken');
useEffect(() => {
if (isDefined(validationToken) && isDefined(approvedAccessDomainId)) {
validateApprovedAccessDomainMutation({
variables: {
input: {
validationToken,
approvedAccessDomainId,
},
},
onCompleted: () => {
enqueueSnackBar('Approved access domain validated', {
dedupeKey: 'approved-access-domain-validation-dedupe-key',
variant: SnackBarVariant.Success,
});
},
onError: () => {
enqueueSnackBar('Error validating approved access domain', {
dedupeKey: 'approved-access-domain-validation-error-dedupe-key',
variant: SnackBarVariant.Error,
});
},
});
}
// Validate approved access domain only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <></>;
};
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const CREATE_APPROVED_ACCESS_DOMAIN = gql`
mutation CreateApprovedAccessDomain(
$input: CreateApprovedAccessDomainInput!
) {
createApprovedAccessDomain(input: $input) {
id
domain
isValidated
createdAt
}
}
`;
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_APPROVED_ACCESS_DOMAIN = gql`
mutation DeleteApprovedAccessDomain(
$input: DeleteApprovedAccessDomainInput!
) {
deleteApprovedAccessDomain(input: $input)
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const VALIDATE_APPROVED_ACCESS_DOMAIN = gql`
mutation ValidateApprovedAccessDomain(
$input: ValidateApprovedAccessDomainInput!
) {
validateApprovedAccessDomain(input: $input) {
id
isValidated
domain
createdAt
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const GET_ALL_APPROVED_ACCESS_DOMAINS = gql`
query GetApprovedAccessDomains {
getApprovedAccessDomains {
id
createdAt
domain
isValidated
}
}
`;
@@ -0,0 +1,9 @@
import { createState } from '@ui/utilities/state/utils/createState';
import { ApprovedAccessDomain } from '~/generated/graphql';
export const approvedAccessDomainsState = createState<
Omit<ApprovedAccessDomain, '__typename'>[]
>({
key: 'ApprovedAccessDomainsState',
defaultValue: [],
});
@@ -7,7 +7,6 @@ import { useSetRecordIndexEntityCount } from '@/object-record/record-index/hooks
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useSetTableColumns } from '@/object-record/record-table/hooks/useSetTableColumns';
import { SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockColumnDefinitions';
import { SIGN_IN_BACKGROUND_MOCK_SORT_DEFINITIONS } from '@/sign-in-background-mock/constants/SignInBackgroundMockSortDefinitions';
import { SIGN_IN_BACKGROUND_MOCK_VIEW_FIELDS } from '@/sign-in-background-mock/constants/SignInBackgroundMockViewFields';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useInitViewBar } from '@/views/hooks/useInitViewBar';
@@ -43,18 +42,14 @@ export const SignInBackgroundMockContainerEffect = ({
objectNameSingular,
});
const {
setAvailableSortDefinitions,
setAvailableFieldDefinitions,
setViewObjectMetadataId,
} = useInitViewBar(viewId);
const { setAvailableFieldDefinitions, setViewObjectMetadataId } =
useInitViewBar(viewId);
const { setRecordIndexEntityCount } = useSetRecordIndexEntityCount(viewId);
useEffect(() => {
setViewObjectMetadataId?.(objectMetadataItem.id);
setAvailableSortDefinitions?.(SIGN_IN_BACKGROUND_MOCK_SORT_DEFINITIONS);
setAvailableFieldDefinitions?.(SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS);
setAvailableTableColumns(SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS);
@@ -70,7 +65,6 @@ export const SignInBackgroundMockContainerEffect = ({
setContextStoreCurrentObjectMetadataItem(objectMetadataItem);
}, [
setViewObjectMetadataId,
setAvailableSortDefinitions,
setAvailableFieldDefinitions,
objectMetadataItem,
setAvailableTableColumns,
@@ -1,34 +0,0 @@
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
export const SIGN_IN_BACKGROUND_MOCK_SORT_DEFINITIONS = [
{
fieldMetadataId: '20202020-5e4e-4007-a630-8a2617914889',
label: 'Domain Name',
iconName: 'IconLink',
},
{
fieldMetadataId: '20202020-7fbd-41ad-b64d-25a15ff62f04',
label: 'Employees',
iconName: 'IconUsers',
},
{
fieldMetadataId: 'REPLACE_ME',
label: 'Name',
iconName: 'IconBuildingSkyscraper',
},
{
fieldMetadataId: '20202020-ad10-4117-a039-3f04b7a5f939',
label: 'Address',
iconName: 'IconMap',
},
{
fieldMetadataId: '20202020-4dc2-47c9-bb15-6e6f19ba9e46',
label: 'Creation date',
iconName: 'IconCalendar',
},
{
fieldMetadataId: '20202020-9e9f-4235-98b2-c76f3e2d281e',
label: 'ICP',
iconName: 'IconTarget',
},
] as SortDefinition[];
@@ -29,7 +29,9 @@ export enum SettingsPath {
IntegrationNewDatabaseConnection = 'integrations/:databaseKey/new',
Security = 'security',
NewSSOIdentityProvider = 'security/sso/new',
EditSSOIdentityProvider = 'security/sso/:identityProviderId',
NewApprovedAccessDomain = 'security/approved-access-domain/new',
Webhooks = 'webhooks',
DevelopersNewWebhook = 'developers/webhooks/new',
DevelopersNewWebhookDetail = 'developers/webhooks/:webhookId',
Releases = 'releases',
AdminPanel = 'admin-panel',
@@ -6,7 +6,8 @@ import { IconX, UndecoratedLink } from 'twenty-ui';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useIsWorkspaceActivationStatusSuspended } from '@/workspace/hooks/useIsWorkspaceActivationStatusSuspended';
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
import { WorkspaceActivationStatus } from 'twenty-shared';
type NavigationDrawerBackButtonProps = {
title: string;
@@ -52,7 +53,10 @@ export const NavigationDrawerBackButton = ({
navigationDrawerExpandedMemorizedState,
);
const isWorkspaceSuspended = useIsWorkspaceActivationStatusSuspended();
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
WorkspaceActivationStatus.SUSPENDED,
);
if (isWorkspaceSuspended) {
return <StyledContainer />;
}
@@ -1,5 +1,6 @@
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { useUpsertRecordSort } from '@/object-record/record-sort/hooks/useUpsertRecordSort';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
@@ -25,6 +26,10 @@ export const EditableSortChip = ({ recordSort }: EditableSortChipProps) => {
removeRecordSort(recordSort.fieldMetadataId);
};
const { fieldMetadataItem } = useFieldMetadataItemById(
recordSort.fieldMetadataId,
);
const handleClick = () => {
const newSort: RecordSort = {
...recordSort,
@@ -39,7 +44,7 @@ export const EditableSortChip = ({ recordSort }: EditableSortChipProps) => {
<SortOrFilterChip
key={recordSort.fieldMetadataId}
testId={recordSort.fieldMetadataId}
labelValue={recordSort.definition.label}
labelValue={fieldMetadataItem.label}
Icon={recordSort.direction === 'desc' ? IconArrowDown : IconArrowUp}
onRemove={handleRemoveClick}
onClick={handleClick}
@@ -15,6 +15,7 @@ import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryP
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { SoftDeleteFilterChip } from '@/views/components/SoftDeleteFilterChip';
import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters';
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '@/views/hooks/useApplyCurrentViewSortsToCurrentRecordSorts';
@@ -22,9 +23,8 @@ import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAr
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates';
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { isNonEmptyArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared';
@@ -123,8 +123,8 @@ export const ViewBarDetails = ({
currentRecordFiltersComponentState,
);
const availableSortDefinitions = useRecoilComponentValueV2(
availableSortDefinitionsComponentState,
const currentRecordSorts = useRecoilComponentValueV2(
currentRecordSortsComponentState,
);
const { objectNameSingular } = useObjectNameSingularFromPlural({
@@ -206,19 +206,14 @@ export const ViewBarDetails = ({
<StyledSeperator />
</StyledSeperatorContainer>
)}
{mapViewSortsToSorts(
currentViewWithCombinedFiltersAndSorts?.viewSorts ?? [],
availableSortDefinitions,
).map((recordSort) => (
{currentRecordSorts.map((recordSort) => (
<EditableSortChip
key={recordSort.fieldMetadataId}
recordSort={recordSort}
/>
))}
{isNonEmptyArray(recordFilters) &&
isNonEmptyArray(
currentViewWithCombinedFiltersAndSorts?.viewSorts,
) && (
isNonEmptyArray(currentRecordSorts) && (
<StyledSeperatorContainer>
<StyledSeperator />
</StyledSeperatorContainer>
@@ -1,7 +1,6 @@
import { contextStoreCurrentObjectMetadataItemComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
@@ -59,14 +58,8 @@ export const ViewBarRecordSortEffect = () => {
return;
}
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
if (isDefined(currentView)) {
setCurrentRecordSorts(
mapViewSortsToSorts(currentView.viewSorts, sortDefinitions),
);
setCurrentRecordSorts(mapViewSortsToSorts(currentView.viewSorts));
setHasInitializedCurrentRecordSorts(true);
}
}
@@ -3,47 +3,27 @@ import { useEffect } from 'react';
import { onSortSelectComponentState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState';
import { useUpsertRecordSort } from '@/object-record/record-sort/hooks/useUpsertRecordSort';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useUpsertCombinedViewSorts } from '@/views/hooks/useUpsertCombinedViewSorts';
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { isDefined } from 'twenty-shared';
export const ViewBarSortEffect = () => {
const { upsertCombinedViewSort } = useUpsertCombinedViewSorts();
// TDOO: verify this instance id works
const availableSortDefinitions = useRecoilComponentValueV2(
availableSortDefinitionsComponentState,
);
const { upsertRecordSort } = useUpsertRecordSort();
const setOnSortSelect = useSetRecoilComponentStateV2(
onSortSelectComponentState,
);
// TDOO: verify this instance id works
const setAvailableSortDefinitionsInSortDropdown =
useSetRecoilComponentStateV2(availableSortDefinitionsComponentState);
useEffect(() => {
if (isDefined(availableSortDefinitions)) {
setAvailableSortDefinitionsInSortDropdown(availableSortDefinitions);
}
setOnSortSelect(() => (sort: RecordSort | null) => {
if (isDefined(sort)) {
upsertCombinedViewSort(sort);
upsertRecordSort(sort);
}
});
}, [
availableSortDefinitions,
setAvailableSortDefinitionsInSortDropdown,
setOnSortSelect,
upsertCombinedViewSort,
upsertRecordSort,
]);
}, [setOnSortSelect, upsertCombinedViewSort, upsertRecordSort]);
return <></>;
};
@@ -96,11 +96,6 @@ describe('useApplyCurrentViewSortsToCurrentRecordSorts', () => {
id: mockViewSort.id,
fieldMetadataId: mockViewSort.fieldMetadataId,
direction: mockViewSort.direction,
definition: {
fieldMetadataId: mockViewSort.fieldMetadataId,
iconName: mockFieldMetadataItem.icon ?? '',
label: mockFieldMetadataItem.label,
},
} satisfies RecordSort,
]);
});
@@ -70,11 +70,6 @@ describe('useApplyViewSortsToCurrentRecordSorts', () => {
id: mockViewSort.id,
fieldMetadataId: mockViewSort.fieldMetadataId,
direction: mockViewSort.direction,
definition: {
fieldMetadataId: mockViewSort.fieldMetadataId,
label: mockFieldMetadataItem.label,
iconName: mockFieldMetadataItem.icon ?? '',
},
} satisfies RecordSort,
]);
});
@@ -1,6 +1,4 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
@@ -25,18 +23,9 @@ export const useApplyCurrentViewSortsToCurrentRecordSorts = () => {
currentRecordSortsComponentState,
);
const { sortableFieldMetadataItems } =
useSortableFieldMetadataItemsInRecordIndexContext();
const applyCurrentViewSortsToCurrentRecordSorts = () => {
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
if (isDefined(currentView)) {
setCurrentRecordSorts(
mapViewSortsToSorts(currentView.viewSorts, sortDefinitions),
);
setCurrentRecordSorts(mapViewSortsToSorts(currentView.viewSorts));
}
};
@@ -1,5 +1,3 @@
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewSort } from '@/views/types/ViewSort';
@@ -10,15 +8,8 @@ export const useApplyViewSortsToCurrentRecordSorts = () => {
currentRecordSortsComponentState,
);
const { sortableFieldMetadataItems } =
useSortableFieldMetadataItemsInRecordIndexContext();
const applyViewSortsToCurrentRecordSorts = (viewSorts: ViewSort[]) => {
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: sortableFieldMetadataItems,
});
const recordSortsToApply = mapViewSortsToSorts(viewSorts, sortDefinitions);
const recordSortsToApply = mapViewSortsToSorts(viewSorts);
setCurrentRecordSorts(recordSortsToApply);
};
@@ -1,6 +1,5 @@
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { availableFieldDefinitionsComponentState } from '@/views/states/availableFieldDefinitionsComponentState';
import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState';
import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState';
export const useInitViewBar = (viewBarInstanceId?: string) => {
@@ -9,11 +8,6 @@ export const useInitViewBar = (viewBarInstanceId?: string) => {
viewBarInstanceId,
);
const setAvailableSortDefinitions = useSetRecoilComponentStateV2(
availableSortDefinitionsComponentState,
viewBarInstanceId,
);
const setViewObjectMetadataId = useSetRecoilComponentStateV2(
viewObjectMetadataIdComponentState,
viewBarInstanceId,
@@ -21,7 +15,6 @@ export const useInitViewBar = (viewBarInstanceId?: string) => {
return {
setAvailableFieldDefinitions,
setAvailableSortDefinitions,
setViewObjectMetadataId,
};
};
@@ -1,5 +1,4 @@
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
@@ -103,7 +102,6 @@ export const useUpsertCombinedViewSorts = (viewBarComponentId?: string) => {
...unsavedToUpsertViewSorts,
{
...upsertedSort,
id: v4(),
__typename: 'ViewSort',
} satisfies ViewSort,
]);
@@ -1,11 +0,0 @@
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const availableSortDefinitionsComponentState = createComponentStateV2<
SortDefinition[]
>({
key: 'availableSortDefinitionsComponentState',
defaultValue: [],
componentInstanceContext: ViewComponentInstanceContext,
});
@@ -13,13 +13,6 @@ import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { FieldMetadataType } from '~/generated/graphql';
const baseDefinition = {
fieldMetadataId: '05731f68-6e7a-4903-8374-c0b6a9063482',
label: 'label',
iconName: 'iconName',
fieldName: 'fieldName',
};
const baseFieldMetadataItem = {
id: '05731f68-6e7a-4903-8374-c0b6a9063482',
createdAt: '2021-01-01',
@@ -44,12 +37,9 @@ describe('mapViewSortsToSorts', () => {
id: 'id',
fieldMetadataId: '05731f68-6e7a-4903-8374-c0b6a9063482',
direction: 'asc',
definition: baseDefinition,
},
];
expect(mapViewSortsToSorts(viewSorts, [baseDefinition])).toEqual(
expectedSorts,
);
expect(mapViewSortsToSorts(viewSorts)).toEqual(expectedSorts);
});
});
@@ -1,7 +1,6 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
@@ -31,10 +30,6 @@ export const getQueryVariablesFromView = ({
const { viewFilterGroups, viewFilters, viewSorts } = view;
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: fieldMetadataItems,
});
const filter = computeViewRecordGqlOperationFilter(
filterValueDependencies,
mapViewFiltersToFilters(viewFilters, fieldMetadataItems),
@@ -44,7 +39,7 @@ export const getQueryVariablesFromView = ({
const orderBy = turnSortsIntoOrderBy(
objectMetadataItem,
mapViewSortsToSorts(viewSorts, sortDefinitions),
mapViewSortsToSorts(viewSorts),
);
return {
@@ -1,27 +1,15 @@
import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition';
import { isDefined } from 'twenty-shared';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { ViewSort } from '../types/ViewSort';
export const mapViewSortsToSorts = (
viewSorts: ViewSort[],
availableSortDefinitions: SortDefinition[],
): RecordSort[] => {
export const mapViewSortsToSorts = (viewSorts: ViewSort[]): RecordSort[] => {
return viewSorts
.map((viewSort) => {
const availableSortDefinition = availableSortDefinitions.find(
(sortDefinition) =>
sortDefinition.fieldMetadataId === viewSort.fieldMetadataId,
);
if (!availableSortDefinition) return null;
return {
id: viewSort.id,
fieldMetadataId: viewSort.fieldMetadataId,
direction: viewSort.direction,
definition: availableSortDefinition,
};
})
.filter(isDefined);
@@ -0,0 +1,11 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { WorkspaceActivationStatus } from 'twenty-shared';
export const useIsWorkspaceActivationStatusEqualsTo = (
activationStatus: WorkspaceActivationStatus,
): boolean => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
return currentWorkspace?.activationStatus === activationStatus;
};
@@ -1,11 +0,0 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { WorkspaceActivationStatus } from '~/generated/graphql';
export const useIsWorkspaceActivationStatusSuspended = (): boolean => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
return (
currentWorkspace?.activationStatus === WorkspaceActivationStatus.SUSPENDED
);
};
@@ -8,7 +8,7 @@ import {
IconTrashX,
Section,
} from 'twenty-ui';
import { Role, SettingsFeatures } from '~/generated-metadata/graphql';
import { Role, SettingsPermissions } from '~/generated-metadata/graphql';
import { RolePermissionsObjectsTableHeader } from '~/pages/settings/roles/components/RolePermissionsObjectsTableHeader';
import { RolePermissionsSettingsTableHeader } from '~/pages/settings/roles/components/RolePermissionsSettingsTableHeader';
import { RolePermissionsSettingsTableRow } from '~/pages/settings/roles/components/RolePermissionsSettingsTableRow';
@@ -63,43 +63,43 @@ export const RolePermissions = ({ role }: RolePermissionsProps) => {
const settingsPermissionsConfig = [
{
key: SettingsFeatures.API_KEYS_AND_WEBHOOKS,
key: SettingsPermissions.API_KEYS_AND_WEBHOOKS,
label: 'API Keys and Webhooks',
type: 'Developer',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.ROLES,
key: SettingsPermissions.ROLES,
label: 'Roles',
type: 'Members',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.WORKSPACE,
key: SettingsPermissions.WORKSPACE,
label: 'Workspace Settings',
type: 'General',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.WORKSPACE_USERS,
key: SettingsPermissions.WORKSPACE_USERS,
label: 'Workspace Users',
type: 'Members',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.DATA_MODEL,
key: SettingsPermissions.DATA_MODEL,
label: 'Data Model',
type: 'Data Model',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.ADMIN_PANEL,
key: SettingsPermissions.ADMIN_PANEL,
label: 'Admin Panel',
type: 'Admin Panel',
value: role.canUpdateAllSettings,
},
{
key: SettingsFeatures.SECURITY,
key: SettingsPermissions.SECURITY,
label: 'Security Settings',
type: 'Security',
value: role.canUpdateAllSettings,
@@ -4,11 +4,14 @@ import { H2Title, IconLock, Section, Tag } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsReadDocumentationButton } from '@/settings/developers/components/SettingsReadDocumentationButton';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityOptionsList } from '@/settings/security/components/SettingsSecurityOptionsList';
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsApprovedAccessDomainsListCard } from '@/settings/security/components/approvedAccessDomains/SettingsApprovedAccessDomainsListCard';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
const StyledContainer = styled.div`
width: 100%;
@@ -21,13 +24,17 @@ const StyledMainContent = styled.div`
min-height: 200px;
`;
const StyledSSOSection = styled(Section)`
const StyledSection = styled(Section)`
flex-shrink: 0;
`;
export const SettingsSecurity = () => {
const { t } = useLingui();
const IsApprovedAccessDomainsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsApprovedAccessDomainsEnabled,
);
return (
<SubMenuTopBarContainer
title={t`Security`}
@@ -42,7 +49,7 @@ export const SettingsSecurity = () => {
>
<SettingsPageContainer>
<StyledMainContent>
<StyledSSOSection>
<StyledSection>
<H2Title
title={t`SSO`}
description={t`Configure an SSO connection`}
@@ -56,14 +63,23 @@ export const SettingsSecurity = () => {
}
/>
<SettingsSSOIdentitiesProvidersListCard />
</StyledSSOSection>
</StyledSection>
{IsApprovedAccessDomainsEnabled && (
<StyledSection>
<H2Title
title={t`Approved Email Domain`}
description={t`Anyone with an email address at these domains is allowed to sign up for this workspace.`}
/>
<SettingsApprovedAccessDomainsListCard />
</StyledSection>
)}
<Section>
<StyledContainer>
<H2Title
title={t`Authentication`}
description={t`Customize your workspace security`}
/>
<SettingsSecurityOptionsList />
<SettingsSecurityAuthProvidersOptionsList />
</StyledContainer>
</Section>
</StyledMainContent>
@@ -0,0 +1,152 @@
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { Trans, useLingui } from '@lingui/react/macro';
import { TextInput } from '@/ui/input/components/TextInput';
import { z } from 'zod';
import { H2Title, Section } from 'twenty-ui';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useCreateApprovedAccessDomainMutation } from '~/generated/graphql';
export const SettingsSecurityApprovedAccessDomain = () => {
const navigate = useNavigateSettings();
const { t } = useLingui();
const { enqueueSnackBar } = useSnackBar();
const [createApprovedAccessDomain] = useCreateApprovedAccessDomainMutation();
const formConfig = useForm<{ domain: string; email: string }>({
mode: 'onSubmit',
resolver: zodResolver(
z
.object({
domain: z
.string()
.regex(
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/,
{
message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`,
},
)
.max(256),
email: z.string().min(1, {
message: t`Email can not be empty`,
}),
})
.strict(),
),
defaultValues: {
email: '',
domain: '',
},
});
const domain = formConfig.watch('domain');
const handleSave = async () => {
try {
if (!formConfig.formState.isValid) {
return;
}
createApprovedAccessDomain({
variables: {
input: {
domain: formConfig.getValues('domain'),
email:
formConfig.getValues('email') +
'@' +
formConfig.getValues('domain'),
},
},
onCompleted: () => {
enqueueSnackBar(t`Domain added successfully.`, {
variant: SnackBarVariant.Success,
});
navigate(SettingsPath.Security);
},
onError: (error) => {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
},
});
} catch (error) {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
};
return (
<SubMenuTopBarContainer
title="New Approved Access Domain"
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Security)}
onSave={formConfig.handleSubmit(handleSave)}
/>
}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Security</Trans>,
href: getSettingsPath(SettingsPath.Security),
},
{ children: <Trans>New Approved Access Domain</Trans> },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title="Domain" description="The name of your Domain" />
<Controller
name="domain"
control={formConfig.control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextInput
autoComplete="off"
value={value}
onChange={(domain: string) => {
onChange(domain);
}}
fullWidth
placeholder="yourdomain.com"
error={error?.message}
/>
)}
/>
</Section>
<Section>
<H2Title
title="Email verification"
description="We will send your a link to verify domain ownership"
/>
<Controller
name="email"
control={formConfig.control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextInput
autoComplete="off"
value={value.split('@')[0]}
onChange={onChange}
fullWidth
error={error?.message}
/>
)}
/>
{domain}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,7 +1,7 @@
/* @license Enterprise */
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SettingsSSOIdentitiesProvidersForm';
import SettingsSSOIdentitiesProvidersForm from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersForm';
import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider';
import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider';
import { sSOIdentityProviderDefaultValues } from '@/settings/security/utils/sSOIdentityProviderDefaultValues';
@@ -11,6 +11,7 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import pick from 'lodash.pick';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -64,14 +65,14 @@ export const SettingsSecuritySSOIdentifyProvider = () => {
}
links={[
{
children: 'Workspace',
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: 'Security',
children: <Trans>Security</Trans>,
href: getSettingsPath(SettingsPath.Security),
},
{ children: 'New' },
{ children: <Trans>New SSO provider</Trans> },
]}
>
<FormProvider
@@ -45,7 +45,7 @@ export const SettingsDomain = () => {
.regex(
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/,
{
message: t`Invalid custom domain. Custom domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`,
message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`,
},
)
.max(256)
@@ -3,7 +3,7 @@ import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
import {
FeatureFlagKey,
OnboardingStatus,
SettingsFeatures,
SettingsPermissions,
SubscriptionInterval,
SubscriptionStatus,
User,
@@ -129,7 +129,7 @@ export const mockedUserData: MockedUser = {
workspaceMember: mockedWorkspaceMemberData,
currentWorkspace: mockCurrentWorkspace,
currentUserWorkspace: {
settingsPermissions: [SettingsFeatures.WORKSPACE_USERS],
settingsPermissions: [SettingsPermissions.WORKSPACE_USERS],
},
locale: 'en',
workspaces: [{ workspace: mockCurrentWorkspace }],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-server",
"version": "0.42.0-canary",
"version": "0.42.16",
"description": "",
"author": "",
"private": true,
@@ -8,6 +8,8 @@ import {
BaseCommandRunner,
} from 'src/database/commands/base.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
export type ActiveWorkspacesCommandOptions = BaseCommandOptions & {
workspaceId?: string;
startFromWorkspaceId?: string;
@@ -19,7 +21,10 @@ export abstract class ActiveWorkspacesCommandRunner extends BaseCommandRunner {
private startFromWorkspaceId: string | undefined;
private workspaceCountLimit: number | undefined;
constructor(protected readonly workspaceRepository: Repository<Workspace>) {
constructor(
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super();
}
@@ -122,6 +127,54 @@ export abstract class ActiveWorkspacesCommandRunner extends BaseCommandRunner {
);
}
protected async processEachWorkspaceWithWorkspaceDataSource(
workspaceIds: string[],
callback: ({
workspaceId,
index,
total,
dataSource,
}: {
workspaceId: string;
index: number;
total: number;
dataSource: WorkspaceDataSource;
}) => Promise<void>,
): Promise<void> {
this.logger.log(
chalk.green(`Running command on ${workspaceIds.length} workspaces`),
);
for (const [index, workspaceId] of workspaceIds.entries()) {
this.logger.log(
chalk.green(
`Processing workspace ${workspaceId} (${index + 1}/${
workspaceIds.length
})`,
),
);
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
workspaceId,
false,
);
try {
await callback({
workspaceId,
index,
total: workspaceIds.length,
dataSource,
});
} catch (error) {
this.logger.error(`Error in workspace ${workspaceId}: ${error}`);
}
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
}
protected abstract executeActiveWorkspacesCommand(
passedParams: string[],
options: BaseCommandOptions,
@@ -7,8 +7,6 @@ import { DataSeedDemoWorkspaceCommand } from 'src/database/commands/data-seed-de
import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-demo-workspace/data-seed-demo-workspace.module';
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
import { UpgradeTo0_40CommandModule } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module';
import { UpgradeTo0_41CommandModule } from 'src/database/commands/upgrade-version/0-41/0-41-upgrade-version.module';
import { UpgradeTo0_42CommandModule } from 'src/database/commands/upgrade-version/0-42/0-42-upgrade-version.module';
import { UpgradeTo0_43CommandModule } from 'src/database/commands/upgrade-version/0-43/0-43-upgrade-version.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
@@ -51,8 +49,6 @@ import { WorkspaceSyncMetadataModule } from 'src/engine/workspace-manager/worksp
DataSeedDemoWorkspaceModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
UpgradeTo0_40CommandModule,
UpgradeTo0_41CommandModule,
UpgradeTo0_42CommandModule,
UpgradeTo0_43CommandModule,
FeatureFlagModule,
@@ -1,245 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { isCommandLogger } from 'src/database/commands/logger';
import { AGGREGATE_OPERATIONS } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import {
VIEW_FIELD_STANDARD_FIELD_IDS,
VIEW_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';
@Command({
name: 'upgrade-0.40:migrate-aggregate-operation-options',
description: 'Add aggregate operations options to relevant fields',
})
export class MigrateAggregateOperationOptionsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository);
}
ADDITIONAL_AGGREGATE_OPERATIONS = [
{
value: AGGREGATE_OPERATIONS.countEmpty,
label: 'Count empty',
position: 5,
color: 'red',
},
{
value: AGGREGATE_OPERATIONS.countNotEmpty,
label: 'Count not empty',
position: 6,
color: 'purple',
},
{
value: AGGREGATE_OPERATIONS.countUniqueValues,
label: 'Count unique values',
position: 7,
color: 'sky',
},
{
value: AGGREGATE_OPERATIONS.percentageEmpty,
label: 'Percent empty',
position: 8,
color: 'turquoise',
},
{
value: AGGREGATE_OPERATIONS.percentageNotEmpty,
label: 'Percent not empty',
position: 9,
color: 'yellow',
},
];
ADDITIONAL_AGGREGATE_OPERATIONS_VALUES =
this.ADDITIONAL_AGGREGATE_OPERATIONS.map((option) => option.value);
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to migrate aggregate operations options to include count operations',
);
if (isCommandLogger(this.logger)) {
this.logger.setVerbose(options.verbose ?? false);
}
let workspaceIterator = 1;
for (const workspaceId of workspaceIds) {
this.logger.log(
`Running command for workspace ${workspaceId} ${workspaceIterator}/${workspaceIds.length}`,
);
try {
const viewFieldObjectMetadata =
await this.objectMetadataRepository.findOne({
where: {
workspaceId,
standardId: STANDARD_OBJECT_IDS.viewField,
},
});
if (!isDefined(viewFieldObjectMetadata)) {
throw new Error(
`View field object metadata not found for workspace ${workspaceId}`,
);
}
const viewFieldAggregateOperationFieldMetadata =
await this.fieldMetadataRepository.findOne({
where: {
workspaceId,
objectMetadataId: viewFieldObjectMetadata.id,
standardId: VIEW_FIELD_STANDARD_FIELD_IDS.aggregateOperation,
},
});
if (isDefined(viewFieldAggregateOperationFieldMetadata)) {
await this.updateAggregateOperationField(
workspaceId,
viewFieldAggregateOperationFieldMetadata,
viewFieldObjectMetadata,
);
}
const viewObjectMetadata = await this.objectMetadataRepository.findOne({
where: {
workspaceId,
standardId: STANDARD_OBJECT_IDS.view,
},
});
if (!isDefined(viewObjectMetadata)) {
throw new Error(
`View object metadata not found for workspace ${workspaceId}`,
);
}
const viewAggregateOperationFieldMetadata =
await this.fieldMetadataRepository.findOne({
where: {
workspaceId,
objectMetadataId: viewObjectMetadata.id,
standardId: VIEW_STANDARD_FIELD_IDS.kanbanAggregateOperation,
},
});
if (isDefined(viewAggregateOperationFieldMetadata)) {
await this.updateAggregateOperationField(
workspaceId,
viewAggregateOperationFieldMetadata,
viewObjectMetadata,
);
}
if (
isDefined(viewAggregateOperationFieldMetadata) ||
isDefined(viewFieldAggregateOperationFieldMetadata)
) {
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
}
workspaceIterator++;
this.logger.log(
chalk.green(`Command completed for workspace ${workspaceId}.`),
);
} catch {
this.logger.log(chalk.red(`Error in workspace ${workspaceId}.`));
workspaceIterator++;
}
}
this.logger.log(chalk.green(`Command completed!`));
}
private async updateAggregateOperationField(
workspaceId: string,
fieldMetadata: FieldMetadataEntity,
objectMetadata: ObjectMetadataEntity,
) {
if (
fieldMetadata.options.some((option) => {
return this.ADDITIONAL_AGGREGATE_OPERATIONS_VALUES.includes(
option.value as AGGREGATE_OPERATIONS,
);
})
) {
this.logger.log(
`Aggregate operation field metadata ${fieldMetadata.name} already has the required options`,
);
} else {
const updatedFieldMetadata = {
...fieldMetadata,
options: [
...fieldMetadata.options,
...this.ADDITIONAL_AGGREGATE_OPERATIONS.map((operation) => ({
...operation,
id: v4(),
})),
],
};
await this.fieldMetadataRepository.save(updatedFieldMetadata);
await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(
`update-${objectMetadata.nameSingular}-aggregate-operation`,
),
workspaceId,
[
{
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.ALTER,
columns: this.workspaceMigrationFactory.createColumnActions(
WorkspaceMigrationColumnActionType.ALTER,
fieldMetadata,
updatedFieldMetadata,
),
} satisfies WorkspaceMigrationTableAction,
],
);
}
}
}
@@ -1,248 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command, Option } from 'nest-commander';
import { WorkspaceActivationStatus } from 'twenty-shared';
import { In, Repository } from 'typeorm';
import {
BaseCommandOptions,
BaseCommandRunner,
} from 'src/database/commands/base.command';
import { rawDataSource } from 'src/database/typeorm/raw/raw.datasource';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
type UpdateInactiveWorkspaceStatusOptions = BaseCommandOptions & {
workspaceIds: string[];
};
@Command({
name: 'upgrade-0.40:update-inactive-workspace-status',
description:
'Update the status of inactive workspaces to SUSPENDED and delete them',
})
export class UpdateInactiveWorkspaceStatusCommand extends BaseCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(DataSourceEntity, 'metadata')
protected readonly datasourceRepository: Repository<DataSourceEntity>,
@InjectRepository(WorkspaceMigrationEntity, 'metadata')
protected readonly workspaceMigrationRepository: Repository<WorkspaceMigrationEntity>,
@InjectRepository(BillingSubscription, 'core')
protected readonly subscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlag, 'core')
protected readonly featureFlagRepository: Repository<FeatureFlag>,
@InjectRepository(KeyValuePair, 'core')
protected readonly keyValuePairRepository: Repository<KeyValuePair>,
@InjectRepository(UserWorkspace, 'core')
protected readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(User, 'core')
protected readonly userRepository: Repository<User>,
private readonly typeORMService: TypeORMService,
) {
super();
}
@Option({
flags: '-w, --workspace-ids [workspaceIds]',
description: 'Workspace ids to process (comma separated)',
})
parseWorkspaceIds(val: string): string[] {
return val.split(',');
}
override async executeBaseCommand(
_passedParams: string[],
options: UpdateInactiveWorkspaceStatusOptions,
): Promise<void> {
const whereCondition: any = {
activationStatus: WorkspaceActivationStatus.INACTIVE,
};
if (options.workspaceIds?.length > 0) {
whereCondition.id = In(options.workspaceIds);
}
const workspaces = await this.workspaceRepository.find({
where: whereCondition,
});
if (options.dryRun) {
this.logger.log(chalk.yellow('Dry run mode: No changes will be applied'));
}
this.logger.log(
chalk.blue(
`Found ${workspaces.length} inactive workspace${
workspaces.length > 1 ? 's' : ''
}`,
),
);
await rawDataSource.initialize();
for (const workspace of workspaces) {
this.logger.log(
chalk.blue(
`Processing workspace ${workspace.id} with name ${workspace.displayName}`,
),
);
// Check if the workspace has a datasource
const datasource = await this.datasourceRepository.findOne({
where: { workspaceId: workspace.id },
});
const schemaName = datasource?.schema;
const postgresSchemaExists = await this.typeORMService
.getMainDataSource()
.query(
`SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '${schemaName}'`,
);
if (!schemaName || !postgresSchemaExists) {
await this.deleteWorkspaceAndMarkAsSuspended(workspace, options);
continue;
}
const subscriptions = await this.subscriptionRepository.find({
where: { workspaceId: workspace.id },
});
if (subscriptions.length > 1) {
this.logger.warn(chalk.red('More than one subscription found'));
continue;
}
const subscription = subscriptions[0];
if (!subscription) {
this.logger.log(chalk.red('No subscription found'));
await this.deleteWorkspaceAndMarkAsSuspendedAndDeleteAllData(
workspace,
schemaName,
options,
);
continue;
}
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
if (
([
SubscriptionStatus.Canceled,
SubscriptionStatus.Incomplete,
SubscriptionStatus.IncompleteExpired,
SubscriptionStatus.Unpaid,
SubscriptionStatus.Paused,
].includes(subscription.status) &&
subscription.canceledAt &&
subscription.canceledAt < thirtyDaysAgo) ||
(subscription.canceledAt === null &&
subscription.updatedAt &&
subscription.updatedAt < thirtyDaysAgo)
) {
await this.deleteWorkspaceAndMarkAsSuspendedAndDeleteAllData(
workspace,
schemaName,
options,
subscription,
);
continue;
}
await this.markAsSuspended(workspace, options);
}
}
private async deleteWorkspaceAndMarkAsSuspended(
workspace: Workspace,
options: UpdateInactiveWorkspaceStatusOptions,
) {
this.logger.log(
chalk.blue('(!!) Deleting workspace and marking as suspended'),
);
if (!options.dryRun) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
});
await this.workspaceRepository.softRemove({ id: workspace.id });
}
}
private async deleteWorkspaceAndMarkAsSuspendedAndDeleteAllData(
workspace: Workspace,
schemaName: string,
options: UpdateInactiveWorkspaceStatusOptions,
billingSubscription?: BillingSubscription,
) {
this.logger.warn(
chalk.blue(
`(!!!) Deleting workspace and marking as suspended and deleting all data for workspace updated at ${workspace.updatedAt} with subscription status ${billingSubscription?.status} and subscription updatedAt ${billingSubscription?.updatedAt} and canceledAt ${billingSubscription?.canceledAt}`,
),
);
if (!options.dryRun) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
});
await this.workspaceRepository.softRemove({ id: workspace.id });
await this.datasourceRepository.delete({ workspaceId: workspace.id });
await this.workspaceMigrationRepository.delete({
workspaceId: workspace.id,
});
await this.featureFlagRepository.delete({ workspaceId: workspace.id });
await this.keyValuePairRepository.delete({ workspaceId: workspace.id });
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { workspaceId: workspace.id },
});
for (const userWorkspace of userWorkspaces) {
await this.userWorkspaceRepository.delete({ id: userWorkspace.id });
const remainingUserWorkspaces =
await this.userWorkspaceRepository.count({
where: { userId: userWorkspace.userId },
});
if (remainingUserWorkspaces === 0) {
await this.userRepository.softRemove({ id: userWorkspace.userId });
}
}
await this.typeORMService
.getMainDataSource()
.query(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`);
}
}
private async markAsSuspended(
workspace: Workspace,
options: UpdateInactiveWorkspaceStatusOptions,
) {
this.logger.log(chalk.blue('(!) Marking as suspended'));
if (!options.dryRun) {
await this.workspaceRepository.update(workspace.id, {
activationStatus: WorkspaceActivationStatus.SUSPENDED,
});
}
}
}
@@ -1,37 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { MigrateAggregateOperationOptionsCommand } from 'src/database/commands/upgrade-version/0-40/0-40-migrate-aggregate-operations-options.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Command({
name: 'upgrade-0.40',
description: 'Upgrade to 0.40',
})
export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly migrateAggregateOperationOptionsCommand: MigrateAggregateOperationOptionsCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: BaseCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to upgrade to 0.40');
await this.migrateAggregateOperationOptionsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}
@@ -1,55 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MigrateAggregateOperationOptionsCommand } from 'src/database/commands/upgrade-version/0-40/0-40-migrate-aggregate-operations-options.command';
import { UpdateInactiveWorkspaceStatusCommand } from 'src/database/commands/upgrade-version/0-40/0-40-update-inactive-workspace-status.command';
import { UpgradeTo0_40Command } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
@Module({
imports: [
TypeOrmModule.forFeature(
[
Workspace,
BillingSubscription,
FeatureFlag,
KeyValuePair,
User,
UserWorkspace,
],
'core',
),
TypeOrmModule.forFeature(
[
ObjectMetadataEntity,
FieldMetadataEntity,
DataSourceEntity,
WorkspaceMigrationEntity,
],
'metadata',
),
WorkspaceMigrationRunnerModule,
WorkspaceMigrationModule,
WorkspaceMetadataVersionModule,
TypeORMModule,
],
providers: [
UpgradeTo0_40Command,
MigrateAggregateOperationOptionsCommand,
UpdateInactiveWorkspaceStatusCommand,
],
})
export class UpgradeTo0_40CommandModule {}
@@ -1,195 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared';
import { In, Repository, TableColumn } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { CommandLogger } from 'src/database/commands/logger';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
@Command({
name: 'upgrade-0.41:add-context-to-actor-composite-type',
description: 'Add context to actor composite type.',
})
export class AddContextToActorCompositeTypeCommand extends ActiveWorkspacesCommandRunner {
protected readonly logger;
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository);
this.logger = new CommandLogger({
constructorName: this.constructor.name,
verbose: false,
});
this.logger.setVerbose(false);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(`Running add-context-to-actor-composite-type command`);
if (options?.dryRun) {
this.logger.log(chalk.yellow('Dry run mode: No changes will be applied'));
}
for (const workspaceId of workspaceIds) {
try {
await this.execute(workspaceId, options?.dryRun);
this.logger.verbose(`Added for workspace: ${workspaceId}`);
} catch (error) {
this.logger.error(`Error for workspace: ${workspaceId}`, error);
}
}
}
private async execute(workspaceId: string, dryRun = false): Promise<void> {
this.logger.verbose(`Adding for workspace: ${workspaceId}`);
const actorFields = await this.fieldMetadataRepository.find({
where: {
type: FieldMetadataType.ACTOR,
workspaceId,
},
relations: ['object'],
});
// Filter and update fields with EMAIL or CALENDAR source
for (const field of actorFields) {
if (!field || !field.object) {
this.logger.verbose(
'field.objectMetadata is null',
workspaceId,
field.id,
);
continue;
}
await this.addContextColumn(
field,
`${field.name}Context`,
workspaceId,
dryRun,
);
const fieldRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
field.object.nameSingular,
);
if (!dryRun) {
const rowsToUpdate = await fieldRepository.update(
{
[field.name + 'Source']: In([
FieldActorSource.EMAIL,
FieldActorSource.CALENDAR,
]),
[field.name + 'Context']: {},
},
{
[field.name + 'Context']: {
provider: 'google',
},
},
);
this.logger.verbose(
`updated ${rowsToUpdate ? rowsToUpdate.affected : 0} rows`,
);
}
}
if (!dryRun) {
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
}
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
private async addContextColumn(
field: FieldMetadataEntity,
newColumnName: string,
workspaceId: string,
dryRun = false,
): Promise<void> {
const workspaceDataSource =
await this.workspaceDataSourceService.connectToWorkspaceDataSource(
workspaceId,
);
if (!workspaceDataSource) {
this.logger.verbose('No workspace data source found');
return;
}
const queryRunner = workspaceDataSource?.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const schemaName =
this.workspaceDataSourceService.getSchemaName(workspaceId);
try {
const hasColumn = await queryRunner.hasColumn(
`${schemaName}.${computeTableName(
field.object.nameSingular,
field?.object?.isCustom,
)}`,
newColumnName,
);
if (hasColumn) {
return;
}
if (!dryRun) {
await queryRunner.addColumn(
`${schemaName}.${computeTableName(
field.object.nameSingular,
field?.object?.isCustom,
)}`,
new TableColumn({
name: newColumnName,
type: 'jsonb',
default: `'{}'::"jsonb"`,
isNullable: true,
}),
);
await queryRunner.commitTransaction();
}
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -1,140 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared';
import { Repository } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { isCommandLogger } from 'src/database/commands/logger';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
deduceRelationDirection,
RelationDirection,
} from 'src/engine/utils/deduce-relation-direction.util';
@Command({
name: 'upgrade-0.41:migrate-relations-to-field-metadata',
description: 'Migrate relations to field metadata',
})
export class MigrateRelationsToFieldMetadataCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to create many to one relations');
if (isCommandLogger(this.logger)) {
this.logger.setVerbose(options.verbose ?? false);
}
try {
for (const [index, workspaceId] of workspaceIds.entries()) {
await this.processWorkspace(workspaceId, index, workspaceIds.length);
}
this.logger.log(chalk.green('Command completed!'));
} catch (error) {
this.logger.log(chalk.red('Error in workspace'));
}
}
private async processWorkspace(
workspaceId: string,
index: number,
total: number,
): Promise<void> {
try {
this.logger.log(
`Running command for workspace ${workspaceId} ${index + 1}/${total}`,
);
const fieldMetadataCollection = (await this.fieldMetadataRepository.find({
where: { workspaceId, type: FieldMetadataType.RELATION },
relations: ['fromRelationMetadata', 'toRelationMetadata'],
})) as unknown as FieldMetadataEntity<FieldMetadataType.RELATION>[];
if (!fieldMetadataCollection.length) {
this.logger.log(
chalk.yellow(
`No relation field metadata found for workspace ${workspaceId}.`,
),
);
return;
}
const fieldMetadataToUpdateCollection = fieldMetadataCollection.map(
(fieldMetadata) => this.mapFieldMetadata(fieldMetadata),
);
if (fieldMetadataToUpdateCollection.length > 0) {
await this.fieldMetadataRepository.save(
fieldMetadataToUpdateCollection,
);
}
this.logger.log(
chalk.green(`Command completed for workspace ${workspaceId}.`),
);
} catch {
this.logger.log(chalk.red(`Error in workspace ${workspaceId}.`));
}
}
private mapFieldMetadata(
fieldMetadata: FieldMetadataEntity<FieldMetadataType.RELATION>,
): FieldMetadataEntity<FieldMetadataType.RELATION> {
const relationMetadata =
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
const relationDirection = deduceRelationDirection(
fieldMetadata,
relationMetadata,
);
let relationType = relationMetadata.relationType as unknown as RelationType;
if (
relationDirection === RelationDirection.TO &&
relationType === RelationType.ONE_TO_MANY
) {
relationType = RelationType.MANY_TO_ONE;
}
const relationTargetFieldMetadataId =
relationDirection === RelationDirection.FROM
? relationMetadata.toFieldMetadataId
: relationMetadata.fromFieldMetadataId;
const relationTargetObjectMetadataId =
relationDirection === RelationDirection.FROM
? relationMetadata.toObjectMetadataId
: relationMetadata.fromObjectMetadataId;
return {
...fieldMetadata,
settings: {
relationType,
onDelete: relationMetadata.onDeleteAction,
},
relationTargetFieldMetadataId,
relationTargetObjectMetadataId,
};
}
}
@@ -1,98 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Command({
name: 'upgrade-0.41:remove-duplicate-mcmas',
description: 'Remove duplicate mcmas.',
})
export class RemoveDuplicateMcmasCommand extends ActiveWorkspacesCommandRunner {
protected readonly logger: Logger;
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
this.logger = new Logger(this.constructor.name);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
const { dryRun } = _options;
for (const workspaceId of workspaceIds) {
try {
await this.execute(workspaceId, dryRun);
} catch (error) {
this.logger.error(
`Error removing duplicate mcmas for workspace ${workspaceId}: ${error}`,
);
}
}
}
private async execute(workspaceId: string, dryRun = false): Promise<void> {
this.logger.log(`Removing duplicate mcmas for workspace: ${workspaceId}`);
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'messageChannelMessageAssociation',
);
const queryBuilder = repository.createQueryBuilder(
'messageChannelMessageAssociation',
);
const duplicateMcmas = await queryBuilder
.select(`"messageChannelId"`)
.addSelect(`"messageId"`)
.where(`"deletedAt" IS NULL`)
.groupBy(`"messageId"`)
.addGroupBy(`"messageChannelId"`)
.having(`COUNT("messageChannelId") > 1`)
.getRawMany();
this.logger.log(`Found ${duplicateMcmas.length} duplicate mcmas`);
for (const duplicateMca of duplicateMcmas) {
const mcmas = await repository.find({
where: {
messageId: duplicateMca.messageId,
messageChannelId: duplicateMca.messageChannelId,
},
});
this.logger.log(
`Found ${mcmas.length} mcmas for message ${duplicateMca.messageId} and message channel ${duplicateMca.messageChannelId}`,
);
const mcaIdsToDelete = mcmas.slice(1).map((mca) => mca.id);
if (mcaIdsToDelete.length > 0) {
this.logger.log(`Deleting ${mcaIdsToDelete.length} mcas`);
if (!dryRun) {
await repository.delete(mcaIdsToDelete);
}
}
}
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
}
@@ -1,207 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { EntityManager, IsNull, Not, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { createWorkspaceViews } from 'src/engine/workspace-manager/standard-objects-prefill-data/create-workspace-views';
import { workflowRunsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflow-runs-all.view';
import { workflowVersionsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflow-versions-all.view';
import { workflowsAllView } from 'src/engine/workspace-manager/standard-objects-prefill-data/views/workflows-all.view';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
@Command({
name: 'upgrade-0.41:workflow-seed-views',
description: 'Seed workflow views for workspace.',
})
export class SeedWorkflowViewsCommand extends ActiveWorkspacesCommandRunner {
protected readonly logger: Logger;
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly objectMetadataService: ObjectMetadataService,
) {
super(workspaceRepository);
this.logger = new Logger(this.constructor.name);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
_workspaceIds: string[],
): Promise<void> {
const { dryRun } = _options;
for (const workspaceId of _workspaceIds) {
await this.execute(workspaceId, dryRun);
}
}
private async execute(workspaceId: string, dryRun = false): Promise<void> {
this.logger.log(`Seeding workflow views for workspace: ${workspaceId}`);
const workflowObjectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
standardId: STANDARD_OBJECT_IDS.workflow,
},
});
if (!workflowObjectMetadata) {
this.logger.error('Workflow object metadata not found');
return;
}
await this.seedWorkflowViews(
workspaceId,
workflowObjectMetadata.id,
dryRun,
);
await this.seedWorkspaceFavorite(
workspaceId,
workflowObjectMetadata.id,
dryRun,
);
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
private async seedWorkflowViews(
workspaceId: string,
workflowObjectMetadataId: string,
dryRun = false,
) {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'view',
);
const existingWorkflowView = await viewRepository.findOne({
where: {
objectMetadataId: workflowObjectMetadataId,
},
});
if (existingWorkflowView) {
this.logger.log(`View already exists: ${existingWorkflowView.id}`);
return;
}
if (dryRun) {
this.logger.log(`Dry run: not creating view`);
return;
}
const { objectMetadataStandardIdToIdMap } =
await this.objectMetadataService.getObjectMetadataStandardIdToIdMap(
workspaceId,
);
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
if (!workspaceDataSource) {
this.logger.error('Could not connect to workspace data source');
return;
}
const viewDefinitions = [
workflowsAllView(objectMetadataStandardIdToIdMap),
workflowVersionsAllView(objectMetadataStandardIdToIdMap),
workflowRunsAllView(objectMetadataStandardIdToIdMap),
];
await workspaceDataSource.transaction(
async (entityManager: EntityManager) => {
return createWorkspaceViews(
entityManager,
dataSourceMetadata.schema,
viewDefinitions,
);
},
);
}
private async seedWorkspaceFavorite(
workspaceId: string,
workflowObjectMetadataId: string,
dryRun = false,
) {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'view',
);
const workflowView = await viewRepository.findOne({
where: {
objectMetadataId: workflowObjectMetadataId,
},
});
if (!workflowView) {
this.logger.error('Workflow view not found');
return;
}
if (dryRun) {
this.logger.log(`Dry run: not creating favorite`);
return;
}
const favoriteRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'favorite',
);
const existingFavorites = await favoriteRepository.find({
where: {
viewId: Not(IsNull()),
},
});
const workflowFavorite = existingFavorites.find(
(favorite) => favorite.viewId === workflowView.id,
);
if (workflowFavorite) {
this.logger.log(`Favorite already exists: ${workflowFavorite.id}`);
return;
}
await favoriteRepository.insert({
viewId: workflowView.id,
position: existingFavorites.length,
});
}
}
@@ -1,72 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command';
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { AddContextToActorCompositeTypeCommand } from 'src/database/commands/upgrade-version/0-41/0-41-add-context-to-actor-composite-type';
import { MigrateRelationsToFieldMetadataCommand } from 'src/database/commands/upgrade-version/0-41/0-41-migrate-relations-to-field-metadata.command';
import { RemoveDuplicateMcmasCommand } from 'src/database/commands/upgrade-version/0-41/0-41-remove-duplicate-mcmas';
import { SeedWorkflowViewsCommand } from 'src/database/commands/upgrade-version/0-41/0-41-seed-workflow-views.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command';
@Command({
name: 'upgrade-0.41',
description: 'Upgrade to 0.41',
})
export class UpgradeTo0_41Command extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly seedWorkflowViewsCommand: SeedWorkflowViewsCommand,
private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand,
private readonly migrateRelationsToFieldMetadata: MigrateRelationsToFieldMetadataCommand,
private readonly addContextToActorCompositeType: AddContextToActorCompositeTypeCommand,
private readonly removeDuplicateMcmasCommand: RemoveDuplicateMcmasCommand,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
passedParam: string[],
options: BaseCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log('Running command to upgrade to 0.41');
await this.removeDuplicateMcmasCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.addContextToActorCompositeType.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand(
passedParam,
{
...options,
force: true,
},
workspaceIds,
);
await this.seedWorkflowViewsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.migrateRelationsToFieldMetadata.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
}
}

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