Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6956ead47 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -20,6 +20,9 @@ services:
|
||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||
|
||||
LOGIC_FUNCTION_TYPE: ${LOGIC_FUNCTION_TYPE}
|
||||
CODE_INTERPRETER_TYPE: ${CODE_INTERPRETER_TYPE}
|
||||
|
||||
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
|
||||
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
|
||||
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
|
||||
@@ -74,6 +77,9 @@ services:
|
||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||
|
||||
LOGIC_FUNCTION_TYPE: ${LOGIC_FUNCTION_TYPE}
|
||||
CODE_INTERPRETER_TYPE: ${CODE_INTERPRETER_TYPE}
|
||||
|
||||
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
|
||||
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
|
||||
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
|
||||
|
||||
+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: Nullable<MessageDescriptor | string>;
|
||||
label: MessageDescriptor | string;
|
||||
shortLabel?: Nullable<MessageDescriptor | string>;
|
||||
description?: MessageDescriptor | string;
|
||||
Icon: IconComponent;
|
||||
|
||||
+4
-2
@@ -42,7 +42,9 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
|
||||
getCommandMenuItemLabel(
|
||||
addToFavoritesCommandMenuItem?.shortLabel ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -57,7 +59,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');
|
||||
|
||||
+3
-1
@@ -60,7 +60,9 @@ export const Default: Story = {
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
|
||||
getCommandMenuItemLabel(
|
||||
addToFavoritesCommandMenuItem?.shortLabel ?? '',
|
||||
),
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
|
||||
+5
-3
@@ -65,7 +65,9 @@ export const AsButton: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.shortLabel),
|
||||
getCommandMenuItemLabel(
|
||||
addToFavoritesCommandMenuItem?.shortLabel ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -101,7 +103,7 @@ export const AsListItem: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
await canvas.findByText(
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label),
|
||||
getCommandMenuItemLabel(addToFavoritesCommandMenuItem?.label ?? ''),
|
||||
),
|
||||
);
|
||||
expect(addToFavoritesMock).toHaveBeenCalled();
|
||||
@@ -137,7 +139,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();
|
||||
},
|
||||
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
|
||||
import { Command } from '@/command-menu-item/display/components/Command';
|
||||
import { CommandConfigContext } from '@/command-menu-item/contexts/CommandConfigContext';
|
||||
import { computeProgressText } from '@/command-menu-item/utils/computeProgressText';
|
||||
import { getCommandMenuItemLabel } from '@/command-menu-item/utils/getCommandMenuItemLabel';
|
||||
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
|
||||
@@ -78,7 +78,9 @@ export const DeleteMultipleRecordsCommand = () => {
|
||||
|
||||
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
|
||||
|
||||
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
|
||||
const originalShortLabel = getCommandMenuItemLabel(
|
||||
actionConfig.shortLabel ?? '',
|
||||
);
|
||||
|
||||
const progressText = computeProgressText(progress);
|
||||
|
||||
|
||||
+3
-1
@@ -85,7 +85,9 @@ export const DestroyMultipleRecordsCommand = () => {
|
||||
|
||||
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
|
||||
|
||||
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
|
||||
const originalShortLabel = getCommandMenuItemLabel(
|
||||
actionConfig.shortLabel ?? '',
|
||||
);
|
||||
|
||||
const progressText = computeProgressText(progress);
|
||||
|
||||
|
||||
+3
-1
@@ -49,7 +49,9 @@ export const ExportMultipleRecordsCommand = () => {
|
||||
|
||||
const originalLabel = getCommandMenuItemLabel(actionConfig.label);
|
||||
|
||||
const originalShortLabel = getCommandMenuItemLabel(actionConfig.shortLabel);
|
||||
const originalShortLabel = getCommandMenuItemLabel(
|
||||
actionConfig.shortLabel ?? '',
|
||||
);
|
||||
|
||||
const progressText = computeProgressText(exportProgress);
|
||||
|
||||
|
||||
+5
-20
@@ -16,7 +16,6 @@ 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';
|
||||
@@ -74,15 +73,7 @@ const buildCommandMenuItemFromFrontComponent = ({
|
||||
mountContext,
|
||||
commandMenuContextApi,
|
||||
}: BuildCommandMenuItemFromFrontComponentParams) => {
|
||||
const displayLabel = interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
|
||||
const displayShortLabel = interpolateCommandMenuItemLabel({
|
||||
label: item.shortLabel,
|
||||
context: commandMenuContextApi,
|
||||
});
|
||||
const displayLabel = item.label;
|
||||
|
||||
const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON);
|
||||
|
||||
@@ -94,7 +85,7 @@ const buildCommandMenuItemFromFrontComponent = ({
|
||||
} else {
|
||||
openFrontComponentInSidePanel({
|
||||
frontComponentId: item.frontComponentId,
|
||||
pageTitle: displayLabel ?? '',
|
||||
pageTitle: displayLabel,
|
||||
pageIcon: Icon,
|
||||
recordContext: isDefined(mountContext)
|
||||
? {
|
||||
@@ -111,7 +102,7 @@ const buildCommandMenuItemFromFrontComponent = ({
|
||||
key: `command-menu-item-front-component-${item.id}`,
|
||||
scope,
|
||||
label: displayLabel,
|
||||
shortLabel: displayShortLabel,
|
||||
shortLabel: item.shortLabel,
|
||||
position: item.position,
|
||||
isPinned,
|
||||
Icon,
|
||||
@@ -169,14 +160,8 @@ const buildCommandItemFromEngineKey = ({
|
||||
type,
|
||||
key: `command-menu-item-engine-${item.id}`,
|
||||
scope,
|
||||
label: interpolateCommandMenuItemLabel({
|
||||
label: item.label,
|
||||
context: commandMenuContextApi,
|
||||
}),
|
||||
shortLabel: interpolateCommandMenuItemLabel({
|
||||
label: item.shortLabel,
|
||||
context: commandMenuContextApi,
|
||||
}),
|
||||
label: item.label,
|
||||
shortLabel: item.shortLabel,
|
||||
position: item.position,
|
||||
isPinned,
|
||||
Icon,
|
||||
|
||||
@@ -14,7 +14,7 @@ export type CommandMenuItemConfig = {
|
||||
type: CommandMenuItemType;
|
||||
scope: CommandMenuItemScope;
|
||||
key: string;
|
||||
label: Nullable<MessageDescriptor | string>;
|
||||
label: MessageDescriptor | string;
|
||||
shortLabel?: Nullable<MessageDescriptor | string>;
|
||||
description?: MessageDescriptor | string;
|
||||
position: number;
|
||||
|
||||
+1
-7
@@ -1,14 +1,8 @@
|
||||
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: Nullable<string | MessageDescriptor>,
|
||||
label: 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 { type MessageDescriptor } from '@lingui/core';
|
||||
import { i18n, type MessageDescriptor } from '@lingui/core';
|
||||
import { isString } from '@sniptt/guards';
|
||||
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: Nullable<string | MessageDescriptor>;
|
||||
label: string | MessageDescriptor;
|
||||
shortLabel?: Nullable<string | MessageDescriptor>;
|
||||
Icon: IconComponent;
|
||||
isPrimaryCTA?: boolean;
|
||||
@@ -30,16 +30,22 @@ 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 = getCommandMenuItemLabel(command.label);
|
||||
const resolvedLabel = getCommandMenuButtonLabel(command.label);
|
||||
|
||||
const resolvedShortLabel = isDefined(command.shortLabel)
|
||||
? getCommandMenuItemLabel(command.shortLabel)
|
||||
? getCommandMenuButtonLabel(command.shortLabel)
|
||||
: undefined;
|
||||
|
||||
const buttonAccent = command.isPrimaryCTA ? 'blue' : 'default';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FIND_MINIMAL_METADATA } from '@/metadata-store/graphql/queries/findMinimalMetadata';
|
||||
import {
|
||||
ALL_METADATA_ENTITY_KEYS,
|
||||
metadataStoreState,
|
||||
type MetadataEntityKey,
|
||||
} from '@/metadata-store/states/metadataStoreState';
|
||||
@@ -32,8 +31,6 @@ export const useLoadMinimalMetadata = () => {
|
||||
|
||||
const staleEntityKeys: MetadataEntityKey[] = [];
|
||||
|
||||
const entityKeysWithServerHash = new Set<MetadataEntityKey>();
|
||||
|
||||
if (isDefined(collectionHashes)) {
|
||||
for (const { collectionName, hash } of collectionHashes) {
|
||||
const entityKey = mapAllMetadataNameToEntityKey(collectionName);
|
||||
@@ -42,8 +39,6 @@ export const useLoadMinimalMetadata = () => {
|
||||
continue;
|
||||
}
|
||||
|
||||
entityKeysWithServerHash.add(entityKey);
|
||||
|
||||
const entry = store.get(metadataStoreState.atomFamily(entityKey));
|
||||
|
||||
if (entry.currentCollectionHash !== hash) {
|
||||
@@ -57,15 +52,6 @@ export const useLoadMinimalMetadata = () => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const entityKey of ALL_METADATA_ENTITY_KEYS) {
|
||||
if (
|
||||
!entityKeysWithServerHash.has(entityKey) &&
|
||||
!staleEntityKeys.includes(entityKey)
|
||||
) {
|
||||
staleEntityKeys.push(entityKey);
|
||||
}
|
||||
}
|
||||
|
||||
const objectsEntry = store.get(
|
||||
metadataStoreState.atomFamily('objectMetadataItems'),
|
||||
);
|
||||
|
||||
-1
@@ -54,7 +54,6 @@ export const useSaveNavigationMenuItemsDraft = () => {
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (objectMetadataItem.color === draftItem.color) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+3
-4
@@ -144,10 +144,9 @@ export const WorkflowEditActionEmailBase = ({
|
||||
(account) => account.id === formData.connectedAccountId,
|
||||
);
|
||||
|
||||
const missingDraftScopes =
|
||||
action.type === 'DRAFT_EMAIL' && isDefined(selectedAccount)
|
||||
? getMissingDraftEmailScopes(selectedAccount)
|
||||
: [];
|
||||
const missingDraftScopes = isDefined(selectedAccount)
|
||||
? getMissingDraftEmailScopes(selectedAccount)
|
||||
: [];
|
||||
|
||||
const missingScopes =
|
||||
isDefined(selectedAccount) &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.8.0-canary.0",
|
||||
"version": "0.7.0",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
|
||||
@@ -5,7 +5,6 @@ import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.
|
||||
import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command';
|
||||
import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command';
|
||||
import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question';
|
||||
import { WorkspaceExportModule } from 'src/database/commands/workspace-export/workspace-export.module';
|
||||
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
@@ -40,7 +39,6 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
imports: [
|
||||
UpgradeVersionCommandModule,
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
WorkspaceExportModule,
|
||||
// Cron command dependencies
|
||||
MessagingImportManagerModule,
|
||||
CalendarEventImportManagerModule,
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { makeNavigationMenuItemTypeNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1773681736596-makeNavigationMenuItemTypeNotNull.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-20:backfill-navigation-menu-item-type',
|
||||
description:
|
||||
'Backfill navigation menu item type based on existing columns, then apply NOT NULL and CHECK constraints',
|
||||
})
|
||||
export class BackfillNavigationMenuItemTypeCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
private hasRunOnce = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
@InjectDataSource()
|
||||
private readonly coreDataSource: DataSource,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
if (this.hasRunOnce) {
|
||||
this.logger.warn(
|
||||
'Skipping has already been run once BackfillNavigationMenuItemTypeCommand',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryRunner = this.coreDataSource.createQueryRunner();
|
||||
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
await this.backfillType(queryRunner);
|
||||
await this.cleanConflictingColumns(queryRunner);
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Rolling back BackfillNavigationMenuItemTypeCommand data backfill: ${error.message}`,
|
||||
);
|
||||
|
||||
await queryRunner.release();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
await makeNavigationMenuItemTypeNotNullQueries(queryRunner);
|
||||
await queryRunner.commitTransaction();
|
||||
this.logger.log('Successfully run BackfillNavigationMenuItemTypeCommand');
|
||||
this.hasRunOnce = true;
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.error(
|
||||
`Rolling back BackfillNavigationMenuItemTypeCommand schema changes: ${error.message}`,
|
||||
);
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async backfillType(
|
||||
queryRunner: ReturnType<DataSource['createQueryRunner']>,
|
||||
): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "type" = 'OBJECT' WHERE "type" = 'VIEW' AND "targetObjectMetadataId" IS NOT NULL AND "targetRecordId" IS NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "type" = 'RECORD' WHERE "type" IS NULL AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "type" = 'OBJECT' WHERE "type" IS NULL AND "targetObjectMetadataId" IS NOT NULL AND "targetRecordId" IS NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "type" = 'VIEW' WHERE "type" IS NULL AND "viewId" IS NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "type" = 'LINK' WHERE "type" IS NULL AND "link" IS NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "type" = 'FOLDER' WHERE "type" IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
private async cleanConflictingColumns(
|
||||
queryRunner: ReturnType<DataSource['createQueryRunner']>,
|
||||
): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "targetRecordId" = NULL, "targetObjectMetadataId" = NULL, "link" = NULL WHERE "type" = 'VIEW'`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "link" = NULL WHERE "type" = 'RECORD'`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "targetRecordId" = NULL, "link" = NULL WHERE "type" = 'OBJECT'`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "targetRecordId" = NULL, "targetObjectMetadataId" = NULL WHERE "type" = 'LINK'`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`UPDATE "core"."navigationMenuItem" SET "viewId" = NULL, "targetRecordId" = NULL, "targetObjectMetadataId" = NULL, "link" = NULL WHERE "type" = 'FOLDER'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-3
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
|
||||
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
|
||||
import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command';
|
||||
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
@@ -32,14 +31,12 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
],
|
||||
providers: [
|
||||
BackfillCommandMenuItemsCommand,
|
||||
BackfillNavigationMenuItemTypeCommand,
|
||||
BackfillPageLayoutsCommand,
|
||||
SeedCliApplicationRegistrationCommand,
|
||||
MigrateRichTextToTextCommand,
|
||||
],
|
||||
exports: [
|
||||
BackfillCommandMenuItemsCommand,
|
||||
BackfillNavigationMenuItemTypeCommand,
|
||||
BackfillPageLayoutsCommand,
|
||||
SeedCliApplicationRegistrationCommand,
|
||||
MigrateRichTextToTextCommand,
|
||||
|
||||
+1
-4
@@ -29,10 +29,9 @@ import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/command
|
||||
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command';
|
||||
import { AddMissingSystemFieldsToStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-add-missing-system-fields-to-standard-objects.command';
|
||||
import { BackfillMessageChannelMessageAssociationMessageFolderCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-message-channel-message-association-message-folder.command';
|
||||
import { BackfillMissingStandardViewsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-missing-standard-views.command';
|
||||
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
|
||||
import { BackfillSystemFieldsIsSystemCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-system-fields-is-system.command';
|
||||
import { FixInvalidStandardUniversalIdentifiersCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-fix-invalid-standard-universal-identifiers.command';
|
||||
import { BackfillMissingStandardViewsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-missing-standard-views.command';
|
||||
import { SeedServerIdCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-seed-server-id.command';
|
||||
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
|
||||
import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command';
|
||||
@@ -88,7 +87,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
protected readonly seedServerIdCommand: SeedServerIdCommand,
|
||||
|
||||
// 1.20 Commands
|
||||
protected readonly backfillNavigationMenuItemTypeCommand: BackfillNavigationMenuItemTypeCommand,
|
||||
protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand,
|
||||
protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand,
|
||||
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
|
||||
@@ -139,7 +137,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
];
|
||||
|
||||
const commands_1200: VersionCommands = [
|
||||
this.backfillNavigationMenuItemTypeCommand,
|
||||
this.migrateRichTextToTextCommand,
|
||||
this.backfillCommandMenuItemsCommand,
|
||||
this.backfillPageLayoutsCommand,
|
||||
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
import { formatSqlValue } from 'src/database/commands/workspace-export/utils/format-sql-value.util';
|
||||
|
||||
describe('formatSqlValue', () => {
|
||||
it('should return NULL for null and undefined', () => {
|
||||
expect(formatSqlValue(null)).toBe('NULL');
|
||||
expect(formatSqlValue(undefined)).toBe('NULL');
|
||||
});
|
||||
|
||||
it('should return unquoted TRUE/FALSE for booleans', () => {
|
||||
expect(formatSqlValue(true)).toBe('TRUE');
|
||||
expect(formatSqlValue(false)).toBe('FALSE');
|
||||
});
|
||||
|
||||
it('should return unquoted numbers', () => {
|
||||
expect(formatSqlValue(42)).toBe('42');
|
||||
expect(formatSqlValue(3.14)).toBe('3.14');
|
||||
expect(formatSqlValue(-1)).toBe('-1');
|
||||
expect(formatSqlValue(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('should return NULL for NaN and Infinity', () => {
|
||||
expect(formatSqlValue(NaN)).toBe('NULL');
|
||||
expect(formatSqlValue(Infinity)).toBe('NULL');
|
||||
expect(formatSqlValue(-Infinity)).toBe('NULL');
|
||||
});
|
||||
|
||||
it('should return unquoted bigint', () => {
|
||||
expect(formatSqlValue(BigInt(9007199254740991))).toBe('9007199254740991');
|
||||
});
|
||||
|
||||
it('should escape single quotes in strings', () => {
|
||||
expect(formatSqlValue("it's")).toBe("'it''s'");
|
||||
});
|
||||
|
||||
it('should handle backslashes with E-string prefix', () => {
|
||||
expect(formatSqlValue('path\\to\\file')).toBe("E'path\\\\to\\\\file'");
|
||||
});
|
||||
|
||||
it('should format dates as escaped ISO strings', () => {
|
||||
const date = new Date('2024-01-15T10:30:00.000Z');
|
||||
|
||||
expect(formatSqlValue(date)).toBe("'2024-01-15T10:30:00.000Z'");
|
||||
});
|
||||
|
||||
it('should JSON-serialize objects when isJsonColumn is true', () => {
|
||||
const value = { key: 'value' };
|
||||
|
||||
expect(formatSqlValue(value, true)).toBe('\'{"key":"value"}\'');
|
||||
});
|
||||
|
||||
it('should JSON-serialize plain objects even when isJsonColumn is false', () => {
|
||||
const value = { key: 'value' };
|
||||
|
||||
expect(formatSqlValue(value, false)).toBe('\'{"key":"value"}\'');
|
||||
});
|
||||
|
||||
it('should return empty PostgreSQL array literal for empty arrays', () => {
|
||||
expect(formatSqlValue([])).toBe("'{}'");
|
||||
});
|
||||
|
||||
it('should format string arrays as PostgreSQL array literals', () => {
|
||||
expect(formatSqlValue(['a', 'b', 'c'])).toBe('\'{"a","b","c"}\'');
|
||||
});
|
||||
|
||||
it('should escape single quotes in array elements', () => {
|
||||
expect(formatSqlValue(["O'Reilly"])).toBe("'{\"O''Reilly\"}'");
|
||||
});
|
||||
|
||||
it('should format arrays with null elements as PostgreSQL array literals', () => {
|
||||
expect(formatSqlValue([null, 'foo', 'bar'])).toBe('\'{NULL,"foo","bar"}\'');
|
||||
});
|
||||
|
||||
it('should JSON-serialize arrays of objects', () => {
|
||||
const value = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
expect(formatSqlValue(value)).toBe('\'[{"id":1},{"id":2}]\'');
|
||||
});
|
||||
|
||||
it('should throw on strings containing null bytes', () => {
|
||||
expect(() => formatSqlValue('hello\0world')).toThrow(
|
||||
'Null bytes are not allowed',
|
||||
);
|
||||
});
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
export const buildInsertPrefix = (
|
||||
schemaName: string,
|
||||
tableName: string,
|
||||
columnNames: string[],
|
||||
): string => {
|
||||
const escapedColumnNames = columnNames.map(escapeIdentifier).join(', ');
|
||||
|
||||
return `INSERT INTO ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} (${escapedColumnNames}) VALUES `;
|
||||
};
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util';
|
||||
|
||||
const JSON_COLUMN_TYPES = new Set(['json', 'jsonb']);
|
||||
|
||||
type WorkspaceTableColumnSets = {
|
||||
jsonColumns: Set<string>;
|
||||
generatedColumns: Set<string>;
|
||||
};
|
||||
|
||||
export const buildWorkspaceTableColumnSets = (
|
||||
workspaceId: string,
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
fieldMetadatas: FieldMetadataEntity[],
|
||||
): WorkspaceTableColumnSets => {
|
||||
const jsonColumns = new Set<string>();
|
||||
const generatedColumns = new Set<string>();
|
||||
|
||||
const flatObjectMetadata = objectMetadata as unknown as FlatObjectMetadata;
|
||||
|
||||
for (const fieldMetadata of fieldMetadatas) {
|
||||
const flatFieldMetadata = fieldMetadata as unknown as FlatFieldMetadata;
|
||||
|
||||
const columnDefinitions = generateColumnDefinitions({
|
||||
flatFieldMetadata,
|
||||
flatObjectMetadata,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
for (const columnDefinition of columnDefinitions) {
|
||||
if (JSON_COLUMN_TYPES.has(columnDefinition.type)) {
|
||||
jsonColumns.add(columnDefinition.name);
|
||||
}
|
||||
|
||||
if (columnDefinition.type === 'tsvector') {
|
||||
generatedColumns.add(columnDefinition.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { jsonColumns, generatedColumns };
|
||||
};
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { escapeLiteral } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
|
||||
export const formatSqlValue = (
|
||||
value: unknown,
|
||||
isJsonColumn = false,
|
||||
): string => {
|
||||
if (!isDefined(value)) return 'NULL';
|
||||
|
||||
if (isJsonColumn) {
|
||||
return escapeLiteral(JSON.stringify(value));
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return 'NULL';
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') return String(value);
|
||||
|
||||
if (value instanceof Date) return escapeLiteral(value.toISOString());
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "'{}'";
|
||||
|
||||
if (isDefined(value[0]) && typeof value[0] === 'object') {
|
||||
return escapeLiteral(JSON.stringify(value));
|
||||
}
|
||||
|
||||
const formattedElements = value.map((element) => {
|
||||
if (!isDefined(element)) return 'NULL';
|
||||
|
||||
const stringElement = String(element);
|
||||
const escapedElement = stringElement
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"');
|
||||
|
||||
return `"${escapedElement}"`;
|
||||
});
|
||||
|
||||
const arrayLiteral = `{${formattedElements.join(',')}}`;
|
||||
|
||||
return `'${arrayLiteral.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return escapeLiteral(JSON.stringify(value));
|
||||
}
|
||||
|
||||
return escapeLiteral(String(value));
|
||||
};
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
export const generateInsertStatement = (
|
||||
insertPrefix: string,
|
||||
formattedValues: string[],
|
||||
): string => `${insertPrefix}(${formattedValues.join(', ')});\n`;
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
|
||||
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
|
||||
import {
|
||||
escapeIdentifier,
|
||||
escapeLiteral,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { generateColumnDefinitions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/generate-column-definitions.util';
|
||||
import {
|
||||
type CreateEnumOperationSpec,
|
||||
EnumOperation,
|
||||
collectEnumOperationsForObject,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/workspace-schema-enum-operations.util';
|
||||
|
||||
export const generateWorkspaceSchemaDdl = (
|
||||
workspaceId: string,
|
||||
schemaName: string,
|
||||
objectMetadatas: ObjectMetadataEntity[],
|
||||
fieldsByObjectId: Map<string, FieldMetadataEntity[]>,
|
||||
): string[] => {
|
||||
const statements: string[] = [];
|
||||
|
||||
for (const objectMetadata of objectMetadatas) {
|
||||
if (!objectMetadata.isActive) continue;
|
||||
|
||||
const tableName = computeTableName(
|
||||
objectMetadata.nameSingular,
|
||||
objectMetadata.isCustom,
|
||||
);
|
||||
const fieldMetadatas = fieldsByObjectId.get(objectMetadata.id) ?? [];
|
||||
|
||||
const flatFieldMetadatas = fieldMetadatas as unknown as FlatFieldMetadata[];
|
||||
const flatObjectMetadata = objectMetadata as unknown as FlatObjectMetadata;
|
||||
|
||||
const enumOperations = collectEnumOperationsForObject({
|
||||
tableName,
|
||||
operation: EnumOperation.CREATE,
|
||||
flatFieldMetadatas,
|
||||
});
|
||||
|
||||
for (const enumOperation of enumOperations) {
|
||||
const createOp = enumOperation as CreateEnumOperationSpec;
|
||||
const escapedValues = createOp.values.map(escapeLiteral).join(', ');
|
||||
|
||||
statements.push(
|
||||
`CREATE TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(createOp.enumName)} AS ENUM (${escapedValues});`,
|
||||
);
|
||||
}
|
||||
|
||||
const columnDefinitions = flatFieldMetadatas.flatMap((flatFieldMetadata) =>
|
||||
generateColumnDefinitions({
|
||||
flatFieldMetadata,
|
||||
flatObjectMetadata,
|
||||
workspaceId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (columnDefinitions.length === 0) continue;
|
||||
|
||||
const columnsSql = columnDefinitions
|
||||
.map(
|
||||
(columnDefinition) => ` ${buildSqlColumnDefinition(columnDefinition)}`,
|
||||
)
|
||||
.join(',\n');
|
||||
|
||||
statements.push(
|
||||
`CREATE TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} (\n${columnsSql}\n);`,
|
||||
);
|
||||
}
|
||||
|
||||
return statements;
|
||||
};
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export const getCoreEntityMetadatasWithWorkspaceId = (
|
||||
dataSource: DataSource,
|
||||
) => {
|
||||
return dataSource.entityMetadatas.filter((entityMetadata) =>
|
||||
entityMetadata.columns.some(
|
||||
(column) => column.propertyName === 'workspaceId',
|
||||
),
|
||||
);
|
||||
};
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { Command, CommandRunner, Option } from 'nest-commander';
|
||||
|
||||
import { WorkspaceExportService } from 'src/database/commands/workspace-export/workspace-export.service';
|
||||
|
||||
type WorkspaceExportCommandOptions = {
|
||||
workspaceId: string;
|
||||
outputPath: string;
|
||||
tables?: string;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'workspace:export',
|
||||
description: 'Export a workspace as SQL INSERT statements',
|
||||
})
|
||||
export class WorkspaceExportCommand extends CommandRunner {
|
||||
private readonly logger = new Logger(WorkspaceExportCommand.name);
|
||||
|
||||
constructor(private readonly workspaceExportService: WorkspaceExportService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--workspace-id <workspaceId>',
|
||||
description: 'Workspace UUID to export',
|
||||
required: true,
|
||||
})
|
||||
parseWorkspaceId(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--output-path <outputPath>',
|
||||
description: 'Directory to write the .sql file',
|
||||
defaultValue: '/tmp/exports',
|
||||
})
|
||||
parseOutputPath(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--tables <tables>',
|
||||
description:
|
||||
'Comma-separated workspace table names to export (uses nameSingular from ObjectMetadata)',
|
||||
})
|
||||
parseTables(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
async run(
|
||||
_passedParams: string[],
|
||||
options: WorkspaceExportCommandOptions,
|
||||
): Promise<void> {
|
||||
const tableFilter = options.tables?.split(',').map((table) => table.trim());
|
||||
|
||||
try {
|
||||
const filePath = await this.workspaceExportService.exportWorkspace({
|
||||
workspaceId: options.workspaceId,
|
||||
outputPath: options.outputPath,
|
||||
tableFilter,
|
||||
});
|
||||
|
||||
this.logger.log(`Export complete: ${filePath}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Export failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceExportCommand } from 'src/database/commands/workspace-export/workspace-export.command';
|
||||
import { WorkspaceExportService } from 'src/database/commands/workspace-export/workspace-export.service';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ObjectMetadataEntity, FieldMetadataEntity]),
|
||||
],
|
||||
providers: [WorkspaceExportCommand, WorkspaceExportService],
|
||||
})
|
||||
export class WorkspaceExportModule {}
|
||||
-325
@@ -1,325 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { once } from 'events';
|
||||
import { type WriteStream, createWriteStream, mkdirSync } from 'fs';
|
||||
import { finished } from 'stream/promises';
|
||||
|
||||
import {
|
||||
DataSource,
|
||||
type EntityMetadata,
|
||||
type QueryRunner,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
|
||||
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
|
||||
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
|
||||
import { getCoreEntityMetadatasWithWorkspaceId } from 'src/database/commands/workspace-export/utils/get-core-entity-metadatas-with-workspace-id.util';
|
||||
import { generateWorkspaceSchemaDdl } from 'src/database/commands/workspace-export/utils/generate-workspace-schema-ddl.util';
|
||||
import { buildInsertPrefix } from 'src/database/commands/workspace-export/utils/build-insert-prefix.util';
|
||||
import { buildWorkspaceTableColumnSets } from 'src/database/commands/workspace-export/utils/build-workspace-table-column-sets.util';
|
||||
import { formatSqlValue } from 'src/database/commands/workspace-export/utils/format-sql-value.util';
|
||||
import { generateInsertStatement } from 'src/database/commands/workspace-export/utils/generate-insert-statement.util';
|
||||
|
||||
const BATCH_SIZE = 5000;
|
||||
|
||||
type WorkspaceExportParams = {
|
||||
workspaceId: string;
|
||||
outputPath: string;
|
||||
tableFilter?: string[];
|
||||
};
|
||||
|
||||
type RowFilter = {
|
||||
filterColumn: string;
|
||||
filterValue: string;
|
||||
};
|
||||
|
||||
type WriteRowsOptions = {
|
||||
schemaName: string;
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
queryRunner: QueryRunner;
|
||||
stream: WriteStream;
|
||||
rowFilter?: RowFilter;
|
||||
jsonColumns?: Set<string>;
|
||||
excludedColumns?: Set<string>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceExportService {
|
||||
private readonly logger = new Logger(WorkspaceExportService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
@InjectRepository(ObjectMetadataEntity)
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
@InjectRepository(FieldMetadataEntity)
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
) {}
|
||||
|
||||
async exportWorkspace({
|
||||
workspaceId,
|
||||
outputPath,
|
||||
tableFilter,
|
||||
}: WorkspaceExportParams): Promise<string> {
|
||||
const workspace = await this.dataSource
|
||||
.getRepository(WorkspaceEntity)
|
||||
.findOne({ where: { id: workspaceId } });
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace ${workspaceId} not found`);
|
||||
}
|
||||
|
||||
const schemaName = getWorkspaceSchemaName(workspaceId);
|
||||
|
||||
this.logger.log(`Exporting workspace ${workspaceId} (${schemaName})`);
|
||||
|
||||
const objectMetadatas = await this.objectMetadataRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
const fieldMetadatas = await this.fieldMetadataRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
const fieldsByObjectId = new Map<string, FieldMetadataEntity[]>();
|
||||
|
||||
for (const fieldMetadata of fieldMetadatas) {
|
||||
const objectFields =
|
||||
fieldsByObjectId.get(fieldMetadata.objectMetadataId) ?? [];
|
||||
|
||||
objectFields.push(fieldMetadata);
|
||||
fieldsByObjectId.set(fieldMetadata.objectMetadataId, objectFields);
|
||||
}
|
||||
|
||||
mkdirSync(outputPath, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filePath = `${outputPath}/${workspaceId}-${timestamp}.sql`;
|
||||
const stream = createWriteStream(filePath);
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
|
||||
try {
|
||||
stream.write("SET session_replication_role = 'replica';\n\n");
|
||||
|
||||
await this.writeCoreEntityRows(workspaceId, queryRunner, stream);
|
||||
|
||||
stream.write(
|
||||
`\nCREATE SCHEMA IF NOT EXISTS ${escapeIdentifier(schemaName)};\n\n`,
|
||||
);
|
||||
|
||||
this.writeWorkspaceSchemaDdl(
|
||||
workspaceId,
|
||||
schemaName,
|
||||
objectMetadatas,
|
||||
fieldsByObjectId,
|
||||
stream,
|
||||
);
|
||||
|
||||
await this.writeWorkspaceDataRows(
|
||||
workspaceId,
|
||||
schemaName,
|
||||
objectMetadatas,
|
||||
fieldsByObjectId,
|
||||
tableFilter,
|
||||
queryRunner,
|
||||
stream,
|
||||
);
|
||||
|
||||
stream.write("\nSET session_replication_role = 'origin';\n");
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
stream.end();
|
||||
await finished(stream);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async writeCoreEntityRows(
|
||||
workspaceId: string,
|
||||
queryRunner: QueryRunner,
|
||||
stream: WriteStream,
|
||||
): Promise<void> {
|
||||
const workspaceEntityMetadata = this.dataSource.entityMetadatas.find(
|
||||
(entityMetadata) => entityMetadata.tableName === 'workspace',
|
||||
);
|
||||
|
||||
if (workspaceEntityMetadata) {
|
||||
await this.writeRows({
|
||||
schemaName: workspaceEntityMetadata.schema || 'core',
|
||||
tableName: workspaceEntityMetadata.tableName,
|
||||
displayName: workspaceEntityMetadata.tableName,
|
||||
queryRunner,
|
||||
stream,
|
||||
rowFilter: { filterColumn: 'id', filterValue: workspaceId },
|
||||
jsonColumns: this.buildJsonColumnSet(workspaceEntityMetadata),
|
||||
});
|
||||
}
|
||||
|
||||
const coreEntityMetadatas = getCoreEntityMetadatasWithWorkspaceId(
|
||||
this.dataSource,
|
||||
);
|
||||
|
||||
for (const entityMetadata of coreEntityMetadatas) {
|
||||
try {
|
||||
await this.writeRows({
|
||||
schemaName: entityMetadata.schema || 'core',
|
||||
tableName: entityMetadata.tableName,
|
||||
displayName: entityMetadata.tableName,
|
||||
queryRunner,
|
||||
stream,
|
||||
rowFilter: {
|
||||
filterColumn: 'workspaceId',
|
||||
filterValue: workspaceId,
|
||||
},
|
||||
jsonColumns: this.buildJsonColumnSet(entityMetadata),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`${entityMetadata.tableName}: skipped`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildJsonColumnSet(entityMetadata: EntityMetadata): Set<string> {
|
||||
return new Set(
|
||||
entityMetadata.columns
|
||||
.filter((column) => column.type === 'jsonb' || column.type === 'json')
|
||||
.map((column) => column.databaseName),
|
||||
);
|
||||
}
|
||||
|
||||
private async writeRows({
|
||||
schemaName,
|
||||
tableName,
|
||||
displayName,
|
||||
queryRunner,
|
||||
stream,
|
||||
rowFilter,
|
||||
jsonColumns,
|
||||
excludedColumns,
|
||||
}: WriteRowsOptions): Promise<void> {
|
||||
const whereClause = rowFilter
|
||||
? ` WHERE "${rowFilter.filterColumn}" = $1`
|
||||
: '';
|
||||
const queryParameters = rowFilter ? [rowFilter.filterValue] : [];
|
||||
|
||||
const [{ count: totalCount }] = await queryRunner.query(
|
||||
`SELECT COUNT(*)::int as count FROM "${schemaName}"."${tableName}"${whereClause}`,
|
||||
queryParameters,
|
||||
);
|
||||
|
||||
if (totalCount === 0) return;
|
||||
|
||||
this.logger.log(` ${displayName}: ${totalCount} rows`);
|
||||
|
||||
let insertPrefix: string | undefined;
|
||||
|
||||
for (let offset = 0; offset < totalCount; offset += BATCH_SIZE) {
|
||||
const rows: Record<string, unknown>[] = await queryRunner.query(
|
||||
`SELECT * FROM "${schemaName}"."${tableName}"${whereClause} ORDER BY "id" LIMIT ${BATCH_SIZE} OFFSET ${offset}`,
|
||||
queryParameters,
|
||||
);
|
||||
|
||||
const batchStatements: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const columnNames = Object.keys(row).filter(
|
||||
(columnName) => !excludedColumns?.has(columnName),
|
||||
);
|
||||
|
||||
if (!insertPrefix) {
|
||||
insertPrefix = buildInsertPrefix(schemaName, tableName, columnNames);
|
||||
}
|
||||
|
||||
const formattedValues = columnNames.map((columnName) =>
|
||||
formatSqlValue(row[columnName], jsonColumns?.has(columnName)),
|
||||
);
|
||||
|
||||
batchStatements.push(
|
||||
generateInsertStatement(insertPrefix, formattedValues),
|
||||
);
|
||||
}
|
||||
|
||||
if (!stream.write(batchStatements.join(''))) {
|
||||
await once(stream, 'drain');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private writeWorkspaceSchemaDdl(
|
||||
workspaceId: string,
|
||||
schemaName: string,
|
||||
objectMetadatas: ObjectMetadataEntity[],
|
||||
fieldsByObjectId: Map<string, FieldMetadataEntity[]>,
|
||||
stream: WriteStream,
|
||||
): void {
|
||||
this.logger.log('Generating workspace schema DDL from metadata...');
|
||||
|
||||
const ddlStatements = generateWorkspaceSchemaDdl(
|
||||
workspaceId,
|
||||
schemaName,
|
||||
objectMetadatas,
|
||||
fieldsByObjectId,
|
||||
);
|
||||
|
||||
this.logger.log(` ${ddlStatements.length} DDL statements`);
|
||||
|
||||
for (const statement of ddlStatements) {
|
||||
stream.write(statement + '\n');
|
||||
}
|
||||
|
||||
stream.write('\n');
|
||||
}
|
||||
|
||||
private async writeWorkspaceDataRows(
|
||||
workspaceId: string,
|
||||
schemaName: string,
|
||||
objectMetadatas: ObjectMetadataEntity[],
|
||||
fieldsByObjectId: Map<string, FieldMetadataEntity[]>,
|
||||
tableFilter: string[] | undefined,
|
||||
queryRunner: QueryRunner,
|
||||
stream: WriteStream,
|
||||
): Promise<void> {
|
||||
for (const objectMetadata of objectMetadatas) {
|
||||
if (!objectMetadata.isActive) continue;
|
||||
|
||||
const tableName = computeTableName(
|
||||
objectMetadata.nameSingular,
|
||||
objectMetadata.isCustom,
|
||||
);
|
||||
|
||||
if (tableFilter && !tableFilter.includes(objectMetadata.nameSingular)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectFieldMetadatas =
|
||||
fieldsByObjectId.get(objectMetadata.id) ?? [];
|
||||
|
||||
const { jsonColumns, generatedColumns } = buildWorkspaceTableColumnSets(
|
||||
workspaceId,
|
||||
objectMetadata,
|
||||
objectFieldMetadatas,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.writeRows({
|
||||
schemaName,
|
||||
tableName,
|
||||
displayName: objectMetadata.nameSingular,
|
||||
queryRunner,
|
||||
stream,
|
||||
jsonColumns,
|
||||
excludedColumns: generatedColumns,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`${objectMetadata.nameSingular}: skipped`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -11,15 +11,29 @@ export class AddTypeToNavigationMenuItem1773681736596
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" ADD "type" "core"."navigationMenuItem_type_enum"`,
|
||||
`ALTER TABLE "core"."navigationMenuItem" ADD "type" "core"."navigationMenuItem_type_enum" NOT NULL DEFAULT 'VIEW'`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_target_fields"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (
|
||||
("type" = 'FOLDER')
|
||||
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'VIEW')
|
||||
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'LINK' AND "link" IS NOT NULL)
|
||||
)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_type_fields"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_target_fields" CHECK (("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL))`,
|
||||
);
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
import { makeNavigationMenuItemTypeNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1773681736596-makeNavigationMenuItemTypeNotNull.util';
|
||||
|
||||
export class MakeNavigationMenuItemTypeNotNull1773822077682
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'MakeNavigationMenuItemTypeNotNull1773822077682';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const savepointName = 'sp_make_navigation_menu_item_type_not_null';
|
||||
|
||||
try {
|
||||
await queryRunner.query(`SAVEPOINT ${savepointName}`);
|
||||
|
||||
await makeNavigationMenuItemTypeNotNullQueries(queryRunner);
|
||||
|
||||
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
} catch (e) {
|
||||
try {
|
||||
await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
} catch (rollbackError) {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error(
|
||||
'Failed to rollback to savepoint in MakeNavigationMenuItemTypeNotNull1773822077682',
|
||||
rollbackError,
|
||||
);
|
||||
throw rollbackError;
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error(
|
||||
'Swallowing MakeNavigationMenuItemTypeNotNull1773822077682 error',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "CHK_navigation_menu_item_type_fields"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" DROP NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
export const makeNavigationMenuItemTypeNotNullQueries = async (
|
||||
queryRunner: QueryRunner,
|
||||
): Promise<void> => {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" ALTER COLUMN "type" SET NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "CHK_navigation_menu_item_type_fields" CHECK (
|
||||
("type" = 'FOLDER')
|
||||
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'VIEW' AND "viewId" IS NOT NULL)
|
||||
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'LINK' AND "link" IS NOT NULL)
|
||||
)`,
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
|
||||
universalIdentifier: commandMenuItemManifest.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
label: commandMenuItemManifest.label,
|
||||
shortLabel: commandMenuItemManifest.shortLabel ?? null,
|
||||
shortLabel: null,
|
||||
position: 0,
|
||||
icon: commandMenuItemManifest.icon ?? null,
|
||||
isPinned: commandMenuItemManifest.isPinned ?? false,
|
||||
|
||||
+18
-21
@@ -1,5 +1,4 @@
|
||||
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type TimelineThreadDTO } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
|
||||
import { extractParticipantSummary } from 'src/engine/core-modules/messaging/utils/extract-participant-summary.util';
|
||||
@@ -22,25 +21,23 @@ export const formatThreads = (
|
||||
[key: string]: MessageChannelVisibility;
|
||||
},
|
||||
): TimelineThreadDTO[] => {
|
||||
return threads
|
||||
.filter((thread) => isDefined(threadParticipantsByThreadId[thread.id]))
|
||||
.map((thread) => {
|
||||
const visibility = threadVisibilityByThreadId[thread.id];
|
||||
return threads.map((thread) => {
|
||||
const visibility = threadVisibilityByThreadId[thread.id];
|
||||
|
||||
return {
|
||||
...thread,
|
||||
subject:
|
||||
visibility === MessageChannelVisibility.SHARE_EVERYTHING ||
|
||||
visibility === MessageChannelVisibility.SUBJECT
|
||||
? thread.subject
|
||||
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
|
||||
lastMessageBody:
|
||||
visibility === MessageChannelVisibility.SHARE_EVERYTHING
|
||||
? thread.lastMessageBody
|
||||
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
|
||||
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
|
||||
visibility,
|
||||
read: true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...thread,
|
||||
subject:
|
||||
visibility === MessageChannelVisibility.SHARE_EVERYTHING ||
|
||||
visibility === MessageChannelVisibility.SUBJECT
|
||||
? thread.subject
|
||||
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
|
||||
lastMessageBody:
|
||||
visibility === MessageChannelVisibility.SHARE_EVERYTHING
|
||||
? thread.lastMessageBody
|
||||
: FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
|
||||
...extractParticipantSummary(threadParticipantsByThreadId[thread.id]),
|
||||
visibility,
|
||||
read: true,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
+2
-1
@@ -39,7 +39,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
|
||||
'CHK_navigation_menu_item_type_fields',
|
||||
`("type" = 'FOLDER')
|
||||
OR ("type" = 'OBJECT' AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'VIEW' AND "viewId" IS NOT NULL)
|
||||
OR ("type" = 'VIEW')
|
||||
OR ("type" = 'RECORD' AND "targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)
|
||||
OR ("type" = 'LINK' AND "link" IS NOT NULL)`,
|
||||
)
|
||||
@@ -87,6 +87,7 @@ export class NavigationMenuItemEntity
|
||||
nullable: false,
|
||||
type: 'enum',
|
||||
enum: NavigationMenuItemType,
|
||||
default: NavigationMenuItemType.VIEW,
|
||||
})
|
||||
type: NavigationMenuItemType;
|
||||
|
||||
|
||||
-1
@@ -1,7 +1,6 @@
|
||||
import { type MetadataUniversalFlatEntityPropertiesToCompare } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-properties-to-compare.type';
|
||||
|
||||
export const OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES = [
|
||||
'color',
|
||||
'labelSingular',
|
||||
'labelPlural',
|
||||
'description',
|
||||
|
||||
-15
@@ -117,21 +117,6 @@ export class ObjectMetadataResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async color(
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context: I18nContext,
|
||||
): Promise<string> {
|
||||
const i18n = this.i18nService.getI18nInstance(context.req.locale);
|
||||
|
||||
return resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'color',
|
||||
context.req.locale,
|
||||
i18n,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
|
||||
@Mutation(() => ObjectMetadataDTO)
|
||||
async createOneObject(
|
||||
|
||||
-85
@@ -28,12 +28,10 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
labelPlural: 'My Customs',
|
||||
description: 'Custom Description',
|
||||
icon: 'custom-icon',
|
||||
color: 'blue',
|
||||
isCustom: true,
|
||||
standardOverrides: undefined,
|
||||
} satisfies Pick<
|
||||
ObjectMetadataDTO,
|
||||
| 'color'
|
||||
| 'labelPlural'
|
||||
| 'labelSingular'
|
||||
| 'description'
|
||||
@@ -58,12 +56,10 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
labelPlural: 'My Customs',
|
||||
description: 'Custom Description',
|
||||
icon: 'custom-icon',
|
||||
color: 'blue',
|
||||
isCustom: true,
|
||||
standardOverrides: undefined,
|
||||
} satisfies Pick<
|
||||
ObjectMetadataDTO,
|
||||
| 'color'
|
||||
| 'labelPlural'
|
||||
| 'labelSingular'
|
||||
| 'description'
|
||||
@@ -88,12 +84,10 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
labelPlural: 'My Customs',
|
||||
description: 'Custom Description',
|
||||
icon: 'custom-icon',
|
||||
color: 'blue',
|
||||
isCustom: true,
|
||||
standardOverrides: undefined,
|
||||
} satisfies Pick<
|
||||
ObjectMetadataDTO,
|
||||
| 'color'
|
||||
| 'labelPlural'
|
||||
| 'labelSingular'
|
||||
| 'description'
|
||||
@@ -111,36 +105,6 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
|
||||
expect(result).toBe('custom-icon');
|
||||
});
|
||||
|
||||
it('should return the object value for custom color object', () => {
|
||||
const objectMetadata = {
|
||||
labelSingular: 'My Custom',
|
||||
labelPlural: 'My Customs',
|
||||
description: 'Custom Description',
|
||||
icon: 'custom-icon',
|
||||
color: 'green',
|
||||
isCustom: true,
|
||||
standardOverrides: undefined,
|
||||
} satisfies Pick<
|
||||
ObjectMetadataDTO,
|
||||
| 'color'
|
||||
| 'labelPlural'
|
||||
| 'labelSingular'
|
||||
| 'description'
|
||||
| 'icon'
|
||||
| 'isCustom'
|
||||
| 'standardOverrides'
|
||||
>;
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'color',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('green');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard objects - Icon overrides', () => {
|
||||
@@ -167,55 +131,6 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard objects - Color overrides', () => {
|
||||
it('should return override color when available for standard object', () => {
|
||||
const objectMetadata = {
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
description: 'Standard Description',
|
||||
icon: 'default-icon',
|
||||
color: 'blue',
|
||||
isCustom: false,
|
||||
standardOverrides: {
|
||||
color: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'color',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('red');
|
||||
});
|
||||
|
||||
it('should return base color when no override exists for standard object', () => {
|
||||
const objectMetadata = {
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
description: 'Standard Description',
|
||||
icon: 'default-icon',
|
||||
color: 'blue',
|
||||
isCustom: false,
|
||||
standardOverrides: undefined,
|
||||
};
|
||||
|
||||
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||
mockI18n._.mockReturnValue('generated-message-id');
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'color',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('blue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Standard objects - Translation overrides', () => {
|
||||
it('should return translation override when available for non-icon objects', () => {
|
||||
const objectMetadata = {
|
||||
|
||||
+5
-7
@@ -9,7 +9,6 @@ import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metad
|
||||
export const resolveObjectMetadataStandardOverride = (
|
||||
objectMetadata: Pick<
|
||||
ObjectMetadataDTO,
|
||||
| 'color'
|
||||
| 'labelPlural'
|
||||
| 'labelSingular'
|
||||
| 'description'
|
||||
@@ -17,7 +16,7 @@ export const resolveObjectMetadataStandardOverride = (
|
||||
| 'isCustom'
|
||||
| 'standardOverrides'
|
||||
>,
|
||||
labelKey: 'color' | 'labelPlural' | 'labelSingular' | 'description' | 'icon',
|
||||
labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon',
|
||||
locale: keyof typeof APP_LOCALES | undefined,
|
||||
i18nInstance: I18n,
|
||||
): string => {
|
||||
@@ -28,16 +27,15 @@ export const resolveObjectMetadataStandardOverride = (
|
||||
}
|
||||
|
||||
if (
|
||||
(labelKey === 'icon' || labelKey === 'color') &&
|
||||
isDefined(objectMetadata.standardOverrides?.[labelKey])
|
||||
labelKey === 'icon' &&
|
||||
isDefined(objectMetadata.standardOverrides?.icon)
|
||||
) {
|
||||
return objectMetadata.standardOverrides[labelKey];
|
||||
return objectMetadata.standardOverrides.icon;
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(objectMetadata.standardOverrides?.translations) &&
|
||||
labelKey !== 'icon' &&
|
||||
labelKey !== 'color'
|
||||
labelKey !== 'icon'
|
||||
) {
|
||||
const translationValue =
|
||||
objectMetadata.standardOverrides.translations[safeLocale]?.[labelKey];
|
||||
|
||||
+2
-66
@@ -26,7 +26,6 @@ import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
|
||||
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
|
||||
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
@@ -137,13 +136,9 @@ export class WorkspaceEventEmitterService {
|
||||
const enrichedMetadataEventBatch = isMetadata
|
||||
? await this.enrichFieldMetadataEventsWithRelations(
|
||||
eventBatch as MetadataEventBatch,
|
||||
).then((batch) =>
|
||||
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(batch),
|
||||
)
|
||||
.then((batch) =>
|
||||
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
|
||||
batch,
|
||||
),
|
||||
)
|
||||
.then((batch) => this.resolveObjectMetadataStandardOverrides(batch))
|
||||
: undefined;
|
||||
|
||||
for (const [streamChannelId, streamData] of streamsData) {
|
||||
@@ -313,65 +308,6 @@ export class WorkspaceEventEmitterService {
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private resolveObjectMetadataStandardOverrides(
|
||||
metadataEventBatch: MetadataEventBatch,
|
||||
): MetadataEventBatch {
|
||||
if (metadataEventBatch.metadataName !== 'objectMetadata') {
|
||||
return metadataEventBatch;
|
||||
}
|
||||
|
||||
const enrichedEvents = metadataEventBatch.events.map((event) => {
|
||||
const enrichedProperties = { ...event.properties };
|
||||
|
||||
if (
|
||||
'before' in enrichedProperties &&
|
||||
isDefined(enrichedProperties.before)
|
||||
) {
|
||||
enrichedProperties.before =
|
||||
this.applyStandardOverridesToObjectMetadataRecord(
|
||||
enrichedProperties.before as Record<string, unknown>,
|
||||
) as typeof enrichedProperties.before;
|
||||
}
|
||||
|
||||
if (
|
||||
'after' in enrichedProperties &&
|
||||
isDefined(enrichedProperties.after)
|
||||
) {
|
||||
enrichedProperties.after =
|
||||
this.applyStandardOverridesToObjectMetadataRecord(
|
||||
enrichedProperties.after as Record<string, unknown>,
|
||||
) as typeof enrichedProperties.after;
|
||||
}
|
||||
|
||||
return { ...event, properties: enrichedProperties } as typeof event;
|
||||
});
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private applyStandardOverridesToObjectMetadataRecord(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const standardOverrides = record.standardOverrides as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
if (!isDefined(standardOverrides)) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const resolved = { ...record };
|
||||
|
||||
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
|
||||
if (isDefined(standardOverrides[key])) {
|
||||
resolved[key] = standardOverrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async processObjectRecordStreamEvents(
|
||||
streamChannelId: string,
|
||||
streamData: EventStreamData,
|
||||
|
||||
+44
-46
@@ -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 ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
label: 'Navigate to next record',
|
||||
shortLabel: null,
|
||||
icon: 'IconChevronDown',
|
||||
position: 0,
|
||||
@@ -21,8 +21,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
navigateToPreviousRecord: {
|
||||
universalIdentifier: 'ec10f871-415b-420b-8150-7e09f6f04833',
|
||||
label:
|
||||
'Navigate to previous ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
label: 'Navigate to previous record',
|
||||
shortLabel: null,
|
||||
icon: 'IconChevronUp',
|
||||
position: 1,
|
||||
@@ -37,8 +36,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
createNewRecord: {
|
||||
universalIdentifier: '08d255bf-58cd-47a5-bd82-78c5c58592f1',
|
||||
label: 'Create new ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
shortLabel: 'New ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
label: 'Create new record',
|
||||
shortLabel: 'New record',
|
||||
icon: 'IconPlus',
|
||||
position: 2,
|
||||
isPinned: true,
|
||||
@@ -67,7 +66,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
deleteMultipleRecords: {
|
||||
universalIdentifier: 'cde86f1f-2c13-42b1-812b-f2b2b468cb83',
|
||||
label: 'Delete ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Delete records',
|
||||
shortLabel: 'Delete',
|
||||
icon: 'IconTrash',
|
||||
position: 4,
|
||||
@@ -82,7 +81,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
restoreSingleRecord: {
|
||||
universalIdentifier: '8b3a1cae-3e4d-43c1-a71f-48592b2e47ff',
|
||||
label: 'Restore ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
label: 'Restore record',
|
||||
shortLabel: 'Restore',
|
||||
icon: 'IconRefresh',
|
||||
position: 5,
|
||||
@@ -97,7 +96,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
restoreMultipleRecords: {
|
||||
universalIdentifier: '8b740c9d-d99a-45a8-812f-809caaf420ac',
|
||||
label: 'Restore ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Restore records',
|
||||
shortLabel: 'Restore',
|
||||
icon: 'IconRefresh',
|
||||
position: 6,
|
||||
@@ -112,8 +111,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
destroySingleRecord: {
|
||||
universalIdentifier: '44a78417-c394-4bc8-961f-98b503030ddb',
|
||||
label:
|
||||
'Permanently destroy ${capitalize(objectMetadataItem.labelSingular)}',
|
||||
label: 'Permanently destroy record',
|
||||
shortLabel: 'Destroy',
|
||||
icon: 'IconTrashX',
|
||||
position: 7,
|
||||
@@ -128,7 +126,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
destroyMultipleRecords: {
|
||||
universalIdentifier: 'c630b3fb-7920-40d1-9906-77d0aa797608',
|
||||
label: 'Permanently destroy ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Permanently destroy records',
|
||||
shortLabel: 'Destroy',
|
||||
icon: 'IconTrashX',
|
||||
position: 8,
|
||||
@@ -143,7 +141,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,
|
||||
@@ -158,7 +156,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,
|
||||
@@ -216,7 +214,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
updateMultipleRecords: {
|
||||
universalIdentifier: '2e080651-f098-4a78-bea9-7a70002dc57c',
|
||||
label: 'Update ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Update records',
|
||||
shortLabel: 'Update',
|
||||
icon: 'IconEdit',
|
||||
position: 14,
|
||||
@@ -231,7 +229,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
mergeMultipleRecords: {
|
||||
universalIdentifier: '6c14eb04-8e7e-4d47-93c0-8ec4834e2e60',
|
||||
label: 'Merge ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Merge records',
|
||||
shortLabel: 'Merge',
|
||||
icon: 'IconArrowMerge',
|
||||
position: 15,
|
||||
@@ -246,7 +244,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
exportMultipleRecords: {
|
||||
universalIdentifier: 'f71f68e5-7b6e-4c03-8161-c48434d7777c',
|
||||
label: 'Export ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Export records',
|
||||
shortLabel: 'Export',
|
||||
icon: 'IconFileExport',
|
||||
position: 16,
|
||||
@@ -260,7 +258,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
importRecords: {
|
||||
universalIdentifier: 'a2dc9de7-4798-422e-bb55-bfad7b9bdbe8',
|
||||
label: 'Import ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Import records',
|
||||
shortLabel: 'Import',
|
||||
icon: 'IconFileImport',
|
||||
position: 17,
|
||||
@@ -274,7 +272,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,
|
||||
@@ -288,8 +286,8 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
seeDeletedRecords: {
|
||||
universalIdentifier: 'd63c21c3-9785-4750-be87-5f36269b8e0d',
|
||||
label: 'See deleted ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'See deleted records',
|
||||
shortLabel: 'Deleted records',
|
||||
icon: 'IconRotate2',
|
||||
position: 19,
|
||||
isPinned: false,
|
||||
@@ -316,7 +314,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
hideDeletedRecords: {
|
||||
universalIdentifier: '1420db7f-0fba-49e2-b23e-4b7caa0fafa0',
|
||||
label: 'Hide deleted ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Hide deleted records',
|
||||
shortLabel: 'Hide deleted',
|
||||
icon: 'IconEyeOff',
|
||||
position: 21,
|
||||
@@ -586,8 +584,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,
|
||||
@@ -602,8 +600,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,
|
||||
@@ -618,8 +616,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,
|
||||
@@ -634,8 +632,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,
|
||||
@@ -650,7 +648,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,
|
||||
@@ -682,8 +680,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,
|
||||
@@ -697,8 +695,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,
|
||||
@@ -712,8 +710,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,
|
||||
@@ -743,8 +741,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,
|
||||
@@ -759,8 +757,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,
|
||||
@@ -775,8 +773,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,
|
||||
@@ -791,8 +789,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,
|
||||
@@ -807,7 +805,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
searchRecords: {
|
||||
universalIdentifier: 'fa24e25e-68f8-4548-82ff-c7b5168b7c7d',
|
||||
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Search records',
|
||||
shortLabel: 'Search',
|
||||
icon: 'IconSearch',
|
||||
position: 60,
|
||||
@@ -821,7 +819,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
},
|
||||
searchRecordsFallback: {
|
||||
universalIdentifier: 'c659890c-7266-46c9-bfe1-75cefff8b6d0',
|
||||
label: 'Search ${capitalize(objectMetadataItem.labelPlural)}',
|
||||
label: 'Search records',
|
||||
shortLabel: 'Search',
|
||||
icon: 'IconSearch',
|
||||
position: 61,
|
||||
|
||||
+5
-1
@@ -61,7 +61,11 @@ export class FlatObjectMetadataValidatorService {
|
||||
};
|
||||
|
||||
if (!buildOptions.isSystemBuild && existingFlatObjectMetadata.isSystem) {
|
||||
const allowedOverrideKeys = new Set(['standardOverrides', 'isActive']);
|
||||
const allowedOverrideKeys = new Set([
|
||||
'standardOverrides',
|
||||
'isActive',
|
||||
'color',
|
||||
]);
|
||||
const disallowedProperties = Object.keys(flatEntityUpdate).filter(
|
||||
(property) => !allowedOverrideKeys.has(property),
|
||||
);
|
||||
|
||||
+2
-3
@@ -2,7 +2,7 @@ import { Scope } from '@nestjs/common';
|
||||
|
||||
import { MessageParticipantRole } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { And, Any, ILike, In, IsNull, Not, Or } from 'typeorm';
|
||||
import { And, Any, ILike, In, Not, Or } from 'typeorm';
|
||||
import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events';
|
||||
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
@@ -108,7 +108,6 @@ export class BlocklistItemDeleteMessagesJob {
|
||||
where: {
|
||||
connectedAccount: {
|
||||
accountOwnerId: workspaceMemberId,
|
||||
deletedAt: IsNull(),
|
||||
},
|
||||
},
|
||||
relations: ['connectedAccount'],
|
||||
@@ -117,7 +116,7 @@ export class BlocklistItemDeleteMessagesJob {
|
||||
for (const messageChannel of messageChannels) {
|
||||
const messageChannelHandles = [messageChannel.handle];
|
||||
|
||||
if (isDefined(messageChannel.connectedAccount.handleAliases)) {
|
||||
if (messageChannel.connectedAccount.handleAliases) {
|
||||
messageChannelHandles.push(
|
||||
...messageChannel.connectedAccount.handleAliases.split(','),
|
||||
);
|
||||
|
||||
-9
@@ -2,7 +2,6 @@ import {
|
||||
MessageImportDriverException,
|
||||
MessageImportDriverExceptionCode,
|
||||
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const parseMicrosoftMessagesImportError = (
|
||||
error: {
|
||||
@@ -13,14 +12,6 @@ export const parseMicrosoftMessagesImportError = (
|
||||
options?: { cause?: Error },
|
||||
): MessageImportDriverException => {
|
||||
if (error.statusCode === 400) {
|
||||
if (!isDefined(error.message)) {
|
||||
return new MessageImportDriverException(
|
||||
`Microsoft Graph API returned 400 with empty error body`,
|
||||
MessageImportDriverExceptionCode.TEMPORARY_ERROR,
|
||||
{ cause: options?.cause },
|
||||
);
|
||||
}
|
||||
|
||||
return new MessageImportDriverException(
|
||||
`Invalid request to Microsoft Graph API: ${error.message}`,
|
||||
MessageImportDriverExceptionCode.UNKNOWN,
|
||||
|
||||
+35
-63
@@ -1,46 +1,7 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`Standard object metadata update should succeed when updating description 1`] = `
|
||||
{
|
||||
"color": "",
|
||||
"description": "Updated test description for company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
"isActive": true,
|
||||
"labelPlural": "Companies",
|
||||
"labelSingular": "Company",
|
||||
"namePlural": "companies",
|
||||
"nameSingular": "company",
|
||||
"shortcut": "C",
|
||||
"standardOverrides": {
|
||||
"color": null,
|
||||
"description": "Updated test description for company",
|
||||
"icon": null,
|
||||
"labelPlural": null,
|
||||
"labelSingular": null,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Standard object metadata update should succeed when updating icon 1`] = `
|
||||
{
|
||||
"color": "",
|
||||
"description": "A company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
"isActive": true,
|
||||
"labelPlural": "Companies",
|
||||
"labelSingular": "Company",
|
||||
"namePlural": "companies",
|
||||
"nameSingular": "company",
|
||||
"shortcut": "C",
|
||||
"standardOverrides": null,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Standard object metadata update should succeed when setting isActive to false 1`] = `
|
||||
{
|
||||
"color": "",
|
||||
"description": "A company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
@@ -54,9 +15,43 @@ exports[`Standard object metadata update should succeed when setting isActive to
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Standard object metadata update should succeed when updating description 1`] = `
|
||||
{
|
||||
"description": "Updated test description for company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
"isActive": true,
|
||||
"labelPlural": "Companies",
|
||||
"labelSingular": "Company",
|
||||
"namePlural": "companies",
|
||||
"nameSingular": "company",
|
||||
"shortcut": "C",
|
||||
"standardOverrides": {
|
||||
"description": "Updated test description for company",
|
||||
"icon": null,
|
||||
"labelPlural": null,
|
||||
"labelSingular": null,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Standard object metadata update should succeed when updating icon 1`] = `
|
||||
{
|
||||
"description": "A company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
"isActive": true,
|
||||
"labelPlural": "Companies",
|
||||
"labelSingular": "Company",
|
||||
"namePlural": "companies",
|
||||
"nameSingular": "company",
|
||||
"shortcut": "C",
|
||||
"standardOverrides": null,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Standard object metadata update should succeed when updating labelSingular and labelPlural 1`] = `
|
||||
{
|
||||
"color": "",
|
||||
"description": "A company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
@@ -67,7 +62,6 @@ exports[`Standard object metadata update should succeed when updating labelSingu
|
||||
"nameSingular": "company",
|
||||
"shortcut": "C",
|
||||
"standardOverrides": {
|
||||
"color": null,
|
||||
"description": null,
|
||||
"icon": null,
|
||||
"labelPlural": "Businesses",
|
||||
@@ -75,25 +69,3 @@ exports[`Standard object metadata update should succeed when updating labelSingu
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Standard object metadata update should succeed when updating color 1`] = `
|
||||
{
|
||||
"color": "red",
|
||||
"description": "A company",
|
||||
"icon": "IconBuildingSkyscraper",
|
||||
"id": Any<String>,
|
||||
"isActive": true,
|
||||
"labelPlural": "Companies",
|
||||
"labelSingular": "Company",
|
||||
"namePlural": "companies",
|
||||
"nameSingular": "company",
|
||||
"shortcut": "C",
|
||||
"standardOverrides": {
|
||||
"color": "red",
|
||||
"description": null,
|
||||
"icon": null,
|
||||
"labelPlural": null,
|
||||
"labelSingular": null,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
-9
@@ -46,12 +46,6 @@ const successfulUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContex
|
||||
labelPlural: 'Businesses',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when updating color',
|
||||
context: {
|
||||
color: 'red',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const allTestsUseCases = [...successfulUpdateTestsUseCase];
|
||||
@@ -73,7 +67,6 @@ describe('Standard object metadata update should succeed', () => {
|
||||
namePlural
|
||||
labelSingular
|
||||
labelPlural
|
||||
color
|
||||
description
|
||||
icon
|
||||
isActive
|
||||
@@ -128,7 +121,6 @@ describe('Standard object metadata update should succeed', () => {
|
||||
namePlural
|
||||
labelSingular
|
||||
labelPlural
|
||||
color
|
||||
description
|
||||
icon
|
||||
isActive
|
||||
@@ -136,7 +128,6 @@ describe('Standard object metadata update should succeed', () => {
|
||||
standardOverrides {
|
||||
labelSingular
|
||||
labelPlural
|
||||
color
|
||||
description
|
||||
icon
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsT
|
||||
|
||||
export type CommandMenuItemManifest = SyncableEntityOptions & {
|
||||
label: string;
|
||||
shortLabel?: string;
|
||||
icon?: string;
|
||||
isPinned?: boolean;
|
||||
availabilityType?: 'GLOBAL' | 'RECORD_SELECTION' | 'FALLBACK';
|
||||
|
||||
-316
@@ -1,316 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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
@@ -1,21 +0,0 @@
|
||||
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
@@ -1,75 +0,0 @@
|
||||
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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
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
@@ -0,0 +1,143 @@
|
||||
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,11 +23,8 @@ 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