Support template variables in command menu item labels (#18707)
- Adds template variable interpolation (`${...}`) to command menu item
labels and short labels, enabling dynamic text like `Create new
${capitalize(objectMetadataItem.labelSingular)}` instead of static
`Create new record`.
- Supports `capitalize` and `lowercase` transform functions within
template expressions.
This commit is contained in:
+1
-1
@@ -38,7 +38,7 @@ export const CommandListItem = ({
|
||||
id={action.key}
|
||||
Icon={action.Icon}
|
||||
label={getCommandMenuItemLabel(action.label)}
|
||||
description={getCommandMenuItemLabel(action.description ?? '')}
|
||||
description={getCommandMenuItemLabel(action.description)}
|
||||
to={to}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
hotKeys={action.hotKeys}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import { type MenuItemAccent } from 'twenty-ui/navigation';
|
||||
|
||||
export type CommandMenuItemDisplayProps = {
|
||||
key: string;
|
||||
label: MessageDescriptor | string;
|
||||
label: Nullable<MessageDescriptor | string>;
|
||||
shortLabel?: Nullable<MessageDescriptor | string>;
|
||||
description?: MessageDescriptor | string;
|
||||
Icon: IconComponent;
|
||||
|
||||
+2
-4
@@ -42,9 +42,7 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(
|
||||
addToFavoritesCommandMenuItem?.shortLabel ?? '',
|
||||
),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -59,7 +57,7 @@ export const WithLink: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const menuItem = await canvas.findByText(
|
||||
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel ?? ''),
|
||||
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.shortLabel),
|
||||
);
|
||||
expect(menuItem).toBeVisible();
|
||||
expect(canvas.getByRole('link')).toHaveAttribute('href', '/objects/people');
|
||||
|
||||
+1
-3
@@ -60,9 +60,7 @@ export const Default: Story = {
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(
|
||||
addToFavoritesCommandMenuItem?.shortLabel ?? '',
|
||||
),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
|
||||
+3
-5
@@ -65,9 +65,7 @@ export const AsButton: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(
|
||||
addToFavoritesCommandMenuItem?.shortLabel ?? '',
|
||||
),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -103,7 +101,7 @@ export const AsListItem: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -139,7 +137,7 @@ export const AsDropdownItem: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -68,7 +68,7 @@ export const WithLink: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const dropdownItem = await canvas.findByText(
|
||||
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
|
||||
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
|
||||
);
|
||||
expect(dropdownItem).toBeVisible();
|
||||
},
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { CommandListItem } from '@/command-menu-item/display/components/CommandListItem';
|
||||
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
|
||||
import { NoSelectionRecordCommandKeys } from '@/command-menu-item/record/no-selection/types/NoSelectionRecordCommandKeys';
|
||||
import { SingleRecordCommandKeys } from '@/command-menu-item/record/single-record/types/SingleRecordCommandKeys';
|
||||
import { createMockCommandMenuItems } from '@/command-menu-item/mock/command-menu-items.mock';
|
||||
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
|
||||
import { SelectableListComponentInstanceContext } from '@/ui/layout/selectable-list/states/contexts/SelectableListComponentInstanceContext';
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
@@ -53,7 +53,7 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -68,7 +68,7 @@ export const WithLink: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const listItem = await canvas.findByText(
|
||||
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label ?? ''),
|
||||
getCommandMenuItemLabel(goToPeopleCommandMenuItem?.label),
|
||||
);
|
||||
expect(listItem).toBeVisible();
|
||||
},
|
||||
|
||||
+2
-4
@@ -1,5 +1,5 @@
|
||||
import { Command } from '@/command-menu-item/display/components/Command';
|
||||
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
|
||||
import { Command } from '@/command-menu-item/display/components/Command';
|
||||
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
|
||||
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
|
||||
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
|
||||
@@ -78,9 +78,7 @@ export const DeleteMultipleRecordsCommand = () => {
|
||||
|
||||
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
|
||||
|
||||
const originalShortLabel = getCommandMenuItemLabel(
|
||||
actionConfig.shortLabel ?? '',
|
||||
);
|
||||
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
|
||||
|
||||
const progressText = computeProgressText(progress);
|
||||
|
||||
|
||||
+1
-3
@@ -85,9 +85,7 @@ export const DestroyMultipleRecordsCommand = () => {
|
||||
|
||||
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
|
||||
|
||||
const originalShortLabel = getCommandMenuItemLabel(
|
||||
actionConfig.shortLabel ?? '',
|
||||
);
|
||||
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
|
||||
|
||||
const progressText = computeProgressText(progress);
|
||||
|
||||
|
||||
+1
-3
@@ -49,9 +49,7 @@ export const ExportMultipleRecordsCommand = () => {
|
||||
|
||||
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
|
||||
|
||||
const originalShortLabel = getCommandMenuItemLabel(
|
||||
actionConfig.shortLabel ?? '',
|
||||
);
|
||||
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
|
||||
|
||||
const progressText = computeProgressText(exportProgress);
|
||||
|
||||
|
||||
+20
-5
@@ -16,6 +16,7 @@ import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomState
|
||||
import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
import {
|
||||
evaluateConditionalAvailabilityExpression,
|
||||
interpolateCommandMenuItemLabel,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { type IconComponent, useIcons } from 'twenty-ui/display';
|
||||
@@ -73,7 +74,15 @@ const buildCommandMenuItemFromFrontComponent = ({
|
||||
mountContext,
|
||||
commandMenuContextApi,
|
||||
}: BuildCommandMenuItemFromFrontComponentParams) => {
|
||||
const displayLabel = item.label;
|
||||
const displayLabel = interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
|
||||
const displayShortLabel = interpolateCommandMenuItemLabel({
|
||||
label: item.shortLabel,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
|
||||
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
|
||||
|
||||
@@ -85,7 +94,7 @@ const buildCommandMenuItemFromFrontComponent = ({
|
||||
} else {
|
||||
openFrontComponentInSidePanel({
|
||||
frontComponentId: item.frontComponentId,
|
||||
pageTitle: displayLabel,
|
||||
pageTitle: displayLabel ?? '',
|
||||
pageIcon: Icon,
|
||||
recordContext: isDefined(mountContext)
|
||||
? {
|
||||
@@ -102,7 +111,7 @@ const buildCommandMenuItemFromFrontComponent = ({
|
||||
key: `command-menu-item-front-component-${item.id}`,
|
||||
scope,
|
||||
label: displayLabel,
|
||||
shortLabel: item.shortLabel,
|
||||
shortLabel: displayShortLabel,
|
||||
position: item.position,
|
||||
isPinned,
|
||||
Icon,
|
||||
@@ -160,8 +169,14 @@ const buildCommandItemFromEngineKey = ({
|
||||
type,
|
||||
key: `command-menu-item-engine-${item.id}`,
|
||||
scope,
|
||||
label: item.label,
|
||||
shortLabel: item.shortLabel,
|
||||
label: interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
}),
|
||||
shortLabel: interpolateCommandMenuItemLabel({
|
||||
label: item.shortLabel,
|
||||
context: commandMenuContextApi,
|
||||
}),
|
||||
position: item.position,
|
||||
isPinned,
|
||||
Icon,
|
||||
|
||||
@@ -14,7 +14,7 @@ export type CommandMenuItemConfig = {
|
||||
type: CommandMenuItemType;
|
||||
scope: CommandMenuItemScope;
|
||||
key: string;
|
||||
label: MessageDescriptor | string;
|
||||
label: Nullable<MessageDescriptor | string>;
|
||||
shortLabel?: Nullable<MessageDescriptor | string>;
|
||||
description?: MessageDescriptor | string;
|
||||
position: number;
|
||||
|
||||
+7
-1
@@ -1,8 +1,14 @@
|
||||
import { i18n, type MessageDescriptor } from '@lingui/core';
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getCommandMenuItemLabel = (
|
||||
label: string | MessageDescriptor,
|
||||
label: Nullable<string | MessageDescriptor>,
|
||||
): string => {
|
||||
if (!isDefined(label)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return isString(label) ? label : i18n._(label);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
|
||||
import { styled } from '@linaria/react';
|
||||
import { i18n, type MessageDescriptor } from '@lingui/core';
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { type MouseEvent } from 'react';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -20,7 +20,7 @@ const StyledWrapper = styled.div`
|
||||
export type CommandMenuButtonProps = {
|
||||
command: {
|
||||
key: string;
|
||||
label: string | MessageDescriptor;
|
||||
label: Nullable<string | MessageDescriptor>;
|
||||
shortLabel?: Nullable<string | MessageDescriptor>;
|
||||
Icon: IconComponent;
|
||||
isPrimaryCTA?: boolean;
|
||||
@@ -30,22 +30,16 @@ export type CommandMenuButtonProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const getCommandMenuButtonLabel = (
|
||||
label: string | MessageDescriptor,
|
||||
): string => {
|
||||
return isString(label) ? label : i18n._(label);
|
||||
};
|
||||
|
||||
export const CommandMenuButton = ({
|
||||
command,
|
||||
onClick,
|
||||
to,
|
||||
disabled = false,
|
||||
}: CommandMenuButtonProps) => {
|
||||
const resolvedLabel = getCommandMenuButtonLabel(command.label);
|
||||
const resolvedLabel = getCommandMenuItemLabel(command.label);
|
||||
|
||||
const resolvedShortLabel = isDefined(command.shortLabel)
|
||||
? getCommandMenuButtonLabel(command.shortLabel)
|
||||
? getCommandMenuItemLabel(command.shortLabel)
|
||||
: undefined;
|
||||
|
||||
const buttonAccent = command.isPrimaryCTA ? 'blue' : 'default';
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
|
||||
universalIdentifier: commandMenuItemManifest.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
label: commandMenuItemManifest.label,
|
||||
shortLabel: null,
|
||||
shortLabel: commandMenuItemManifest.shortLabel ?? null,
|
||||
position: 0,
|
||||
icon: commandMenuItemManifest.icon ?? null,
|
||||
isPinned: commandMenuItemManifest.isPinned ?? false,
|
||||
|
||||
+46
-44
@@ -6,7 +6,7 @@ import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-ite
|
||||
export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
navigateToNextRecord: {
|
||||
universalIdentifier: '3db2457d-8e96-4b8e-94c9-ed95d3f95738',
|
||||
label: 'Navigate to next record',
|
||||
label: 'Navigate to next ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
shortLabel: null,
|
||||
icon: 'IconChevronDown',
|
||||
position: 0,
|
||||
@@ -21,7 +21,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
navigateToPreviousRecord: {
|
||||
universalIdentifier: 'ec10f871-415b-420b-8150-7e09f6f04833',
|
||||
label: 'Navigate to previous record',
|
||||
label:
|
||||
'Navigate to previous ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
shortLabel: null,
|
||||
icon: 'IconChevronUp',
|
||||
position: 1,
|
||||
@@ -36,8 +37,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
createNewRecord: {
|
||||
universalIdentifier: '08d255bf-58cd-47a5-bd82-78c5c58592f1',
|
||||
label: 'Create new record',
|
||||
shortLabel: 'New record',
|
||||
label: 'Create new ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
icon: 'IconPlus',
|
||||
position: 2,
|
||||
isPinned: true,
|
||||
@@ -66,7 +67,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
deleteMultipleRecords: {
|
||||
universalIdentifier: 'cde86f1f-2c13-42b1-812b-f2b2b468cb83',
|
||||
label: 'Delete records',
|
||||
label: 'Delete ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Delete',
|
||||
icon: 'IconTrash',
|
||||
position: 4,
|
||||
@@ -81,7 +82,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
restoreSingleRecord: {
|
||||
universalIdentifier: '8b3a1cae-3e4d-43c1-a71f-48592b2e47ff',
|
||||
label: 'Restore record',
|
||||
label: 'Restore ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
shortLabel: 'Restore',
|
||||
icon: 'IconRefresh',
|
||||
position: 5,
|
||||
@@ -96,7 +97,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
restoreMultipleRecords: {
|
||||
universalIdentifier: '8b740c9d-d99a-45a8-812f-809caaf420ac',
|
||||
label: 'Restore records',
|
||||
label: 'Restore ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Restore',
|
||||
icon: 'IconRefresh',
|
||||
position: 6,
|
||||
@@ -111,7 +112,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
destroySingleRecord: {
|
||||
universalIdentifier: '44a78417-c394-4bc8-961f-98b503030ddb',
|
||||
label: 'Permanently destroy record',
|
||||
label:
|
||||
'Permanently destroy ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
shortLabel: 'Destroy',
|
||||
icon: 'IconTrashX',
|
||||
position: 7,
|
||||
@@ -126,7 +128,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
destroyMultipleRecords: {
|
||||
universalIdentifier: 'c630b3fb-7920-40d1-9906-77d0aa797608',
|
||||
label: 'Permanently destroy records',
|
||||
label: 'Permanently destroy ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Destroy',
|
||||
icon: 'IconTrashX',
|
||||
position: 8,
|
||||
@@ -141,7 +143,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
addToFavorites: {
|
||||
universalIdentifier: '38bf80c3-bd55-4753-80ba-38aa66429a03',
|
||||
label: 'Add to favorites',
|
||||
label: 'Add to Favorites',
|
||||
shortLabel: null,
|
||||
icon: 'IconHeart',
|
||||
position: 9,
|
||||
@@ -156,7 +158,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
removeFromFavorites: {
|
||||
universalIdentifier: '3ea42507-44fa-4895-a36d-cbfef7355a50',
|
||||
label: 'Remove from favorites',
|
||||
label: 'Remove from Favorites',
|
||||
shortLabel: null,
|
||||
icon: 'IconHeartOff',
|
||||
position: 10,
|
||||
@@ -214,7 +216,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
updateMultipleRecords: {
|
||||
universalIdentifier: '2e080651-f098-4a78-bea9-7a70002dc57c',
|
||||
label: 'Update records',
|
||||
label: 'Update ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Update',
|
||||
icon: 'IconEdit',
|
||||
position: 14,
|
||||
@@ -229,7 +231,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
mergeMultipleRecords: {
|
||||
universalIdentifier: '6c14eb04-8e7e-4d47-93c0-8ec4834e2e60',
|
||||
label: 'Merge records',
|
||||
label: 'Merge ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Merge',
|
||||
icon: 'IconArrowMerge',
|
||||
position: 15,
|
||||
@@ -244,7 +246,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
exportMultipleRecords: {
|
||||
universalIdentifier: 'f71f68e5-7b6e-4c03-8161-c48434d7777c',
|
||||
label: 'Export records',
|
||||
label: 'Export ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Export',
|
||||
icon: 'IconFileExport',
|
||||
position: 16,
|
||||
@@ -258,7 +260,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
importRecords: {
|
||||
universalIdentifier: 'a2dc9de7-4798-422e-bb55-bfad7b9bdbe8',
|
||||
label: 'Import records',
|
||||
label: 'Import ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Import',
|
||||
icon: 'IconFileImport',
|
||||
position: 17,
|
||||
@@ -272,7 +274,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
exportView: {
|
||||
universalIdentifier: '80680f2a-c426-48b3-a839-c63a6183dc4b',
|
||||
label: 'Export view',
|
||||
label: 'Export View',
|
||||
shortLabel: 'Export',
|
||||
icon: 'IconFileExport',
|
||||
position: 18,
|
||||
@@ -286,8 +288,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeDeletedRecords: {
|
||||
universalIdentifier: 'd63c21c3-9785-4750-be87-5f36269b8e0d',
|
||||
label: 'See deleted records',
|
||||
shortLabel: 'Deleted records',
|
||||
label: 'See deleted ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
icon: 'IconRotate2',
|
||||
position: 19,
|
||||
isPinned: false,
|
||||
@@ -314,7 +316,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
hideDeletedRecords: {
|
||||
universalIdentifier: '1420db7f-0fba-49e2-b23e-4b7caa0fafa0',
|
||||
label: 'Hide deleted records',
|
||||
label: 'Hide deleted ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Hide deleted',
|
||||
icon: 'IconEyeOff',
|
||||
position: 21,
|
||||
@@ -584,8 +586,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeActiveVersionWorkflow: {
|
||||
universalIdentifier: '31790508-75ff-4e4c-a768-83bd1b0718e0',
|
||||
label: 'See active version',
|
||||
shortLabel: 'See active version',
|
||||
label: 'See Active Version',
|
||||
shortLabel: 'See Active Version',
|
||||
icon: 'IconVersions',
|
||||
position: 46,
|
||||
isPinned: false,
|
||||
@@ -600,8 +602,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeRunsWorkflow: {
|
||||
universalIdentifier: 'e57efc2d-00a2-493a-b76c-f2dabd23a5eb',
|
||||
label: 'See runs',
|
||||
shortLabel: 'See runs',
|
||||
label: 'See Runs',
|
||||
shortLabel: 'See Runs',
|
||||
icon: 'IconHistoryToggle',
|
||||
position: 47,
|
||||
isPinned: true,
|
||||
@@ -616,8 +618,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeVersionsWorkflow: {
|
||||
universalIdentifier: '92781d24-b875-4282-8cdb-d127f04a5c7d',
|
||||
label: 'See versions history',
|
||||
shortLabel: 'See versions',
|
||||
label: 'See Versions History',
|
||||
shortLabel: 'See Versions',
|
||||
icon: 'IconVersions',
|
||||
position: 48,
|
||||
isPinned: false,
|
||||
@@ -632,8 +634,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
addNodeWorkflow: {
|
||||
universalIdentifier: '818117fa-6cad-4ebc-83c1-40f4afc28d94',
|
||||
label: 'Add a node',
|
||||
shortLabel: 'Add a node',
|
||||
label: 'Add a Node',
|
||||
shortLabel: 'Add a Node',
|
||||
icon: 'IconPlus',
|
||||
position: 49,
|
||||
isPinned: true,
|
||||
@@ -648,7 +650,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
tidyUpWorkflow: {
|
||||
universalIdentifier: '1f3a3cab-161a-4775-af47-11be4d0bf411',
|
||||
label: 'Tidy up workflow',
|
||||
label: 'Tidy up Workflow',
|
||||
shortLabel: 'Tidy up',
|
||||
icon: 'IconReorder',
|
||||
position: 50,
|
||||
@@ -680,8 +682,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
goToRuns: {
|
||||
universalIdentifier: '1ba959da-ff49-4c1f-a517-2b78ee200508',
|
||||
label: 'Go to runs',
|
||||
shortLabel: 'See runs',
|
||||
label: 'Go to Runs',
|
||||
shortLabel: 'See Runs',
|
||||
icon: 'IconHistoryToggle',
|
||||
position: 52,
|
||||
isPinned: false,
|
||||
@@ -695,8 +697,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeVersionWorkflowRun: {
|
||||
universalIdentifier: 'cc3a065c-c89e-40ac-9449-4272c55b1bb8',
|
||||
label: 'See version',
|
||||
shortLabel: 'See version',
|
||||
label: 'See Version',
|
||||
shortLabel: 'See Version',
|
||||
icon: 'IconVersions',
|
||||
position: 53,
|
||||
isPinned: true,
|
||||
@@ -710,8 +712,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeWorkflowWorkflowRun: {
|
||||
universalIdentifier: '9d9cc62d-3543-45c3-93f3-23d2d8979f2b',
|
||||
label: 'See workflow',
|
||||
shortLabel: 'See workflow',
|
||||
label: 'See Workflow',
|
||||
shortLabel: 'See Workflow',
|
||||
icon: 'IconSettingsAutomation',
|
||||
position: 54,
|
||||
isPinned: true,
|
||||
@@ -741,8 +743,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeRunsWorkflowVersion: {
|
||||
universalIdentifier: '44e305c7-4f0a-45ec-803f-6471b56455cb',
|
||||
label: 'See runs',
|
||||
shortLabel: 'See runs',
|
||||
label: 'See Runs',
|
||||
shortLabel: 'See Runs',
|
||||
icon: 'IconHistoryToggle',
|
||||
position: 56,
|
||||
isPinned: true,
|
||||
@@ -757,8 +759,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeWorkflowWorkflowVersion: {
|
||||
universalIdentifier: 'b43052db-023e-4083-9b63-2c2dfbfd1320',
|
||||
label: 'See workflow',
|
||||
shortLabel: 'See workflow',
|
||||
label: 'See Workflow',
|
||||
shortLabel: 'See Workflow',
|
||||
icon: 'IconSettingsAutomation',
|
||||
position: 57,
|
||||
isPinned: true,
|
||||
@@ -773,8 +775,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
useAsDraftWorkflowVersion: {
|
||||
universalIdentifier: '483c0c1d-ea4d-4a4d-8a59-2dcf9f8e38f6',
|
||||
label: 'Use as draft',
|
||||
shortLabel: 'Use as draft',
|
||||
label: 'Use as Draft',
|
||||
shortLabel: 'Use as Draft',
|
||||
icon: 'IconPencil',
|
||||
position: 58,
|
||||
isPinned: true,
|
||||
@@ -789,8 +791,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeVersionsWorkflowVersion: {
|
||||
universalIdentifier: '1d4abeb7-2750-4af7-9a92-fbadd2a9e4ba',
|
||||
label: 'See versions history',
|
||||
shortLabel: 'See versions',
|
||||
label: 'See Versions History',
|
||||
shortLabel: 'See Versions',
|
||||
icon: 'IconVersions',
|
||||
position: 59,
|
||||
isPinned: false,
|
||||
@@ -805,7 +807,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
searchRecords: {
|
||||
universalIdentifier: 'fa24e25e-68f8-4548-82ff-c7b5168b7c7d',
|
||||
label: 'Search records',
|
||||
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Search',
|
||||
icon: 'IconSearch',
|
||||
position: 60,
|
||||
@@ -819,7 +821,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
searchRecordsFallback: {
|
||||
universalIdentifier: 'c659890c-7266-46c9-bfe1-75cefff8b6d0',
|
||||
label: 'Search records',
|
||||
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Search',
|
||||
icon: 'IconSearch',
|
||||
position: 61,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsT
|
||||
|
||||
export type CommandMenuItemManifest = SyncableEntityOptions & {
|
||||
label: string;
|
||||
shortLabel?: string;
|
||||
icon?: string;
|
||||
isPinned?: boolean;
|
||||
availabilityType?: 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK';
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
CommandMenuContextApiPageType,
|
||||
type CommandMenuContextApi,
|
||||
} from '@/types';
|
||||
import { interpolateCommandMenuItemLabel } from '../interpolateCommandMenuItemLabel';
|
||||
|
||||
const buildContext = (
|
||||
overrides: Partial<CommandMenuContextApi> = {},
|
||||
): CommandMenuContextApi => ({
|
||||
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
|
||||
isInSidePanel: false,
|
||||
isPageInEditMode: false,
|
||||
favoriteRecordIds: [],
|
||||
isSelectAll: false,
|
||||
hasAnySoftDeleteFilterOnView: false,
|
||||
numberOfSelectedRecords: 0,
|
||||
objectPermissions: {
|
||||
objectMetadataId: '',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
rowLevelPermissionPredicates: [],
|
||||
rowLevelPermissionPredicateGroups: [],
|
||||
},
|
||||
selectedRecords: [],
|
||||
featureFlags: {},
|
||||
targetObjectReadPermissions: {},
|
||||
targetObjectWritePermissions: {},
|
||||
objectMetadataItem: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('interpolateCommandMenuItemLabel', () => {
|
||||
describe('sequential invocations (global regex lastIndex)', () => {
|
||||
it('should interpolate correctly when called multiple times in sequence', () => {
|
||||
const context = buildContext({
|
||||
numberOfSelectedRecords: 2,
|
||||
objectMetadataItem: { labelPlural: 'people' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'First: ${numberOfSelectedRecords}',
|
||||
context,
|
||||
}),
|
||||
).toBe('First: 2');
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Second: ${objectMetadataItem.labelPlural}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Second: people');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain strings without template variables', () => {
|
||||
it('should return the label unchanged when no template variables are present', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({ label: 'Delete', context }),
|
||||
).toBe('Delete');
|
||||
});
|
||||
|
||||
it('should return null when label is null', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({ label: null, context }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when label is undefined', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({ label: undefined, context }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an empty string for an empty label', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(interpolateCommandMenuItemLabel({ label: '', context })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('simple template variable interpolation', () => {
|
||||
it('should interpolate a top-level context property', () => {
|
||||
const context = buildContext({ numberOfSelectedRecords: 5 });
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Selected: ${numberOfSelectedRecords}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Selected: 5');
|
||||
});
|
||||
|
||||
it('should interpolate objectMetadataItem.labelSingular', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelSingular: 'person' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Create new ${objectMetadataItem.labelSingular}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Create new person');
|
||||
});
|
||||
|
||||
it('should interpolate objectMetadataItem.labelPlural', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelPlural: 'companies' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Delete ${objectMetadataItem.labelPlural}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Delete companies');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple template variables', () => {
|
||||
it('should interpolate multiple variables in one label', () => {
|
||||
const context = buildContext({
|
||||
numberOfSelectedRecords: 3,
|
||||
objectMetadataItem: { labelPlural: 'people' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label:
|
||||
'${numberOfSelectedRecords} ${objectMetadataItem.labelPlural} selected',
|
||||
context,
|
||||
}),
|
||||
).toBe('3 people selected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested property access', () => {
|
||||
it('should resolve deeply nested properties', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: {
|
||||
labelSingular: 'opportunity',
|
||||
nameSingular: 'opportunity',
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'New ${objectMetadataItem.nameSingular}',
|
||||
context,
|
||||
}),
|
||||
).toBe('New opportunity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing context values', () => {
|
||||
it('should return empty string for undefined nested property', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'New ${objectMetadataItem.labelSingular}',
|
||||
context,
|
||||
}),
|
||||
).toBe('New ');
|
||||
});
|
||||
|
||||
it('should return empty string for empty objectMetadataItem', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Create new ${objectMetadataItem.labelSingular}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Create new ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unresolvable property paths', () => {
|
||||
it('should return empty string for a path that cannot be resolved', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Label ${nonExistent.deep.path}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Label ');
|
||||
});
|
||||
|
||||
it('should not resolve inherited Object.prototype members', () => {
|
||||
const context = buildContext();
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Val: ${toString}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Val: ');
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Val: ${objectMetadataItem.hasOwnProperty}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Val: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean context values', () => {
|
||||
it('should stringify boolean values', () => {
|
||||
const context = buildContext({ isInSidePanel: true });
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Side panel: ${isInSidePanel}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Side panel: true');
|
||||
});
|
||||
|
||||
it('should pass through string values as-is without forced lowercasing', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelSingular: 'Person' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Create new ${objectMetadataItem.labelSingular}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Create new Person');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capitalize transform', () => {
|
||||
it('should capitalize the first character of a resolved value', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelSingular: 'person' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: '${capitalize(objectMetadataItem.labelSingular)} details',
|
||||
context,
|
||||
}),
|
||||
).toBe('Person details');
|
||||
});
|
||||
|
||||
it('should capitalize a mixed-case value', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelPlural: 'companyRecords' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: '${capitalize(objectMetadataItem.labelPlural)} selected',
|
||||
context,
|
||||
}),
|
||||
).toBe('CompanyRecords selected');
|
||||
});
|
||||
|
||||
it('should return empty string when the nested property is undefined', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: '${capitalize(objectMetadataItem.labelSingular)} details',
|
||||
context,
|
||||
}),
|
||||
).toBe(' details');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lowercase transform', () => {
|
||||
it('should lowercase the resolved value', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelSingular: 'Person' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Create ${lowercase(objectMetadataItem.labelSingular)}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Create person');
|
||||
});
|
||||
|
||||
it('should lowercase all characters', () => {
|
||||
const context = buildContext({
|
||||
objectMetadataItem: { labelPlural: 'People' },
|
||||
});
|
||||
|
||||
expect(
|
||||
interpolateCommandMenuItemLabel({
|
||||
label: 'Delete ${lowercase(objectMetadataItem.labelPlural)}',
|
||||
context,
|
||||
}),
|
||||
).toBe('Delete people');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
|
||||
import { Parser } from 'expr-eval-fork';
|
||||
|
||||
import { isDefined } from '../validation/isDefined';
|
||||
|
||||
import { safeGetNestedProperty } from './safeGetNestedProperty';
|
||||
|
||||
type ArrayMethod = 'every' | 'some';
|
||||
|
||||
const createArrayPropCheck = (
|
||||
method: ArrayMethod,
|
||||
predicate: (value: unknown) => boolean,
|
||||
) => {
|
||||
return (array: unknown, prop: string) => {
|
||||
if (!isNonEmptyArray(array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array[method]((item) =>
|
||||
predicate(safeGetNestedProperty(item, prop)),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createArrayPropValueCheck = (
|
||||
method: ArrayMethod,
|
||||
predicate: (value: unknown, target: unknown) => boolean,
|
||||
) => {
|
||||
return (array: unknown, prop: string, value: unknown) => {
|
||||
if (!isNonEmptyArray(array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array[method]((item) =>
|
||||
predicate(safeGetNestedProperty(item, prop), value),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const conditionalAvailabilityParser = new Parser();
|
||||
|
||||
conditionalAvailabilityParser.functions.isDefined = (value: unknown) =>
|
||||
isDefined(value);
|
||||
|
||||
conditionalAvailabilityParser.functions.isNonEmptyString = (value: unknown) =>
|
||||
isNonEmptyString(value);
|
||||
|
||||
conditionalAvailabilityParser.functions.includes = (
|
||||
array: unknown,
|
||||
value: unknown,
|
||||
) => Array.isArray(array) && array.includes(value);
|
||||
|
||||
conditionalAvailabilityParser.functions.arrayLength = (value: unknown) =>
|
||||
Array.isArray(value) ? value.length : 0;
|
||||
|
||||
conditionalAvailabilityParser.functions.every = createArrayPropCheck(
|
||||
'every',
|
||||
Boolean,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.everyDefined = createArrayPropCheck(
|
||||
'every',
|
||||
isDefined,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.some = createArrayPropCheck(
|
||||
'some',
|
||||
Boolean,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.someDefined = createArrayPropCheck(
|
||||
'some',
|
||||
isDefined,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.someNonEmptyString =
|
||||
createArrayPropCheck('some', isNonEmptyString);
|
||||
|
||||
conditionalAvailabilityParser.functions.none = createArrayPropCheck(
|
||||
'every',
|
||||
(value) => !Boolean(value),
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.noneDefined = createArrayPropCheck(
|
||||
'every',
|
||||
(value) => !isDefined(value),
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.everyEquals = createArrayPropValueCheck(
|
||||
'every',
|
||||
(a, b) => a === b,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.someEquals = createArrayPropValueCheck(
|
||||
'some',
|
||||
(a, b) => a === b,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.noneEquals = createArrayPropValueCheck(
|
||||
'every',
|
||||
(a, b) => a !== b,
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.includesEvery =
|
||||
createArrayPropValueCheck(
|
||||
'every',
|
||||
(array, value) => Array.isArray(array) && array.includes(value),
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.includesSome =
|
||||
createArrayPropValueCheck(
|
||||
'some',
|
||||
(array, value) => Array.isArray(array) && array.includes(value),
|
||||
);
|
||||
|
||||
conditionalAvailabilityParser.functions.includesNone =
|
||||
createArrayPropValueCheck(
|
||||
'every',
|
||||
(array, value) => Array.isArray(array) && !array.includes(value),
|
||||
);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type EvaluationContext } from 'expr-eval-fork';
|
||||
|
||||
import { conditionalAvailabilityParser } from './conditionalAvailabilityParser';
|
||||
|
||||
export const evaluateConditionalAvailabilityExpression = (
|
||||
expression: string | null | undefined,
|
||||
context: EvaluationContext,
|
||||
): boolean => {
|
||||
if (!isNonEmptyString(expression)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = conditionalAvailabilityParser.parse(expression);
|
||||
|
||||
return parsed.evaluate(context) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { type Nullable } from '@/types';
|
||||
|
||||
import { capitalize } from '../strings/capitalize';
|
||||
import { isDefined } from '../validation/isDefined';
|
||||
|
||||
import { safeGetNestedProperty } from './safeGetNestedProperty';
|
||||
|
||||
const TEMPLATE_VARIABLE_REGEX = /\$\{([^{}]+)\}/g;
|
||||
const HAS_TEMPLATE_VARIABLE_REGEX = /\$\{[^{}]+\}/;
|
||||
const TRANSFORM_FUNCTION_CALL_REGEX = /^(\w+)\((.+)\)$/;
|
||||
|
||||
const LABEL_TRANSFORM_FUNCTIONS: Record<string, (value: string) => string> = {
|
||||
capitalize,
|
||||
lowercase: (value: string) => value.toLowerCase(),
|
||||
};
|
||||
|
||||
const resolveTemplateExpression = ({
|
||||
expression,
|
||||
context,
|
||||
}: {
|
||||
expression: string;
|
||||
context: Record<string, unknown>;
|
||||
}): string => {
|
||||
const trimmedExpression = expression.trim();
|
||||
const transformFunctionMatch = trimmedExpression.match(
|
||||
TRANSFORM_FUNCTION_CALL_REGEX,
|
||||
);
|
||||
|
||||
const expressionToEvaluate = transformFunctionMatch
|
||||
? transformFunctionMatch[2].trim()
|
||||
: trimmedExpression;
|
||||
|
||||
const transformFunction = transformFunctionMatch
|
||||
? LABEL_TRANSFORM_FUNCTIONS[transformFunctionMatch[1]]
|
||||
: undefined;
|
||||
|
||||
const resolvedPropertyValue = safeGetNestedProperty(
|
||||
context,
|
||||
expressionToEvaluate,
|
||||
);
|
||||
|
||||
if (!isDefined(resolvedPropertyValue)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stringValue = String(resolvedPropertyValue);
|
||||
|
||||
return isDefined(transformFunction)
|
||||
? transformFunction(stringValue)
|
||||
: stringValue;
|
||||
};
|
||||
|
||||
export const interpolateCommandMenuItemLabel = ({
|
||||
label,
|
||||
context,
|
||||
}: {
|
||||
label: Nullable<string>;
|
||||
context: Record<string, unknown>;
|
||||
}): Nullable<string> => {
|
||||
if (!isDefined(label)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!HAS_TEMPLATE_VARIABLE_REGEX.test(label)) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return label.replace(TEMPLATE_VARIABLE_REGEX, (match, expression: string) => {
|
||||
try {
|
||||
return resolveTemplateExpression({ expression, context });
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
|
||||
import { isDefined } from '../validation/isDefined';
|
||||
|
||||
const BLOCKED_PROPERTY_NAMES = new Set([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
]);
|
||||
|
||||
export const safeGetNestedProperty = (
|
||||
objectToEvaluate: unknown,
|
||||
path: string,
|
||||
): unknown => {
|
||||
if (!isString(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = path.split('.');
|
||||
|
||||
let currentObject: unknown = objectToEvaluate;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isDefined(currentObject) || !isObject(currentObject)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
BLOCKED_PROPERTY_NAMES.has(part) ||
|
||||
!Object.prototype.hasOwnProperty.call(currentObject, part)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
currentObject = (currentObject as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return currentObject;
|
||||
};
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
isNonEmptyArray,
|
||||
isNonEmptyString,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@sniptt/guards';
|
||||
import { type EvaluationContext, Parser } from 'expr-eval-fork';
|
||||
|
||||
import { isDefined } from '../validation/isDefined';
|
||||
|
||||
const BLOCKED_PROPERTY_NAMES = new Set([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
]);
|
||||
|
||||
const safeGetNestedProperty = (
|
||||
objectToEvaluate: unknown,
|
||||
path: string,
|
||||
): unknown => {
|
||||
if (!isString(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = path.split('.');
|
||||
|
||||
let currentObject: unknown = objectToEvaluate;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isDefined(currentObject) || !isObject(currentObject)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (BLOCKED_PROPERTY_NAMES.has(part)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
currentObject = (currentObject as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return currentObject;
|
||||
};
|
||||
|
||||
type ArrayMethod = 'every' | 'some';
|
||||
|
||||
const createArrayPropCheck = (
|
||||
method: ArrayMethod,
|
||||
predicate: (value: unknown) => boolean,
|
||||
) => {
|
||||
return (array: unknown, prop: string) => {
|
||||
if (!isNonEmptyArray(array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array[method]((item) =>
|
||||
predicate(safeGetNestedProperty(item, prop)),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createArrayPropValueCheck = (
|
||||
method: ArrayMethod,
|
||||
predicate: (value: unknown, target: unknown) => boolean,
|
||||
) => {
|
||||
return (array: unknown, prop: string, value: unknown) => {
|
||||
if (!isNonEmptyArray(array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array[method]((item) =>
|
||||
predicate(safeGetNestedProperty(item, prop), value),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
parser.functions.isDefined = (value: unknown) => isDefined(value);
|
||||
parser.functions.isNonEmptyString = (value: unknown) => isNonEmptyString(value);
|
||||
parser.functions.includes = (array: unknown, value: unknown) =>
|
||||
Array.isArray(array) && array.includes(value);
|
||||
parser.functions.arrayLength = (value: unknown) =>
|
||||
Array.isArray(value) ? value.length : 0;
|
||||
|
||||
parser.functions.every = createArrayPropCheck('every', Boolean);
|
||||
parser.functions.everyDefined = createArrayPropCheck('every', isDefined);
|
||||
parser.functions.some = createArrayPropCheck('some', Boolean);
|
||||
parser.functions.someDefined = createArrayPropCheck('some', isDefined);
|
||||
parser.functions.someNonEmptyString = createArrayPropCheck(
|
||||
'some',
|
||||
isNonEmptyString,
|
||||
);
|
||||
parser.functions.none = createArrayPropCheck(
|
||||
'every',
|
||||
(value) => !Boolean(value),
|
||||
);
|
||||
parser.functions.noneDefined = createArrayPropCheck(
|
||||
'every',
|
||||
(value) => !isDefined(value),
|
||||
);
|
||||
|
||||
parser.functions.everyEquals = createArrayPropValueCheck(
|
||||
'every',
|
||||
(a, b) => a === b,
|
||||
);
|
||||
parser.functions.someEquals = createArrayPropValueCheck(
|
||||
'some',
|
||||
(a, b) => a === b,
|
||||
);
|
||||
parser.functions.noneEquals = createArrayPropValueCheck(
|
||||
'every',
|
||||
(a, b) => a !== b,
|
||||
);
|
||||
|
||||
parser.functions.includesEvery = createArrayPropValueCheck(
|
||||
'every',
|
||||
(array, value) => Array.isArray(array) && array.includes(value),
|
||||
);
|
||||
parser.functions.includesSome = createArrayPropValueCheck(
|
||||
'some',
|
||||
(array, value) => Array.isArray(array) && array.includes(value),
|
||||
);
|
||||
parser.functions.includesNone = createArrayPropValueCheck(
|
||||
'every',
|
||||
(array, value) => Array.isArray(array) && !array.includes(value),
|
||||
);
|
||||
|
||||
export const evaluateConditionalAvailabilityExpression = (
|
||||
expression: string | null | undefined,
|
||||
context: EvaluationContext,
|
||||
): boolean => {
|
||||
if (!isNonEmptyString(expression)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parser.parse(expression);
|
||||
|
||||
return parsed.evaluate(context) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -23,8 +23,11 @@ export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfOb
|
||||
export { upsertPropertiesOfItemIntoArrayOfObjectsComparingId } from './array/upsertPropertiesOfItemIntoArrayOfObjectsComparingId';
|
||||
export { assertUnreachable } from './assertUnreachable';
|
||||
export { base64UrlEncode } from './base64UrlEncode';
|
||||
export { conditionalAvailabilityParser } from './command-menu-items/conditionalAvailabilityParser';
|
||||
export { evaluateConditionalAvailabilityExpression } from './command-menu-items/evaluateConditionalAvailabilityExpression';
|
||||
export { interpolateCommandMenuItemLabel } from './command-menu-items/interpolateCommandMenuItemLabel';
|
||||
export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty';
|
||||
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
|
||||
export { evaluateConditionalAvailabilityExpression } from './conditional-availability/evaluateConditionalAvailabilityExpression';
|
||||
export { isPlainDateAfter } from './date/isPlainDateAfter';
|
||||
export { isPlainDateBefore } from './date/isPlainDateBefore';
|
||||
export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';
|
||||
|
||||
Reference in New Issue
Block a user