Activate/Deactivate workflow and Discard Draft (#7022)

## Setup

This PR can be tested only if some feature flags have specific values:

- `IsWorkflowEnabled` equals `true`
- `IsQueryRunnerTwentyORMEnabled` equals `false`

These feature flags weren't committed to don't break other branches.

## What this PR brings

- Display buttons to activate and deactivate a workflow version and a
button to discard the current draft version. I also scaffolded a "Test"
button, which doesn't do anything for now.
- Wired the activate, deactivate and discard draft buttons to the
backend.
- Made it possible to "edit" active and deactivated versions by
automatically creating a new draft version when the user tries to edit
the version.
- Hide the "Discard Draft", button if the current version is not a draft
or is the first version ever created.
- On the backend, don't consider discarded drafts when checking if a new
draft version can be created.
- On the backend, disallow deleting the first created workflow version.
Otherwise, we will end up with a blank canvas in the front end, and it
will be impossible to recover from it.
- On the backend, disallow running deactivation steps if the workflow
version is not currently active. Previously, we were throwing, which is
unnecessary as it's a valid case.

## Spotted bugs that we must dive into

### Duplicate workflow versions in Apollo cache


https://github.com/user-attachments/assets/7cfffd06-11e0-417a-8da0-f9a5f43b84e2

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Baptiste Devessier
2024-09-25 18:09:31 +02:00
committed by GitHub
parent 75b493ba6c
commit 729c990546
76 changed files with 19152 additions and 27309 deletions

View File

@ -1,45 +1,48 @@
export const PERSON_FRAGMENT = `
__typename
updatedAt
myCustomObjectId
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
name {
firstName
lastName
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
email
position
createdBy {
source
workspaceMemberId
name
}
avatarUrl
deletedAt
createdAt
updatedAt
jobTitle
intro
workPrefereance
performanceRating
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
performanceRating
createdAt
phone {
city
companyId
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
createdBy {
source
workspaceMemberId
name
}
id
city
companyId
intro
workPrefereance
position
emails {
primaryEmail
additionalEmails
}
avatarUrl
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
`

View File

@ -18,7 +18,6 @@ const basePerson = {
},
createdAt: '',
city: '',
email: '',
jobTitle: '',
name: {
firstName: '',

View File

@ -4,7 +4,6 @@ import { ReactNode, useEffect } from 'react';
import { RecoilRoot, useRecoilState } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import {
mockPageSize,
peopleMockWithIdsOnly,
@ -18,6 +17,7 @@ import {
} from '@/object-record/hooks/__mocks__/useFetchAllRecordIds';
import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds';
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
const mocks = [
{
@ -75,7 +75,7 @@ describe('useFetchAllRecordIds', () => {
);
useEffect(() => {
setObjectMetadataItems(getObjectMetadataItemsMock());
setObjectMetadataItems(generatedMockObjectMetadataItems);
}, [setObjectMetadataItems]);
return useFetchAllRecordIds({

View File

@ -5,7 +5,6 @@ import { RecoilRoot, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import {
query,
responseData,
@ -13,6 +12,7 @@ import {
} from '@/object-record/hooks/__mocks__/useFindManyRecords';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
const mocks = [
{
@ -65,11 +65,9 @@ describe('useFindManyRecords', () => {
locale: 'en',
});
const mockObjectMetadataItems = getObjectMetadataItemsMock();
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
setMetadataItems(mockObjectMetadataItems);
setMetadataItems(generatedMockObjectMetadataItems);
return useFindManyRecords({
objectNameSingular: 'person',

View File

@ -1,11 +1,11 @@
import { ReactNode } from 'react';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
@ -18,7 +18,7 @@ describe('useGenerateFindManyRecordsForMultipleMetadataItemsQuery', () => {
const { result } = renderHook(
() => {
return useGenerateCombinedFindManyRecordsQuery({
operationSignatures: getObjectMetadataItemsMock()
operationSignatures: generatedMockObjectMetadataItems
.slice(0, 2)
.map((item) => ({
objectNameSingular: item.nameSingular,

View File

@ -72,11 +72,11 @@ export const linkFieldDefinition: FieldDefinition<FieldLinkMetadata> = {
},
};
const phoneFieldMetadataItem = mockedPersonObjectMetadataItem.fields?.find(
({ name }) => name === 'phone',
const phonesFieldMetadataItem = mockedPersonObjectMetadataItem.fields?.find(
({ name }) => name === 'phones',
);
export const phoneFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: phoneFieldMetadataItem!,
export const phonesFieldDefinition = formatFieldMetadataItemAsFieldDefinition({
field: phonesFieldMetadataItem!,
objectMetadataItem: mockedPersonObjectMetadataItem,
});

View File

@ -1,10 +1,10 @@
import { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import { IconPencil } from 'twenty-ui';
import {
phoneFieldDefinition,
phonesFieldDefinition,
relationFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
@ -29,7 +29,7 @@ const getWrapper =
</FieldContext.Provider>
);
const PhoneWrapper = getWrapper(phoneFieldDefinition);
const PhoneWrapper = getWrapper(phonesFieldDefinition);
const RelationWrapper = getWrapper(relationFieldDefinition);
describe('useGetButtonIcon', () => {

View File

@ -1,8 +1,8 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { phoneFieldDefinition } from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { phonesFieldDefinition } from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@ -12,7 +12,7 @@ const recordId = 'recordId';
const Wrapper = ({ children }: { children: ReactNode }) => (
<FieldContext.Provider
value={{
fieldDefinition: phoneFieldDefinition,
fieldDefinition: phonesFieldDefinition,
recordId,
hotkeyScope: 'hotkeyScope',
isLabelIdentifier: false,

View File

@ -3,7 +3,7 @@ import { ReactNode } from 'react';
import { RecoilRoot } from 'recoil';
import {
phoneFieldDefinition,
phonesFieldDefinition,
ratingFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
@ -29,7 +29,7 @@ const getWrapper =
);
const RatingWrapper = getWrapper(ratingFieldDefinition);
const PhoneWrapper = getWrapper(phoneFieldDefinition);
const PhoneWrapper = getWrapper(phonesFieldDefinition);
describe('useIsFieldInputOnly', () => {
it('should return true', () => {

View File

@ -4,7 +4,7 @@ import { RecoilRoot } from 'recoil';
import {
actorFieldDefinition,
phoneFieldDefinition,
phonesFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useIsFieldReadOnly } from '@/object-record/record-field/hooks/useIsFieldReadOnly';
@ -29,7 +29,7 @@ const getWrapper =
);
const ActorWrapper = getWrapper(actorFieldDefinition);
const PhoneWrapper = getWrapper(phoneFieldDefinition);
const PhoneWrapper = getWrapper(phonesFieldDefinition);
describe('useIsFieldReadOnly', () => {
it('should return true', () => {

View File

@ -8,7 +8,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { PERSON_FRAGMENT } from '@/object-record/hooks/__mocks__/personFragment';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
phoneFieldDefinition,
phonesFieldDefinition,
relationFieldDefinition,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import {
@ -33,7 +33,16 @@ const mocks: MockedResponse[] = [
{
request: {
query,
variables: { idToUpdate: 'recordId', input: { phone: '+1 123 456' } },
variables: {
idToUpdate: 'recordId',
input: {
phones: {
primaryPhoneNumber: '123 456',
primaryPhoneCountryCode: '+1',
additionalPhones: [],
},
},
},
},
result: jest.fn(() => ({
data: {
@ -98,7 +107,7 @@ const getWrapper =
);
};
const PhoneWrapper = getWrapper(phoneFieldDefinition);
const PhoneWrapper = getWrapper(phonesFieldDefinition);
const RelationWrapper = getWrapper(relationFieldDefinition);
describe('usePersistField', () => {
@ -118,7 +127,11 @@ describe('usePersistField', () => {
);
act(() => {
result.current.persistField('+1 123 456');
result.current.persistField({
primaryPhoneNumber: '123 456',
primaryPhoneCountryCode: '+1',
additionalPhones: [],
});
});
await waitFor(() => {

View File

@ -26,35 +26,13 @@ const mocks: MockedResponse[] = [
) {
updateCompany(id: $idToUpdate, data: $input) {
__typename
id
visaSponsorship
createdBy {
source
workspaceMemberId
name
}
updatedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
position
annualRecurringRevenue {
amountMicros
currencyCode
}
employees
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
workPolicy
visaSponsorship
address {
addressStreet1
addressStreet2
@ -65,16 +43,38 @@ const mocks: MockedResponse[] = [
addressLat
addressLng
}
position
employees
deletedAt
accountOwnerId
annualRecurringRevenue {
amountMicros
currencyCode
}
id
name
updatedAt
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
myCustomField
createdAt
accountOwnerId
createdBy {
source
workspaceMemberId
name
}
workPolicy
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
tagline
idealCustomerProfile
}

View File

@ -12,6 +12,7 @@ import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import gql from 'graphql-tag';
import { BrowserRouter as Router } from 'react-router-dom';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
const defaultResponseData = {
pageInfo: {
@ -65,7 +66,7 @@ const mockPerson = {
city: 'city',
companyId: '1',
intro: 'intro',
workPrefereance: 'workPrefereance',
workPreference: 'workPrefereance',
};
const mocks: MockedResponse[] = [
{
@ -86,48 +87,51 @@ const mocks: MockedResponse[] = [
edges {
node {
__typename
updatedAt
myCustomObjectId
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
name {
firstName
lastName
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
email
position
createdBy {
source
workspaceMemberId
name
}
avatarUrl
deletedAt
createdAt
updatedAt
jobTitle
intro
workPrefereance
performanceRating
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
performanceRating
createdAt
phone {
city
companyId
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
createdBy {
source
workspaceMemberId
name
}
id
city
companyId
intro
workPrefereance
position
emails {
primaryEmail
additionalEmails
}
avatarUrl
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
}
cursor
}
@ -292,9 +296,17 @@ describe('useTableData', () => {
},
);
const personObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
);
const updatedAtFieldMetadataItem = personObjectMetadataItem?.fields.find(
(field) => field.name === 'updatedAt',
);
await act(async () => {
result.current.setKanbanFieldName.setKanbanFieldMetadataName(
result.current.kanbanData.hiddenBoardFields[0].metadata.fieldName,
updatedAtFieldMetadataItem?.name,
);
});
@ -309,7 +321,7 @@ describe('useTableData', () => {
{
defaultValue: 'now',
editButtonIcon: undefined,
fieldMetadataId: '102963b7-3e77-4293-a1e6-1ab59a02b663',
fieldMetadataId: updatedAtFieldMetadataItem?.id,
iconName: 'IconCalendarClock',
isFilterable: true,
isLabelIdentifier: false,
@ -329,7 +341,7 @@ describe('useTableData', () => {
relationType: undefined,
targetFieldMetadataName: '',
},
position: 0,
position: 7,
showLabel: undefined,
size: 100,
type: 'DATE_TIME',

View File

@ -5,7 +5,7 @@ import { ComponentDecorator } from 'twenty-ui';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import {
RecordFieldValueSelectorContextProvider,
@ -21,10 +21,9 @@ import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorato
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellFieldContextWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
import { mockPerformance } from './mock';
const objectMetadataItems = getObjectMetadataItemsMock();
const RelationFieldValueSetterEffect = () => {
const setEntity = useSetRecoilState(
recordStoreFamilyState(mockPerformance.recordId),
@ -48,7 +47,7 @@ const RelationFieldValueSetterEffect = () => {
mockPerformance.relationFieldValue,
);
setObjectMetadataItems(objectMetadataItems);
setObjectMetadataItems(generatedMockObjectMetadataItems);
}, [setEntity, setRelationEntity, setRecordValue, setObjectMetadataItems]);
return null;

View File

@ -5,12 +5,12 @@ import { createState } from 'twenty-ui';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { textfieldDefinition } from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
const draftValue = 'updated Name';
@ -55,8 +55,6 @@ const updateOneRecordMock = jest.fn();
createOneRecord: createOneRecordMock,
});
const objectMetadataItems = getObjectMetadataItemsMock();
const Wrapper = ({
children,
pendingRecordIdMockedValue,
@ -68,7 +66,7 @@ const Wrapper = ({
}) => (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(objectMetadataItemsState, objectMetadataItems);
snapshot.set(objectMetadataItemsState, generatedMockObjectMetadataItems);
snapshot.set(pendingRecordIdState, pendingRecordIdMockedValue);
snapshot.set(draftValueState, draftValueMockedValue);
}}

View File

@ -2,9 +2,9 @@ import { act, renderHook } from '@testing-library/react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
const scopeId = 'scopeId';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
@ -13,8 +13,6 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
</RelationPickerScopeInternalContext.Provider>
);
const objectMetadataItemsMock = getObjectMetadataItemsMock();
const opportunityId = 'cb702502-4b1d-488e-9461-df3fb096ebf6';
const personId = 'ab091fd9-1b81-4dfd-bfdb-564ffee032a2';
@ -70,7 +68,7 @@ describe('useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'
},
);
act(() => {
result.current.setObjectMetadata(objectMetadataItemsMock);
result.current.setObjectMetadata(generatedMockObjectMetadataItems);
});
expect(

View File

@ -26,35 +26,13 @@ const companyMocks = [
) {
createCompanies(data: $data, upsert: $upsert) {
__typename
id
visaSponsorship
createdBy {
source
workspaceMemberId
name
}
updatedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
position
annualRecurringRevenue {
amountMicros
currencyCode
}
employees
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
workPolicy
visaSponsorship
address {
addressStreet1
addressStreet2
@ -65,16 +43,38 @@ const companyMocks = [
addressLat
addressLng
}
position
employees
deletedAt
accountOwnerId
annualRecurringRevenue {
amountMicros
currencyCode
}
id
name
updatedAt
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
myCustomField
createdAt
accountOwnerId
createdBy {
source
workspaceMemberId
name
}
workPolicy
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
tagline
idealCustomerProfile
}