Spaces:
Build error
Build error
twenty / packages /twenty-shared /src /utils /command-menu-items /__tests__ /evaluateConditionalAvailabilityExpression.test.ts
| import { ContextStorePageType, type CommandMenuContextApi } from '@/types'; | |
| import { evaluateConditionalAvailabilityExpression } from '../evaluateConditionalAvailabilityExpression'; | |
| const buildContext = ( | |
| overrides: Partial<CommandMenuContextApi> = {}, | |
| ): CommandMenuContextApi => ({ | |
| pageType: ContextStorePageType.Index, | |
| isInSidePanel: false, | |
| isDashboardPageLayoutInEditMode: false, | |
| isLayoutCustomizationModeEnabled: false, | |
| favoriteRecordIds: [], | |
| isSelectAll: false, | |
| hasAnySoftDeleteFilterOnView: false, | |
| numberOfSelectedRecords: 0, | |
| objectPermissions: { | |
| objectMetadataId: '', | |
| canReadObjectRecords: true, | |
| canUpdateObjectRecords: true, | |
| canSoftDeleteObjectRecords: true, | |
| canDestroyObjectRecords: false, | |
| restrictedFields: {}, | |
| rowLevelPermissionPredicates: [], | |
| rowLevelPermissionPredicateGroups: [], | |
| }, | |
| selectedRecords: [], | |
| featureFlags: {}, | |
| permissionFlags: {}, | |
| targetObjectReadPermissions: {}, | |
| targetObjectWritePermissions: {}, | |
| canImpersonate: false, | |
| canAccessFullAdminPanel: false, | |
| objectMetadataItem: {}, | |
| objectMetadataLabel: '', | |
| ...overrides, | |
| }); | |
| describe('evaluateConditionalAvailabilityExpression', () => { | |
| describe('arrayLength for favoriteRecordIds', () => { | |
| it('should evaluate arrayLength(favoriteRecordIds) < numberOfSelectedRecords when some selected are not favorites', () => { | |
| const expression = | |
| 'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView'; | |
| const context = buildContext({ | |
| favoriteRecordIds: ['id-1'], | |
| numberOfSelectedRecords: 2, | |
| selectedRecords: [ | |
| { id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null }, | |
| { id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null }, | |
| ], | |
| hasAnySoftDeleteFilterOnView: false, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(true); | |
| }); | |
| it('should deny when all selected are already favorites', () => { | |
| const expression = | |
| 'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView'; | |
| const context = buildContext({ | |
| favoriteRecordIds: ['id-1', 'id-2'], | |
| numberOfSelectedRecords: 2, | |
| selectedRecords: [ | |
| { id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null }, | |
| { id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null }, | |
| ], | |
| hasAnySoftDeleteFilterOnView: false, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(false); | |
| }); | |
| it('should evaluate arrayLength(favoriteRecordIds) == numberOfSelectedRecords when all selected are favorites', () => { | |
| const expression = | |
| 'arrayLength(favoriteRecordIds) == numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView'; | |
| const context = buildContext({ | |
| favoriteRecordIds: ['id-1', 'id-2'], | |
| numberOfSelectedRecords: 2, | |
| selectedRecords: [ | |
| { id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null }, | |
| { id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null }, | |
| ], | |
| hasAnySoftDeleteFilterOnView: false, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(true); | |
| }); | |
| it('should reject favoriteRecordIds.length (expr-eval-fork parses "length" as unary op)', () => { | |
| const expression = 'favoriteRecordIds.length < numberOfSelectedRecords'; | |
| const context = buildContext({ | |
| favoriteRecordIds: ['id-1'], | |
| numberOfSelectedRecords: 2, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(false); | |
| }); | |
| }); | |
| describe('includesSome', () => { | |
| it('should return true when some records include the value in the array prop', () => { | |
| const expression = 'includesSome(selectedRecords, "statuses", "ACTIVE")'; | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { id: 'id-1', statuses: ['ACTIVE'] }, | |
| { id: 'id-2', statuses: ['DRAFT'] }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(true); | |
| }); | |
| it('should return false when no record includes the value in the array prop', () => { | |
| const expression = 'includesSome(selectedRecords, "statuses", "ACTIVE")'; | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { id: 'id-1', statuses: ['DRAFT'] }, | |
| { id: 'id-2', statuses: ['DRAFT'] }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(false); | |
| }); | |
| }); | |
| describe('includesNone', () => { | |
| it('should return true when no record includes the value in the array prop', () => { | |
| const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")'; | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { id: 'id-1', statuses: ['DRAFT'] }, | |
| { id: 'id-2', statuses: ['DRAFT'] }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(true); | |
| }); | |
| it('should return false when any record includes the value in the array prop', () => { | |
| const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")'; | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { id: 'id-1', statuses: ['ACTIVE'] }, | |
| { id: 'id-2', statuses: ['DRAFT'] }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(false); | |
| }); | |
| it('should return false when all records include the value in the array prop', () => { | |
| const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")'; | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { id: 'id-1', statuses: ['ACTIVE', 'DRAFT'] }, | |
| { id: 'id-2', statuses: ['ACTIVE'] }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(false); | |
| }); | |
| }); | |
| describe('empty array guard against vacuous truth', () => { | |
| it('should return false for every() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'every(selectedRecords, "active")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for everyDefined() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'everyDefined(selectedRecords, "name")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for none() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'none(selectedRecords, "deletedAt")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for noneDefined() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'noneDefined(selectedRecords, "deletedAt")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for everyEquals() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'everyEquals(selectedRecords, "status", "DRAFT")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for noneEquals() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'noneEquals(selectedRecords, "status", "ACTIVE")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for includesEvery() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'includesEvery(selectedRecords, "statuses", "ACTIVE")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for includesNone() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'includesNone(selectedRecords, "statuses", "ACTIVE")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for some() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'some(selectedRecords, "active")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for someDefined() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'someDefined(selectedRecords, "name")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should return false for includesSome() on empty selectedRecords', () => { | |
| const context = buildContext({ selectedRecords: [] }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'includesSome(selectedRecords, "statuses", "ACTIVE")', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| }); | |
| describe('isSelectAll bypasses selectedRecords array checks', () => { | |
| it('should return true for "isSelectAll or noneDefined(...)" when isSelectAll is true and selectedRecords is empty', () => { | |
| const context = buildContext({ | |
| isSelectAll: true, | |
| selectedRecords: [], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'isSelectAll or noneDefined(selectedRecords, "deletedAt")', | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| it('should return true for "isSelectAll or everyDefined(...)" when isSelectAll is true and selectedRecords is empty', () => { | |
| const context = buildContext({ | |
| isSelectAll: true, | |
| selectedRecords: [], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'isSelectAll or everyDefined(selectedRecords, "deletedAt")', | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| it('should evaluate the full deleteRecords expression to true in select-all mode', () => { | |
| const expression = | |
| 'numberOfSelectedRecords >= 1 and not hasAnySoftDeleteFilterOnView and objectPermissions.canSoftDeleteObjectRecords and (isSelectAll or noneDefined(selectedRecords, "deletedAt"))'; | |
| const context = buildContext({ | |
| isSelectAll: true, | |
| selectedRecords: [], | |
| numberOfSelectedRecords: 50, | |
| hasAnySoftDeleteFilterOnView: false, | |
| objectPermissions: { | |
| objectMetadataId: '', | |
| canReadObjectRecords: true, | |
| canUpdateObjectRecords: true, | |
| canSoftDeleteObjectRecords: true, | |
| canDestroyObjectRecords: false, | |
| restrictedFields: {}, | |
| rowLevelPermissionPredicates: [], | |
| rowLevelPermissionPredicateGroups: [], | |
| }, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression(expression, context), | |
| ).toBe(true); | |
| }); | |
| it('should still respect noneDefined when isSelectAll is false and records are present', () => { | |
| const expression = | |
| 'isSelectAll or noneDefined(selectedRecords, "deletedAt")'; | |
| const contextWithDeletedRecord = buildContext({ | |
| isSelectAll: false, | |
| selectedRecords: [ | |
| { id: 'id-1', createdAt: '', updatedAt: '', deletedAt: '2024-01-01' }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| expression, | |
| contextWithDeletedRecord, | |
| ), | |
| ).toBe(false); | |
| }); | |
| }); | |
| describe('activateWorkflow expression regression', () => { | |
| const activateWorkflowExpression = | |
| 'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and (everyEquals(selectedRecords, "currentVersion.status", "DRAFT") or includesNone(selectedRecords, "statuses", "ACTIVE")) and noneDefined(selectedRecords, "deletedAt")'; | |
| it('should not show Activate for an already-active workflow', () => { | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { | |
| id: 'wf-1', | |
| deletedAt: null, | |
| statuses: ['ACTIVE'], | |
| currentVersion: { | |
| status: 'ACTIVE', | |
| trigger: { type: 'MANUAL' }, | |
| steps: [{ id: 'step-1' }], | |
| }, | |
| }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| activateWorkflowExpression, | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should show Activate for a draft workflow with no active version', () => { | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { | |
| id: 'wf-1', | |
| deletedAt: null, | |
| statuses: ['DRAFT'], | |
| currentVersion: { | |
| status: 'DRAFT', | |
| trigger: { type: 'MANUAL' }, | |
| steps: [{ id: 'step-1' }], | |
| }, | |
| }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| activateWorkflowExpression, | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| it('should show Activate for a draft version when another version is active', () => { | |
| const context = buildContext({ | |
| selectedRecords: [ | |
| { | |
| id: 'wf-1', | |
| deletedAt: null, | |
| statuses: ['ACTIVE', 'DRAFT'], | |
| currentVersion: { | |
| status: 'DRAFT', | |
| trigger: { type: 'MANUAL' }, | |
| steps: [{ id: 'step-1' }], | |
| }, | |
| }, | |
| ], | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| activateWorkflowExpression, | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| }); | |
| describe('permissionFlags gating', () => { | |
| it('should hide exportRecords when EXPORT_CSV permission flag is missing', () => { | |
| const context = buildContext({ permissionFlags: {} }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'permissionFlags.EXPORT_CSV', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should show exportRecords when EXPORT_CSV permission flag is present', () => { | |
| const context = buildContext({ | |
| permissionFlags: { EXPORT_CSV: true }, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'permissionFlags.EXPORT_CSV', | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| it('should hide importRecords when IMPORT_CSV permission flag is missing even if soft-delete filter is off', () => { | |
| const context = buildContext({ | |
| hasAnySoftDeleteFilterOnView: false, | |
| permissionFlags: {}, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should show importRecords when IMPORT_CSV permission flag is present and soft-delete filter is off', () => { | |
| const context = buildContext({ | |
| hasAnySoftDeleteFilterOnView: false, | |
| permissionFlags: { IMPORT_CSV: true }, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV', | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| }); | |
| describe('pageType gating for index-only commands', () => { | |
| it('should hide createNewView on a record page', () => { | |
| const context = buildContext({ | |
| pageType: ContextStorePageType.Record, | |
| hasAnySoftDeleteFilterOnView: false, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'pageType == "INDEX_PAGE" and not hasAnySoftDeleteFilterOnView', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should show createNewView on the index page', () => { | |
| const context = buildContext({ | |
| pageType: ContextStorePageType.Index, | |
| hasAnySoftDeleteFilterOnView: false, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'pageType == "INDEX_PAGE" and not hasAnySoftDeleteFilterOnView', | |
| context, | |
| ), | |
| ).toBe(true); | |
| }); | |
| it('should hide importRecords on a record page even with IMPORT_CSV permission', () => { | |
| const context = buildContext({ | |
| pageType: ContextStorePageType.Record, | |
| hasAnySoftDeleteFilterOnView: false, | |
| permissionFlags: { IMPORT_CSV: true }, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'pageType == "INDEX_PAGE" and not hasAnySoftDeleteFilterOnView and permissionFlags.IMPORT_CSV', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should hide exportView on a record page even with EXPORT_CSV permission', () => { | |
| const context = buildContext({ | |
| pageType: ContextStorePageType.Record, | |
| permissionFlags: { EXPORT_CSV: true }, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'pageType == "INDEX_PAGE" and permissionFlags.EXPORT_CSV', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should hide seeDeletedRecords on a record page', () => { | |
| const context = buildContext({ | |
| pageType: ContextStorePageType.Record, | |
| hasAnySoftDeleteFilterOnView: false, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'pageType == "INDEX_PAGE" and not hasAnySoftDeleteFilterOnView', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| it('should hide hideDeletedRecords on a record page', () => { | |
| const context = buildContext({ | |
| pageType: ContextStorePageType.Record, | |
| hasAnySoftDeleteFilterOnView: true, | |
| }); | |
| expect( | |
| evaluateConditionalAvailabilityExpression( | |
| 'pageType == "INDEX_PAGE" and hasAnySoftDeleteFilterOnView', | |
| context, | |
| ), | |
| ).toBe(false); | |
| }); | |
| }); | |
| }); | |