[FRONT COMPONENTS] Introduce conditionalAvailabilityExpression to command menu items (#18319)
## PR Description - Uses `expr-eval` to enable front components (SDK plugins) to define conditional availability as declarative expressions. - Moves shared types and constants to `twenty-shared` - Introduces a `conditionalAvailabilityExpression` field on `CommandMenuItemEntity`, allowing command menu items to store an `expr-eval` compatible expression string that is evaluated against a CommandMenuContext to determine if the item should be shown. - Creates an esbuild transform plugin `conditional-availability-transform-plugin` in `twenty-sdk` that converts TypeScript conditional availability expressions into `expr-eval` compatible syntax at build time, so SDK developers can write natural TS expressions that get transformed to evaluable strings. - Removes deprecated `forceRegisteredActionsByKey` state and its usage. - Creates `useCommandMenuContext` hook that builds the full `CommandMenuContext` object from React state, which is then passed to `useCommandMenuItemFrontComponentActions` for evaluating conditional availability expressions.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
import { defineFrontComponent, numberOfSelectedRecords } from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'comparison-operator',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'comparison-operator-cmd',
|
||||
label: 'Comparison Operator',
|
||||
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
|
||||
},
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
defineFrontComponent,
|
||||
numberOfSelectedRecords,
|
||||
objectPermissions,
|
||||
selectedRecord,
|
||||
} from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'complex-soft-delete',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'complex-soft-delete-cmd',
|
||||
label: 'Complex Soft Delete',
|
||||
conditionalAvailabilityExpression:
|
||||
objectPermissions.canSoftDeleteObjectRecords &&
|
||||
!selectedRecord.isRemote &&
|
||||
numberOfSelectedRecords > 0,
|
||||
},
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineFrontComponent, isDefined, selectedRecord } from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'custom-function',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'custom-function-cmd',
|
||||
label: 'Custom Function',
|
||||
conditionalAvailabilityExpression: isDefined(selectedRecord.deletedAt),
|
||||
},
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { defineFrontComponent, featureFlags, objectPermissions } from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'feature-flag-gated',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'feature-flag-gated-cmd',
|
||||
label: 'Feature Flag Gated',
|
||||
conditionalAvailabilityExpression:
|
||||
featureFlags.IS_AI_ENABLED && objectPermissions.canReadObjectRecords,
|
||||
},
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineFrontComponent, isFavorite, isRemote, isShowPage } from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'parenthesized-expression',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'parenthesized-expression-cmd',
|
||||
label: 'Parenthesized Expression',
|
||||
conditionalAvailabilityExpression: (isShowPage || isFavorite) && !isRemote,
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
defineFrontComponent,
|
||||
isInRightDrawer,
|
||||
objectPermissions,
|
||||
} from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'permissions-check',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'permissions-check-cmd',
|
||||
label: 'Permissions Check',
|
||||
conditionalAvailabilityExpression:
|
||||
objectPermissions.canUpdateObjectRecords && !isInRightDrawer,
|
||||
},
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineFrontComponent, isShowPage } from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'simple-boolean',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'simple-boolean-cmd',
|
||||
label: 'Simple Boolean',
|
||||
conditionalAvailabilityExpression: isShowPage,
|
||||
},
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { defineFrontComponent, isShowPage, selectedRecord } from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'string-comparison',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'string-comparison-cmd',
|
||||
label: 'String Comparison',
|
||||
conditionalAvailabilityExpression:
|
||||
isShowPage && selectedRecord.company.name === 'apple',
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
defineFrontComponent,
|
||||
isShowPage,
|
||||
targetObjectWritePermissions,
|
||||
} from '@/sdk';
|
||||
|
||||
const MyComponent = () => null;
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'target-permissions',
|
||||
component: MyComponent,
|
||||
command: {
|
||||
universalIdentifier: 'target-permissions-cmd',
|
||||
label: 'Target Permissions',
|
||||
conditionalAvailabilityExpression:
|
||||
isShowPage && targetObjectWritePermissions.person,
|
||||
},
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { toExprEval } from '@/cli/utilities/build/common/conditional-availability/utils/to-expr-eval';
|
||||
|
||||
describe('toExprEval', () => {
|
||||
describe('strict equality (===) to loose equality (==)', () => {
|
||||
it('should convert a single ===', () => {
|
||||
expect(toExprEval('a === b')).toBe('a == b');
|
||||
});
|
||||
|
||||
it('should convert multiple ===', () => {
|
||||
expect(toExprEval('a === b === c')).toBe('a == b == c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strict inequality (!==) to loose inequality (!=)', () => {
|
||||
it('should convert a single !==', () => {
|
||||
expect(toExprEval('a !== b')).toBe('a != b');
|
||||
});
|
||||
|
||||
it('should convert multiple !==', () => {
|
||||
expect(toExprEval('a !== 0 !== b')).toBe('a != 0 != b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logical AND (&&) to and', () => {
|
||||
it('should convert a single &&', () => {
|
||||
expect(toExprEval('a && b')).toBe('a and b');
|
||||
});
|
||||
|
||||
it('should convert multiple &&', () => {
|
||||
expect(toExprEval('a && b && c')).toBe('a and b and c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logical OR (||) to or', () => {
|
||||
it('should convert a single ||', () => {
|
||||
expect(toExprEval('a || b')).toBe('a or b');
|
||||
});
|
||||
|
||||
it('should convert multiple ||', () => {
|
||||
expect(toExprEval('a || b || c')).toBe('a or b or c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logical NOT (!) to not', () => {
|
||||
it('should convert a prefix !', () => {
|
||||
expect(toExprEval('!a')).toBe('not a');
|
||||
});
|
||||
|
||||
it('should convert multiple prefix !', () => {
|
||||
expect(toExprEval('!a && !b')).toBe('not a and not b');
|
||||
});
|
||||
|
||||
it('should not convert ! that is part of !==', () => {
|
||||
expect(toExprEval('a !== b')).toBe('a != b');
|
||||
});
|
||||
|
||||
it('should not convert ! that is part of !=', () => {
|
||||
expect(toExprEval('a != b')).toBe('a != b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined operators', () => {
|
||||
it('should convert a complex expression with all operators', () => {
|
||||
expect(toExprEval('a && !b || c === 1 && d !== 0')).toBe(
|
||||
'a and not b or c == 1 and d != 0',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle parenthesized expressions', () => {
|
||||
expect(toExprEval('(a || b) && !c')).toBe('(a or b) and not c');
|
||||
});
|
||||
|
||||
it('should handle nested property access', () => {
|
||||
expect(toExprEval('obj.prop === true && !obj.other')).toBe(
|
||||
'obj.prop == true and not obj.other',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return an empty string unchanged', () => {
|
||||
expect(toExprEval('')).toBe('');
|
||||
});
|
||||
|
||||
it('should return a plain identifier unchanged', () => {
|
||||
expect(toExprEval('isShowPage')).toBe('isShowPage');
|
||||
});
|
||||
|
||||
it('should leave numeric comparisons intact', () => {
|
||||
expect(toExprEval('count > 0')).toBe('count > 0');
|
||||
});
|
||||
});
|
||||
});
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from '@/cli/utilities/build/common/conditional-availability/utils/transform-conditional-availability-expressions';
|
||||
import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
|
||||
|
||||
const MOCKS_DIR = path.join(__dirname, '__mocks__');
|
||||
|
||||
const readMock = (filename: string): string =>
|
||||
fs.readFileSync(path.join(MOCKS_DIR, filename), 'utf8');
|
||||
|
||||
const buildMockCommandMenuContextApi = (
|
||||
overrides: Partial<CommandMenuContextApi> = {},
|
||||
): CommandMenuContextApi => ({
|
||||
isShowPage: false,
|
||||
isInRightDrawer: false,
|
||||
isFavorite: false,
|
||||
isRemote: false,
|
||||
isNoteOrTask: false,
|
||||
isSelectAll: false,
|
||||
hasAnySoftDeleteFilterOnView: false,
|
||||
numberOfSelectedRecords: 0,
|
||||
objectPermissions: {
|
||||
objectMetadataId: 'test-metadata-id',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
rowLevelPermissionPredicates: [],
|
||||
rowLevelPermissionPredicateGroups: [],
|
||||
},
|
||||
selectedRecord: undefined,
|
||||
featureFlags: {},
|
||||
targetObjectReadPermissions: {},
|
||||
targetObjectWritePermissions: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const CONDITIONAL_AVAILABILITY_EXPRESSION_REGEX =
|
||||
/conditionalAvailabilityExpression\s*:\s*("(?:[^"\\]|\\.)*")/;
|
||||
|
||||
const extractConditionalAvailabilityExpressionFromTransformedSource = (
|
||||
transformedSource: string,
|
||||
): string | undefined => {
|
||||
const match = transformedSource.match(
|
||||
CONDITIONAL_AVAILABILITY_EXPRESSION_REGEX,
|
||||
);
|
||||
|
||||
if (!match?.[1]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JSON.parse(match[1]) as string;
|
||||
};
|
||||
|
||||
const transformMockAndEvaluate = (
|
||||
filename: string,
|
||||
context: CommandMenuContextApi,
|
||||
): boolean => {
|
||||
const source = readMock(filename);
|
||||
|
||||
const transformedSourceForEsBuildPlugin =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
const conditionalAvailabilityExpression =
|
||||
extractConditionalAvailabilityExpressionFromTransformedSource(
|
||||
transformedSourceForEsBuildPlugin,
|
||||
);
|
||||
|
||||
return evaluateConditionalAvailabilityExpression(
|
||||
conditionalAvailabilityExpression,
|
||||
context,
|
||||
);
|
||||
};
|
||||
|
||||
describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
|
||||
describe('transform correctness', () => {
|
||||
it('should convert && to and', () => {
|
||||
const source = '{ conditionalAvailabilityExpression: a && b }';
|
||||
const result =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(result).toBe('{ conditionalAvailabilityExpression: "a and b"}');
|
||||
});
|
||||
|
||||
it('should convert || to or', () => {
|
||||
const source = '{ conditionalAvailabilityExpression: a || b }';
|
||||
const result =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(result).toBe('{ conditionalAvailabilityExpression: "a or b"}');
|
||||
});
|
||||
|
||||
it('should convert ! to not', () => {
|
||||
const source = '{ conditionalAvailabilityExpression: !a }';
|
||||
const result =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(result).toBe('{ conditionalAvailabilityExpression: "not a"}');
|
||||
});
|
||||
|
||||
it('should convert === to ==', () => {
|
||||
const source = '{ conditionalAvailabilityExpression: a === 1 }';
|
||||
const result =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(result).toBe('{ conditionalAvailabilityExpression: "a == 1"}');
|
||||
});
|
||||
|
||||
it('should convert !== to !=', () => {
|
||||
const source = '{ conditionalAvailabilityExpression: a !== 0 }';
|
||||
const result =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(result).toBe('{ conditionalAvailabilityExpression: "a != 0"}');
|
||||
});
|
||||
|
||||
it('should convert all operators in a complex expression', () => {
|
||||
const source =
|
||||
'{ conditionalAvailabilityExpression: a && !b || c === 1 && d !== 0 }';
|
||||
const result =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(result).toBe(
|
||||
'{ conditionalAvailabilityExpression: "a and not b or c == 1 and d != 0"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex edge cases', () => {
|
||||
it('should skip already-quoted string expressions without leading space', () => {
|
||||
const source = '{ conditionalAvailabilityExpression:"already quoted" }';
|
||||
const transformed =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(transformed).toBe(source);
|
||||
});
|
||||
|
||||
it('should skip single-quoted string expressions without leading space', () => {
|
||||
const source = "{ conditionalAvailabilityExpression:'already quoted' }";
|
||||
const transformed =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(transformed).toBe(source);
|
||||
});
|
||||
|
||||
it('should skip template literal expressions without leading space', () => {
|
||||
const source = '{ conditionalAvailabilityExpression:`already quoted` }';
|
||||
const transformed =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(transformed).toBe(source);
|
||||
});
|
||||
|
||||
it('should handle multiple expressions in one source', () => {
|
||||
const source = [
|
||||
'const a = { conditionalAvailabilityExpression: isShowPage };',
|
||||
'const b = { conditionalAvailabilityExpression: isFavorite && !isRemote };',
|
||||
].join('\n');
|
||||
const transformed =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(transformed).toContain(
|
||||
'conditionalAvailabilityExpression: "isShowPage"',
|
||||
);
|
||||
expect(transformed).toContain(
|
||||
'conditionalAvailabilityExpression: "isFavorite and not isRemote"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle extra spaces around colon', () => {
|
||||
const source =
|
||||
'{ conditionalAvailabilityExpression : isShowPage && isFavorite }';
|
||||
const transformed =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
expect(transformed).toContain('"isShowPage and isFavorite"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('e2e: mock file -> transform -> evaluate', () => {
|
||||
describe('simple-boolean-front-component', () => {
|
||||
it('should evaluate isShowPage when true', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'simple-boolean-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate isShowPage when false', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'simple-boolean-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permissions-check-front-component', () => {
|
||||
it('should allow when has permission and not in right drawer', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isInRightDrawer: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'permissions-check-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when in right drawer', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isInRightDrawer: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'permissions-check-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparison-operator-front-component', () => {
|
||||
it('should allow when records are selected', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
numberOfSelectedRecords: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'comparison-operator-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when no records are selected', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
numberOfSelectedRecords: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'comparison-operator-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feature-flag-gated-front-component', () => {
|
||||
it('should allow when feature flag is enabled', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
featureFlags: { IS_AI_ENABLED: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'feature-flag-gated-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when feature flag is disabled', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
featureFlags: { IS_AI_ENABLED: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'feature-flag-gated-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex-soft-delete-front-component', () => {
|
||||
it('should allow soft delete for local record with selection', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
numberOfSelectedRecords: 2,
|
||||
selectedRecord: {
|
||||
id: 'rec-1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
deletedAt: null,
|
||||
isRemote: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'complex-soft-delete-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny soft delete when record is remote', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
numberOfSelectedRecords: 1,
|
||||
selectedRecord: {
|
||||
id: 'rec-1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
deletedAt: null,
|
||||
isRemote: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'complex-soft-delete-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parenthesized-expression-front-component', () => {
|
||||
it('should allow when favorite and not remote', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: false,
|
||||
isFavorite: true,
|
||||
isRemote: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'parenthesized-expression-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when remote even if show page', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: true,
|
||||
isFavorite: false,
|
||||
isRemote: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'parenthesized-expression-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom-function-front-component', () => {
|
||||
it('should return false when deletedAt is null', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
selectedRecord: {
|
||||
id: 'rec-1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'custom-function-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when deletedAt is set', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
selectedRecord: {
|
||||
id: 'rec-1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
deletedAt: '2024-06-01',
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'custom-function-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string-comparison-front-component', () => {
|
||||
it('should match when on show page and company name matches', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: true,
|
||||
selectedRecord: {
|
||||
id: 'rec-1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
deletedAt: null,
|
||||
company: { name: 'apple' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'string-comparison-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when company name differs', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: true,
|
||||
selectedRecord: {
|
||||
id: 'rec-1',
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
deletedAt: null,
|
||||
company: { name: 'google' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'string-comparison-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('target-permissions-front-component', () => {
|
||||
it('should allow when on show page with write permission', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: true,
|
||||
targetObjectWritePermissions: { person: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'target-permissions-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when not on show page', () => {
|
||||
const context = buildMockCommandMenuContextApi({
|
||||
isShowPage: false,
|
||||
targetObjectWritePermissions: { person: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
transformMockAndEvaluate(
|
||||
'target-permissions-front-component.ts',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import type * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from './utils/transform-conditional-availability-expressions';
|
||||
|
||||
export const conditionalAvailabilityTransformPlugin: esbuild.Plugin = {
|
||||
name: 'conditional-availability-transform',
|
||||
setup: (build) => {
|
||||
build.onLoad({ filter: /\.tsx?$/ }, async (args) => {
|
||||
const source = await fs.readFile(args.path, 'utf8');
|
||||
|
||||
if (!source.includes('conditionalAvailabilityExpression')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transformedSource =
|
||||
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
|
||||
|
||||
if (transformedSource === source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
contents: transformedSource,
|
||||
loader: args.path.endsWith('.tsx') ? 'tsx' : 'ts',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const toExprEval = (raw: string): string =>
|
||||
raw
|
||||
.replace(/!==/g, '!=')
|
||||
.replace(/===/g, '==')
|
||||
.replace(/&&/g, 'and')
|
||||
.replace(/\|\|/g, 'or')
|
||||
.replace(/!(?!=)/g, 'not ');
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { toExprEval } from './to-expr-eval';
|
||||
|
||||
const CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN =
|
||||
/(conditionalAvailabilityExpression\s*:\s*)(?!['"`])([^,}]+)/g;
|
||||
|
||||
export const transformConditionalAvailabilityExpressionsForEsBuildPlugin = (
|
||||
source: string,
|
||||
): string =>
|
||||
source.replace(
|
||||
CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN,
|
||||
(_, prefix: string, rawExpression: string) =>
|
||||
prefix + JSON.stringify(toExprEval(rawExpression.trim())),
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TargetFunction,
|
||||
} from '@/cli/utilities/build/manifest/manifest-extract-config';
|
||||
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
|
||||
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
|
||||
import {
|
||||
type ApplicationConfig,
|
||||
type FrontComponentConfig,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type AssetManifest,
|
||||
ASSETS_DIR,
|
||||
type FieldManifest,
|
||||
type FrontComponentCommandManifest,
|
||||
type FrontComponentManifest,
|
||||
type LogicFunctionManifest,
|
||||
type Manifest,
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
} from 'twenty-shared/application';
|
||||
import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
|
||||
|
||||
const loadSources = async (appPath: string): Promise<string[]> => {
|
||||
return await glob(['**/*.ts', '**/*.tsx'], {
|
||||
@@ -237,7 +238,7 @@ export const buildManifest = async (
|
||||
|
||||
errors.push(...extract.errors);
|
||||
|
||||
const { component, ...rest } = extract.config;
|
||||
const { component, command, ...rest } = extract.config;
|
||||
|
||||
const relativeFilePath = relative(appPath, filePath);
|
||||
|
||||
@@ -248,6 +249,8 @@ export const buildManifest = async (
|
||||
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
|
||||
builtComponentChecksum: '',
|
||||
isHeadless: rest.isHeadless ?? false,
|
||||
// transformed by conditionalAvailabilityTransformPlugin
|
||||
command: command as FrontComponentCommandManifest,
|
||||
};
|
||||
|
||||
frontComponents.push(config);
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin';
|
||||
import { type ValidationResult } from '@/sdk';
|
||||
import * as esbuild from 'esbuild';
|
||||
import * as fs from 'fs-extra';
|
||||
@@ -74,7 +75,7 @@ const loadModule = async ({
|
||||
...(reactPath && { react: reactPath }),
|
||||
...(reactDomPath && { 'react-dom': reactDomPath }),
|
||||
},
|
||||
plugins: [manifestMockPlugin],
|
||||
plugins: [conditionalAvailabilityTransformPlugin, manifestMockPlugin],
|
||||
logLevel: 'silent',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user