Files
twenty/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts
T
Weiko 2ccc293f99 Gate export/import command menu items by permission flag (#19991)
## Summary
- Hides the `exportRecords`, `exportView`, and `importRecords` command
menu actions from
users whose role does not hold the matching `EXPORT_CSV` / `IMPORT_CSV`
permission flag.
- Exposes the current user's role permission flags to
`conditionalAvailabilityExpression`
by adding `permissionFlags: Record<string, boolean>` to
`CommandMenuContextApi`, mirroring
 how `featureFlags` is already accessible.
- Adds a `2.1.0` workspace upgrade command that rewrites the three
existing rows on every
 active/suspended workspace.

## Before
<img width="1294" height="287" alt="Screenshot 2026-04-22 at 19 37 40"
src="https://github.com/user-attachments/assets/11ca8635-14d7-40a0-9ca0-76329c54e3c6"
/>

## After
<img width="1283" height="285" alt="Screenshot 2026-04-22 at 19 32 25"
src="https://github.com/user-attachments/assets/5e49fa8a-4541-42ee-96da-4c1de7d00aae"
/>
2026-04-24 15:22:44 +00:00

515 lines
16 KiB
TypeScript

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: {},
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);
});
});
});