Set opportunity stage as editable (#3838)

* Set opportunity stage as editable

* Fix comments

* Add command for migration

* Fixes

---------

Co-authored-by: Thomas Trompette <thomast@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas Trompette
2024-02-09 14:44:11 +01:00
committed by GitHub
parent 0185c2a36e
commit 9ceff84bbf
11 changed files with 83 additions and 43 deletions

View File

@ -138,7 +138,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
}} }}
/> />
)} )}
{!!onRemove && ( {!!onRemove && !isDefault && (
<MenuItem <MenuItem
accent="danger" accent="danger"
LeftIcon={IconTrash} LeftIcon={IconTrash}

View File

@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
@ -26,6 +27,15 @@ import {
RelationMetadataType, RelationMetadataType,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
const canPersistFieldMetadataItemUpdate = (
fieldMetadataItem: FieldMetadataItem,
) => {
return (
fieldMetadataItem.isCustom ||
fieldMetadataItem.type === FieldMetadataType.Select
);
};
export const SettingsObjectFieldEdit = () => { export const SettingsObjectFieldEdit = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
@ -172,6 +182,9 @@ export const SettingsObjectFieldEdit = () => {
navigate(`/settings/objects/${objectSlug}`); navigate(`/settings/objects/${objectSlug}`);
}; };
const shouldDisplaySaveAndCancel =
canPersistFieldMetadataItemUpdate(activeMetadataField);
return ( return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer> <SettingsPageContainer>
@ -186,7 +199,7 @@ export const SettingsObjectFieldEdit = () => {
{ children: activeMetadataField.label }, { children: activeMetadataField.label },
]} ]}
/> />
{activeMetadataField.isCustom && ( {shouldDisplaySaveAndCancel && (
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!canSave} isSaveDisabled={!canSave}
onCancel={() => navigate(`/settings/objects/${objectSlug}`)} onCancel={() => navigate(`/settings/objects/${objectSlug}`)}

View File

@ -31,7 +31,7 @@ export const seedOpportunity = async (
amountCurrencyCode: 'USD', amountCurrencyCode: 'USD',
closeDate: new Date(), closeDate: new Date(),
probability: 0.5, probability: 0.5,
stage: 'new', stage: 'NEW',
position: 0, position: 0,
pipelineStepId: '6edf4ead-006a-46e1-9c6d-228f1d0143c9', pipelineStepId: '6edf4ead-006a-46e1-9c6d-228f1d0143c9',
pointOfContactId: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', pointOfContactId: '86083141-1c0e-494c-a1b6-85b1c6fefaa5',
@ -44,7 +44,7 @@ export const seedOpportunity = async (
amountCurrencyCode: 'USD', amountCurrencyCode: 'USD',
closeDate: new Date(), closeDate: new Date(),
probability: 0.5, probability: 0.5,
stage: 'meeting', stage: 'MEETING',
position: 1, position: 1,
pipelineStepId: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a', pipelineStepId: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a',
pointOfContactId: '93c72d2e-f517-42fd-80ae-14173b3b70ae', pointOfContactId: '93c72d2e-f517-42fd-80ae-14173b3b70ae',
@ -57,7 +57,7 @@ export const seedOpportunity = async (
amountCurrencyCode: 'USD', amountCurrencyCode: 'USD',
closeDate: new Date(), closeDate: new Date(),
probability: 0.5, probability: 0.5,
stage: 'proposal', stage: 'PROPOSAL',
position: 2, position: 2,
pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02', pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02',
pointOfContactId: '9b324a88-6784-4449-afdf-dc62cb8702f2', pointOfContactId: '9b324a88-6784-4449-afdf-dc62cb8702f2',
@ -70,7 +70,7 @@ export const seedOpportunity = async (
amountCurrencyCode: 'USD', amountCurrencyCode: 'USD',
closeDate: new Date(), closeDate: new Date(),
probability: 0.5, probability: 0.5,
stage: 'proposal', stage: 'PROPOSAL',
position: 3, position: 3,
pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02', pipelineStepId: '30b14887-d592-427d-bd97-6e670158db02',
pointOfContactId: '98406e26-80f1-4dff-b570-a74942528de3', pointOfContactId: '98406e26-80f1-4dff-b570-a74942528de3',

View File

@ -14,31 +14,31 @@ export const seedPipelineStep = async (
.values([ .values([
{ {
id: '6edf4ead-006a-46e1-9c6d-228f1d0143c9', id: '6edf4ead-006a-46e1-9c6d-228f1d0143c9',
name: 'New', name: 'NEW',
color: 'red', color: 'red',
position: 0, position: 0,
}, },
{ {
id: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a', id: 'd8361722-03fb-4e65-bd4f-ec9e52e5ec0a',
name: 'Screening', name: 'SCREENING',
color: 'purple', color: 'purple',
position: 1, position: 1,
}, },
{ {
id: '30b14887-d592-427d-bd97-6e670158db02', id: '30b14887-d592-427d-bd97-6e670158db02',
name: 'Meeting', name: 'MEETING',
color: 'sky', color: 'sky',
position: 2, position: 2,
}, },
{ {
id: 'db5a6648-d80d-4020-af64-4817ab4a12e8', id: 'db5a6648-d80d-4020-af64-4817ab4a12e8',
name: 'Proposal', name: 'PROPOSAL',
color: 'turquoise', color: 'turquoise',
position: 3, position: 3,
}, },
{ {
id: 'bea8bb7b-5467-48a6-9a8a-a8fa500123fe', id: 'bea8bb7b-5467-48a6-9a8a-a8fa500123fe',
name: 'Customer', name: 'CUSTOMER',
color: 'yellow', color: 'yellow',
position: 4, position: 4,
}, },

View File

@ -186,15 +186,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
throw new NotFoundException('Field does not exist'); throw new NotFoundException('Field does not exist');
} }
if (existingFieldMetadata.isCustom === false) {
// We can only update the isActive field for standard fields
fieldMetadataInput = {
id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId,
};
}
const objectMetadata = const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace( await this.objectMetadataService.findOneWithinWorkspace(
fieldMetadataInput.workspaceId, fieldMetadataInput.workspaceId,
@ -217,7 +208,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
throw new BadRequestException('Cannot deactivate label identifier field'); throw new BadRequestException('Cannot deactivate label identifier field');
} }
// Check if the id of the options has been provided
if (fieldMetadataInput.options) { if (fieldMetadataInput.options) {
for (const option of fieldMetadataInput.options) { for (const option of fieldMetadataInput.options) {
if (!option.id) { if (!option.id) {
@ -226,9 +216,17 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
} }
} }
const updatedFieldMetadata = await super.updateOne(id, fieldMetadataInput); const updatableFieldInput =
existingFieldMetadata.isCustom === false
? this.buildUpdatableStandardFieldInput(
fieldMetadataInput,
existingFieldMetadata,
)
: fieldMetadataInput;
if (fieldMetadataInput.options || fieldMetadataInput.defaultValue) { const updatedFieldMetadata = await super.updateOne(id, updatableFieldInput);
if (updatableFieldInput.options || updatableFieldInput.defaultValue) {
await this.workspaceMigrationService.createCustomMigration( await this.workspaceMigrationService.createCustomMigration(
generateMigrationName(`update-${updatedFieldMetadata.name}`), generateMigrationName(`update-${updatedFieldMetadata.name}`),
existingFieldMetadata.workspaceId, existingFieldMetadata.workspaceId,
@ -288,4 +286,27 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
public async deleteFieldsMetadata(workspaceId: string) { public async deleteFieldsMetadata(workspaceId: string) {
await this.fieldMetadataRepository.delete({ workspaceId }); await this.fieldMetadataRepository.delete({ workspaceId });
} }
private buildUpdatableStandardFieldInput(
fieldMetadataInput: UpdateFieldInput,
existingFieldMetadata: FieldMetadataEntity,
) {
let fieldMetadataInputOverrided = {};
fieldMetadataInputOverrided = {
id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue,
};
if (existingFieldMetadata.type === FieldMetadataType.SELECT) {
fieldMetadataInputOverrided = {
...fieldMetadataInputOverrided,
options: fieldMetadataInput.options,
};
}
return fieldMetadataInputOverrided as UpdateFieldInput;
}
} }

View File

@ -13,7 +13,7 @@ const getRandomPipelineStepId = (pipelineStepIds: { id: string }[]) =>
pipelineStepIds[Math.floor(Math.random() * pipelineStepIds.length)].id; pipelineStepIds[Math.floor(Math.random() * pipelineStepIds.length)].id;
const getRandomStage = () => { const getRandomStage = () => {
const stages = ['new', 'screening', 'meeting', 'proposal', 'customer']; const stages = ['NEW', 'SCREENING', 'MEETING', 'PROPOSAL', 'CUSTOMER'];
return stages[Math.floor(Math.random() * stages.length)]; return stages[Math.floor(Math.random() * stages.length)];
}; };

View File

@ -11,27 +11,27 @@ export const pipelineStepPrefillData = async (
.orIgnore() .orIgnore()
.values([ .values([
{ {
name: 'New', name: 'NEW',
color: 'red', color: 'red',
position: 0, position: 0,
}, },
{ {
name: 'Screening', name: 'SCREENING',
color: 'purple', color: 'purple',
position: 1, position: 1,
}, },
{ {
name: 'Meeting', name: 'MEETING',
color: 'sky', color: 'sky',
position: 2, position: 2,
}, },
{ {
name: 'Proposal', name: 'PROPOSAL',
color: 'turquoise', color: 'turquoise',
position: 3, position: 3,
}, },
{ {
name: 'Customer', name: 'CUSTOMER',
color: 'yellow', color: 'yellow',
position: 4, position: 4,
}, },

View File

@ -11,27 +11,27 @@ export const pipelineStepPrefillData = async (
.orIgnore() .orIgnore()
.values([ .values([
{ {
name: 'New', name: 'NEW',
color: 'red', color: 'red',
position: 0, position: 0,
}, },
{ {
name: 'Screening', name: 'SCREENING',
color: 'purple', color: 'purple',
position: 1, position: 1,
}, },
{ {
name: 'Meeting', name: 'MEETING',
color: 'sky', color: 'sky',
position: 2, position: 2,
}, },
{ {
name: 'Proposal', name: 'PROPOSAL',
color: 'turquoise', color: 'turquoise',
position: 3, position: 3,
}, },
{ {
name: 'Customer', name: 'CUSTOMER',
color: 'yellow', color: 'yellow',
position: 4, position: 4,
}, },

View File

@ -138,13 +138,13 @@ export class WorkspaceMigrationEnumService {
.map((e) => `'${e}'`) .map((e) => `'${e}'`)
.join(', ')}]`; .join(', ')}]`;
} else { } else {
defaultValue = `'${columnDefinition.defaultValue}'`; defaultValue = this.getStringifyValue(columnDefinition.defaultValue);
} }
} }
await queryRunner.query(` await queryRunner.query(`
UPDATE "${schemaName}"."${tableName}" UPDATE "${schemaName}"."${tableName}"
SET "${columnDefinition.columnName}" = ${defaultValue} SET "${columnDefinition.columnName}" = '${defaultValue}'
WHERE "${columnDefinition.columnName}" NOT IN (${enumValues WHERE "${columnDefinition.columnName}" NOT IN (${enumValues
.map((e) => `'${e}'`) .map((e) => `'${e}'`)
.join(', ')}) .join(', ')})
@ -159,7 +159,7 @@ export class WorkspaceMigrationEnumService {
newEnumTypeName: string, newEnumTypeName: string,
) { ) {
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" TYPE "${schemaName}"."${newEnumTypeName}" USING ("${columnName}"::text::"${schemaName}"."${newEnumTypeName}")`, `ALTER TABLE "${schemaName}"."${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT, ALTER COLUMN "${columnName}" TYPE "${schemaName}"."${newEnumTypeName}" USING ("${columnName}"::text::"${schemaName}"."${newEnumTypeName}")`,
); );
} }
@ -184,4 +184,8 @@ export class WorkspaceMigrationEnumService {
RENAME TO "${oldEnumTypeName}" RENAME TO "${oldEnumTypeName}"
`); `);
} }
private getStringifyValue(value: any) {
return typeof value === 'string' ? value : `'${value}'`;
}
} }

View File

@ -275,6 +275,8 @@ export class WorkspaceMigrationRunnerService {
tableName, tableName,
migrationColumn, migrationColumn,
); );
return;
} }
if ( if (

View File

@ -63,18 +63,18 @@ export class OpportunityObjectMetadata extends BaseObjectMetadata {
description: 'Opportunity stage', description: 'Opportunity stage',
icon: 'IconProgressCheck', icon: 'IconProgressCheck',
options: [ options: [
{ value: 'new', label: 'New', position: 0, color: 'red' }, { value: 'NEW', label: 'New', position: 0, color: 'red' },
{ value: 'screening', label: 'Screening', position: 1, color: 'purple' }, { value: 'SCREENING', label: 'Screening', position: 1, color: 'purple' },
{ value: 'meeting', label: 'Meeting', position: 2, color: 'sky' }, { value: 'MEETING', label: 'Meeting', position: 2, color: 'sky' },
{ {
value: 'proposal', value: 'PROPOSAL',
label: 'Proposal', label: 'Proposal',
position: 3, position: 3,
color: 'turquoise', color: 'turquoise',
}, },
{ value: 'customer', label: 'Customer', position: 4, color: 'yellow' }, { value: 'CUSTOMER', label: 'Customer', position: 4, color: 'yellow' },
], ],
defaultValue: { value: 'new' }, defaultValue: { value: 'NEW' },
}) })
stage: string; stage: string;