Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2f8e0226 | ||
|
|
cb44b22e15 |
+205
@@ -0,0 +1,205 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type View } from '@/views/types/View';
|
||||
import {
|
||||
NavigationMenuItemType,
|
||||
ViewKey,
|
||||
type NavigationMenuItem,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
|
||||
|
||||
type ObjectMetadata = Pick<
|
||||
EnrichedObjectMetadataItem,
|
||||
'id' | 'labelPlural' | 'nameSingular'
|
||||
>;
|
||||
type ViewMetadata = Pick<View, 'id' | 'name' | 'objectMetadataId' | 'key'>;
|
||||
|
||||
const objectMetadataItems: ObjectMetadata[] = [
|
||||
{ id: 'obj-1', labelPlural: 'Notes', nameSingular: 'note' },
|
||||
{ id: 'obj-2', labelPlural: 'Companies', nameSingular: 'company' },
|
||||
];
|
||||
|
||||
const views: ViewMetadata[] = [
|
||||
{
|
||||
id: 'view-index',
|
||||
name: 'All Notes',
|
||||
objectMetadataId: 'obj-1',
|
||||
key: ViewKey.INDEX,
|
||||
},
|
||||
{
|
||||
id: 'view-custom',
|
||||
name: 'My Custom View',
|
||||
objectMetadataId: 'obj-1',
|
||||
key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const baseItem: NavigationMenuItem = {
|
||||
id: 'nav-1',
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
position: 0,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
describe('getNavigationMenuItemLabel', () => {
|
||||
describe('when type is OBJECT', () => {
|
||||
it('should return labelPlural for a matching object', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
targetObjectMetadataId: 'obj-1',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'Notes',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string when object is not found', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
targetObjectMetadataId: 'nonexistent',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is VIEW', () => {
|
||||
it('should return the resolved view name for an INDEX view', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewId: 'view-index',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'All Notes',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the view name for a non-INDEX view', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewId: 'view-custom',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'My Custom View',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string when view is not found', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
viewId: 'nonexistent',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is LINK', () => {
|
||||
it('should return the item name when present', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.LINK,
|
||||
name: 'Documentation',
|
||||
link: 'https://docs.example.com',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'Documentation',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the link URL when name is null', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.LINK,
|
||||
name: null,
|
||||
link: 'https://docs.example.com',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'https://docs.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return "Link" when both name and link are empty', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.LINK,
|
||||
name: null,
|
||||
link: ' ',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'Link',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is RECORD', () => {
|
||||
it('should return labelIdentifier from targetRecordIdentifier', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.RECORD,
|
||||
targetRecordIdentifier: {
|
||||
id: 'rec-1',
|
||||
labelIdentifier: 'Acme Corp',
|
||||
},
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'Acme Corp',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string when targetRecordIdentifier is missing', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.RECORD,
|
||||
targetRecordIdentifier: null,
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when type is FOLDER', () => {
|
||||
it('should return the item name when present', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
name: 'Sales',
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'Sales',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return "Folder" when name is null', () => {
|
||||
const item = {
|
||||
...baseItem,
|
||||
type: NavigationMenuItemType.FOLDER,
|
||||
name: null,
|
||||
};
|
||||
|
||||
expect(getNavigationMenuItemLabel(item, objectMetadataItems, views)).toBe(
|
||||
'Folder',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+19
-60
@@ -1,9 +1,10 @@
|
||||
import { getLinkNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemComputedLink';
|
||||
import { getObjectNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemComputedLink';
|
||||
import { getRecordNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/record/utils/getRecordNavigationMenuItemComputedLink';
|
||||
import { getViewNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/view/utils/getViewNavigationMenuItemComputedLink';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { recordIdentifierToObjectRecordIdentifier } from '@/navigation-menu-item/common/utils/recordIdentifierToObjectRecordIdentifier';
|
||||
import { type View } from '@/views/types/View';
|
||||
import { ViewKey } from '@/views/types/ViewKey';
|
||||
import { AppPath, NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { getAppPath, isDefined } from 'twenty-shared/utils';
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getNavigationMenuItemComputedLink = (
|
||||
@@ -12,64 +13,22 @@ export const getNavigationMenuItemComputedLink = (
|
||||
views: Pick<View, 'id' | 'objectMetadataId' | 'key'>[],
|
||||
): string => {
|
||||
switch (item.type) {
|
||||
case NavigationMenuItemType.OBJECT: {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(meta) => meta.id === item.targetObjectMetadataId,
|
||||
case NavigationMenuItemType.OBJECT:
|
||||
return getObjectNavigationMenuItemComputedLink(
|
||||
item,
|
||||
objectMetadataItems,
|
||||
views,
|
||||
);
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return '';
|
||||
}
|
||||
const indexView = views.find(
|
||||
(view) =>
|
||||
view.objectMetadataId === objectMetadataItem.id &&
|
||||
view.key === ViewKey.INDEX,
|
||||
case NavigationMenuItemType.VIEW:
|
||||
return getViewNavigationMenuItemComputedLink(
|
||||
item,
|
||||
objectMetadataItems,
|
||||
views,
|
||||
);
|
||||
return getAppPath(
|
||||
AppPath.RecordIndexPage,
|
||||
{ objectNamePlural: objectMetadataItem.namePlural },
|
||||
indexView ? { viewId: indexView.id } : {},
|
||||
);
|
||||
}
|
||||
case NavigationMenuItemType.VIEW: {
|
||||
const view = views.find((view) => view.id === item.viewId);
|
||||
if (!isDefined(view)) {
|
||||
return '';
|
||||
}
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(meta) => meta.id === view.objectMetadataId,
|
||||
);
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return '';
|
||||
}
|
||||
return getAppPath(
|
||||
AppPath.RecordIndexPage,
|
||||
{ objectNamePlural: objectMetadataItem.namePlural },
|
||||
{ viewId: item.viewId! },
|
||||
);
|
||||
}
|
||||
case NavigationMenuItemType.LINK: {
|
||||
const linkUrl = (item.link ?? '').trim();
|
||||
if (linkUrl.startsWith('http://') || linkUrl.startsWith('https://')) {
|
||||
return linkUrl;
|
||||
}
|
||||
return linkUrl ? `https://${linkUrl}` : '';
|
||||
}
|
||||
case NavigationMenuItemType.RECORD: {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(meta) => meta.id === item.targetObjectMetadataId,
|
||||
);
|
||||
if (
|
||||
!isDefined(objectMetadataItem) ||
|
||||
!isDefined(item.targetRecordIdentifier)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const objectRecordIdentifier = recordIdentifierToObjectRecordIdentifier({
|
||||
recordIdentifier: item.targetRecordIdentifier,
|
||||
objectMetadataItem,
|
||||
});
|
||||
return objectRecordIdentifier.linkToShowPage ?? '';
|
||||
}
|
||||
case NavigationMenuItemType.LINK:
|
||||
return getLinkNavigationMenuItemComputedLink(item);
|
||||
case NavigationMenuItemType.RECORD:
|
||||
return getRecordNavigationMenuItemComputedLink(item, objectMetadataItems);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
+15
-31
@@ -1,8 +1,11 @@
|
||||
import { getFolderNavigationMenuItemLabel } from '@/navigation-menu-item/display/folder/utils/getFolderNavigationMenuItemLabel';
|
||||
import { getLinkNavigationMenuItemLabel } from '@/navigation-menu-item/display/link/utils/getLinkNavigationMenuItemLabel';
|
||||
import { getObjectNavigationMenuItemLabel } from '@/navigation-menu-item/display/object/utils/getObjectNavigationMenuItemLabel';
|
||||
import { getRecordNavigationMenuItemLabel } from '@/navigation-menu-item/display/record/utils/getRecordNavigationMenuItemLabel';
|
||||
import { getViewNavigationMenuItemLabel } from '@/navigation-menu-item/display/view/utils/getViewNavigationMenuItemLabel';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type View } from '@/views/types/View';
|
||||
import { ViewKey } from '@/views/types/ViewKey';
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getNavigationMenuItemLabel = (
|
||||
@@ -14,35 +17,16 @@ export const getNavigationMenuItemLabel = (
|
||||
views: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'key'>[],
|
||||
): string => {
|
||||
switch (item.type) {
|
||||
case NavigationMenuItemType.OBJECT: {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(meta) => meta.id === item.targetObjectMetadataId,
|
||||
);
|
||||
return objectMetadataItem?.labelPlural ?? '';
|
||||
}
|
||||
case NavigationMenuItemType.VIEW: {
|
||||
const view = views.find((view) => view.id === item.viewId);
|
||||
if (!isDefined(view)) {
|
||||
return '';
|
||||
}
|
||||
if (view.key === ViewKey.INDEX) {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(meta) => meta.id === view.objectMetadataId,
|
||||
);
|
||||
return objectMetadataItem?.labelPlural ?? view.name;
|
||||
}
|
||||
return view.name;
|
||||
}
|
||||
case NavigationMenuItemType.LINK: {
|
||||
const linkUrl = (item.link ?? '').trim();
|
||||
return (item.name ?? linkUrl) || 'Link';
|
||||
}
|
||||
case NavigationMenuItemType.RECORD: {
|
||||
return item.targetRecordIdentifier?.labelIdentifier ?? '';
|
||||
}
|
||||
case NavigationMenuItemType.FOLDER: {
|
||||
return item.name ?? 'Folder';
|
||||
}
|
||||
case NavigationMenuItemType.OBJECT:
|
||||
return getObjectNavigationMenuItemLabel(item, objectMetadataItems);
|
||||
case NavigationMenuItemType.VIEW:
|
||||
return getViewNavigationMenuItemLabel(item, views);
|
||||
case NavigationMenuItemType.LINK:
|
||||
return getLinkNavigationMenuItemLabel(item);
|
||||
case NavigationMenuItemType.RECORD:
|
||||
return getRecordNavigationMenuItemLabel(item);
|
||||
case NavigationMenuItemType.FOLDER:
|
||||
return getFolderNavigationMenuItemLabel(item);
|
||||
default:
|
||||
return item.name ?? '';
|
||||
}
|
||||
|
||||
-9
@@ -1,23 +1,14 @@
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type View } from '@/views/types/View';
|
||||
import { ViewKey } from '@/views/types/ViewKey';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getViewNavigationMenuItemLabel = (
|
||||
item: Pick<NavigationMenuItem, 'viewId'>,
|
||||
views: Pick<View, 'id' | 'name' | 'objectMetadataId' | 'key'>[],
|
||||
objectMetadataItems: Pick<EnrichedObjectMetadataItem, 'id' | 'labelPlural'>[],
|
||||
): string => {
|
||||
const view = views.find((view) => view.id === item.viewId);
|
||||
if (!isDefined(view)) {
|
||||
return '';
|
||||
}
|
||||
if (view.key === ViewKey.INDEX) {
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(meta) => meta.id === view.objectMetadataId,
|
||||
);
|
||||
return objectMetadataItem?.labelPlural ?? view.name;
|
||||
}
|
||||
return view.name;
|
||||
};
|
||||
|
||||
+68
@@ -42,6 +42,74 @@ describe('SubdomainManagerService', () => {
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
});
|
||||
|
||||
describe('getSubdomainAndDomainFromUrl', () => {
|
||||
it('should extract subdomain from a full URL with protocol', () => {
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONTEND_URL: 'https://twenty.com',
|
||||
DEFAULT_SUBDOMAIN: 'app',
|
||||
};
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result =
|
||||
domainServerConfigService.getSubdomainAndDomainFromUrl(
|
||||
'https://myworkspace.twenty.com',
|
||||
);
|
||||
|
||||
expect(result.subdomain).toBe('myworkspace');
|
||||
expect(result.domain).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle URL without protocol scheme', () => {
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONTEND_URL: 'https://twenty.com',
|
||||
DEFAULT_SUBDOMAIN: 'app',
|
||||
};
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result =
|
||||
domainServerConfigService.getSubdomainAndDomainFromUrl(
|
||||
'myworkspace.twenty.com',
|
||||
);
|
||||
|
||||
expect(result.subdomain).toBe('myworkspace');
|
||||
expect(result.domain).toBeNull();
|
||||
});
|
||||
|
||||
it('should return custom domain for non-front domain URLs', () => {
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
const env = {
|
||||
FRONTEND_URL: 'https://twenty.com',
|
||||
DEFAULT_SUBDOMAIN: 'app',
|
||||
};
|
||||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
return env[key];
|
||||
});
|
||||
|
||||
const result =
|
||||
domainServerConfigService.getSubdomainAndDomainFromUrl(
|
||||
'custom.example.com',
|
||||
);
|
||||
|
||||
expect(result.subdomain).toBeUndefined();
|
||||
expect(result.domain).toBe('custom.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it('should build the base URL from environment variables', () => {
|
||||
jest
|
||||
|
||||
+3
-1
@@ -46,7 +46,9 @@ export class DomainServerConfigService {
|
||||
}
|
||||
|
||||
getSubdomainAndDomainFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
const urlWithProtocol = url.includes('://') ? url : `https://${url}`;
|
||||
|
||||
const { hostname: originHostname } = new URL(urlWithProtocol);
|
||||
|
||||
const frontDomain = this.getFrontUrl().hostname;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user