Compare commits

...
11 Commits
Author SHA1 Message Date
EtienneandCharles Bochet 177bba0b61 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:14:39 +01:00
Charles Bochet 5ec2b0f1aa Bump version 2025-02-21 12:14:32 +01:00
prastoin d1335a7330 chore(all): v0.42.13 2025-02-21 12:08:48 +01:00
Raphaël BosiandGitHub ec9587414b Fix open record in setting for command menu v1 (#10383)
- Hide open record in settings for command menu version 1 users
- Create a selector to check which version of the command menu the user
has before returning if the record should be opened in the record page
or in the side panel
2025-02-21 12:06:27 +01:00
Charles BochetandGitHub 311fc402d5 Enhance ActiveWorkspaceCommand to order, limit and startFrom workspaceId (#10378)
I have tested it locally with all combinaisons of params
2025-02-21 10:37:04 +01:00
nitinandGitHub 4720afe3fb health status refactor followup fix (#10379) 2025-02-21 10:32:00 +01:00
Raphaël BosiandGitHub 9f454c565b 410 open in side panel (#10363)
Closes https://github.com/twentyhq/core-team-issues/issues/410

- Added `openRecordIn` column in the `view` entity, which is set to
`SIDE_PANEL` by default
- Created a new option inside the view option dropdown to be able to set
`openRecordIn`
- Updated all record show page openings to reflect the setting behavior
- For `workflow`, `workflowVersion` and `workflowRun` (what I call
workflow objects), we want the default view `openRecordIn` to be set to
`RECORD_PAGE`. When seeding the views for the new workspaces, we set
`openRecordIn` to `RECORD_PAGE` for workflow objects. Since the workflow
objects views `openRecordIn` will be set to the default value
`SIDE_PANEL` for the existing workspaces when the sync metadata runs, I
created a script to run in the 0.43 update to update this value.
- Updated `closeCommandMenu` because of problems introduced by the
animate presence wrapper around the command menu. We now reset the
states at the end of the animation.

Note: We want to be able to open all workflow objects pages in the side
panel, but this requires some refactoring of the workflow module. For
now @Bonapara wanted to allow the possibility to change the
`openRecordIn` setting to `SIDE_PANEL` even for the workflows even if
it's buggy and not ready for the moment. Since this is an experimental
feature, it shouldn't cause too many problems.
2025-02-21 09:27:33 +00:00
e301c7856b Add all object level read-only behavior (#10356)
Fixes https://github.com/twentyhq/core-team-issues/issues/427

---------

Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
2025-02-21 09:59:47 +01:00
nitinandGitHub c46f7848b7 refactor + new account sync metrics + isolating health status inside folder admin-panel > health-status (#10314)
closes https://github.com/twentyhq/core-team-issues/issues/444
https://github.com/twentyhq/core-team-issues/issues/443
https://github.com/twentyhq/core-team-issues/issues/442
2025-02-21 09:48:47 +01:00
Charles Bochet 41bbb4b47f Fix upgrade command 0.42 2025-02-21 00:20:44 +01:00
Weiko d397981d99 Fix billing page routing 2025-02-20 23:49:16 +01:00
199 changed files with 2733 additions and 1149 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-e2e-testing",
"version": "0.42.0-canary",
"version": "0.42.14",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.42.0-canary",
"version": "0.42.14",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.42.0-canary",
"version": "0.42.14",
"private": true,
"type": "module",
"scripts": {
+35 -46
View File
@@ -27,7 +27,10 @@ export type ActivateWorkspaceInput = {
export type AdminPanelHealthServiceData = {
__typename?: 'AdminPanelHealthServiceData';
description: Scalars['String'];
details?: Maybe<Scalars['String']>;
id: Scalars['String'];
label: Scalars['String'];
queues?: Maybe<Array<AdminPanelWorkerQueueHealth>>;
status: AdminPanelHealthServiceStatus;
};
@@ -37,17 +40,11 @@ export enum AdminPanelHealthServiceStatus {
OUTAGE = 'OUTAGE'
}
export enum AdminPanelIndicatorHealthStatusInputEnum {
DATABASE = 'DATABASE',
MESSAGE_SYNC = 'MESSAGE_SYNC',
REDIS = 'REDIS',
WORKER = 'WORKER'
}
export type AdminPanelWorkerQueueHealth = {
__typename?: 'AdminPanelWorkerQueueHealth';
id: Scalars['String'];
metrics: WorkerQueueMetrics;
name: Scalars['String'];
queueName: Scalars['String'];
status: AdminPanelHealthServiceStatus;
workers: Scalars['Float'];
};
@@ -614,6 +611,13 @@ export type GetServerlessFunctionSourceCodeInput = {
version?: Scalars['String'];
};
export enum HealthIndicatorId {
connectedAccount = 'connectedAccount',
database = 'database',
redis = 'redis',
worker = 'worker'
}
export enum IdentityProviderType {
OIDC = 'OIDC',
SAML = 'SAML'
@@ -1306,7 +1310,7 @@ export type QueryGetAvailablePackagesArgs = {
export type QueryGetIndicatorHealthStatusArgs = {
indicatorName: AdminPanelIndicatorHealthStatusInputEnum;
indicatorId: HealthIndicatorId;
};
@@ -1630,10 +1634,14 @@ export type Support = {
export type SystemHealth = {
__typename?: 'SystemHealth';
database: AdminPanelHealthServiceData;
messageSync: AdminPanelHealthServiceData;
redis: AdminPanelHealthServiceData;
worker: AdminPanelHealthServiceData;
services: Array<SystemHealthService>;
};
export type SystemHealthService = {
__typename?: 'SystemHealthService';
id: HealthIndicatorId;
label: Scalars['String'];
status: AdminPanelHealthServiceStatus;
};
export type TimelineCalendarEvent = {
@@ -2292,16 +2300,16 @@ export type GetEnvironmentVariablesGroupedQueryVariables = Exact<{ [key: string]
export type GetEnvironmentVariablesGroupedQuery = { __typename?: 'Query', getEnvironmentVariablesGrouped: { __typename?: 'EnvironmentVariablesOutput', groups: Array<{ __typename?: 'EnvironmentVariablesGroupData', name: EnvironmentVariablesGroup, description: string, isHiddenOnLoad: boolean, variables: Array<{ __typename?: 'EnvironmentVariable', name: string, description: string, value: string, sensitive: boolean }> }> } };
export type GetIndicatorHealthStatusQueryVariables = Exact<{
indicatorName: AdminPanelIndicatorHealthStatusInputEnum;
indicatorId: HealthIndicatorId;
}>;
export type GetIndicatorHealthStatusQuery = { __typename?: 'Query', getIndicatorHealthStatus: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null, queues?: Array<{ __typename?: 'AdminPanelWorkerQueueHealth', name: string, status: AdminPanelHealthServiceStatus, workers: number, metrics: { __typename?: 'WorkerQueueMetrics', failed: number, completed: number, waiting: number, active: number, delayed: number, prioritized: number } }> | null } };
export type GetIndicatorHealthStatusQuery = { __typename?: 'Query', getIndicatorHealthStatus: { __typename?: 'AdminPanelHealthServiceData', id: string, label: string, description: string, status: AdminPanelHealthServiceStatus, details?: string | null, queues?: Array<{ __typename?: 'AdminPanelWorkerQueueHealth', id: string, queueName: string, status: AdminPanelHealthServiceStatus, workers: number, metrics: { __typename?: 'WorkerQueueMetrics', failed: number, completed: number, waiting: number, active: number, delayed: number, prioritized: number } }> | null } };
export type GetSystemHealthStatusQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', database: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null }, redis: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null }, worker: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, queues?: Array<{ __typename?: 'AdminPanelWorkerQueueHealth', name: string, workers: number, status: AdminPanelHealthServiceStatus, metrics: { __typename?: 'WorkerQueueMetrics', failed: number, completed: number, waiting: number, active: number, delayed: number, prioritized: number } }> | null }, messageSync: { __typename?: 'AdminPanelHealthServiceData', status: AdminPanelHealthServiceStatus, details?: string | null } } };
export type GetSystemHealthStatusQuery = { __typename?: 'Query', getSystemHealthStatus: { __typename?: 'SystemHealth', services: Array<{ __typename?: 'SystemHealthService', id: HealthIndicatorId, label: string, status: AdminPanelHealthServiceStatus }> } };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
@@ -4018,12 +4026,16 @@ export type GetEnvironmentVariablesGroupedQueryHookResult = ReturnType<typeof us
export type GetEnvironmentVariablesGroupedLazyQueryHookResult = ReturnType<typeof useGetEnvironmentVariablesGroupedLazyQuery>;
export type GetEnvironmentVariablesGroupedQueryResult = Apollo.QueryResult<GetEnvironmentVariablesGroupedQuery, GetEnvironmentVariablesGroupedQueryVariables>;
export const GetIndicatorHealthStatusDocument = gql`
query GetIndicatorHealthStatus($indicatorName: AdminPanelIndicatorHealthStatusInputEnum!) {
getIndicatorHealthStatus(indicatorName: $indicatorName) {
query GetIndicatorHealthStatus($indicatorId: HealthIndicatorId!) {
getIndicatorHealthStatus(indicatorId: $indicatorId) {
id
label
description
status
details
queues {
name
id
queueName
status
workers
metrics {
@@ -4051,7 +4063,7 @@ export const GetIndicatorHealthStatusDocument = gql`
* @example
* const { data, loading, error } = useGetIndicatorHealthStatusQuery({
* variables: {
* indicatorName: // value for 'indicatorName'
* indicatorId: // value for 'indicatorId'
* },
* });
*/
@@ -4069,33 +4081,10 @@ export type GetIndicatorHealthStatusQueryResult = Apollo.QueryResult<GetIndicato
export const GetSystemHealthStatusDocument = gql`
query GetSystemHealthStatus {
getSystemHealthStatus {
database {
services {
id
label
status
details
}
redis {
status
details
}
worker {
status
queues {
name
workers
status
metrics {
failed
completed
waiting
active
delayed
prioritized
}
}
}
messageSync {
status
details
}
}
}
@@ -11,6 +11,7 @@ import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRec
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useState } from 'react';
@@ -21,6 +22,8 @@ export const useDeleteMultipleRecordsAction: ActionHookWithObjectMetadataItem =
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
useState(false);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural,
});
@@ -77,6 +80,7 @@ export const useDeleteMultipleRecordsAction: ActionHookWithObjectMetadataItem =
const isRemoteObject = objectMetadataItem.isRemote;
const shouldBeRegistered =
!hasObjectReadOnlyPermission &&
!isRemoteObject &&
!isDeletedFilterActive &&
isDefined(contextStoreNumberOfSelectedRecords) &&
@@ -12,6 +12,7 @@ import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRec
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useState } from 'react';
@@ -26,6 +27,8 @@ export const useDestroyMultipleRecordsAction: ActionHookWithObjectMetadataItem =
recordTableId: objectMetadataItem.namePlural,
});
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { destroyManyRecords } = useDestroyManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
@@ -86,6 +89,7 @@ export const useDestroyMultipleRecordsAction: ActionHookWithObjectMetadataItem =
const isRemoteObject = objectMetadataItem.isRemote;
const shouldBeRegistered =
!hasObjectReadOnlyPermission &&
!isRemoteObject &&
isDeletedFilterActive &&
isDefined(contextStoreNumberOfSelectedRecords) &&
@@ -1,6 +1,7 @@
import { ActionHookWithObjectMetadataItem } from '@/action-menu/actions/types/ActionHook';
import { useCreateNewTableRecord } from '@/object-record/record-table/hooks/useCreateNewTableRecords';
import { getRecordIndexIdFromObjectNamePlural } from '@/object-record/utils/getRecordIndexIdFromObjectNamePlural';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
export const useCreateNewTableRecordNoSelectionRecordAction: ActionHookWithObjectMetadataItem =
({ objectMetadataItem }) => {
@@ -8,6 +9,8 @@ export const useCreateNewTableRecordNoSelectionRecordAction: ActionHookWithObjec
objectMetadataItem.namePlural,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { createNewTableRecord } = useCreateNewTableRecord({
objectMetadataItem,
recordTableId,
@@ -18,7 +21,7 @@ export const useCreateNewTableRecordNoSelectionRecordAction: ActionHookWithObjec
};
return {
shouldBeRegistered: true,
shouldBeRegistered: !hasObjectReadOnlyPermission,
onClick,
};
};
@@ -6,6 +6,7 @@ import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { isNull } from '@sniptt/guards';
@@ -21,6 +22,8 @@ export const useDeleteSingleRecordAction: ActionHookWithObjectMetadataItem = ({
const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] =
useState(false);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { resetTableRowSelection } = useRecordTable({
recordTableId: objectMetadataItem.namePlural,
});
@@ -61,7 +64,9 @@ export const useDeleteSingleRecordAction: ActionHookWithObjectMetadataItem = ({
const { isInRightDrawer } = useContext(ActionMenuContext);
const shouldBeRegistered =
!isRemoteObject && isNull(selectedRecord?.deletedAt);
!isRemoteObject &&
isNull(selectedRecord?.deletedAt) &&
!hasObjectReadOnlyPermission;
const onClick = () => {
if (!shouldBeRegistered) {
@@ -4,6 +4,7 @@ import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { AppPath } from '@/types/AppPath';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
@@ -20,6 +21,8 @@ export const useDestroySingleRecordAction: ActionHookWithObjectMetadataItem = ({
const [isDestroyRecordsModalOpen, setIsDestroyRecordsModalOpen] =
useState(false);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const navigateApp = useNavigateApp();
const { resetTableRowSelection } = useRecordTable({
@@ -54,7 +57,9 @@ export const useDestroySingleRecordAction: ActionHookWithObjectMetadataItem = ({
const { isInRightDrawer } = useContext(ActionMenuContext);
const shouldBeRegistered =
!isRemoteObject && isDefined(selectedRecord?.deletedAt);
!hasObjectReadOnlyPermission &&
!isRemoteObject &&
isDefined(selectedRecord?.deletedAt);
const onClick = () => {
if (!shouldBeRegistered) {
@@ -17,6 +17,7 @@ import { DropZone } from '@/activities/files/components/DropZone';
import { useAttachments } from '@/activities/files/hooks/useAttachments';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { isDefined } from 'twenty-shared';
const StyledAttachmentsContainer = styled.div`
@@ -46,6 +47,8 @@ export const Attachments = ({
const [isDraggingFile, setIsDraggingFile] = useState(false);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (isDefined(e.target.files)) onUploadFile?.(e.target.files[0]);
};
@@ -91,12 +94,14 @@ export const Attachments = ({
onChange={handleFileChange}
type="file"
/>
<Button
Icon={IconPlus}
title="Add file"
variant="secondary"
onClick={handleUploadFileClick}
/>
{!hasObjectReadOnlyPermission && (
<Button
Icon={IconPlus}
title="Add file"
variant="secondary"
onClick={handleUploadFileClick}
/>
)}
</AnimatedPlaceholderEmptyContainer>
)}
</StyledDropZoneContainer>
@@ -115,13 +120,15 @@ export const Attachments = ({
title="All"
attachments={attachments ?? []}
button={
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add file"
onClick={handleUploadFileClick}
></Button>
!hasObjectReadOnlyPermission && (
<Button
Icon={IconPlus}
size="small"
variant="secondary"
title="Add file"
onClick={handleUploadFileClick}
></Button>
)
}
/>
</StyledAttachmentsContainer>
@@ -4,6 +4,7 @@ import { NoteList } from '@/activities/notes/components/NoteList';
import { useNotes } from '@/activities/notes/hooks/useNotes';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import styled from '@emotion/styled';
import {
AnimatedPlaceholder,
@@ -31,6 +32,8 @@ export const Notes = ({
}) => {
const { notes, loading } = useNotes(targetableObject);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Note,
});
@@ -56,16 +59,18 @@ export const Notes = ({
There are no associated notes with this record.
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconPlus}
title="New note"
variant="secondary"
onClick={() =>
openCreateActivity({
targetableObjects: [targetableObject],
})
}
/>
{!hasObjectReadOnlyPermission && (
<Button
Icon={IconPlus}
title="New note"
variant="secondary"
onClick={() =>
openCreateActivity({
targetableObjects: [targetableObject],
})
}
/>
)}
</AnimatedPlaceholderEmptyContainer>
);
}
@@ -17,6 +17,7 @@ import { useTasks } from '@/activities/tasks/hooks/useTasks';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { Task } from '@/activities/types/Task';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import groupBy from 'lodash.groupby';
import { AddTaskButton } from './AddTaskButton';
@@ -38,6 +39,8 @@ export const TaskGroups = ({ targetableObjects }: TaskGroupsProps) => {
targetableObjects: targetableObjects ?? [],
});
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const openCreateActivity = useOpenCreateActivityDrawer({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
@@ -71,16 +74,18 @@ export const TaskGroups = ({ targetableObjects }: TaskGroupsProps) => {
All tasks addressed. Maintain the momentum.
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<Button
Icon={IconPlus}
title="New task"
variant={'secondary'}
onClick={() =>
openCreateActivity({
targetableObjects: targetableObjects ?? [],
})
}
/>
{!hasObjectReadOnlyPermission && (
<Button
Icon={IconPlus}
title="New task"
variant={'secondary'}
onClick={() =>
openCreateActivity({
targetableObjects: targetableObjects ?? [],
})
}
/>
)}
</AnimatedPlaceholderEmptyContainer>
);
}
@@ -300,7 +300,7 @@ export const SettingsRoutes = ({
<Route
element={
<SettingsProtectedRouteWrapper
requiredFeatureFlag={FeatureFlagKey.IsBillingPlansEnabled}
settingsPermission={SettingsFeatures.WORKSPACE}
/>
}
>
@@ -1,7 +1,10 @@
import { createState } from '@ui/utilities/state/utils/createState';
import { UserWorkspace } from '~/generated/graphql';
export type CurrentUserWorkspace = Pick<UserWorkspace, 'settingsPermissions'>;
export type CurrentUserWorkspace = Pick<
UserWorkspace,
'settingsPermissions' | 'objectRecordsPermissions'
>;
export const currentUserWorkspaceState =
createState<CurrentUserWorkspace | null>({
@@ -48,7 +48,11 @@ export const CommandMenuContainer = ({
}: {
children: React.ReactNode;
}) => {
const { toggleCommandMenu, closeCommandMenu } = useCommandMenu();
const {
toggleCommandMenu,
closeCommandMenu,
onCommandMenuCloseAnimationComplete,
} = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
@@ -98,7 +102,7 @@ export const CommandMenuContainer = ({
>
<ActionMenuContext.Provider
value={{
isInRightDrawer: false,
isInRightDrawer: true,
onActionExecutedCallback: ({ key }) => {
if (
key !== RecordAgnosticActionsKey.SEARCH_RECORDS &&
@@ -121,7 +125,10 @@ export const CommandMenuContainer = ({
<RunWorkflowRecordAgnosticActionMenuEntriesSetter />
)}
<ActionMenuConfirmationModals />
<AnimatePresence mode="wait">
<AnimatePresence
mode="wait"
onExitComplete={onCommandMenuCloseAnimationComplete}
>
{isCommandMenuOpened && (
<StyledCommandMenu
data-testid="command-menu"
@@ -34,16 +34,17 @@ export const CommandMenuContextChipGroupsWithRecordSelection = ({
/>
));
const recordSelectionContextChip = totalCount
? {
text: getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount,
),
Icons: Avatars,
}
: undefined;
const recordSelectionContextChip =
totalCount && records.length > 0
? {
text: getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount,
),
Icons: Avatars,
}
: undefined;
const contextChipsWithRecordSelection = [
recordSelectionContextChip,
@@ -21,7 +21,7 @@ export const CommandMenuContextRecordChip = ({
instanceId,
});
if (loading || !totalCount) {
if (loading || !totalCount || records.length === 0) {
return null;
}
@@ -192,6 +192,7 @@ describe('useCommandMenu', () => {
act(() => {
result.current.commandMenu.goBackFromCommandMenu();
result.current.commandMenu.onCommandMenuCloseAnimationComplete();
});
expect(result.current.commandMenuNavigationStack).toEqual([]);
@@ -74,31 +74,33 @@ export const useCommandMenu = () => {
);
const closeCommandMenu = useRecoilCallback(
({ snapshot, set }) =>
({ set }) =>
() => {
const isCommandMenuOpened = snapshot
.getLoadable(isCommandMenuOpenedState)
.getValue();
set(isCommandMenuOpenedState, false);
},
[],
);
if (isCommandMenuOpened) {
resetContextStoreStates('command-menu');
resetContextStoreStates('command-menu-previous');
const onCommandMenuCloseAnimationComplete = useRecoilCallback(
({ set }) =>
() => {
resetContextStoreStates('command-menu');
resetContextStoreStates('command-menu-previous');
set(viewableRecordIdState, null);
set(commandMenuPageState, CommandMenuPages.Root);
set(commandMenuPageInfoState, {
title: undefined,
Icon: undefined,
});
set(isCommandMenuOpenedState, false);
set(commandMenuSearchState, '');
set(commandMenuNavigationStackState, []);
resetSelectedItem();
set(hasUserSelectedCommandState, false);
goBackToPreviousHotkeyScope();
set(viewableRecordIdState, null);
set(commandMenuPageState, CommandMenuPages.Root);
set(commandMenuPageInfoState, {
title: undefined,
Icon: undefined,
});
set(isCommandMenuOpenedState, false);
set(commandMenuSearchState, '');
set(commandMenuNavigationStackState, []);
resetSelectedItem();
set(hasUserSelectedCommandState, false);
goBackToPreviousHotkeyScope();
emitRightDrawerCloseEvent();
}
emitRightDrawerCloseEvent();
},
[goBackToPreviousHotkeyScope, resetContextStoreStates, resetSelectedItem],
);
@@ -109,7 +111,10 @@ export const useCommandMenu = () => {
page,
pageTitle,
pageIcon,
}: CommandMenuNavigationStackItem) => {
resetNavigationStack = false,
}: CommandMenuNavigationStackItem & {
resetNavigationStack?: boolean;
}) => {
set(commandMenuPageState, page);
set(commandMenuPageInfoState, {
title: pageTitle,
@@ -120,10 +125,14 @@ export const useCommandMenu = () => {
.getLoadable(commandMenuNavigationStackState)
.getValue();
set(commandMenuNavigationStackState, [
...currentNavigationStack,
{ page, pageTitle, pageIcon },
]);
if (resetNavigationStack) {
set(commandMenuNavigationStackState, [{ page, pageTitle, pageIcon }]);
} else {
set(commandMenuNavigationStackState, [
...currentNavigationStack,
{ page, pageTitle, pageIcon },
]);
}
openCommandMenu();
};
},
@@ -248,6 +257,7 @@ export const useCommandMenu = () => {
? t`New ${capitalizedObjectNameSingular}`
: capitalizedObjectNameSingular,
pageIcon: Icon,
resetNavigationStack: true,
});
};
},
@@ -315,6 +325,7 @@ export const useCommandMenu = () => {
return {
openRootCommandMenu,
closeCommandMenu,
onCommandMenuCloseAnimationComplete,
navigateCommandMenu,
navigateCommandMenuHistory,
goBackFromCommandMenu,
@@ -7,6 +7,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState';
import { AppPath } from '@/types/AppPath';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewType } from '@/views/types/ViewType';
import { getCompanyObjectMetadataItem } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
@@ -38,6 +39,7 @@ const renderHooks = ({
type: ViewType.Table,
key: null,
isCompact: false,
openRecordIn: ViewOpenRecordInType.SIDE_PANEL,
viewFields: [],
viewGroups: [],
viewSorts: [],
@@ -1,9 +1,13 @@
import { AvatarChip, AvatarChipVariant } from 'twenty-ui';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { MouseEvent } from 'react';
import { useRecoilValue } from 'recoil';
export type RecordChipProps = {
objectNameSingular: string;
@@ -23,8 +27,20 @@ export const RecordChip = ({
record,
});
const { openRecordInCommandMenu } = useCommandMenu();
const recordIndexOpenRecordIn = useRecoilValue(
recordIndexOpenRecordInSelector,
);
const handleClick = (e: MouseEvent<Element>) => {
e.stopPropagation();
if (recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL) {
openRecordInCommandMenu({
recordId: record.id,
objectNameSingular,
});
}
};
return (
@@ -36,7 +52,11 @@ export const RecordChip = ({
className={className}
variant={variant}
onClick={handleClick}
to={getLinkToShowPage(objectNameSingular, record)}
to={
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
? getLinkToShowPage(objectNameSingular, record)
: undefined
}
/>
);
};
@@ -6,6 +6,7 @@ import { ObjectOptionsDropdownRecordGroupFieldsContent } from '@/object-record/o
import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent';
import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent';
import { ObjectOptionsDropdownViewSettingsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsContent';
import { ObjectOptionsDropdownViewSettingsOpenInContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownViewSettingsOpenInContent';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
export const ObjectOptionsDropdownContent = () => {
@@ -14,6 +15,8 @@ export const ObjectOptionsDropdownContent = () => {
switch (currentContentId) {
case 'viewSettings':
return <ObjectOptionsDropdownViewSettingsContent />;
case 'viewSettingsOpenIn':
return <ObjectOptionsDropdownViewSettingsOpenInContent />;
case 'fields':
return <ObjectOptionsDropdownFieldsContent />;
case 'hiddenFields':
@@ -32,7 +32,9 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewType } from '@/views/types/ViewType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const ObjectOptionsDropdownMenuContent = () => {
const {
@@ -99,13 +101,17 @@ export const ObjectOptionsDropdownMenuContent = () => {
objectMetadataItem.nameSingular !== CoreObjectNameSingular.Note &&
objectMetadataItem.nameSingular !== CoreObjectNameSingular.Task;
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
return (
<>
<DropdownMenuHeader StartIcon={CurrentViewIcon ?? IconList}>
{currentView?.name}
</DropdownMenuHeader>
{/** TODO: Should be removed when view settings contains more options */}
{viewType === ViewType.Kanban && (
{(isCommandMenuV2Enabled || viewType === ViewType.Kanban) && (
<>
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
@@ -118,6 +124,7 @@ export const ObjectOptionsDropdownMenuContent = () => {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
onClick={() => onContentChange('fields')}
@@ -1,21 +1,34 @@
import {
IconBaselineDensitySmall,
IconChevronLeft,
IconLayoutNavbar,
IconLayoutSidebarRight,
MenuItem,
MenuItemToggle,
} from 'twenty-ui';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewType } from '@/views/types/ViewType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const ObjectOptionsDropdownViewSettingsContent = () => {
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const { recordIndexId, objectMetadataItem, viewType, resetContent } =
useOptionsDropdown();
const {
recordIndexId,
objectMetadataItem,
viewType,
resetContent,
onContentChange,
} = useOptionsDropdown();
const { isCompactModeActive, setAndPersistIsCompactModeActive } =
useObjectOptionsForBoard({
@@ -24,12 +37,35 @@ export const ObjectOptionsDropdownViewSettingsContent = () => {
viewBarId: recordIndexId,
});
const recordIndexOpenRecordIn = useRecoilValue(recordIndexOpenRecordInState);
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetContent}>
View settings
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
{isCommandMenuV2Enabled && (
<MenuItem
onClick={() => onContentChange('viewSettingsOpenIn')}
LeftIcon={
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL
? IconLayoutSidebarRight
: IconLayoutNavbar
}
text="Open in"
contextualText={
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL
? 'Side Panel'
: 'Record Page'
}
hasSubMenu
/>
)}
{viewType === ViewType.Kanban && (
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
@@ -0,0 +1,60 @@
import {
IconChevronLeft,
IconLayoutNavbar,
IconLayoutSidebarRight,
MenuItemSelect,
} from 'twenty-ui';
import { useObjectOptions } from '@/object-record/object-options-dropdown/hooks/useObjectOptions';
import { useOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useOptionsDropdown';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
export const ObjectOptionsDropdownViewSettingsOpenInContent = () => {
const { onContentChange } = useOptionsDropdown();
const recordIndexOpenRecordIn = useRecoilValue(recordIndexOpenRecordInState);
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const { setAndPersistOpenRecordIn } = useObjectOptions();
return (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={() => onContentChange('viewSettings')}
>
{t`Open in`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<MenuItemSelect
LeftIcon={IconLayoutSidebarRight}
text="Side Panel"
selected={recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL}
onClick={() =>
setAndPersistOpenRecordIn(
ViewOpenRecordInType.SIDE_PANEL,
currentViewWithCombinedFiltersAndSorts,
)
}
/>
<MenuItemSelect
LeftIcon={IconLayoutNavbar}
text="Record Page"
selected={
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
}
onClick={() =>
setAndPersistOpenRecordIn(
ViewOpenRecordInType.RECORD_PAGE,
currentViewWithCombinedFiltersAndSorts,
)
}
/>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,29 @@
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
export const useObjectOptions = () => {
const setRecordIndexOpenRecordIn = useSetRecoilState(
recordIndexOpenRecordInState,
);
const { updateCurrentView } = useUpdateCurrentView();
const setAndPersistOpenRecordIn = useCallback(
(openRecordIn: ViewOpenRecordInType, view: GraphQLView | undefined) => {
if (!view) return;
setRecordIndexOpenRecordIn(openRecordIn);
updateCurrentView({
openRecordIn,
});
},
[setRecordIndexOpenRecordIn, updateCurrentView],
);
return {
setAndPersistOpenRecordIn,
};
};
@@ -1,5 +1,6 @@
export type ObjectOptionsContentId =
| 'viewSettings'
| 'viewSettingsOpenIn'
| 'fields'
| 'hiddenFields'
| 'recordGroups'
@@ -1,42 +1,45 @@
import {
AvatarChipVariant,
Checkbox,
CheckboxVariant,
LightIconButton,
IconEye,
IconEyeOff,
} from 'twenty-ui';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { RecordBoardCardHeaderContainer } from '@/object-record/record-board/record-board-card/components/RecordBoardCardHeaderContainer';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import styled from '@emotion/styled';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dispatch, SetStateAction, useContext, useState } from 'react';
import { StopPropagationContainer } from '@/object-record/record-board/record-board-card/components/StopPropagationContainer';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState';
import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import {
FieldContext,
RecordUpdateHook,
RecordUpdateHookParams,
} from '@/object-record/record-field/contexts/FieldContext';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { useRecoilValue } from 'recoil';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { RecordIdentifierChip } from '@/object-record/record-index/components/RecordIndexRecordChip';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState';
import { StopPropagationContainer } from '@/object-record/record-board/record-board-card/components/StopPropagationContainer';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import styled from '@emotion/styled';
import { Dispatch, SetStateAction, useContext, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
AvatarChipVariant,
Checkbox,
CheckboxVariant,
IconEye,
IconEyeOff,
LightIconButton,
} from 'twenty-ui';
const StyledTextInput = styled(TextInput)`
border-radius: ${({ theme }) => theme.border.radius.sm};
@@ -116,6 +119,12 @@ export const RecordBoardCardHeader = ({
return [updateEntity, { loading: false }];
};
const recordIndexOpenRecordIn = useRecoilValue(
recordIndexOpenRecordInSelector,
);
const { openRecordInCommandMenu } = useCommandMenu();
return (
<RecordBoardCardHeaderContainer showCompactView={showCompactView}>
<StopPropagationContainer>
@@ -178,7 +187,21 @@ export const RecordBoardCardHeader = ({
record={record as ObjectRecord}
variant={AvatarChipVariant.Transparent}
maxWidth={150}
to={indexIdentifierUrl(recordId)}
onClick={
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL
? () => {
openRecordInCommandMenu({
recordId,
objectNameSingular: objectMetadataItem.nameSingular,
});
}
: undefined
}
to={
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
? indexIdentifierUrl(recordId)
: undefined
}
/>
)}
</StopPropagationContainer>
@@ -11,6 +11,7 @@ import { useColumnNewCardActions } from '@/object-record/record-board/record-boa
import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled';
import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope';
import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { IconDotsVertical, IconPlus, LightIconButton, Tag } from 'twenty-ui';
@@ -97,6 +98,8 @@ export const RecordBoardColumnHeader = () => {
columnDefinition.id ?? '',
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { isOpportunitiesCompanyFieldDisabled } =
useIsOpportunitiesCompanyFieldDisabled();
@@ -146,12 +149,13 @@ export const RecordBoardColumnHeader = () => {
Icon={IconDotsVertical}
onClick={handleBoardColumnMenuOpen}
/>
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={() => handleNewButtonClick('first', isOpportunity)}
/>
{!hasObjectReadOnlyPermission && (
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={() => handleNewButtonClick('first', isOpportunity)}
/>
)}
</StyledHeaderActions>
)}
</StyledRightContainer>
@@ -1,6 +1,7 @@
import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard';
import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard';
import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useRecoilValue } from 'recoil';
export const RecordBoardColumnNewRecord = ({
@@ -16,8 +17,15 @@ export const RecordBoardColumnNewRecord = ({
scopeId: columnId,
}),
);
const { handleCreateSuccess } = useAddNewCard();
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
if (hasObjectReadOnlyPermission) {
return null;
}
return (
<>
{newRecord.isCreating && newRecord.position === position && (
@@ -1,4 +1,5 @@
import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconPlus } from 'twenty-ui';
@@ -29,6 +30,12 @@ export const RecordBoardColumnNewRecordButton = ({
const { handleNewButtonClick } = useColumnNewCardActions(columnId);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
if (hasObjectReadOnlyPermission) {
return null;
}
return (
<StyledNewButton onClick={() => handleNewButtonClick('last', false)}>
<IconPlus size={theme.icon.size.md} />
@@ -3,6 +3,7 @@ import { useContext } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useRecoilValue } from 'recoil';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldValueReadOnly } from '../utils/isFieldValueReadOnly';
@@ -20,11 +21,14 @@ export const useIsFieldValueReadOnly = () => {
objectNameSingular: metadata.objectMetadataNameSingular ?? '',
});
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
return isFieldValueReadOnly({
objectNameSingular: metadata.objectMetadataNameSingular,
fieldName: metadata.fieldName,
fieldType: type,
isObjectRemote: objectMetadataItem.isRemote,
isRecordDeleted: recordFromStore?.deletedAt,
hasObjectReadOnlyPermission,
});
};
@@ -1,6 +1,10 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { RecordChip } from '@/object-record/components/RecordChip';
import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay';
import { RecordIdentifierChip } from '@/object-record/record-index/components/RecordIndexRecordChip';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { useRecoilValue } from 'recoil';
import { ChipSize } from 'twenty-ui';
export const ChipFieldDisplay = () => {
@@ -11,6 +15,12 @@ export const ChipFieldDisplay = () => {
labelIdentifierLink,
} = useChipFieldDisplay();
const recordIndexOpenRecordIn = useRecoilValue(
recordIndexOpenRecordInSelector,
);
const { openRecordInCommandMenu } = useCommandMenu();
if (!recordValue) {
return null;
}
@@ -20,7 +30,21 @@ export const ChipFieldDisplay = () => {
objectNameSingular={objectNameSingular}
record={recordValue}
size={ChipSize.Small}
to={labelIdentifierLink}
to={
recordIndexOpenRecordIn === ViewOpenRecordInType.RECORD_PAGE
? labelIdentifierLink
: undefined
}
onClick={
recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL
? () => {
openRecordInCommandMenu({
recordId: recordValue.id,
objectNameSingular,
});
}
: undefined
}
/>
) : (
<RecordChip objectNameSingular={objectNameSingular} record={recordValue} />
@@ -12,6 +12,7 @@ type isFieldValueReadOnlyParams = {
fieldType?: FieldMetadataType;
isObjectRemote?: boolean;
isRecordDeleted?: boolean;
hasObjectReadOnlyPermission?: boolean;
};
export const isFieldValueReadOnly = ({
@@ -20,6 +21,7 @@ export const isFieldValueReadOnly = ({
fieldType,
isObjectRemote = false,
isRecordDeleted = false,
hasObjectReadOnlyPermission = false,
}: isFieldValueReadOnlyParams) => {
if (fieldName === 'noteTargets' || fieldName === 'taskTargets') {
return true;
@@ -33,6 +35,10 @@ export const isFieldValueReadOnly = ({
return true;
}
if (hasObjectReadOnlyPermission) {
return true;
}
if (isWorkflowSubObjectMetadata(objectNameSingular)) {
return true;
}
@@ -4,14 +4,15 @@ import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/use
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
import { RecordGroupAction } from '@/object-record/record-group/types/RecordGroupActions';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useHasSettingsPermission } from '@/settings/roles/hooks/useHasSettingsPermission';
import { SettingsPath } from '@/types/SettingsPath';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { ViewType } from '@/views/types/ViewType';
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { isDefined, SettingsFeatures } from 'twenty-shared';
import { IconEyeOff, IconSettings } from 'twenty-ui';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -69,37 +70,36 @@ export const useRecordGroupActions = ({
recordGroupFieldMetadata,
]);
const recordGroupActions: RecordGroupAction[] = useMemo(
() =>
[
{
id: 'edit',
label: 'Edit',
icon: IconSettings,
position: 0,
callback: () => {
navigateToSelectSettings();
},
},
{
id: 'hide',
label: 'Hide',
icon: IconEyeOff,
position: 1,
callback: () => {
handleRecordGroupVisibilityChange({
...recordGroupDefinition,
isVisible: false,
});
},
},
].filter(isDefined),
[
handleRecordGroupVisibilityChange,
navigateToSelectSettings,
recordGroupDefinition,
],
const hasAccessToDataModelSettings = useHasSettingsPermission(
SettingsFeatures.DATA_MODEL,
);
const recordGroupActions: RecordGroupAction[] = [];
if (hasAccessToDataModelSettings) {
recordGroupActions.push({
id: 'edit',
label: 'Edit',
icon: IconSettings,
position: 0,
callback: () => {
navigateToSelectSettings();
},
});
}
recordGroupActions.push({
id: 'hide',
label: 'Hide',
icon: IconEyeOff,
position: 1,
callback: () => {
handleRecordGroupVisibilityChange({
...recordGroupDefinition,
isVisible: false,
});
},
});
return recordGroupActions;
};
@@ -11,6 +11,7 @@ export type RecordIdentifierChipProps = {
size?: ChipSize;
to?: string;
maxWidth?: number;
onClick?: () => void;
};
export const RecordIdentifierChip = ({
@@ -18,6 +19,7 @@ export const RecordIdentifierChip = ({
record,
variant,
size,
onClick,
to,
maxWidth,
}: RecordIdentifierChipProps) => {
@@ -40,6 +42,7 @@ export const RecordIdentifierChip = ({
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
to={to}
onClick={onClick}
variant={variant}
LeftIcon={LeftIcon}
LeftIconColor={LeftIconColor}
@@ -10,6 +10,7 @@ import { recordIndexFiltersState } from '@/object-record/record-index/states/rec
import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
@@ -48,6 +49,9 @@ export const useLoadRecordIndexStates = () => {
recordIndexIsCompactModeActiveState,
);
const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState);
const setRecordIndexOpenRecordIn = useSetRecoilState(
recordIndexOpenRecordInState,
);
const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState(
recordIndexKanbanFieldMetadataIdState,
);
@@ -242,6 +246,7 @@ export const useLoadRecordIndexStates = () => {
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexViewType(view.type);
setRecordIndexOpenRecordIn(view.openRecordIn);
setRecordIndexViewKanbanFieldMetadataIdState(
view.kanbanFieldMetadataId,
);
@@ -272,6 +277,7 @@ export const useLoadRecordIndexStates = () => {
setRecordIndexViewKanbanAggregateOperationState,
setRecordIndexViewKanbanFieldMetadataIdState,
setRecordIndexViewType,
setRecordIndexOpenRecordIn,
],
);
@@ -0,0 +1,8 @@
import { createState } from '@ui/utilities/state/utils/createState';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
export const recordIndexOpenRecordInState = createState<ViewOpenRecordInType>({
key: 'recordIndexOpenRecordInState',
defaultValue: ViewOpenRecordInType.SIDE_PANEL,
});
@@ -0,0 +1,21 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { checkIfFeatureFlagIsEnabledOnWorkspace } from '@/workspace/utils/checkIfFeatureFlagIsEnabledOnWorkspace';
import { selector } from 'recoil';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const recordIndexOpenRecordInSelector = selector<ViewOpenRecordInType>({
key: 'recordIndexOpenRecordInSelector',
get: ({ get }) => {
const currentWorkspace = get(currentWorkspaceState);
const isCommandMenuV2Enabled = checkIfFeatureFlagIsEnabledOnWorkspace(
FeatureFlagKey.IsCommandMenuV2Enabled,
currentWorkspace,
);
return isCommandMenuV2Enabled
? get(recordIndexOpenRecordInState)
: ViewOpenRecordInType.RECORD_PAGE;
},
});
@@ -4,9 +4,11 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
import { RecordTableEmptyStateByGroupNoRecordAtAll } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateByGroupNoRecordAtAll';
import { RecordTableEmptyStateNoGroupNoRecordAtAll } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateNoGroupNoRecordAtAll';
import { RecordTableEmptyStateNoRecordFoundForFilter } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateNoRecordFoundForFilter';
import { RecordTableEmptyStateReadOnly } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateReadOnly';
import { RecordTableEmptyStateRemote } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateRemote';
import { RecordTableEmptyStateSoftDelete } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateSoftDelete';
import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
export const RecordTableEmptyState = () => {
@@ -17,6 +19,8 @@ export const RecordTableEmptyState = () => {
hasRecordGroupsComponentSelector,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { totalCount } = useFindManyRecords({ objectNameSingular, limit: 1 });
const noRecordAtAll = totalCount === 0;
@@ -27,6 +31,10 @@ export const RecordTableEmptyState = () => {
recordTableId,
);
if (hasObjectReadOnlyPermission) {
return <RecordTableEmptyStateReadOnly />;
}
if (isRemote) {
return <RecordTableEmptyStateRemote />;
} else if (isSoftDeleteActive === true) {
@@ -19,6 +19,7 @@ type RecordTableEmptyStateDisplayButtonProps = {
ButtonIcon: IconComponent;
buttonTitle: string;
onClick: () => void;
buttonIsDisabled?: boolean;
};
type RecordTableEmptyStateDisplayProps = {
@@ -54,6 +55,7 @@ export const RecordTableEmptyStateDisplay = (
title={props.buttonTitle}
variant={'secondary'}
onClick={props.onClick}
disabled={props.buttonIsDisabled}
/>
)}
</AnimatedPlaceholderEmptyContainer>
@@ -0,0 +1,24 @@
import { useObjectLabel } from '@/object-metadata/hooks/useObjectLabel';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateDisplay';
import { t } from '@lingui/core/macro';
import { IconPlus } from 'twenty-ui';
export const RecordTableEmptyStateReadOnly = () => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const objectLabel = useObjectLabel(objectMetadataItem);
const buttonTitle = `Add a ${objectLabel}`;
return (
<RecordTableEmptyStateDisplay
title={t`No records found`}
subTitle={t`You are not allowed to create records in this object`}
animatedPlaceholderType="noRecord"
buttonTitle={buttonTitle}
ButtonIcon={IconPlus}
buttonIsDisabled={true}
/>
);
};
@@ -1,18 +1,19 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useSelectedTableCellEditMode } from '@/object-record/record-table/record-table-cell/hooks/useSelectedTableCellEditMode';
import { recordTablePendingRecordIdByGroupComponentFamilyState } from '@/object-record/record-table/states/recordTablePendingRecordIdByGroupComponentFamilyState';
import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState';
import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { shouldRedirectToShowPageOnCreation } from '@/object-record/utils/shouldRedirectToShowPageOnCreation';
import { AppPath } from '@/types/AppPath';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared';
@@ -62,62 +63,71 @@ export const useCreateNewTableRecord = ({
const { openRecordTitleCell } = useRecordTitleCell();
const createNewTableRecord = async () => {
const recordId = v4();
const createNewTableRecord = useRecoilCallback(
({ snapshot }) =>
async () => {
const recordId = v4();
if (isCommandMenuV2Enabled) {
// TODO: Generalize this behaviour, there will be a view setting to specify
// if the new record should be displayed in the side panel or on the record page
if (shouldRedirectToShowPageOnCreation(objectMetadataItem.nameSingular)) {
await createOneRecord({
id: recordId,
name: 'Untitled',
});
if (isCommandMenuV2Enabled) {
const recordIndexOpenRecordIn = snapshot
.getLoadable(recordIndexOpenRecordInState)
.getValue();
navigate(AppPath.RecordShowPage, {
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: recordId,
});
await createOneRecord({ id: recordId });
// TODO: we should open the record title cell here but because
// we are redirecting to the record show page, the hotkey scope will
// be overridden by the hotkey scope on mount. We need to deprecate
// the useHotkeyScopeOnMount hook.
if (recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL) {
openRecordInCommandMenu({
recordId,
objectNameSingular: objectMetadataItem.nameSingular,
isNewRecord: true,
});
return;
}
openRecordTitleCell({
recordId,
fieldMetadataId:
objectMetadataItem.labelIdentifierFieldMetadataId,
});
} else {
navigate(AppPath.RecordShowPage, {
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: recordId,
});
}
await createOneRecord({ id: recordId });
return;
}
openRecordInCommandMenu({
recordId,
objectNameSingular: objectMetadataItem.nameSingular,
isNewRecord: true,
});
openRecordTitleCell({
recordId,
fieldMetadataId: objectMetadataItem.labelIdentifierFieldMetadataId,
});
return;
}
setPendingRecordId(recordId);
setSelectedTableCellEditMode(-1, 0);
setHotkeyScope(DEFAULT_CELL_SCOPE.scope, DEFAULT_CELL_SCOPE.customScopes);
if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) {
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
objectMetadataItem.labelIdentifierFieldMetadataId,
'table-cell',
),
);
}
};
setPendingRecordId(recordId);
setSelectedTableCellEditMode(-1, 0);
setHotkeyScope(
DEFAULT_CELL_SCOPE.scope,
DEFAULT_CELL_SCOPE.customScopes,
);
if (isDefined(objectMetadataItem.labelIdentifierFieldMetadataId)) {
setActiveDropdownFocusIdAndMemorizePrevious(
getDropdownFocusIdForRecordField(
recordId,
objectMetadataItem.labelIdentifierFieldMetadataId,
'table-cell',
),
);
}
},
[
createOneRecord,
isCommandMenuV2Enabled,
navigate,
objectMetadataItem.labelIdentifierFieldMetadataId,
objectMetadataItem.nameSingular,
openRecordInCommandMenu,
openRecordTitleCell,
setActiveDropdownFocusIdAndMemorizePrevious,
setHotkeyScope,
setPendingRecordId,
setSelectedTableCellEditMode,
],
);
const createNewTableRecordInGroup = useRecoilCallback(
({ set }) =>
(recordGroupId: string) => {
@@ -20,11 +20,14 @@ import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useC
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { isDefined } from 'twenty-shared';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexOpenRecordInSelector } from '@/object-record/record-index/states/selectors/recordIndexOpenRecordInSelector';
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { useNavigate } from 'react-router-dom';
import { IconList } from 'twenty-ui';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
@@ -75,6 +78,8 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const { openRecordInCommandMenu } = useCommandMenu();
const openTableCell = useRecoilCallback(
({ snapshot, set }) =>
({
@@ -115,7 +120,20 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
) {
leaveTableFocus();
navigate(indexIdentifierUrl(recordId));
const openRecordIn = snapshot
.getLoadable(recordIndexOpenRecordInSelector)
.getValue();
if (openRecordIn === ViewOpenRecordInType.RECORD_PAGE) {
navigate(indexIdentifierUrl(recordId));
}
if (openRecordIn === ViewOpenRecordInType.SIDE_PANEL) {
openRecordInCommandMenu({
recordId,
objectNameSingular,
});
}
return;
}
@@ -170,14 +188,15 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
moveEditModeToTableCellPosition,
initDraftValue,
toggleClickOutsideListener,
setActiveDropdownFocusIdAndMemorizePrevious,
leaveTableFocus,
navigate,
indexIdentifierUrl,
openRecordInCommandMenu,
setViewableRecordId,
setViewableRecordNameSingular,
openRightDrawer,
setHotkeyScope,
setActiveDropdownFocusIdAndMemorizePrevious,
],
);
@@ -13,6 +13,7 @@ import { isRecordTableScrolledLeftComponentState } from '@/object-record/record-
import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState';
import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@@ -212,6 +213,8 @@ export const RecordTableHeaderCell = ({
const isReadOnly = isObjectMetadataReadOnly(objectMetadataItem);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
return (
<StyledColumnHeaderCell
key={column.fieldMetadataId}
@@ -229,7 +232,8 @@ export const RecordTableHeaderCell = ({
<RecordTableColumnHeadWithDropdown column={column} />
{(useIsMobile() || iconVisibility) &&
!!column.isLabelIdentifier &&
!isReadOnly && (
!isReadOnly &&
!hasObjectReadOnlyPermission && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
@@ -3,6 +3,7 @@ import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewTableRecord } from '@/object-record/record-table/hooks/useCreateNewTableRecords';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconPlus } from 'twenty-ui';
@@ -15,6 +16,8 @@ export const RecordTableRecordGroupSectionAddNew = () => {
recordIndexAllRecordIdsComponentSelector,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { createNewTableRecordInGroup } = useCreateNewTableRecord({
objectMetadataItem,
recordTableId,
@@ -24,6 +27,10 @@ export const RecordTableRecordGroupSectionAddNew = () => {
createNewTableRecordInGroup(currentRecordGroupId);
};
if (hasObjectReadOnlyPermission) {
return null;
}
return (
<RecordTableActionRow
draggableId={`add-new-record-${currentRecordGroupId}`}
@@ -4,6 +4,7 @@ import { MultipleObjectRecordSelectItem } from '@/object-record/relation-picker/
import { MULTI_OBJECT_RECORD_SELECT_SELECTABLE_LIST_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordSelectSelectableListId';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { recordPickerSearchFilterComponentState } from '@/object-record/relation-picker/states/recordPickerSearchFilterComponentState';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
@@ -75,6 +76,8 @@ export const MultiRecordSelect = ({
instanceId,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
useEffect(() => {
setHotkeyScope(instanceId);
}, [setHotkeyScope, instanceId]);
@@ -144,7 +147,7 @@ export const MultiRecordSelect = ({
<DropdownMenu ref={containerRef} data-select-disable width={200}>
{dropdownPlacement?.includes('end') && (
<>
{isDefined(onCreate) && (
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
@@ -5,6 +5,7 @@ import {
import { useRecordPickerRecordsOptions } from '@/object-record/relation-picker/hooks/useRecordPickerRecordsOptions';
import { useRecordSelectSearch } from '@/object-record/relation-picker/hooks/useRecordSelectSearch';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
@@ -48,6 +49,8 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
RecordPickerComponentInstanceContext,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const { records, recordPickerSearchFilter } = useRecordPickerRecordsOptions({
objectNameSingular,
selectedRecordIds,
@@ -69,9 +72,11 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
<>
{dropdownPlacement?.includes('end') && (
<>
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
)}
{records.recordsToSelect.length > 0 && <DropdownMenuSeparator />}
{shouldDisplayDropdownMenuItems && (
<SingleRecordSelectMenuItems
@@ -120,7 +125,7 @@ export const SingleRecordSelectMenuItemsWithSearch = ({
{records.recordsToSelect.length > 0 && isDefined(onCreate) && (
<DropdownMenuSeparator />
)}
{isDefined(onCreate) && (
{isDefined(onCreate) && !hasObjectReadOnlyPermission && (
<DropdownMenuItemsContainer scrollable={false}>
{createNewButton}
</DropdownMenuItemsContainer>
@@ -1,11 +0,0 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const shouldRedirectToShowPageOnCreation = (
objectNameSingular: string,
) => {
if (objectNameSingular === CoreObjectNameSingular.Workflow) {
return true;
}
return false;
};
@@ -10,6 +10,7 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF
createdAt: true,
updatedAt: true,
isCompact: true,
openRecordIn: true,
objectMetadataId: true,
position: true,
type: true,
@@ -1,44 +0,0 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
export const SettingsAdminHealthMessageSyncCountersTable = ({
details,
}: {
details: string | null | undefined;
}) => {
const parsedDetails = details ? JSON.parse(details) : null;
if (!parsedDetails) {
return null;
}
return (
<Table>
<TableRow>
<TableHeader>Status</TableHeader>
<TableHeader align="right">Count</TableHeader>
</TableRow>
<TableRow>
<TableCell>Message Not Synced</TableCell>
<TableCell align="right">{parsedDetails.counters.NOT_SYNCED}</TableCell>
</TableRow>
<TableRow>
<TableCell>Message Sync Ongoing</TableCell>
<TableCell align="right">{parsedDetails.counters.ONGOING}</TableCell>
</TableRow>
<TableRow>
<TableCell>Total Jobs</TableCell>
<TableCell align="right">{parsedDetails.totalJobs}</TableCell>
</TableRow>
<TableRow>
<TableCell>Failed Jobs</TableCell>
<TableCell align="right">{parsedDetails.failedJobs}</TableCell>
</TableRow>
<TableRow>
<TableCell>Failure Rate</TableCell>
<TableCell align="right">{parsedDetails.failureRate}%</TableCell>
</TableRow>
</Table>
);
};
@@ -1,66 +0,0 @@
import { SettingsAdminHealthMessageSyncCountersTable } from '@/settings/admin-panel/components/SettingsAdminHealthMessageSyncCountersTable';
import { SettingsHealthStatusListCard } from '@/settings/admin-panel/components/SettingsHealthStatusListCard';
import { AdminHealthService } from '@/settings/admin-panel/types/AdminHealthService';
import styled from '@emotion/styled';
import { H2Title, Section } from 'twenty-ui';
import {
AdminPanelHealthServiceStatus,
useGetSystemHealthStatusQuery,
} from '~/generated/graphql';
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsAdminHealthStatus = () => {
const { data, loading } = useGetSystemHealthStatusQuery({
fetchPolicy: 'network-only',
});
const services = [
{
id: 'DATABASE',
name: 'Database Status',
...data?.getSystemHealthStatus.database,
},
{ id: 'REDIS', name: 'Redis Status', ...data?.getSystemHealthStatus.redis },
{
id: 'WORKER',
name: 'Worker Status',
status: data?.getSystemHealthStatus.worker.status,
queues: data?.getSystemHealthStatus.worker.queues,
},
].filter((service): service is AdminHealthService => !!service.status);
const isMessageSyncCounterDown =
!data?.getSystemHealthStatus.messageSync.status ||
data?.getSystemHealthStatus.messageSync.status ===
AdminPanelHealthServiceStatus.OUTAGE;
return (
<>
<Section>
<H2Title title="Health Status" description="How your system is doing" />
<SettingsHealthStatusListCard services={services} loading={loading} />
</Section>
<Section>
<H2Title
title="Message Sync Status"
description="How your message sync is doing"
/>
{isMessageSyncCounterDown ? (
<StyledErrorMessage>
{data?.getSystemHealthStatus.messageSync.details ||
'Message sync status is unavailable'}
</StyledErrorMessage>
) : (
<SettingsAdminHealthMessageSyncCountersTable
details={data?.getSystemHealthStatus.messageSync.details}
/>
)}
</Section>
</>
);
};
@@ -1,27 +0,0 @@
import { AdminHealthService } from '@/settings/admin-panel/types/AdminHealthService';
import styled from '@emotion/styled';
import { Status } from 'twenty-ui';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
const StyledRowRightContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsAdminHealthStatusRightContainer = ({
service,
}: {
service: AdminHealthService;
}) => {
return (
<StyledRowRightContainer>
{service.status === AdminPanelHealthServiceStatus.OPERATIONAL && (
<Status color="green" text="Operational" weight="medium" />
)}
{service.status === AdminPanelHealthServiceStatus.OUTAGE && (
<Status color="red" text="Outage" weight="medium" />
)}
</StyledRowRightContainer>
);
};
@@ -1,8 +1,8 @@
import { SettingsAdminEnvVariables } from '@/settings/admin-panel/components/SettingsAdminEnvVariables';
import { SettingsAdminGeneral } from '@/settings/admin-panel/components/SettingsAdminGeneral';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/components/SettingsAdminHealthStatus';
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
export const SettingsAdminTabContent = () => {
@@ -1,43 +0,0 @@
import { AdminHealthService } from '@/settings/admin-panel/types/AdminHealthService';
import styled from '@emotion/styled';
import { SettingsPath } from '@/types/SettingsPath';
import { Link } from 'react-router-dom';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsListCard } from '../../components/SettingsListCard';
import { SettingsAdminHealthStatusRightContainer } from './SettingsAdminHealthStatusRightContainer';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsHealthStatusListCard = ({
services,
loading,
}: {
services: Array<AdminHealthService>;
loading?: boolean;
}) => {
return (
<>
{services.map((service) => (
<>
<StyledLink
to={getSettingsPath(SettingsPath.AdminPanelIndicatorHealthStatus, {
indicatorName: service.id,
})}
>
<SettingsListCard
items={[service]}
getItemLabel={(service) => service.name}
isLoading={loading}
RowRightComponent={({ item: service }) => (
<SettingsAdminHealthStatusRightContainer service={service} />
)}
/>
</StyledLink>
</>
))}
</>
);
};
@@ -1,36 +0,0 @@
import { gql } from '@apollo/client';
export const GET_SYSTEM_HEALTH_STATUS = gql`
query GetSystemHealthStatus {
getSystemHealthStatus {
database {
status
details
}
redis {
status
details
}
worker {
status
queues {
name
workers
status
metrics {
failed
completed
waiting
active
delayed
prioritized
}
}
}
messageSync {
status
details
}
}
}
`;
@@ -0,0 +1,57 @@
import { SettingsAdminHealthAccountSyncCountersTable } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthAccountSyncCountersTable';
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const ConnectedAccountHealthStatus = () => {
const { indicatorHealth } = useContext(SettingsAdminIndicatorHealthContext);
const details = indicatorHealth.details;
if (!details) {
return null;
}
const parsedDetails = JSON.parse(details);
const isMessageSyncDown =
parsedDetails.messageSync?.status === AdminPanelHealthServiceStatus.OUTAGE;
const isCalendarSyncDown =
parsedDetails.calendarSync?.status === AdminPanelHealthServiceStatus.OUTAGE;
const errorMessages = [];
if (isMessageSyncDown) {
errorMessages.push('Message Sync');
}
if (isCalendarSyncDown) {
errorMessages.push('Calendar Sync');
}
return (
<>
{errorMessages.length > 0 && (
<StyledErrorMessage>
{`${errorMessages.join(' and ')} ${errorMessages.length > 1 ? 'are' : 'is'} not available because the service is down`}
</StyledErrorMessage>
)}
{!isMessageSyncDown && parsedDetails.messageSync?.details && (
<SettingsAdminHealthAccountSyncCountersTable
details={parsedDetails.messageSync.details}
title="Message Sync Status"
/>
)}
{!isCalendarSyncDown && parsedDetails.calendarSync?.details && (
<SettingsAdminHealthAccountSyncCountersTable
details={parsedDetails.calendarSync.details}
title="Calendar Sync Status"
/>
)}
</>
);
};
@@ -0,0 +1,45 @@
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { Section } from 'twenty-ui';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
const StyledDetailsContainer = styled.pre`
background-color: ${({ theme }) => theme.background.quaternary};
padding: ${({ theme }) => theme.spacing(6)};
border-radius: ${({ theme }) => theme.border.radius.sm};
white-space: pre-wrap;
font-size: ${({ theme }) => theme.font.size.sm};
margin: 0;
`;
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const DatabaseAndRedisHealthStatus = () => {
const { indicatorHealth, loading } = useContext(
SettingsAdminIndicatorHealthContext,
);
const formattedDetails = indicatorHealth.details
? JSON.stringify(JSON.parse(indicatorHealth.details), null, 2)
: null;
const isDatabaseOrRedisDown =
!indicatorHealth.status ||
indicatorHealth.status === AdminPanelHealthServiceStatus.OUTAGE;
return (
<Section>
{isDatabaseOrRedisDown && !loading ? (
<StyledErrorMessage>
{`${indicatorHealth.label} information is not available because the service is down`}
</StyledErrorMessage>
) : (
<StyledDetailsContainer>{formattedDetails}</StyledDetailsContainer>
)}
</Section>
);
};
@@ -0,0 +1,55 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { H2Title } from 'twenty-ui';
const StyledContainer = styled.div``;
export const SettingsAdminHealthAccountSyncCountersTable = ({
details,
title,
}: {
details: Record<string, any> | null;
title: string;
}) => {
if (!details) {
return null;
}
return (
<StyledContainer>
<H2Title
title={title}
description={`How your ${title.toLowerCase()} is doing`}
/>
<Table>
<TableRow>
<TableHeader>Status</TableHeader>
<TableHeader align="right">Count</TableHeader>
</TableRow>
<TableRow>
<TableCell>Not Synced</TableCell>
<TableCell align="right">{details.counters.NOT_SYNCED}</TableCell>
</TableRow>
<TableRow>
<TableCell>Sync Ongoing</TableCell>
<TableCell align="right">{details.counters.ONGOING}</TableCell>
</TableRow>
<TableRow>
<TableCell>Total Jobs</TableCell>
<TableCell align="right">{details.totalJobs}</TableCell>
</TableRow>
<TableRow>
<TableCell>Failed Jobs</TableCell>
<TableCell align="right">{details.failedJobs}</TableCell>
</TableRow>
<TableRow>
<TableCell>Failure Rate</TableCell>
<TableCell align="right">{details.failureRate}%</TableCell>
</TableRow>
</Table>
</StyledContainer>
);
};
@@ -0,0 +1,19 @@
import { SettingsHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsHealthStatusListCard';
import { H2Title, Section } from 'twenty-ui';
import { useGetSystemHealthStatusQuery } from '~/generated/graphql';
export const SettingsAdminHealthStatus = () => {
const { data, loading } = useGetSystemHealthStatusQuery({
fetchPolicy: 'network-only',
});
const services = data?.getSystemHealthStatus.services ?? [];
return (
<>
<Section>
<H2Title title="Health Status" description="How your system is doing" />
<SettingsHealthStatusListCard services={services} loading={loading} />
</Section>
</>
);
};
@@ -0,0 +1,19 @@
import { Status } from 'twenty-ui';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
export const SettingsAdminHealthStatusRightContainer = ({
status,
}: {
status: AdminPanelHealthServiceStatus;
}) => {
return (
<>
{status === AdminPanelHealthServiceStatus.OPERATIONAL && (
<Status color="green" text="Operational" weight="medium" />
)}
{status === AdminPanelHealthServiceStatus.OUTAGE && (
<Status color="red" text="Outage" weight="medium" />
)}
</>
);
};
@@ -0,0 +1,21 @@
import { ConnectedAccountHealthStatus } from '@/settings/admin-panel/health-status/components/ConnectedAccountHealthStatus';
import { DatabaseAndRedisHealthStatus } from '@/settings/admin-panel/health-status/components/DatabaseAndRedisHealthStatus';
import { WorkerHealthStatus } from '@/settings/admin-panel/health-status/components/WorkerHealthStatus';
import { useParams } from 'react-router-dom';
import { HealthIndicatorId } from '~/generated/graphql';
export const SettingsAdminIndicatorHealthStatusContent = () => {
const { indicatorId } = useParams();
switch (indicatorId) {
case HealthIndicatorId.database:
case HealthIndicatorId.redis:
return <DatabaseAndRedisHealthStatus />;
case HealthIndicatorId.worker:
return <WorkerHealthStatus />;
case HealthIndicatorId.connectedAccount:
return <ConnectedAccountHealthStatus />;
default:
return null;
}
};
@@ -43,7 +43,7 @@ export const SettingsAdminQueueExpandableContainer = ({
selectedQueue: string | null;
}) => {
const selectedQueueData = queues.find(
(queue) => queue.name === selectedQueue,
(queue) => queue.queueName === selectedQueue,
);
return (
@@ -55,10 +55,12 @@ export const SettingsAdminQueueExpandableContainer = ({
<>
<StyledContainer>
<SettingsListCard
items={[{ ...selectedQueueData, id: selectedQueueData.name }]}
items={[
{ ...selectedQueueData, id: selectedQueueData.queueName },
]}
getItemLabel={(
item: AdminPanelWorkerQueueHealth & { id: string },
) => item.name}
) => item.queueName}
isLoading={false}
RowRightComponent={({
item,
@@ -39,11 +39,11 @@ export const SettingsAdminQueueHealthButtons = ({
<StyledQueueButtonsRow>
{queues.map((queue) => (
<StyledQueueHealthButton
key={queue.name}
onClick={() => toggleQueueVisibility(queue.name)}
title={queue.name}
key={queue.queueName}
onClick={() => toggleQueueVisibility(queue.queueName)}
title={queue.queueName}
variant="secondary"
isSelected={selectedQueue === queue.name}
isSelected={selectedQueue === queue.queueName}
status={queue.status}
/>
))}
@@ -0,0 +1,29 @@
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingsPath } from '@/types/SettingsPath';
import { SystemHealthService } from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { SettingsAdminHealthStatusRightContainer } from './SettingsAdminHealthStatusRightContainer';
export const SettingsHealthStatusListCard = ({
services,
loading,
}: {
services: Array<SystemHealthService>;
loading?: boolean;
}) => {
return (
<SettingsListCard
items={services}
getItemLabel={(service) => service.label}
isLoading={loading}
RowRightComponent={({ item: service }) => (
<SettingsAdminHealthStatusRightContainer status={service.status} />
)}
to={(service) =>
getSettingsPath(SettingsPath.AdminPanelIndicatorHealthStatus, {
indicatorId: service.id,
})
}
/>
);
};
@@ -0,0 +1,62 @@
import { SettingsAdminQueueExpandableContainer } from '@/settings/admin-panel/health-status/components/SettingsAdminQueueExpandableContainer';
import { SettingsAdminQueueHealthButtons } from '@/settings/admin-panel/health-status/components/SettingsAdminQueueHealthButtons';
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import styled from '@emotion/styled';
import { useContext, useState } from 'react';
import { H2Title, Section } from 'twenty-ui';
import { AdminPanelHealthServiceStatus } from '~/generated/graphql';
const StyledTitleContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const WorkerHealthStatus = () => {
const { indicatorHealth, loading } = useContext(
SettingsAdminIndicatorHealthContext,
);
const isWorkerDown =
!indicatorHealth.status ||
indicatorHealth.status === AdminPanelHealthServiceStatus.OUTAGE;
const [selectedQueue, setSelectedQueue] = useState<string | null>(null);
const toggleQueueVisibility = (queueName: string) => {
setSelectedQueue(selectedQueue === queueName ? null : queueName);
};
return (
<Section>
<StyledTitleContainer>
<H2Title
title="Queue Status"
description="Background job processing status and metrics"
/>
</StyledTitleContainer>
{isWorkerDown && !loading ? (
<StyledErrorMessage>
Queue information is not available because the worker is down
</StyledErrorMessage>
) : (
<>
<SettingsAdminQueueHealthButtons
queues={indicatorHealth.queues ?? []}
selectedQueue={selectedQueue}
toggleQueueVisibility={toggleQueueVisibility}
/>
<SettingsAdminQueueExpandableContainer
queues={indicatorHealth.queues ?? []}
selectedQueue={selectedQueue}
/>
</>
)}
</Section>
);
};
@@ -0,0 +1,23 @@
import { createContext } from 'react';
import {
AdminPanelHealthServiceData,
AdminPanelHealthServiceStatus,
} from '~/generated/graphql';
type SettingsAdminIndicatorHealthContextType = {
indicatorHealth: AdminPanelHealthServiceData;
loading: boolean;
};
export const SettingsAdminIndicatorHealthContext =
createContext<SettingsAdminIndicatorHealthContextType>({
indicatorHealth: {
id: '',
label: '',
description: '',
status: AdminPanelHealthServiceStatus.OPERATIONAL,
details: '',
queues: [],
},
loading: false,
});
@@ -1,14 +1,16 @@
import { gql } from '@apollo/client';
export const GET_INDICATOR_HEALTH_STATUS = gql`
query GetIndicatorHealthStatus(
$indicatorName: AdminPanelIndicatorHealthStatusInputEnum!
) {
getIndicatorHealthStatus(indicatorName: $indicatorName) {
query GetIndicatorHealthStatus($indicatorId: HealthIndicatorId!) {
getIndicatorHealthStatus(indicatorId: $indicatorId) {
id
label
description
status
details
queues {
name
id
queueName
status
workers
metrics {
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const GET_SYSTEM_HEALTH_STATUS = gql`
query GetSystemHealthStatus {
getSystemHealthStatus {
services {
id
label
status
}
}
}
`;
@@ -1,12 +0,0 @@
import { useGetSystemHealthStatusQuery } from '~/generated/graphql';
export const useGetUptoDateHealthStatus = () => {
const { data, loading } = useGetSystemHealthStatusQuery({
fetchPolicy: 'network-only',
});
return {
healthStatus: data?.getSystemHealthStatus,
healthStatusLoading: loading,
};
};
@@ -1,12 +0,0 @@
import {
AdminPanelHealthServiceData,
AdminPanelWorkerQueueHealth,
} from '~/generated/graphql';
type AdminWorkerService = AdminPanelHealthServiceData & {
id: string;
name: string;
queues: AdminPanelWorkerQueueHealth[] | null | undefined;
};
export type AdminHealthService = AdminWorkerService;
@@ -1,6 +1,6 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronRight, Pill, Card, CardContent } from 'twenty-ui';
import { Card, CardContent, IconChevronRight, Pill } from 'twenty-ui';
import { ReactNode } from 'react';
@@ -1,7 +1,7 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ComponentType } from 'react';
import { IconComponent, IconPlus, Card, CardFooter } from 'twenty-ui';
import { Card, CardFooter, IconComponent, IconPlus } from 'twenty-ui';
import { SettingsListSkeletonCard } from '@/settings/components/SettingsListSkeletonCard';
@@ -44,6 +44,7 @@ type SettingsListCardProps<ListItem extends { id: string }> = {
RowRightComponent: ComponentType<{ item: ListItem }>;
footerButtonLabel?: string;
onFooterButtonClick?: () => void;
to?: (item: ListItem) => string;
};
export const SettingsListCard = <
@@ -61,6 +62,7 @@ export const SettingsListCard = <
RowRightComponent,
onFooterButtonClick,
footerButtonLabel,
to,
}: SettingsListCardProps<ListItem>) => {
const theme = useTheme();
@@ -76,6 +78,7 @@ export const SettingsListCard = <
rightComponent={<RowRightComponent item={item} />}
divider={index < items.length - 1}
onClick={() => onRowClick?.(item)}
to={to?.(item)}
/>
))}
{hasFooter && (
@@ -1,7 +1,9 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { IconComponent, CardContent } from 'twenty-ui';
import { Link } from 'react-router-dom';
import { isDefined } from 'twenty-shared';
import { CardContent, IconComponent } from 'twenty-ui';
const StyledRow = styled(CardContent)`
align-items: center;
@@ -19,12 +21,22 @@ const StyledLabel = styled.span`
flex: 1 0 auto;
`;
const StyledLink = styled(Link)`
text-decoration: none;
color: ${({ theme }) => theme.font.color.secondary};
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
type SettingsListItemCardContentProps = {
label: string;
divider?: boolean;
LeftIcon?: IconComponent;
onClick?: () => void;
rightComponent: ReactNode;
to?: string;
};
export const SettingsListItemCardContent = ({
@@ -33,14 +45,21 @@ export const SettingsListItemCardContent = ({
LeftIcon,
onClick,
rightComponent,
to,
}: SettingsListItemCardContentProps) => {
const theme = useTheme();
return (
const content = (
<StyledRow onClick={onClick} divider={divider}>
{!!LeftIcon && <LeftIcon size={theme.icon.size.md} />}
<StyledLabel>{label}</StyledLabel>
{rightComponent}
</StyledRow>
);
if (isDefined(to)) {
return <StyledLink to={to}>{content}</StyledLink>;
}
return content;
};
@@ -50,7 +50,7 @@ export type SettingsNavigationItem = {
soon?: boolean;
};
export const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
const billing = useRecoilValue(billingState);
const isFunctionSettingsEnabled = false;
@@ -195,3 +195,5 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
},
];
};
export { useSettingsNavigationItems };
@@ -0,0 +1,27 @@
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined, PermissionsOnAllObjectRecords } from 'twenty-shared';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useHasObjectReadOnlyPermission = () => {
const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
const isPermissionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsPermissionsEnabled,
);
if (!isPermissionEnabled) {
return false;
}
if (!isDefined(currentUserWorkspace?.objectRecordsPermissions)) {
return true;
}
return (
currentUserWorkspace?.objectRecordsPermissions.length === 1 &&
currentUserWorkspace?.objectRecordsPermissions.includes(
PermissionsOnAllObjectRecords.READ_ALL_OBJECT_RECORDS,
)
);
};
@@ -35,7 +35,7 @@ export enum SettingsPath {
AdminPanel = 'admin-panel',
FeatureFlags = 'admin-panel/feature-flags',
AdminPanelHealthStatus = 'admin-panel#health-status',
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorName',
AdminPanelIndicatorHealthStatus = 'admin-panel/health-status/:indicatorId',
Lab = 'lab',
Roles = 'roles',
RoleDetail = 'roles/:roleId',
@@ -189,7 +189,7 @@ const TextInputV2Component = forwardRef<
LeftIcon,
autoComplete,
maxLength,
sizeVariant = 'md',
sizeVariant = 'lg',
inheritFontStyles = false,
dataTestId,
autoGrow = false,
@@ -1,3 +1,4 @@
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { Button, IconButton, IconPlus, useIsMobile } from 'twenty-ui';
@@ -11,10 +12,17 @@ export const PageAddButton = ({ onClick }: PageAddButtonProps) => {
const isCommandMenuV2Enabled = useIsFeatureEnabled(
FeatureFlagKey.IsCommandMenuV2Enabled,
);
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const isMobile = useIsMobile();
const { t } = useLingui();
if (hasObjectReadOnlyPermission) {
return null;
}
return (
<>
{isCommandMenuV2Enabled ? (
@@ -17,6 +17,7 @@ import { SHOW_PAGE_ADD_BUTTON_DROPDOWN_ID } from '@/ui/layout/show-page/constant
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isWorkflowSubObjectMetadata } from '@/object-metadata/utils/isWorkflowSubObjectMetadata';
import { useHasObjectReadOnlyPermission } from '@/settings/roles/hooks/useHasObjectReadOnlyPermission';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
import { Dropdown } from '../../dropdown/components/Dropdown';
@@ -39,6 +40,8 @@ export const ShowPageAddButton = ({
activityObjectNameSingular: CoreObjectNameSingular.Task,
});
const hasObjectReadOnlyPermission = useHasObjectReadOnlyPermission();
const handleSelect = (objectNameSingular: CoreObjectNameSingular) => {
if (objectNameSingular === CoreObjectNameSingular.Note) {
openNote({
@@ -67,6 +70,10 @@ export const ShowPageAddButton = ({
return;
}
if (hasObjectReadOnlyPermission) {
return null;
}
return (
<StyledContainer>
<Dropdown
@@ -10,6 +10,7 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { View } from '@/views/types/View';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewType } from '@/views/types/ViewType';
import { act } from 'react';
import { isDefined } from 'twenty-shared';
@@ -51,6 +52,7 @@ describe('useApplyCurrentViewFiltersToCurrentRecordFilters', () => {
type: ViewType.Table,
key: null,
isCompact: false,
openRecordIn: ViewOpenRecordInType.SIDE_PANEL,
viewFields: [],
viewGroups: [],
viewSorts: [],
@@ -4,6 +4,7 @@ import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewSort } from '@/views/types/ViewSort';
import { ViewType } from '@/views/types/ViewType';
@@ -20,6 +21,7 @@ export type GraphQLView = {
kanbanAggregateOperationFieldMetadataId?: string | null;
objectMetadataId: string;
isCompact: boolean;
openRecordIn: ViewOpenRecordInType;
viewFields: ViewField[];
viewFilters: ViewFilter[];
viewFilterGroups?: ViewFilterGroup[];
@@ -4,6 +4,7 @@ import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { ViewGroup } from '@/views/types/ViewGroup';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewSort } from '@/views/types/ViewSort';
import { ViewType } from '@/views/types/ViewType';
@@ -27,5 +28,6 @@ export type View = {
kanbanAggregateOperationFieldMetadataId: string | null;
position: number;
icon: string;
openRecordIn: ViewOpenRecordInType;
__typename: 'View';
};
@@ -0,0 +1,4 @@
export enum ViewOpenRecordInType {
SIDE_PANEL = 'SIDE_PANEL',
RECORD_PAGE = 'RECORD_PAGE',
}
@@ -1,3 +1,4 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
@@ -16,7 +17,7 @@ import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWor
import { isCreateStepNode } from '@/workflow/workflow-diagram/utils/isCreateStepNode';
import { useLingui } from '@lingui/react/macro';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { IconBolt, useIcons } from 'twenty-ui';
@@ -37,12 +38,16 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
commandMenuNavigationStackState,
);
const { isInRightDrawer } = useContext(ActionMenuContext);
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
const isClosingStep = isDefined(selectedNode) === false;
setCommandMenuNavigationStack([]);
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
if (isClosingStep) {
closeRightDrawer();
@@ -76,11 +81,12 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
});
},
[
setCommandMenuNavigationStack,
isInRightDrawer,
setWorkflowSelectedNode,
setHotkeyScope,
openRightDrawer,
getIcon,
setCommandMenuNavigationStack,
closeRightDrawer,
closeCommandMenu,
t,
@@ -1,65 +1,34 @@
import { SettingsAdminQueueExpandableContainer } from '@/settings/admin-panel/components/SettingsAdminQueueExpandableContainer';
import { SettingsAdminQueueHealthButtons } from '@/settings/admin-panel/components/SettingsAdminQueueHealthButtons';
import { SettingsAdminHealthStatusRightContainer } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatusRightContainer';
import { SettingsAdminIndicatorHealthStatusContent } from '@/settings/admin-panel/health-status/components/SettingsAdminIndicatorHealthStatusContent';
import { SettingsAdminIndicatorHealthContext } from '@/settings/admin-panel/health-status/contexts/SettingsAdminIndicatorHealthContext';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { H2Title, Section, Status } from 'twenty-ui';
import { H2Title, Section } from 'twenty-ui';
import {
AdminPanelHealthServiceStatus,
AdminPanelIndicatorHealthStatusInputEnum,
HealthIndicatorId,
useGetIndicatorHealthStatusQuery,
} from '~/generated/graphql';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledStatusContainer = styled.div``;
const StyledTitleContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.color.red};
const StyledH2Title = styled(H2Title)`
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledDetailsContainer = styled.pre`
background-color: ${({ theme }) => theme.background.quaternary};
padding: ${({ theme }) => theme.spacing(6)};
border-radius: ${({ theme }) => theme.border.radius.sm};
white-space: pre-wrap;
font-size: ${({ theme }) => theme.font.size.sm};
`;
export const SettingsAdminIndicatorHealthStatus = () => {
const { t } = useLingui();
const { indicatorName } = useParams();
const { indicatorId } = useParams();
const { data, loading } = useGetIndicatorHealthStatusQuery({
variables: {
indicatorName: indicatorName as AdminPanelIndicatorHealthStatusInputEnum,
indicatorId: indicatorId as HealthIndicatorId,
},
fetchPolicy: 'network-only',
});
const formattedDetails = data?.getIndicatorHealthStatus.details
? JSON.stringify(JSON.parse(data.getIndicatorHealthStatus.details), null, 2)
: null;
const isWorkerDown =
!data?.getIndicatorHealthStatus.status ||
data?.getIndicatorHealthStatus.status ===
AdminPanelHealthServiceStatus.OUTAGE;
const [selectedQueue, setSelectedQueue] = useState<string | null>(null);
const toggleQueueVisibility = (queueName: string) => {
setSelectedQueue(selectedQueue === queueName ? null : queueName);
};
return (
<SubMenuTopBarContainer
links={[
@@ -75,62 +44,40 @@ export const SettingsAdminIndicatorHealthStatus = () => {
children: t`Health Status`,
href: getSettingsPath(SettingsPath.AdminPanelHealthStatus),
},
{ children: `${indicatorName}` },
{ children: `${data?.getIndicatorHealthStatus?.label}` },
]}
>
<SettingsPageContainer>
<Section>
<H2Title title={`${indicatorName}`} description="Health status" />
<StyledStatusContainer>
{data?.getIndicatorHealthStatus.status ===
AdminPanelHealthServiceStatus.OPERATIONAL && (
<Status color="green" text="Operational" weight="medium" />
)}
{data?.getIndicatorHealthStatus.status ===
AdminPanelHealthServiceStatus.OUTAGE && (
<Status color="red" text="Outage" weight="medium" />
)}
</StyledStatusContainer>
</Section>
{indicatorName === AdminPanelIndicatorHealthStatusInputEnum.WORKER ? (
<SettingsAdminIndicatorHealthContext.Provider
value={{
indicatorHealth: {
id: data?.getIndicatorHealthStatus?.id ?? '',
label: data?.getIndicatorHealthStatus?.label ?? '',
description: data?.getIndicatorHealthStatus?.description ?? '',
status:
data?.getIndicatorHealthStatus?.status ??
AdminPanelHealthServiceStatus.OUTAGE,
details: data?.getIndicatorHealthStatus?.details,
queues: data?.getIndicatorHealthStatus?.queues,
},
loading: loading,
}}
>
<Section>
<StyledTitleContainer>
<H2Title
title="Queue Status"
description="Background job processing status and metrics"
/>
</StyledTitleContainer>
{isWorkerDown && !loading ? (
<StyledErrorMessage>
Queue information is not available because the worker is down
</StyledErrorMessage>
) : (
<>
<SettingsAdminQueueHealthButtons
queues={data?.getIndicatorHealthStatus.queues ?? []}
selectedQueue={selectedQueue}
toggleQueueVisibility={toggleQueueVisibility}
<StyledH2Title
title={`${data?.getIndicatorHealthStatus?.label}`}
description={data?.getIndicatorHealthStatus?.description}
/>
{indicatorId !== HealthIndicatorId.connectedAccount &&
data?.getIndicatorHealthStatus?.status && (
<SettingsAdminHealthStatusRightContainer
status={data?.getIndicatorHealthStatus.status}
/>
<SettingsAdminQueueExpandableContainer
queues={data?.getIndicatorHealthStatus.queues ?? []}
selectedQueue={selectedQueue}
/>
</>
)}
)}
</Section>
) : null}
{indicatorName === AdminPanelIndicatorHealthStatusInputEnum.DATABASE ||
indicatorName === AdminPanelIndicatorHealthStatusInputEnum.REDIS ? (
<Section>
{formattedDetails && (
<StyledDetailsContainer>
{formattedDetails}
</StyledDetailsContainer>
)}
</Section>
) : null}
<SettingsAdminIndicatorHealthStatusContent />
</SettingsAdminIndicatorHealthContext.Provider>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -1,6 +1,7 @@
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { View } from '@/views/types/View';
import { ViewKey } from '@/views/types/ViewKey';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewType } from '@/views/types/ViewType';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
@@ -29,6 +30,7 @@ export const mockedViewsData: View[] = [
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordInType.SIDE_PANEL,
viewFilterGroups: [],
viewGroups: [],
viewFields: [],
@@ -48,6 +50,7 @@ export const mockedViewsData: View[] = [
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordInType.SIDE_PANEL,
viewFilterGroups: [],
viewGroups: [],
viewFields: [],
@@ -67,6 +70,7 @@ export const mockedViewsData: View[] = [
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordInType.SIDE_PANEL,
viewFilterGroups: [],
viewGroups: [],
viewFields: [],
@@ -86,6 +90,7 @@ export const mockedViewsData: View[] = [
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
isCompact: false,
openRecordIn: ViewOpenRecordInType.SIDE_PANEL,
viewFilterGroups: [],
viewGroups: [],
viewFields: [],
+3 -1
View File
@@ -13,7 +13,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
type Checkers = Parameters<typeof checker>[0];
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const env = loadEnv(mode, __dirname, '');
console.log(__dirname);
const {
REACT_APP_SERVER_BASE_URL,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-server",
"version": "0.42.0-canary",
"version": "0.42.14",
"description": "",
"author": "",
"private": true,
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { Option } from 'nest-commander';
import { WorkspaceActivationStatus } from 'twenty-shared';
import { In, Repository } from 'typeorm';
import { In, MoreThanOrEqual, Repository } from 'typeorm';
import {
BaseCommandOptions,
@@ -10,19 +10,55 @@ import {
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export type ActiveWorkspacesCommandOptions = BaseCommandOptions & {
workspaceId?: string;
startFromWorkspaceId?: string;
workspaceCountLimit?: number;
};
export abstract class ActiveWorkspacesCommandRunner extends BaseCommandRunner {
private workspaceIds: string[] = [];
private startFromWorkspaceId: string | undefined;
private workspaceCountLimit: number | undefined;
constructor(protected readonly workspaceRepository: Repository<Workspace>) {
super();
}
@Option({
flags: '--start-from-workspace-id [workspace_id]',
description:
'Start from a specific workspace id. Workspaces are processed in ascending order of id.',
required: false,
})
parseStartFromWorkspaceId(val: string): string {
this.startFromWorkspaceId = val;
return val;
}
@Option({
flags: '--workspace-count-limit [count]',
description:
'Limit the number of workspaces to process. Workspaces are processed in ascending order of id.',
required: false,
})
parseWorkspaceCountLimit(val: string): number {
this.workspaceCountLimit = parseInt(val);
if (isNaN(this.workspaceCountLimit)) {
throw new Error('Workspace count limit must be a number');
}
if (this.workspaceCountLimit <= 0) {
throw new Error('Workspace count limit must be greater than 0');
}
return this.workspaceCountLimit;
}
@Option({
flags: '-w, --workspace-id [workspace_id]',
description:
'workspace id. Command runs on all active workspaces if not provided',
'workspace id. Command runs on all active workspaces if not provided.',
required: false,
})
parseWorkspaceId(val: string): string[] {
@@ -39,10 +75,14 @@ export abstract class ActiveWorkspacesCommandRunner extends BaseCommandRunner {
WorkspaceActivationStatus.ACTIVE,
WorkspaceActivationStatus.SUSPENDED,
]),
...(this.startFromWorkspaceId
? { id: MoreThanOrEqual(this.startFromWorkspaceId) }
: {}),
},
order: {
createdAt: 'ASC',
id: 'ASC',
},
take: this.workspaceCountLimit,
});
return activeWorkspaces.map((workspace) => workspace.id);
@@ -105,23 +105,25 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner {
this.logger.setVerbose(options.verbose ?? false);
}
try {
for (const [index, workspaceId] of workspaceIds.entries()) {
for (const [index, workspaceId] of workspaceIds.entries()) {
try {
await this.processWorkspace({
workspaceId,
index,
total: workspaceIds.length,
});
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
} catch (error) {
this.logger.log(
chalk.red(`Error in workspace ${workspaceId}: ${error}`),
);
}
this.logger.log(chalk.green('Command completed!'));
} catch (error) {
this.logger.log(chalk.red('Error in workspace'));
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
}
this.logger.log(chalk.green('Command completed!'));
}
private async processWorkspace({
@@ -170,9 +172,6 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner {
);
}
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
workspaceId,
);
this.logger.log(
chalk.green(`Command completed for workspace ${workspaceId}`),
);
@@ -403,9 +402,19 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner {
return null;
}
return await serverBlockNoteEditor.blocksToMarkdownLossy(
jsonParsedblocknoteFieldValue,
);
let markdown: string | null = null;
try {
markdown = await serverBlockNoteEditor.blocksToMarkdownLossy(
jsonParsedblocknoteFieldValue,
);
} catch (error) {
this.logger.warn(
`Error converting blocknote to markdown for ${blocknoteFieldValue}`,
);
}
return markdown;
}
private async migrateToNewRichTextFieldsColumn({
@@ -0,0 +1,128 @@
import { InjectRepository } from '@nestjs/typeorm';
import chalk from 'chalk';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import {
ActiveWorkspacesCommandOptions,
ActiveWorkspacesCommandRunner,
} from 'src/database/commands/active-workspaces.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { ViewOpenRecordInType } from 'src/modules/view/standard-objects/view.workspace-entity';
@Command({
name: 'upgrade-0.43:update-default-view-record-opening-on-workflow-objects',
description:
'Update default view record opening on workflow objects to record page',
})
export class UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand extends ActiveWorkspacesCommandRunner {
constructor(
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
protected readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {
super(workspaceRepository);
}
async executeActiveWorkspacesCommand(
_passedParam: string[],
_options: ActiveWorkspacesCommandOptions,
workspaceIds: string[],
): Promise<void> {
this.logger.log(
'Running command to update default view record opening on workflow objects to record page',
);
for (const [index, workspaceId] of workspaceIds.entries()) {
await this.processWorkspace(workspaceId, index, workspaceIds.length);
}
this.logger.log(chalk.green('Command completed!'));
}
async processWorkspace(
workspaceId: string,
index: number,
total: number,
): Promise<void> {
try {
this.logger.log(
`Running command for workspace ${workspaceId} ${index + 1}/${total}`,
);
const workflowObjectsMetadata = await this.objectMetadataRepository.find({
select: ['id'],
where: {
workspaceId,
standardId: In([
STANDARD_OBJECT_IDS.workflow,
STANDARD_OBJECT_IDS.workflowVersion,
STANDARD_OBJECT_IDS.workflowRun,
]),
},
});
if (workflowObjectsMetadata.length === 0) {
this.logger.log(
chalk.yellow(
`No workflow objects found for workspace ${workspaceId}`,
),
);
return;
}
await this.updateDefaultViewsRecordOpening(
workflowObjectsMetadata.map((metadata) => metadata.id),
workspaceId,
);
await this.workspaceMigrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(
chalk.green(`Command completed for workspace ${workspaceId}.`),
);
} catch (error) {
this.logger.log(
chalk.red(`Error in workspace ${workspaceId} - ${error.message}`),
);
}
}
private async updateDefaultViewsRecordOpening(
workflowObjectMetadataIds: string[],
workspaceId: string,
): Promise<void> {
const viewRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'view',
);
await viewRepository.update(
{
objectMetadataId: In(workflowObjectMetadataIds),
key: 'INDEX',
},
{
openRecordIn: ViewOpenRecordInType.RECORD_PAGE,
},
);
}
}
@@ -7,6 +7,8 @@ import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-work
import { BaseCommandOptions } from 'src/database/commands/base.command';
import { StandardizationOfActorCompositeContextTypeCommand } from 'src/database/commands/upgrade-version/0-42/0-42-standardization-of-actor-composite-context-type';
import { AddTasksAssignedToMeViewCommand } from 'src/database/commands/upgrade-version/0-43/0-43-add-tasks-assigned-to-me-view.command';
import { MigrateSearchVectorOnNoteAndTaskEntitiesCommand } from 'src/database/commands/upgrade-version/0-43/0-43-migrate-search-vector-on-note-and-task-entities.command';
import { UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand } from 'src/database/commands/upgrade-version/0-43/0-43-update-default-view-record-opening-on-workflow-objects.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Command({
@@ -18,6 +20,8 @@ export class UpgradeTo0_43Command extends ActiveWorkspacesCommandRunner {
@InjectRepository(Workspace, 'core')
protected readonly workspaceRepository: Repository<Workspace>,
private readonly addTasksAssignedToMeViewCommand: AddTasksAssignedToMeViewCommand,
private readonly migrateSearchVectorOnNoteAndTaskEntitiesCommand: MigrateSearchVectorOnNoteAndTaskEntitiesCommand,
private readonly updateDefaultViewRecordOpeningOnWorkflowObjectsCommand: UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand,
private readonly standardizationOfActorCompositeContextTypeCommand: StandardizationOfActorCompositeContextTypeCommand,
) {
super(workspaceRepository);
@@ -36,6 +40,18 @@ export class UpgradeTo0_43Command extends ActiveWorkspacesCommandRunner {
workspaceIds,
);
await this.migrateSearchVectorOnNoteAndTaskEntitiesCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
await this.updateDefaultViewRecordOpeningOnWorkflowObjectsCommand.executeActiveWorkspacesCommand(
passedParam,
options,
workspaceIds,
);
// Note: Introduced in 0.42, ran manually on prod. Introduced to self-host globally on 0.43
await this.standardizationOfActorCompositeContextTypeCommand.executeActiveWorkspacesCommand(
passedParam,
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { StandardizationOfActorCompositeContextTypeCommand } from 'src/database/commands/upgrade-version/0-42/0-42-standardization-of-actor-composite-context-type';
import { AddTasksAssignedToMeViewCommand } from 'src/database/commands/upgrade-version/0-43/0-43-add-tasks-assigned-to-me-view.command';
import { MigrateSearchVectorOnNoteAndTaskEntitiesCommand } from 'src/database/commands/upgrade-version/0-43/0-43-migrate-search-vector-on-note-and-task-entities.command';
import { UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand } from 'src/database/commands/upgrade-version/0-43/0-43-update-default-view-record-opening-on-workflow-objects.command';
import { UpgradeTo0_43Command } from 'src/database/commands/upgrade-version/0-43/0-43-upgrade-version.command';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -30,6 +31,7 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor
UpgradeTo0_43Command,
AddTasksAssignedToMeViewCommand,
MigrateSearchVectorOnNoteAndTaskEntitiesCommand,
UpdateDefaultViewRecordOpeningOnWorkflowObjectsCommand,
StandardizationOfActorCompositeContextTypeCommand,
],
})
@@ -1,11 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { HEALTH_INDICATORS } from 'src/engine/core-modules/admin-panel/constants/health-indicators.constants';
import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
import { HEALTH_ERROR_MESSAGES } from 'src/engine/core-modules/health/constants/health-error-messages.constants';
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
import { MessageSyncHealthIndicator } from 'src/engine/core-modules/health/indicators/message-sync.health';
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
@@ -14,44 +16,21 @@ describe('AdminPanelHealthService', () => {
let databaseHealth: jest.Mocked<DatabaseHealthIndicator>;
let redisHealth: jest.Mocked<RedisHealthIndicator>;
let workerHealth: jest.Mocked<WorkerHealthIndicator>;
let messageSyncHealth: jest.Mocked<MessageSyncHealthIndicator>;
let connectedAccountHealth: jest.Mocked<ConnectedAccountHealth>;
beforeEach(async () => {
databaseHealth = {
isHealthy: jest.fn(),
} as any;
redisHealth = {
isHealthy: jest.fn(),
} as any;
workerHealth = {
isHealthy: jest.fn(),
} as any;
messageSyncHealth = {
isHealthy: jest.fn(),
} as any;
databaseHealth = { isHealthy: jest.fn() } as any;
redisHealth = { isHealthy: jest.fn() } as any;
workerHealth = { isHealthy: jest.fn() } as any;
connectedAccountHealth = { isHealthy: jest.fn() } as any;
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminPanelHealthService,
{
provide: DatabaseHealthIndicator,
useValue: databaseHealth,
},
{
provide: RedisHealthIndicator,
useValue: redisHealth,
},
{
provide: WorkerHealthIndicator,
useValue: workerHealth,
},
{
provide: MessageSyncHealthIndicator,
useValue: messageSyncHealth,
},
{ provide: DatabaseHealthIndicator, useValue: databaseHealth },
{ provide: RedisHealthIndicator, useValue: redisHealth },
{ provide: WorkerHealthIndicator, useValue: workerHealth },
{ provide: ConnectedAccountHealth, useValue: connectedAccountHealth },
],
}).compile();
@@ -62,132 +41,244 @@ describe('AdminPanelHealthService', () => {
expect(service).toBeDefined();
});
it('should transform health check response to SystemHealth format', async () => {
databaseHealth.isHealthy.mockResolvedValue({
database: {
status: 'up',
details: 'Database is healthy',
},
});
redisHealth.isHealthy.mockResolvedValue({
redis: {
status: 'up',
details: 'Redis is connected',
},
});
workerHealth.isHealthy.mockResolvedValue({
worker: {
status: 'up',
queues: [
{
name: 'test',
workers: 1,
metrics: {
active: 1,
completed: 0,
delayed: 4,
failed: 3,
waiting: 0,
prioritized: 0,
describe('getSystemHealthStatus', () => {
it('should transform health check response to SystemHealth format', async () => {
databaseHealth.isHealthy.mockResolvedValue({
database: { status: 'up', details: 'Database is healthy' },
});
redisHealth.isHealthy.mockResolvedValue({
redis: { status: 'up', details: 'Redis is connected' },
});
workerHealth.isHealthy.mockResolvedValue({
worker: {
status: 'up',
queues: [
{
queueName: 'test',
workers: 1,
metrics: {
active: 1,
completed: 0,
delayed: 4,
failed: 3,
waiting: 0,
prioritized: 0,
},
},
],
},
});
connectedAccountHealth.isHealthy.mockResolvedValue({
connectedAccount: {
status: 'up',
details: 'Account sync is operational',
},
});
const result = await service.getSystemHealthStatus();
const expected: SystemHealth = {
services: [
{
...HEALTH_INDICATORS[HealthIndicatorId.database],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.redis],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.worker],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.connectedAccount],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
],
},
});
messageSyncHealth.isHealthy.mockResolvedValue({
messageSync: {
status: 'up',
details: 'Message sync is operational',
},
};
expect(result).toStrictEqual(expected);
});
const result = await service.getSystemHealthStatus();
it('should handle mixed health statuses', async () => {
databaseHealth.isHealthy.mockResolvedValue({
database: { status: 'up' },
});
redisHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
);
workerHealth.isHealthy.mockResolvedValue({
worker: { status: 'up', queues: [] },
});
connectedAccountHealth.isHealthy.mockResolvedValue({
connectedAccount: { status: 'up' },
});
const expected: SystemHealth = {
database: {
const result = await service.getSystemHealthStatus();
expect(result).toStrictEqual({
services: [
{
...HEALTH_INDICATORS[HealthIndicatorId.database],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.redis],
status: AdminPanelHealthServiceStatus.OUTAGE,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.worker],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.connectedAccount],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
},
],
});
});
it('should handle all services down', async () => {
databaseHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.DATABASE_CONNECTION_FAILED),
);
redisHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
);
workerHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.NO_ACTIVE_WORKERS),
);
connectedAccountHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_CHECK_FAILED),
);
const result = await service.getSystemHealthStatus();
expect(result).toStrictEqual({
services: [
{
...HEALTH_INDICATORS[HealthIndicatorId.database],
status: AdminPanelHealthServiceStatus.OUTAGE,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.redis],
status: AdminPanelHealthServiceStatus.OUTAGE,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.worker],
status: AdminPanelHealthServiceStatus.OUTAGE,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.connectedAccount],
status: AdminPanelHealthServiceStatus.OUTAGE,
},
],
});
});
});
describe('getIndicatorHealthStatus', () => {
it('should return health status for database indicator', async () => {
const details = {
version: '15.0',
connections: { active: 5, max: 100 },
};
databaseHealth.isHealthy.mockResolvedValue({
database: {
status: 'up',
details,
},
});
const result = await service.getIndicatorHealthStatus(
HealthIndicatorId.database,
);
expect(result).toStrictEqual({
...HEALTH_INDICATORS[HealthIndicatorId.database],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
details: '"Database is healthy"',
details: JSON.stringify(details),
queues: undefined,
},
redis: {
status: AdminPanelHealthServiceStatus.OPERATIONAL,
details: '"Redis is connected"',
queues: undefined,
},
worker: {
});
});
it('should return health status with queues for worker indicator', async () => {
const mockQueues = [
{
queueName: 'queue1',
workers: 2,
metrics: {
active: 1,
completed: 10,
delayed: 0,
failed: 2,
waiting: 5,
prioritized: 1,
},
},
{
queueName: 'queue2',
workers: 0,
metrics: {
active: 0,
completed: 5,
delayed: 0,
failed: 1,
waiting: 2,
prioritized: 0,
},
},
];
workerHealth.isHealthy.mockResolvedValue({
worker: {
status: 'up',
queues: mockQueues,
},
});
const result = await service.getIndicatorHealthStatus(
HealthIndicatorId.worker,
);
expect(result).toStrictEqual({
...HEALTH_INDICATORS[HealthIndicatorId.worker],
status: AdminPanelHealthServiceStatus.OPERATIONAL,
details: undefined,
queues: [
{
name: 'test',
workers: 1,
status: AdminPanelHealthServiceStatus.OPERATIONAL,
metrics: {
active: 1,
completed: 0,
delayed: 4,
failed: 3,
waiting: 0,
prioritized: 0,
},
},
],
},
messageSync: {
status: AdminPanelHealthServiceStatus.OPERATIONAL,
details: '"Message sync is operational"',
queues: undefined,
},
};
expect(result).toStrictEqual(expected);
});
it('should handle mixed health statuses', async () => {
databaseHealth.isHealthy.mockResolvedValue({
database: { status: 'up' },
});
redisHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
);
workerHealth.isHealthy.mockResolvedValue({
worker: { status: 'up', queues: [] },
});
messageSyncHealth.isHealthy.mockResolvedValue({
messageSync: { status: 'up' },
queues: mockQueues.map((queue) => ({
...queue,
id: `worker-${queue.queueName}`,
status:
queue.workers > 0
? AdminPanelHealthServiceStatus.OPERATIONAL
: AdminPanelHealthServiceStatus.OUTAGE,
})),
});
});
const result = await service.getSystemHealthStatus();
it('should handle failed indicator health check', async () => {
redisHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
);
expect(result).toMatchObject({
database: { status: AdminPanelHealthServiceStatus.OPERATIONAL },
redis: { status: AdminPanelHealthServiceStatus.OUTAGE },
worker: { status: AdminPanelHealthServiceStatus.OPERATIONAL },
messageSync: { status: AdminPanelHealthServiceStatus.OPERATIONAL },
const result = await service.getIndicatorHealthStatus(
HealthIndicatorId.redis,
);
expect(result).toStrictEqual({
...HEALTH_INDICATORS[HealthIndicatorId.redis],
status: AdminPanelHealthServiceStatus.OUTAGE,
details: HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED,
});
});
});
it('should handle all services down', async () => {
databaseHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.DATABASE_CONNECTION_FAILED),
);
redisHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.REDIS_CONNECTION_FAILED),
);
workerHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.NO_ACTIVE_WORKERS),
);
messageSyncHealth.isHealthy.mockRejectedValue(
new Error(HEALTH_ERROR_MESSAGES.MESSAGE_SYNC_CHECK_FAILED),
);
const result = await service.getSystemHealthStatus();
expect(result).toMatchObject({
database: { status: AdminPanelHealthServiceStatus.OUTAGE },
redis: { status: AdminPanelHealthServiceStatus.OUTAGE },
worker: { status: AdminPanelHealthServiceStatus.OUTAGE },
messageSync: { status: AdminPanelHealthServiceStatus.OUTAGE },
it('should throw error for invalid indicator', async () => {
await expect(
// @ts-expect-error Testing invalid input
service.getIndicatorHealthStatus('invalid'),
).rejects.toThrow('Health indicator not found: invalid');
});
});
});
@@ -1,12 +1,13 @@
import { Injectable } from '@nestjs/common';
import { HealthIndicatorResult } from '@nestjs/terminus';
import { HealthIndicatorResult, HealthIndicatorStatus } from '@nestjs/terminus';
import { HEALTH_INDICATORS } from 'src/engine/core-modules/admin-panel/constants/health-indicators.constants';
import { AdminPanelHealthServiceData } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-health-service-data.dto';
import { AdminPanelIndicatorHealthStatusInputEnum } from 'src/engine/core-modules/admin-panel/dtos/admin-panel-indicator-health-status.input';
import { SystemHealth } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-panel/enums/admin-panel-health-service-status.enum';
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
import { ConnectedAccountHealth } from 'src/engine/core-modules/health/indicators/connected-account.health';
import { DatabaseHealthIndicator } from 'src/engine/core-modules/health/indicators/database.health';
import { MessageSyncHealthIndicator } from 'src/engine/core-modules/health/indicators/message-sync.health';
import { RedisHealthIndicator } from 'src/engine/core-modules/health/indicators/redis.health';
import { WorkerHealthIndicator } from 'src/engine/core-modules/health/indicators/worker.health';
@@ -16,57 +17,84 @@ export class AdminPanelHealthService {
private readonly databaseHealth: DatabaseHealthIndicator,
private readonly redisHealth: RedisHealthIndicator,
private readonly workerHealth: WorkerHealthIndicator,
private readonly messageSyncHealth: MessageSyncHealthIndicator,
private readonly connectedAccountHealth: ConnectedAccountHealth,
) {}
private readonly healthIndicators = {
database: this.databaseHealth,
redis: this.redisHealth,
worker: this.workerHealth,
messageSync: this.messageSyncHealth,
[HealthIndicatorId.database]: this.databaseHealth,
[HealthIndicatorId.redis]: this.redisHealth,
[HealthIndicatorId.worker]: this.workerHealth,
[HealthIndicatorId.connectedAccount]: this.connectedAccountHealth,
};
private transformStatus(status: HealthIndicatorStatus) {
return status === 'up'
? AdminPanelHealthServiceStatus.OPERATIONAL
: AdminPanelHealthServiceStatus.OUTAGE;
}
private transformServiceDetails(details: any) {
if (!details) return details;
if (details.messageSync) {
details.messageSync.status = this.transformStatus(
details.messageSync.status,
);
}
if (details.calendarSync) {
details.calendarSync.status = this.transformStatus(
details.calendarSync.status,
);
}
return details;
}
private getServiceStatus(
result: PromiseSettledResult<HealthIndicatorResult>,
indicatorId: HealthIndicatorId,
) {
if (result.status === 'fulfilled') {
const key = Object.keys(result.value)[0];
const serviceResult = result.value[key];
const details = serviceResult.details;
const details = this.transformServiceDetails(serviceResult.details);
const indicator = HEALTH_INDICATORS[indicatorId];
return {
status:
serviceResult.status === 'up'
? AdminPanelHealthServiceStatus.OPERATIONAL
: AdminPanelHealthServiceStatus.OUTAGE,
id: indicatorId,
label: indicator.label,
description: indicator.description,
status: this.transformStatus(serviceResult.status),
details: details ? JSON.stringify(details) : undefined,
queues: serviceResult.queues,
};
}
return {
...HEALTH_INDICATORS[indicatorId],
status: AdminPanelHealthServiceStatus.OUTAGE,
details: result.reason?.message,
details: result.reason?.message?.toString(),
};
}
async getIndicatorHealthStatus(
indicatorName: AdminPanelIndicatorHealthStatusInputEnum,
indicatorId: HealthIndicatorId,
): Promise<AdminPanelHealthServiceData> {
const healthIndicator = this.healthIndicators[indicatorName];
const healthIndicator = this.healthIndicators[indicatorId];
if (!healthIndicator) {
throw new Error(`Health indicator not found: ${indicatorName}`);
throw new Error(`Health indicator not found: ${indicatorId}`);
}
const result = await Promise.allSettled([healthIndicator.isHealthy()]);
const indicatorStatus = this.getServiceStatus(result[0]);
const indicatorStatus = this.getServiceStatus(result[0], indicatorId);
if (indicatorName === 'worker') {
if (indicatorId === HealthIndicatorId.worker) {
return {
...indicatorStatus,
queues: (indicatorStatus?.queues ?? []).map((queue) => ({
...queue,
id: `${indicatorId}-${queue.queueName}`,
status:
queue.workers > 0
? AdminPanelHealthServiceStatus.OPERATIONAL
@@ -79,30 +107,41 @@ export class AdminPanelHealthService {
}
async getSystemHealthStatus(): Promise<SystemHealth> {
const [databaseResult, redisResult, workerResult, messageSyncResult] =
const [databaseResult, redisResult, workerResult, accountSyncResult] =
await Promise.allSettled([
this.databaseHealth.isHealthy(),
this.redisHealth.isHealthy(),
this.workerHealth.isHealthy(),
this.messageSyncHealth.isHealthy(),
this.connectedAccountHealth.isHealthy(),
]);
const workerStatus = this.getServiceStatus(workerResult);
return {
database: this.getServiceStatus(databaseResult),
redis: this.getServiceStatus(redisResult),
worker: {
...workerStatus,
queues: (workerStatus?.queues ?? []).map((queue) => ({
...queue,
status:
queue.workers > 0
? AdminPanelHealthServiceStatus.OPERATIONAL
: AdminPanelHealthServiceStatus.OUTAGE,
})),
},
messageSync: this.getServiceStatus(messageSyncResult),
services: [
{
...HEALTH_INDICATORS[HealthIndicatorId.database],
status: this.getServiceStatus(
databaseResult,
HealthIndicatorId.database,
).status,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.redis],
status: this.getServiceStatus(redisResult, HealthIndicatorId.redis)
.status,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.worker],
status: this.getServiceStatus(workerResult, HealthIndicatorId.worker)
.status,
},
{
...HEALTH_INDICATORS[HealthIndicatorId.connectedAccount],
status: this.getServiceStatus(
accountSyncResult,
HealthIndicatorId.connectedAccount,
).status,
},
],
};
}
}
@@ -11,12 +11,12 @@ import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-p
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity';
import { UserLookupInput } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AdminPanelHealthServiceData } from './dtos/admin-panel-health-service-data.dto';
import { AdminPanelIndicatorHealthStatusInputEnum } from './dtos/admin-panel-indicator-health-status.input';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
@@ -70,11 +70,11 @@ export class AdminPanelResolver {
@Query(() => AdminPanelHealthServiceData)
async getIndicatorHealthStatus(
@Args('indicatorName', {
type: () => AdminPanelIndicatorHealthStatusInputEnum,
@Args('indicatorId', {
type: () => HealthIndicatorId,
})
indicatorName: AdminPanelIndicatorHealthStatusInputEnum,
indicatorId: HealthIndicatorId,
): Promise<AdminPanelHealthServiceData> {
return this.adminPanelHealthService.getIndicatorHealthStatus(indicatorName);
return this.adminPanelHealthService.getIndicatorHealthStatus(indicatorId);
}
}
@@ -0,0 +1,31 @@
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
type HealthIndicatorInfo = {
id: HealthIndicatorId;
label: string;
description: string;
};
export const HEALTH_INDICATORS: Record<HealthIndicatorId, HealthIndicatorInfo> =
{
[HealthIndicatorId.database]: {
id: HealthIndicatorId.database,
label: 'Database Status',
description: 'PostgreSQL database connection status',
},
[HealthIndicatorId.redis]: {
id: HealthIndicatorId.redis,
label: 'Redis Status',
description: 'Redis connection status',
},
[HealthIndicatorId.worker]: {
id: HealthIndicatorId.worker,
label: 'Worker Status',
description: 'Background job worker status',
},
[HealthIndicatorId.connectedAccount]: {
id: HealthIndicatorId.connectedAccount,
label: 'Connected Account Status',
description: 'Connected accounts status',
},
};
@@ -0,0 +1,8 @@
import { Field } from '@nestjs/graphql';
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
export class HealthIndicatorInput {
@Field(() => HealthIndicatorId)
indicatorId: HealthIndicatorId;
}
@@ -5,6 +5,15 @@ import { AdminPanelHealthServiceStatus } from 'src/engine/core-modules/admin-pan
@ObjectType()
export class AdminPanelHealthServiceData {
@Field(() => String)
id: string;
@Field(() => String)
label: string;
@Field(() => String)
description: string;
@Field(() => AdminPanelHealthServiceStatus)
status: AdminPanelHealthServiceStatus;

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