Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e23e6616d4 | ||
|
|
1e82e0481e | ||
|
|
9f84de074b |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-e2e-testing",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../lib/fixtures/blank-workflow';
|
||||
|
||||
test('The workflow run visualizer shows the executed draft version without the last draft changes', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('manual');
|
||||
|
||||
const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
name: 'When record(s) are selected',
|
||||
});
|
||||
|
||||
await manualTriggerAvailabilitySelect.click();
|
||||
|
||||
const alwaysAvailableOption = page.getByText(
|
||||
'When no record(s) are selected',
|
||||
);
|
||||
|
||||
await alwaysAvailableOption.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const launchTestButton = page.getByRole('button', { name: 'Test' });
|
||||
|
||||
await launchTestButton.click();
|
||||
|
||||
const goToExecutionPageLink = page.getByRole('link', {
|
||||
name: 'View execution details',
|
||||
});
|
||||
const executionPageUrl = await goToExecutionPageLink.getAttribute('href');
|
||||
expect(executionPageUrl).not.toBeNull();
|
||||
|
||||
await workflowVisualizer.deleteStep(firstStepId);
|
||||
|
||||
await page.goto(executionPageUrl!);
|
||||
|
||||
const workflowRunName = page.getByText('Execution of v1');
|
||||
|
||||
await expect(workflowRunName).toBeVisible();
|
||||
|
||||
const flowTab = page.getByText('Flow', { exact: true });
|
||||
|
||||
await flowTab.click();
|
||||
|
||||
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
await expect(executedFirstStepNode).toBeVisible();
|
||||
|
||||
await executedFirstStepNode.click();
|
||||
|
||||
await expect(
|
||||
workflowVisualizer.commandMenu.getByRole('textbox').first(),
|
||||
).toHaveValue('Create Record');
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-emails",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-front",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-2
@@ -55,7 +55,6 @@ export const RightDrawerEmailThread = () => {
|
||||
connectedAccountHandle,
|
||||
messageChannelLoading,
|
||||
connectedAccountProvider,
|
||||
lastMessageExternalId,
|
||||
} = useRightDrawerEmailThread();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,7 +117,7 @@ export const RightDrawerEmailThread = () => {
|
||||
let url: string;
|
||||
switch (connectedAccountProvider) {
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
|
||||
url = `https://outlook.office365.com/mail/inbox/id/${messageThreadExternalId}`;
|
||||
window.open(url, '_blank');
|
||||
break;
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
|
||||
-6
@@ -109,7 +109,6 @@ export const useRightDrawerEmailThread = () => {
|
||||
messageId: true,
|
||||
messageChannelId: true,
|
||||
messageThreadExternalId: true,
|
||||
messageExternalId: true,
|
||||
},
|
||||
skip: !lastMessageId || !isMessagesFetchComplete,
|
||||
});
|
||||
@@ -145,10 +144,6 @@ export const useRightDrawerEmailThread = () => {
|
||||
messageChannelMessageAssociationData.length > 0
|
||||
? messageChannelMessageAssociationData[0].messageThreadExternalId
|
||||
: null;
|
||||
const lastMessageExternalId =
|
||||
messageChannelMessageAssociationData.length > 0
|
||||
? messageChannelMessageAssociationData[0].messageExternalId
|
||||
: null;
|
||||
const connectedAccountHandle =
|
||||
messageChannelData.length > 0 ? messageChannelData[0].handle : null;
|
||||
|
||||
@@ -180,7 +175,6 @@ export const useRightDrawerEmailThread = () => {
|
||||
connectedAccountProvider,
|
||||
threadLoading: messagesLoading,
|
||||
messageChannelLoading,
|
||||
lastMessageExternalId,
|
||||
fetchMoreMessages,
|
||||
};
|
||||
};
|
||||
|
||||
-1
@@ -4,5 +4,4 @@ export type MessageChannelMessageAssociation = {
|
||||
messageId: string;
|
||||
messageChannelId: string;
|
||||
messageThreadExternalId: string;
|
||||
messageExternalId: string;
|
||||
};
|
||||
|
||||
@@ -421,15 +421,7 @@ export const SettingsRoutes = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Route
|
||||
element={
|
||||
<SettingsProtectedRouteWrapper
|
||||
settingsPermission={SettingsFeatures.WORKSPACE}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
|
||||
</Route>
|
||||
<Route path={SettingsPath.Lab} element={<SettingsLab />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import { useCloseSortDropdown } from '@/object-record/object-sort-dropdown/hooks
|
||||
import { useResetRecordSortDropdownSearchInput } from '@/object-record/object-sort-dropdown/hooks/useResetRecordSortDropdownSearchInput';
|
||||
import { useResetSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useResetSortDropdown';
|
||||
import { useToggleSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useToggleSortDropdown';
|
||||
import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionDropdownMenuUnfoldedComponentState';
|
||||
import { isRecordSortDirectionMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionMenuUnfoldedComponentState';
|
||||
import { objectSortDropdownSearchInputComponentState } from '@/object-record/object-sort-dropdown/states/objectSortDropdownSearchInputComponentState';
|
||||
import { onSortSelectComponentState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState';
|
||||
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
|
||||
@@ -84,7 +84,7 @@ export const ObjectSortDropdownButton = ({
|
||||
);
|
||||
|
||||
const isRecordSortDirectionMenuUnfolded = useRecoilComponentValueV2(
|
||||
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
|
||||
isRecordSortDirectionMenuUnfoldedComponentState,
|
||||
);
|
||||
|
||||
const { resetSortDropdown } = useResetSortDropdown();
|
||||
@@ -168,7 +168,7 @@ export const ObjectSortDropdownButton = ({
|
||||
useRecoilComponentStateV2(selectedRecordSortDirectionComponentState);
|
||||
|
||||
const setIsRecordSortDirectionMenuUnfolded = useSetRecoilComponentStateV2(
|
||||
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
|
||||
isRecordSortDirectionMenuUnfoldedComponentState,
|
||||
);
|
||||
|
||||
const handleSortDirectionClick = (sortDirection: RecordSortDirection) => {
|
||||
|
||||
+5
-6
@@ -1,19 +1,18 @@
|
||||
import { isRecordSortDirectionDropdownMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionDropdownMenuUnfoldedComponentState';
|
||||
import { isRecordSortDirectionMenuUnfoldedComponentState } from '@/object-record/object-sort-dropdown/states/isRecordSortDirectionMenuUnfoldedComponentState';
|
||||
import { selectedRecordSortDirectionComponentState } from '@/object-record/object-sort-dropdown/states/selectedRecordSortDirectionComponentState';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
|
||||
export const useResetSortDropdown = () => {
|
||||
const setIsRecordSortDirectionDropdownMenuUnfolded =
|
||||
useSetRecoilComponentStateV2(
|
||||
isRecordSortDirectionDropdownMenuUnfoldedComponentState,
|
||||
);
|
||||
const setIsRecordSortDirectionMenuUnfolded = useSetRecoilComponentStateV2(
|
||||
isRecordSortDirectionMenuUnfoldedComponentState,
|
||||
);
|
||||
|
||||
const setSelectedRecordSortDirection = useSetRecoilComponentStateV2(
|
||||
selectedRecordSortDirectionComponentState,
|
||||
);
|
||||
|
||||
const resetSortDropdown = () => {
|
||||
setIsRecordSortDirectionDropdownMenuUnfolded(false);
|
||||
setIsRecordSortDirectionMenuUnfolded(false);
|
||||
setSelectedRecordSortDirection('asc');
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext';
|
||||
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
|
||||
|
||||
export const isRecordSortDirectionDropdownMenuUnfoldedComponentState =
|
||||
export const isRecordSortDirectionMenuUnfoldedComponentState =
|
||||
createComponentStateV2<boolean>({
|
||||
key: 'isRecordSortDirectionDropdownMenuUnfoldedComponentState',
|
||||
key: 'isRecordSortDirectionMenuUnfoldedComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: ObjectSortDropdownComponentInstanceContext,
|
||||
});
|
||||
-4
@@ -1,6 +1,5 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
|
||||
import { isRemoveSortingModalOpenState } from '@/object-record/record-table/states/isRemoveSortingModalOpenState';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useDeleteCombinedViewSorts } from '@/views/hooks/useDeleteCombinedViewSorts';
|
||||
@@ -23,12 +22,9 @@ export const RecordIndexRemoveSortingModal = ({
|
||||
|
||||
const { deleteCombinedViewSort } = useDeleteCombinedViewSorts(recordIndexId);
|
||||
|
||||
const { removeRecordSort } = useRemoveRecordSort();
|
||||
|
||||
const handleRemoveClick = () => {
|
||||
fieldMetadataIds.forEach((id) => {
|
||||
deleteCombinedViewSort(id);
|
||||
removeRecordSort(id);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+2
-3
@@ -29,7 +29,7 @@ export const useHandleToggleColumnSort = ({
|
||||
const { upsertRecordSort } = useUpsertRecordSort();
|
||||
|
||||
const handleToggleColumnSort = useCallback(
|
||||
async (fieldMetadataId: string) => {
|
||||
(fieldMetadataId: string) => {
|
||||
const correspondingColumnDefinition = columnDefinitions.find(
|
||||
(columnDefinition) =>
|
||||
columnDefinition.fieldMetadataId === fieldMetadataId,
|
||||
@@ -48,9 +48,8 @@ export const useHandleToggleColumnSort = ({
|
||||
direction: 'asc',
|
||||
};
|
||||
|
||||
upsertCombinedViewSort(newSort);
|
||||
upsertRecordSort(newSort);
|
||||
|
||||
await upsertCombinedViewSort(newSort);
|
||||
},
|
||||
[columnDefinitions, upsertCombinedViewSort, upsertRecordSort],
|
||||
);
|
||||
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
export const useRemoveRecordSort = () => {
|
||||
const currentRecordSortsCallbackState = useRecoilComponentCallbackStateV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
const removeRecordSort = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
(fieldMetadataId: string) => {
|
||||
const currentRecordSorts = getSnapshotValue(
|
||||
snapshot,
|
||||
currentRecordSortsCallbackState,
|
||||
);
|
||||
|
||||
const hasFoundRecordSortInCurrentRecordSorts = currentRecordSorts.some(
|
||||
(existingSort) => existingSort.fieldMetadataId === fieldMetadataId,
|
||||
);
|
||||
|
||||
if (hasFoundRecordSortInCurrentRecordSorts) {
|
||||
set(currentRecordSortsCallbackState, (currentRecordSorts) => {
|
||||
const newCurrentRecordSorts = [...currentRecordSorts];
|
||||
|
||||
const indexOfSortToRemove = newCurrentRecordSorts.findIndex(
|
||||
(existingSort) =>
|
||||
existingSort.fieldMetadataId === fieldMetadataId,
|
||||
);
|
||||
|
||||
if (indexOfSortToRemove < 0) {
|
||||
return newCurrentRecordSorts;
|
||||
}
|
||||
|
||||
newCurrentRecordSorts.splice(indexOfSortToRemove, 1);
|
||||
|
||||
return newCurrentRecordSorts;
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentRecordSortsCallbackState],
|
||||
);
|
||||
|
||||
return {
|
||||
removeRecordSort,
|
||||
};
|
||||
};
|
||||
@@ -117,8 +117,7 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
label: t`Billing`,
|
||||
path: SettingsPath.Billing,
|
||||
Icon: IconCurrencyDollar,
|
||||
isHidden:
|
||||
!isBillingEnabled || !permissionMap[SettingsFeatures.WORKSPACE],
|
||||
isHidden: !isBillingEnabled,
|
||||
},
|
||||
{
|
||||
label: t`Roles`,
|
||||
@@ -182,9 +181,7 @@ export const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
label: t`Lab`,
|
||||
path: SettingsPath.Lab,
|
||||
Icon: IconFlask,
|
||||
isHidden:
|
||||
!labPublicFeatureFlags.length ||
|
||||
!permissionMap[SettingsFeatures.WORKSPACE],
|
||||
isHidden: !labPublicFeatureFlags.length,
|
||||
},
|
||||
{
|
||||
label: t`Releases`,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IconArrowDown, IconArrowUp } from 'twenty-ui';
|
||||
|
||||
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
|
||||
import { useUpsertRecordSort } from '@/object-record/record-sort/hooks/useUpsertRecordSort';
|
||||
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
|
||||
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
|
||||
@@ -14,15 +13,12 @@ type EditableSortChipProps = {
|
||||
export const EditableSortChip = ({ recordSort }: EditableSortChipProps) => {
|
||||
const { deleteCombinedViewSort } = useDeleteCombinedViewSorts();
|
||||
|
||||
const { removeRecordSort } = useRemoveRecordSort();
|
||||
|
||||
const { upsertCombinedViewSort } = useUpsertCombinedViewSorts();
|
||||
|
||||
const { upsertRecordSort } = useUpsertRecordSort();
|
||||
|
||||
const handleRemoveClick = () => {
|
||||
deleteCombinedViewSort(recordSort.fieldMetadataId);
|
||||
removeRecordSort(recordSort.fieldMetadataId);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { SoftDeleteFilterChip } from '@/views/components/SoftDeleteFilterChip';
|
||||
import { useApplyCurrentViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyCurrentViewFiltersToCurrentRecordFilters';
|
||||
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '@/views/hooks/useApplyCurrentViewSortsToCurrentRecordSorts';
|
||||
import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters';
|
||||
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
|
||||
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
|
||||
@@ -164,14 +163,10 @@ export const ViewBarDetails = ({
|
||||
const { applyCurrentViewFiltersToCurrentRecordFilters } =
|
||||
useApplyCurrentViewFiltersToCurrentRecordFilters();
|
||||
|
||||
const { applyCurrentViewSortsToCurrentRecordSorts } =
|
||||
useApplyCurrentViewSortsToCurrentRecordSorts();
|
||||
|
||||
const handleCancelClick = () => {
|
||||
if (isDefined(viewId)) {
|
||||
resetUnsavedViewStates(viewId);
|
||||
applyCurrentViewFiltersToCurrentRecordFilters();
|
||||
applyCurrentViewSortsToCurrentRecordSorts();
|
||||
toggleSoftDeleteFilterState(false);
|
||||
}
|
||||
};
|
||||
|
||||
-196
@@ -1,196 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
|
||||
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
|
||||
import { ViewSort } from '@/views/types/ViewSort';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||
import { prefetchViewsState } from '@/prefetch/states/prefetchViewsState';
|
||||
|
||||
import { View } from '@/views/types/View';
|
||||
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
import { mockedViewsData } from '~/testing/mock-data/views';
|
||||
import { useApplyCurrentViewSortsToCurrentRecordSorts } from '../useApplyCurrentViewSortsToCurrentRecordSorts';
|
||||
|
||||
const mockObjectMetadataItemNameSingular = 'company';
|
||||
|
||||
describe('useApplyCurrentViewSortsToCurrentRecordSorts', () => {
|
||||
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
|
||||
);
|
||||
|
||||
if (!isDefined(mockObjectMetadataItem)) {
|
||||
throw new Error(
|
||||
'Missing mock object metadata item with name singular "company"',
|
||||
);
|
||||
}
|
||||
|
||||
const mockFieldMetadataItem = mockObjectMetadataItem.fields.find(
|
||||
(field) => field.name === 'name',
|
||||
);
|
||||
|
||||
if (!isDefined(mockFieldMetadataItem)) {
|
||||
throw new Error('Missing mock field metadata item with type TEXT');
|
||||
}
|
||||
|
||||
const mockViewSort: ViewSort = {
|
||||
__typename: 'ViewSort',
|
||||
id: 'sort-1',
|
||||
fieldMetadataId: mockFieldMetadataItem.id,
|
||||
direction: 'asc',
|
||||
};
|
||||
|
||||
const allCompaniesView = mockedViewsData[0];
|
||||
|
||||
const mockView = {
|
||||
...allCompaniesView,
|
||||
viewSorts: [mockViewSort],
|
||||
} satisfies View;
|
||||
|
||||
it('should apply sorts from current view', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const { applyCurrentViewSortsToCurrentRecordSorts } =
|
||||
useApplyCurrentViewSortsToCurrentRecordSorts();
|
||||
|
||||
const currentSorts = useRecoilComponentValueV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
return {
|
||||
applyCurrentViewSortsToCurrentRecordSorts,
|
||||
currentSorts,
|
||||
};
|
||||
},
|
||||
{
|
||||
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
apolloMocks: [],
|
||||
componentInstanceId: 'instanceId',
|
||||
contextStoreCurrentObjectMetadataNameSingular:
|
||||
mockObjectMetadataItemNameSingular,
|
||||
onInitializeRecoilSnapshot: (snapshot) => {
|
||||
snapshot.set(
|
||||
contextStoreCurrentViewIdComponentState.atomFamily({
|
||||
instanceId: 'instanceId',
|
||||
}),
|
||||
mockView.id,
|
||||
);
|
||||
|
||||
snapshot.set(prefetchViewsState, [mockView]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.applyCurrentViewSortsToCurrentRecordSorts();
|
||||
});
|
||||
|
||||
expect(result.current.currentSorts).toEqual([
|
||||
{
|
||||
id: mockViewSort.id,
|
||||
fieldMetadataId: mockViewSort.fieldMetadataId,
|
||||
direction: mockViewSort.direction,
|
||||
definition: {
|
||||
fieldMetadataId: mockViewSort.fieldMetadataId,
|
||||
iconName: mockFieldMetadataItem.icon ?? '',
|
||||
label: mockFieldMetadataItem.label,
|
||||
},
|
||||
} satisfies RecordSort,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not apply sorts when current view is not found', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const { applyCurrentViewSortsToCurrentRecordSorts } =
|
||||
useApplyCurrentViewSortsToCurrentRecordSorts();
|
||||
|
||||
const currentSorts = useRecoilComponentValueV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
return {
|
||||
applyCurrentViewSortsToCurrentRecordSorts,
|
||||
currentSorts,
|
||||
};
|
||||
},
|
||||
{
|
||||
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
apolloMocks: [],
|
||||
componentInstanceId: 'instanceId',
|
||||
contextStoreCurrentObjectMetadataNameSingular:
|
||||
mockObjectMetadataItemNameSingular,
|
||||
onInitializeRecoilSnapshot: (snapshot) => {
|
||||
snapshot.set(
|
||||
contextStoreCurrentViewIdComponentState.atomFamily({
|
||||
instanceId: 'instanceId',
|
||||
}),
|
||||
mockView.id,
|
||||
);
|
||||
|
||||
snapshot.set(prefetchViewsState, []);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.applyCurrentViewSortsToCurrentRecordSorts();
|
||||
});
|
||||
|
||||
expect(result.current.currentSorts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle view with empty sorts', () => {
|
||||
const viewWithNoSorts = {
|
||||
...mockView,
|
||||
viewSorts: [],
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const { applyCurrentViewSortsToCurrentRecordSorts } =
|
||||
useApplyCurrentViewSortsToCurrentRecordSorts();
|
||||
|
||||
const currentSorts = useRecoilComponentValueV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
return {
|
||||
applyCurrentViewSortsToCurrentRecordSorts,
|
||||
currentSorts,
|
||||
};
|
||||
},
|
||||
{
|
||||
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
apolloMocks: [],
|
||||
componentInstanceId: 'instanceId',
|
||||
contextStoreCurrentObjectMetadataNameSingular:
|
||||
mockObjectMetadataItemNameSingular,
|
||||
onInitializeRecoilSnapshot: (snapshot) => {
|
||||
snapshot.set(
|
||||
contextStoreCurrentViewIdComponentState.atomFamily({
|
||||
instanceId: 'instanceId',
|
||||
}),
|
||||
mockView.id,
|
||||
);
|
||||
|
||||
snapshot.set(prefetchViewsState, [viewWithNoSorts]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.applyCurrentViewSortsToCurrentRecordSorts();
|
||||
});
|
||||
|
||||
expect(result.current.currentSorts).toEqual([]);
|
||||
});
|
||||
});
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { ViewSort } from '@/views/types/ViewSort';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||
|
||||
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
|
||||
import { useApplyViewSortsToCurrentRecordSorts } from '../useApplyViewSortsToCurrentRecordSorts';
|
||||
|
||||
const mockObjectMetadataItemNameSingular = 'company';
|
||||
|
||||
describe('useApplyViewSortsToCurrentRecordSorts', () => {
|
||||
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
|
||||
);
|
||||
|
||||
if (!isDefined(mockObjectMetadataItem)) {
|
||||
throw new Error(
|
||||
`Missing mock object metadata item with name singular ${mockObjectMetadataItemNameSingular}`,
|
||||
);
|
||||
}
|
||||
|
||||
const mockFieldMetadataItem = mockObjectMetadataItem.fields.find(
|
||||
(field) => field.name === 'name',
|
||||
);
|
||||
|
||||
if (!isDefined(mockFieldMetadataItem)) {
|
||||
throw new Error(`Missing mock field metadata Name`);
|
||||
}
|
||||
|
||||
const mockViewSort: ViewSort = {
|
||||
__typename: 'ViewSort',
|
||||
id: 'sort-1',
|
||||
fieldMetadataId: mockFieldMetadataItem.id,
|
||||
direction: 'asc',
|
||||
};
|
||||
|
||||
it('should apply view sorts to current record sorts', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const { applyViewSortsToCurrentRecordSorts } =
|
||||
useApplyViewSortsToCurrentRecordSorts();
|
||||
|
||||
const currentSorts = useRecoilComponentValueV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
return { applyViewSortsToCurrentRecordSorts, currentSorts };
|
||||
},
|
||||
{
|
||||
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
apolloMocks: [],
|
||||
componentInstanceId: 'instanceId',
|
||||
contextStoreCurrentObjectMetadataNameSingular:
|
||||
mockObjectMetadataItemNameSingular,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.applyViewSortsToCurrentRecordSorts([mockViewSort]);
|
||||
});
|
||||
|
||||
expect(result.current.currentSorts).toEqual([
|
||||
{
|
||||
id: mockViewSort.id,
|
||||
fieldMetadataId: mockViewSort.fieldMetadataId,
|
||||
direction: mockViewSort.direction,
|
||||
definition: {
|
||||
fieldMetadataId: mockViewSort.fieldMetadataId,
|
||||
label: mockFieldMetadataItem.label,
|
||||
iconName: mockFieldMetadataItem.icon ?? '',
|
||||
},
|
||||
} satisfies RecordSort,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty view sorts array', () => {
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const { applyViewSortsToCurrentRecordSorts } =
|
||||
useApplyViewSortsToCurrentRecordSorts();
|
||||
|
||||
const currentSorts = useRecoilComponentValueV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
return { applyViewSortsToCurrentRecordSorts, currentSorts };
|
||||
},
|
||||
{
|
||||
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
|
||||
apolloMocks: [],
|
||||
componentInstanceId: 'instanceId',
|
||||
contextStoreCurrentObjectMetadataNameSingular:
|
||||
mockObjectMetadataItemNameSingular,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.applyViewSortsToCurrentRecordSorts([]);
|
||||
});
|
||||
|
||||
expect(result.current.currentSorts).toEqual([]);
|
||||
});
|
||||
});
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
|
||||
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const useApplyCurrentViewSortsToCurrentRecordSorts = () => {
|
||||
const currentViewId = useRecoilComponentValueV2(
|
||||
contextStoreCurrentViewIdComponentState,
|
||||
);
|
||||
|
||||
const currentView = useRecoilValue(
|
||||
prefetchViewFromViewIdFamilySelector({
|
||||
viewId: currentViewId ?? '',
|
||||
}),
|
||||
);
|
||||
|
||||
const setCurrentRecordSorts = useSetRecoilComponentStateV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
const { sortableFieldMetadataItems } =
|
||||
useSortableFieldMetadataItemsInRecordIndexContext();
|
||||
|
||||
const applyCurrentViewSortsToCurrentRecordSorts = () => {
|
||||
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
|
||||
fields: sortableFieldMetadataItems,
|
||||
});
|
||||
|
||||
if (isDefined(currentView)) {
|
||||
setCurrentRecordSorts(
|
||||
mapViewSortsToSorts(currentView.viewSorts, sortDefinitions),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
applyCurrentViewSortsToCurrentRecordSorts,
|
||||
};
|
||||
};
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
|
||||
import { useSortableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-sort/hooks/useSortableFieldMetadataItemsInRecordIndexContext';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
|
||||
import { ViewSort } from '@/views/types/ViewSort';
|
||||
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
|
||||
|
||||
export const useApplyViewSortsToCurrentRecordSorts = () => {
|
||||
const setCurrentRecordSorts = useSetRecoilComponentStateV2(
|
||||
currentRecordSortsComponentState,
|
||||
);
|
||||
|
||||
const { sortableFieldMetadataItems } =
|
||||
useSortableFieldMetadataItemsInRecordIndexContext();
|
||||
|
||||
const applyViewSortsToCurrentRecordSorts = (viewSorts: ViewSort[]) => {
|
||||
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
|
||||
fields: sortableFieldMetadataItems,
|
||||
});
|
||||
|
||||
const recordSortsToApply = mapViewSortsToSorts(viewSorts, sortDefinitions);
|
||||
|
||||
setCurrentRecordSorts(recordSortsToApply);
|
||||
};
|
||||
|
||||
return {
|
||||
applyViewSortsToCurrentRecordSorts,
|
||||
};
|
||||
};
|
||||
+4
-1
@@ -16,7 +16,10 @@ export const WorkflowRunVisualizerContent = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkflowRunVisualizerEffect workflowRun={workflowRun} />
|
||||
<WorkflowRunVisualizerEffect
|
||||
workflowRun={workflowRun}
|
||||
workflowVersionId={workflowVersion.id}
|
||||
/>
|
||||
|
||||
<WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} />
|
||||
</>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { flowState } from '@/workflow/states/flowState';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const useFlowOrThrow = () => {
|
||||
const flow = useRecoilValue(flowState);
|
||||
if (!isDefined(flow)) {
|
||||
throw new Error('Expected the flow to be defined');
|
||||
}
|
||||
|
||||
return flow;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { createState } from '@ui/utilities/state/utils/createState';
|
||||
|
||||
export const flowState = createState<
|
||||
| {
|
||||
trigger: WorkflowTrigger | null;
|
||||
steps: WorkflowAction[] | null;
|
||||
}
|
||||
| undefined
|
||||
>({
|
||||
key: 'flowState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createState } from '@ui/utilities/state/utils/createState';
|
||||
|
||||
export const workflowVersionIdState = createState<string | undefined>({
|
||||
key: 'workflowVersionIdState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@@ -1,19 +1,17 @@
|
||||
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { findStepPosition } from '@/workflow/utils/findStepPosition';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const getStepDefinitionOrThrow = ({
|
||||
stepId,
|
||||
trigger,
|
||||
steps,
|
||||
workflowVersion,
|
||||
}: {
|
||||
stepId: string;
|
||||
trigger: WorkflowTrigger | null;
|
||||
steps: Array<WorkflowAction> | null;
|
||||
workflowVersion: WorkflowVersion;
|
||||
}) => {
|
||||
if (stepId === TRIGGER_STEP_ID) {
|
||||
if (!isDefined(trigger)) {
|
||||
if (!isDefined(workflowVersion.trigger)) {
|
||||
return {
|
||||
type: 'trigger',
|
||||
definition: undefined,
|
||||
@@ -22,18 +20,18 @@ export const getStepDefinitionOrThrow = ({
|
||||
|
||||
return {
|
||||
type: 'trigger',
|
||||
definition: trigger,
|
||||
definition: workflowVersion.trigger,
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (!isDefined(steps)) {
|
||||
if (!isDefined(workflowVersion.steps)) {
|
||||
throw new Error(
|
||||
'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one',
|
||||
);
|
||||
}
|
||||
|
||||
const selectedNodePosition = findStepPosition({
|
||||
steps,
|
||||
steps: workflowVersion.steps,
|
||||
stepId: stepId,
|
||||
});
|
||||
if (!isDefined(selectedNodePosition)) {
|
||||
|
||||
-9
@@ -1,5 +1,4 @@
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { flowState } from '@/workflow/states/flowState';
|
||||
import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState';
|
||||
import {
|
||||
WorkflowVersion,
|
||||
@@ -21,7 +20,6 @@ export const WorkflowDiagramEffect = ({
|
||||
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
|
||||
}) => {
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
const setFlow = useSetRecoilState(flowState);
|
||||
|
||||
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
|
||||
({ snapshot, set }) => {
|
||||
@@ -69,21 +67,14 @@ export const WorkflowDiagramEffect = ({
|
||||
useEffect(() => {
|
||||
const currentVersion = workflowWithCurrentVersion?.currentVersion;
|
||||
if (!isDefined(currentVersion)) {
|
||||
setFlow(undefined);
|
||||
setWorkflowDiagram(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setFlow({
|
||||
trigger: currentVersion.trigger,
|
||||
steps: currentVersion.steps,
|
||||
});
|
||||
|
||||
computeAndMergeNewWorkflowDiagram(currentVersion);
|
||||
}, [
|
||||
computeAndMergeNewWorkflowDiagram,
|
||||
setFlow,
|
||||
setWorkflowDiagram,
|
||||
workflowWithCurrentVersion?.currentVersion,
|
||||
]);
|
||||
|
||||
+6
-13
@@ -1,4 +1,4 @@
|
||||
import { flowState } from '@/workflow/states/flowState';
|
||||
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
|
||||
import { WorkflowRun } from '@/workflow/types/Workflow';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
|
||||
@@ -7,25 +7,18 @@ import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const WorkflowRunVisualizerEffect = ({
|
||||
workflowVersionId,
|
||||
workflowRun,
|
||||
}: {
|
||||
workflowVersionId: string;
|
||||
workflowRun: WorkflowRun;
|
||||
}) => {
|
||||
const setFlow = useSetRecoilState(flowState);
|
||||
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(workflowRun.output)) {
|
||||
setFlow(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setFlow({
|
||||
trigger: workflowRun.output.flow.trigger,
|
||||
steps: workflowRun.output.flow.steps,
|
||||
});
|
||||
}, [setFlow, workflowRun.output]);
|
||||
setWorkflowVersionId(workflowVersionId);
|
||||
}, [setWorkflowVersionId, workflowVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(workflowRun.output)) {
|
||||
|
||||
+4
-13
@@ -1,5 +1,5 @@
|
||||
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
|
||||
import { flowState } from '@/workflow/states/flowState';
|
||||
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
|
||||
import { markLeafNodes } from '@/workflow/workflow-diagram/utils/markLeafNodes';
|
||||
@@ -14,21 +14,12 @@ export const WorkflowVersionVisualizerEffect = ({
|
||||
}) => {
|
||||
const workflowVersion = useWorkflowVersion(workflowVersionId);
|
||||
|
||||
const setFlow = useSetRecoilState(flowState);
|
||||
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(workflowVersion)) {
|
||||
setFlow(undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setFlow({
|
||||
trigger: workflowVersion.trigger,
|
||||
steps: workflowVersion.steps,
|
||||
});
|
||||
}, [setFlow, workflowVersion]);
|
||||
setWorkflowVersionId(workflowVersionId);
|
||||
}, [setWorkflowVersionId, workflowVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(workflowVersion)) {
|
||||
|
||||
+1
-5
@@ -1,4 +1,3 @@
|
||||
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
|
||||
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
|
||||
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
|
||||
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
|
||||
@@ -12,8 +11,6 @@ export const RightDrawerWorkflowEditStepContent = ({
|
||||
}: {
|
||||
workflow: WorkflowWithCurrentVersion;
|
||||
}) => {
|
||||
const flow = useFlowOrThrow();
|
||||
|
||||
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
|
||||
if (!isDefined(workflowSelectedNode)) {
|
||||
throw new Error(
|
||||
@@ -29,8 +26,7 @@ export const RightDrawerWorkflowEditStepContent = ({
|
||||
return (
|
||||
<WorkflowStepDetail
|
||||
stepId={workflowSelectedNode}
|
||||
trigger={flow.trigger}
|
||||
steps={flow.steps}
|
||||
workflowVersion={workflow.currentVersion}
|
||||
onActionUpdate={updateStep}
|
||||
onTriggerUpdate={updateTrigger}
|
||||
/>
|
||||
|
||||
+12
-15
@@ -1,25 +1,22 @@
|
||||
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
|
||||
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
|
||||
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
|
||||
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
|
||||
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
|
||||
import { RightDrawerWorkflowViewStepContent } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStepContent';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const RightDrawerWorkflowViewStep = () => {
|
||||
const flow = useFlowOrThrow();
|
||||
const workflowVersionId = useRecoilValue(workflowVersionIdState);
|
||||
if (!isDefined(workflowVersionId)) {
|
||||
throw new Error('Expected a workflow version id');
|
||||
}
|
||||
|
||||
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
|
||||
if (!isDefined(workflowSelectedNode)) {
|
||||
throw new Error(
|
||||
'Expected a node to be selected. Selecting a node is mandatory to view its details.',
|
||||
);
|
||||
const workflowVersion = useWorkflowVersion(workflowVersionId);
|
||||
|
||||
if (!isDefined(workflowVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowStepDetail
|
||||
stepId={workflowSelectedNode}
|
||||
trigger={flow.trigger}
|
||||
steps={flow.steps}
|
||||
readonly
|
||||
/>
|
||||
<RightDrawerWorkflowViewStepContent workflowVersion={workflowVersion} />
|
||||
);
|
||||
};
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
|
||||
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const RightDrawerWorkflowViewStepContent = ({
|
||||
workflowVersion,
|
||||
}: {
|
||||
workflowVersion: WorkflowVersion;
|
||||
}) => {
|
||||
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
|
||||
if (!isDefined(workflowSelectedNode)) {
|
||||
throw new Error(
|
||||
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowStepDetail
|
||||
stepId={workflowSelectedNode}
|
||||
workflowVersion={workflowVersion}
|
||||
readonly
|
||||
/>
|
||||
);
|
||||
};
|
||||
+12
-12
@@ -1,4 +1,8 @@
|
||||
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import {
|
||||
WorkflowAction,
|
||||
WorkflowTrigger,
|
||||
WorkflowVersion,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
|
||||
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
|
||||
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord';
|
||||
@@ -21,34 +25,30 @@ const WorkflowEditActionFormServerlessFunction = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
type WorkflowStepDetailProps = {
|
||||
stepId: string;
|
||||
trigger: WorkflowTrigger | null;
|
||||
steps: Array<WorkflowAction> | null;
|
||||
} & (
|
||||
type WorkflowStepDetailProps =
|
||||
| {
|
||||
stepId: string;
|
||||
workflowVersion: WorkflowVersion;
|
||||
readonly: true;
|
||||
onTriggerUpdate?: undefined;
|
||||
onActionUpdate?: undefined;
|
||||
}
|
||||
| {
|
||||
stepId: string;
|
||||
workflowVersion: WorkflowVersion;
|
||||
readonly?: false;
|
||||
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
|
||||
onActionUpdate: (action: WorkflowAction) => void;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkflowStepDetail = ({
|
||||
stepId,
|
||||
trigger,
|
||||
steps,
|
||||
workflowVersion,
|
||||
...props
|
||||
}: WorkflowStepDetailProps) => {
|
||||
const stepDefinition = getStepDefinitionOrThrow({
|
||||
stepId,
|
||||
trigger,
|
||||
steps,
|
||||
workflowVersion,
|
||||
});
|
||||
if (!isDefined(stepDefinition) || !isDefined(stepDefinition.definition)) {
|
||||
return null;
|
||||
|
||||
+14
-18
@@ -1,4 +1,3 @@
|
||||
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
|
||||
@@ -25,32 +24,27 @@ export const useAvailableVariablesInWorkflowStep = ({
|
||||
const workflowId = useRecoilValue(workflowIdState);
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowId);
|
||||
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
|
||||
const flow = useFlowOrThrow();
|
||||
|
||||
if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trigger = flow.trigger;
|
||||
const steps = flow.steps;
|
||||
|
||||
const stepDefinition = getStepDefinitionOrThrow({
|
||||
stepId: workflowSelectedNode,
|
||||
trigger,
|
||||
steps,
|
||||
workflowVersion: workflow.currentVersion,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(stepDefinition) ||
|
||||
stepDefinition.type === 'trigger' ||
|
||||
!isDefined(steps)
|
||||
!isDefined(workflow.currentVersion.steps)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const previousSteps = [];
|
||||
|
||||
for (const step of steps) {
|
||||
for (const step of workflow.currentVersion.steps) {
|
||||
if (step.id === workflowSelectedNode) {
|
||||
break;
|
||||
}
|
||||
@@ -60,32 +54,34 @@ export const useAvailableVariablesInWorkflowStep = ({
|
||||
const result = [];
|
||||
|
||||
const filteredTriggerOutputSchema = filterOutputSchema(
|
||||
trigger?.settings?.outputSchema as OutputSchema | undefined,
|
||||
workflow.currentVersion.trigger?.settings?.outputSchema as
|
||||
| OutputSchema
|
||||
| undefined,
|
||||
objectNameSingularToSelect,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(trigger) &&
|
||||
isDefined(workflow.currentVersion.trigger) &&
|
||||
isDefined(filteredTriggerOutputSchema) &&
|
||||
!isEmptyObject(filteredTriggerOutputSchema)
|
||||
) {
|
||||
const triggerIconKey =
|
||||
trigger.type === 'DATABASE_EVENT'
|
||||
workflow.currentVersion.trigger.type === 'DATABASE_EVENT'
|
||||
? getTriggerIcon({
|
||||
type: trigger.type,
|
||||
type: workflow.currentVersion.trigger.type,
|
||||
eventName: splitWorkflowTriggerEventName(
|
||||
trigger.settings?.eventName,
|
||||
workflow.currentVersion.trigger.settings?.eventName,
|
||||
).event,
|
||||
})
|
||||
: getTriggerIcon({
|
||||
type: trigger.type,
|
||||
type: workflow.currentVersion.trigger.type,
|
||||
});
|
||||
|
||||
result.push({
|
||||
id: 'trigger',
|
||||
name: isDefined(trigger.name)
|
||||
? trigger.name
|
||||
: getTriggerStepName(trigger),
|
||||
name: isDefined(workflow.currentVersion.trigger.name)
|
||||
? workflow.currentVersion.trigger.name
|
||||
: getTriggerStepName(workflow.currentVersion.trigger),
|
||||
icon: triggerIconKey,
|
||||
outputSchema: filteredTriggerOutputSchema,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-server",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
-5
@@ -54,7 +54,6 @@ type ProcessRichTextFieldsArgs = {
|
||||
richTextFields: FieldMetadataEntity[];
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'upgrade-0.42:migrate-rich-text-field',
|
||||
description: 'Migrate RICH_TEXT fields to new composite structure',
|
||||
@@ -112,10 +111,6 @@ export class MigrateRichTextFieldCommand extends ActiveWorkspacesCommandRunner {
|
||||
index,
|
||||
total: workspaceIds.length,
|
||||
});
|
||||
|
||||
await this.twentyORMGlobalManager.destroyDataSourceForWorkspace(
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(chalk.green('Command completed!'));
|
||||
|
||||
-1
@@ -170,7 +170,6 @@ export abstract class GraphqlQueryBaseResolverService<
|
||||
objectMetadataItemWithFieldMaps,
|
||||
authContext.workspace.id,
|
||||
options.objectMetadataMaps,
|
||||
featureFlagsMap[FeatureFlagKey.IsNewRelationEnabled],
|
||||
);
|
||||
|
||||
const resultWithGettersArray = Array.isArray(resultWithGetters)
|
||||
|
||||
+6
-16
@@ -18,6 +18,7 @@ import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/works
|
||||
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
|
||||
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
|
||||
import { CompositeInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||
@@ -70,7 +71,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId: string,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
workspaceId: string,
|
||||
isNewRelationEnabled: boolean,
|
||||
): Promise<IConnection<ObjectRecord>> {
|
||||
return {
|
||||
...connection,
|
||||
@@ -82,7 +82,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
),
|
||||
})),
|
||||
),
|
||||
@@ -94,7 +93,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId: string,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
workspaceId: string,
|
||||
isNewRelationEnabled: boolean,
|
||||
) {
|
||||
return {
|
||||
...result,
|
||||
@@ -106,7 +104,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -118,7 +115,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId: string,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
workspaceId: string,
|
||||
isNewRelationEnabled: boolean,
|
||||
) {
|
||||
return await Promise.all(
|
||||
recordArray.map(
|
||||
@@ -128,7 +124,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -139,10 +134,14 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId: string,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
workspaceId: string,
|
||||
isNewRelationEnabled: boolean,
|
||||
): Promise<ObjectRecord> {
|
||||
const objectMetadataMapItem = objectMetadataMaps.byId[objectMetadataItemId];
|
||||
|
||||
const isNewRelationEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsNewRelationEnabled,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const handler = this.getHandler(objectMetadataMapItem.nameSingular);
|
||||
|
||||
const relationFields = Object.keys(record)
|
||||
@@ -192,7 +191,6 @@ export class QueryResultGettersFactory {
|
||||
relationObjectMetadataItem.id,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
} else {
|
||||
if (!isDefined(relationField.relationTargetObjectMetadataId)) {
|
||||
@@ -205,7 +203,6 @@ export class QueryResultGettersFactory {
|
||||
relationField.relationTargetObjectMetadataId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +225,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId: string,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
workspaceId: string,
|
||||
isNewRelationEnabled: boolean,
|
||||
) {
|
||||
if (isQueryResultFieldValueAConnection(queryResultField)) {
|
||||
return await this.processConnection(
|
||||
@@ -236,7 +232,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
} else if (isQueryResultFieldValueANestedRecordArray(queryResultField)) {
|
||||
return await this.processNestedRecordArray(
|
||||
@@ -244,7 +239,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
} else if (isQueryResultFieldValueARecordArray(queryResultField)) {
|
||||
return await this.processRecordArray(
|
||||
@@ -252,7 +246,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
} else if (isQueryResultFieldValueARecord(queryResultField)) {
|
||||
return await this.processRecord(
|
||||
@@ -260,7 +253,6 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItemId,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
@@ -277,14 +269,12 @@ export class QueryResultGettersFactory {
|
||||
objectMetadataItem: ObjectMetadataInterface,
|
||||
workspaceId: string,
|
||||
objectMetadataMaps: ObjectMetadataMaps,
|
||||
isNewRelationEnabled: boolean,
|
||||
): Promise<any> {
|
||||
return await this.processQueryResultField(
|
||||
result,
|
||||
objectMetadataItem.id,
|
||||
objectMetadataMaps,
|
||||
workspaceId,
|
||||
isNewRelationEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
-58
@@ -21,7 +21,6 @@ import {
|
||||
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
|
||||
import {
|
||||
RichTextV2Metadata,
|
||||
richTextV2ValueSchema,
|
||||
@@ -231,63 +230,6 @@ export class QueryRunnerArgsFactory {
|
||||
|
||||
return [key, valueInBothFormats];
|
||||
}
|
||||
case FieldMetadataType.LINKS: {
|
||||
const newPrimaryLinkUrl = lowercaseDomain(value?.primaryLinkUrl);
|
||||
|
||||
let secondaryLinks = value?.secondaryLinks;
|
||||
|
||||
if (secondaryLinks) {
|
||||
try {
|
||||
const secondaryLinksArray = JSON.parse(secondaryLinks);
|
||||
|
||||
secondaryLinks = JSON.stringify(
|
||||
secondaryLinksArray.map((link) => {
|
||||
return {
|
||||
...link,
|
||||
url: lowercaseDomain(link.url),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
...value,
|
||||
primaryLinkUrl: newPrimaryLinkUrl,
|
||||
secondaryLinks,
|
||||
},
|
||||
];
|
||||
}
|
||||
case FieldMetadataType.EMAILS: {
|
||||
let additionalEmails = value?.additionalEmails;
|
||||
const primaryEmail = value?.primaryEmail
|
||||
? value.primaryEmail.toLowerCase()
|
||||
: '';
|
||||
|
||||
if (additionalEmails) {
|
||||
try {
|
||||
const emailArray = JSON.parse(additionalEmails) as string[];
|
||||
|
||||
additionalEmails = JSON.stringify(
|
||||
emailArray.map((email) => email.toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
primaryEmail,
|
||||
additionalEmails,
|
||||
},
|
||||
];
|
||||
}
|
||||
default:
|
||||
return [key, value];
|
||||
}
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
|
||||
|
||||
describe('queryRunner LINKS util', () => {
|
||||
it('should leave lowcased domain unchanged', () => {
|
||||
const primaryLinkUrl = 'https://www.example.com/test';
|
||||
const result = lowercaseDomain(primaryLinkUrl);
|
||||
|
||||
expect(result).toBe('https://www.example.com/test');
|
||||
});
|
||||
|
||||
it('should lowercase the domain of the primary link url', () => {
|
||||
const primaryLinkUrl = 'htTps://wwW.exAmple.coM/TEST';
|
||||
const result = lowercaseDomain(primaryLinkUrl);
|
||||
|
||||
expect(result).toBe('https://www.example.com/TEST');
|
||||
});
|
||||
});
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
export const lowercaseDomain = (url: string) => {
|
||||
try {
|
||||
return new URL(url).toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
@@ -34,7 +34,6 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,7 +41,6 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
StripeModule,
|
||||
DomainManagerModule,
|
||||
MessageQueueModule,
|
||||
PermissionsModule,
|
||||
TypeOrmModule.forFeature(
|
||||
[
|
||||
BillingSubscription,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { SettingsFeatures } from 'twenty-shared';
|
||||
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingProductInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-product.input';
|
||||
@@ -27,13 +26,10 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(PermissionsGraphqlApiExceptionFilter)
|
||||
export class BillingResolver {
|
||||
constructor(
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
@@ -59,10 +55,7 @@ export class BillingResolver {
|
||||
}
|
||||
|
||||
@Query(() => BillingSessionOutput)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(SettingsFeatures.WORKSPACE),
|
||||
)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async billingPortalSession(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args() { returnUrlPath }: BillingSessionInput,
|
||||
@@ -141,10 +134,7 @@ export class BillingResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(SettingsFeatures.WORKSPACE),
|
||||
)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async updateBillingSubscription(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.applyBillingSubscription(workspace);
|
||||
|
||||
|
||||
@@ -2,20 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
import { LabResolver } from './lab.resolver';
|
||||
|
||||
import { LabService } from './services/lab.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FeatureFlag, Workspace], 'core'),
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
],
|
||||
imports: [TypeOrmModule.forFeature([FeatureFlag, Workspace], 'core')],
|
||||
providers: [LabService, LabResolver],
|
||||
exports: [LabService],
|
||||
})
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { SettingsFeatures } from 'twenty-shared';
|
||||
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input';
|
||||
import { LabService } from 'src/engine/core-modules/lab/services/lab.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
|
||||
@UseGuards(SettingsPermissionsGuard(SettingsFeatures.WORKSPACE))
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
export class LabResolver {
|
||||
constructor(private labService: LabService) {}
|
||||
|
||||
|
||||
+2
-5
@@ -321,9 +321,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
);
|
||||
|
||||
if (softDelete) {
|
||||
await this.workspaceRepository.softDelete({ id });
|
||||
|
||||
return workspace;
|
||||
return await this.workspaceRepository.softDelete({ id });
|
||||
}
|
||||
|
||||
await this.deleteMetadataSchemaCacheAndUserWorkspace(workspace);
|
||||
@@ -332,9 +330,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
FileWorkspaceFolderDeletionJob.name,
|
||||
{ workspaceId: id },
|
||||
);
|
||||
await this.workspaceRepository.delete(id);
|
||||
|
||||
return workspace;
|
||||
return await this.workspaceRepository.delete(id);
|
||||
}
|
||||
|
||||
async handleRemoveWorkspaceMember(
|
||||
|
||||
+7
-4
@@ -278,8 +278,7 @@ export class CleanerWorkspaceService {
|
||||
|
||||
if (
|
||||
daysSinceSoftDeleted >
|
||||
this.inactiveDaysBeforeDelete - this.inactiveDaysBeforeSoftDelete &&
|
||||
deletedWorkspacesCount < this.maxNumberOfWorkspacesDeletedPerExecution
|
||||
this.inactiveDaysBeforeDelete - this.inactiveDaysBeforeSoftDelete
|
||||
) {
|
||||
this.logger.log(
|
||||
`${dryRun ? 'DRY RUN - ' : ''}Destroying workspace ${workspace.id} ${workspace.displayName}`,
|
||||
@@ -287,16 +286,20 @@ export class CleanerWorkspaceService {
|
||||
if (!dryRun) {
|
||||
await this.workspaceService.deleteWorkspace(workspace.id);
|
||||
}
|
||||
deletedWorkspacesCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (workspaceInactivity > this.inactiveDaysBeforeSoftDelete) {
|
||||
if (
|
||||
workspaceInactivity > this.inactiveDaysBeforeSoftDelete &&
|
||||
deletedWorkspacesCount <=
|
||||
this.maxNumberOfWorkspacesDeletedPerExecution
|
||||
) {
|
||||
await this.informWorkspaceMembersAndSoftDeleteWorkspace(
|
||||
workspace,
|
||||
workspaceInactivity,
|
||||
dryRun,
|
||||
);
|
||||
deletedWorkspacesCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
-13
@@ -29,17 +29,4 @@ describe('getUniqueContactsAndHandles', () => {
|
||||
'jane@twenty.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate handles when they are in different cases', () => {
|
||||
const contacts: Contact[] = [
|
||||
{ handle: 'john@twenty.com', displayName: 'John Doe' },
|
||||
{ handle: 'John@twenty.com', displayName: 'John Doe' },
|
||||
];
|
||||
const result = getUniqueContactsAndHandles(contacts);
|
||||
|
||||
expect(result.uniqueContacts).toEqual([
|
||||
{ handle: 'john@twenty.com', displayName: 'John Doe' },
|
||||
]);
|
||||
expect(result.uniqueHandles).toEqual(['john@twenty.com']);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-6
@@ -11,13 +11,9 @@ export function getUniqueContactsAndHandles(contacts: Contact[]): {
|
||||
return { uniqueContacts: [], uniqueHandles: [] };
|
||||
}
|
||||
|
||||
const uniqueHandles = uniq(
|
||||
contacts.map((participant) => participant.handle.toLocaleLowerCase()),
|
||||
);
|
||||
const uniqueHandles = uniq(contacts.map((participant) => participant.handle));
|
||||
|
||||
const uniqueContacts = uniqBy(contacts, (contact) =>
|
||||
contact.handle.toLocaleLowerCase(),
|
||||
);
|
||||
const uniqueContacts = uniqBy(contacts, 'handle');
|
||||
|
||||
return { uniqueContacts, uniqueHandles };
|
||||
}
|
||||
|
||||
-586
@@ -1,586 +0,0 @@
|
||||
import { gql } from 'graphql-tag';
|
||||
import request from 'supertest';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
|
||||
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/database/typeorm-seeds/core/workspaces';
|
||||
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
describe('Security permissions', () => {
|
||||
let originalWorkspaceState;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Store original workspace state
|
||||
const query = gql`
|
||||
query getWorkspace {
|
||||
currentWorkspace {
|
||||
displayName
|
||||
isGoogleAuthEnabled
|
||||
isMicrosoftAuthEnabled
|
||||
isPasswordAuthEnabled
|
||||
logo
|
||||
isPublicInviteLinkEnabled
|
||||
subdomain
|
||||
isCustomDomainEnabled
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await makeGraphqlAPIRequest({ query });
|
||||
|
||||
originalWorkspaceState = response.body.data.currentWorkspace;
|
||||
|
||||
const enablePermissionsQuery = updateFeatureFlagFactory(
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
'IsPermissionsEnabled',
|
||||
true,
|
||||
);
|
||||
|
||||
await makeGraphqlAPIRequest(enablePermissionsQuery);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const disablePermissionsQuery = updateFeatureFlagFactory(
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
'IsPermissionsEnabled',
|
||||
false,
|
||||
);
|
||||
|
||||
await makeGraphqlAPIRequest(disablePermissionsQuery);
|
||||
|
||||
// Restore workspace state
|
||||
const restoreQuery = gql`
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: {
|
||||
displayName: "${originalWorkspaceState.displayName}",
|
||||
subdomain: "${originalWorkspaceState.subdomain}",
|
||||
logo: "${originalWorkspaceState.logo}",
|
||||
isGoogleAuthEnabled: ${originalWorkspaceState.isGoogleAuthEnabled},
|
||||
isMicrosoftAuthEnabled: ${originalWorkspaceState.isMicrosoftAuthEnabled},
|
||||
isPasswordAuthEnabled: ${originalWorkspaceState.isPasswordAuthEnabled}
|
||||
isPublicInviteLinkEnabled: ${originalWorkspaceState.isPublicInviteLinkEnabled}
|
||||
}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await makeGraphqlAPIRequest({ query: restoreQuery });
|
||||
});
|
||||
|
||||
describe('security permissions', () => {
|
||||
describe('microsoft auth', () => {
|
||||
it('should update workspace when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isMicrosoftAuthEnabled: false }) {
|
||||
id
|
||||
isMicrosoftAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isMicrosoftAuthEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isMicrosoftAuthEnabled: true }) {
|
||||
id
|
||||
isMicrosoftAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('google auth', () => {
|
||||
it('should update workspace when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isGoogleAuthEnabled: false }) {
|
||||
id
|
||||
isGoogleAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isGoogleAuthEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isGoogleAuthEnabled: true }) {
|
||||
id
|
||||
isGoogleAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('password auth', () => {
|
||||
it('should update workspace when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPasswordAuthEnabled: false }) {
|
||||
id
|
||||
isPasswordAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isPasswordAuthEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPasswordAuthEnabled: true }) {
|
||||
id
|
||||
isPasswordAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('public invite link', () => {
|
||||
it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPublicInviteLinkEnabled: false }) {
|
||||
id
|
||||
isPublicInviteLinkEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isPublicInviteLinkEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
|
||||
id
|
||||
isPublicInviteLinkEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace permissions', () => {
|
||||
describe('delete workspace', () => {
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation DeleteCurrentWorkspace {
|
||||
deleteCurrentWorkspace {
|
||||
id
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('display name update', () => {
|
||||
it('should update workspace display name when user has workspace settings permission', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { displayName: "New Workspace Name" }) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.displayName).toBe('New Workspace Name');
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { displayName: "Another New Workspace Name" }) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomain update', () => {
|
||||
it('should update workspace subdomain when user has workspace settings permission', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { subdomain: "new-subdomain" }) {
|
||||
id
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.subdomain).toBe('new-subdomain');
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { subdomain: "another-new-subdomain" }) {
|
||||
id
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom domain update', () => {
|
||||
it('should update workspace custom domain when user has workspace settings permission', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { customDomain: null }) {
|
||||
id
|
||||
customDomain
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.customDomain).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { customDomain: "another-new-custom-domain" }) {
|
||||
id
|
||||
customDomain
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logo update', () => {
|
||||
it('should update workspace logo when user has workspace settings permission', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { logo: "new-logo" }) {
|
||||
id
|
||||
logo
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.logo).toContain('new-logo');
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { logo: "another-new-logo" }) {
|
||||
id
|
||||
logo
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+241
-139
@@ -71,6 +71,247 @@ describe('WorkspaceResolver', () => {
|
||||
await makeGraphqlAPIRequest({ query: restoreQuery });
|
||||
});
|
||||
|
||||
describe('security permissions', () => {
|
||||
describe('microsoft auth', () => {
|
||||
it('should update workspace when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isMicrosoftAuthEnabled: false }) {
|
||||
id
|
||||
isMicrosoftAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isMicrosoftAuthEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isMicrosoftAuthEnabled: true }) {
|
||||
id
|
||||
isMicrosoftAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('google auth', () => {
|
||||
it('should update workspace when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isGoogleAuthEnabled: false }) {
|
||||
id
|
||||
isGoogleAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isGoogleAuthEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isGoogleAuthEnabled: true }) {
|
||||
id
|
||||
isGoogleAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('password auth', () => {
|
||||
it('should update workspace when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPasswordAuthEnabled: false }) {
|
||||
id
|
||||
isPasswordAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isPasswordAuthEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPasswordAuthEnabled: true }) {
|
||||
id
|
||||
isPasswordAuthEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('public invite link', () => {
|
||||
it('should update isPublicInviteLinkEnabled when user has permission (admin role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPublicInviteLinkEnabled: false }) {
|
||||
id
|
||||
isPublicInviteLinkEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
})
|
||||
.expect((res) => {
|
||||
const data = res.body.data.updateWorkspace;
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.isPublicInviteLinkEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation updateWorkspace {
|
||||
updateWorkspace(data: { isPublicInviteLinkEnabled: true }) {
|
||||
id
|
||||
isPublicInviteLinkEnabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace permissions', () => {
|
||||
describe('delete workspace', () => {
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
@@ -342,143 +583,4 @@ describe('WorkspaceResolver', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('billing', () => {
|
||||
describe('updateBillingSubscription', () => {
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation UpdateBillingSubscription {
|
||||
updateBillingSubscription {
|
||||
success
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('billingPortalSession', () => {
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
query BillingPortalSession($returnUrlPath: String!) {
|
||||
billingPortalSession(returnUrlPath: $returnUrlPath) {
|
||||
url
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
returnUrlPath: '/settings/billing',
|
||||
},
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lab', () => {
|
||||
describe('updateLabPublicFeatureFlag', () => {
|
||||
it('should update feature flag when user has workspace settings permission', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation UpdateLabPublicFeatureFlag(
|
||||
$input: UpdateLabPublicFeatureFlagInput!
|
||||
) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
publicFeatureFlag: 'TestFeature',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${ADMIN_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeDefined();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe('Invalid feature flag key'); // this error shows that update has been attempted after the permission check
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a permission error when user does not have permission (member role)', async () => {
|
||||
const queryData = {
|
||||
query: `
|
||||
mutation UpdateLabPublicFeatureFlag(
|
||||
$input: UpdateLabPublicFeatureFlagInput!
|
||||
) {
|
||||
updateLabPublicFeatureFlag(input: $input) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
publicFeatureFlag: 'TestFeature',
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${MEMBER_ACCESS_TOKEN}`)
|
||||
.send(queryData)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.data).toBeNull();
|
||||
expect(res.body.errors).toBeDefined();
|
||||
expect(res.body.errors[0].message).toBe(
|
||||
PermissionsExceptionMessage.PERMISSION_DENIED,
|
||||
);
|
||||
expect(res.body.errors[0].extensions.code).toBe(
|
||||
ErrorCode.FORBIDDEN,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-shared",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-ui",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-website",
|
||||
"version": "0.42.9",
|
||||
"version": "0.42.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"nx": "NX_DEFAULT_PROJECT=twenty-website node ../../node_modules/nx/bin/nx.js",
|
||||
|
||||
Reference in New Issue
Block a user