Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code ee2f8e0226 fix: handle missing protocol in origin for getSubdomainAndDomainFromUrl
https://sonarly.com/issue/4546?type=bug

The `GetPublicWorkspaceDataByDomain` query crashes with `TypeError: Invalid URL` when the `origin` variable is a bare hostname without a protocol scheme (e.g., `hslmx.twenty.com` instead of `https://hslmx.twenty.com`).

Fix: Added URL protocol normalization in `DomainServerConfigService.getSubdomainAndDomainFromUrl()`. When the input URL string lacks a protocol scheme (e.g., `hslmx.twenty.com` instead of `https://hslmx.twenty.com`), the method now prepends `https://` before passing it to `new URL()`. This prevents the `TypeError: Invalid URL` that occurs when API clients call `GetPublicWorkspaceDataByDomain` with a bare hostname.

The check `url.includes('://')` is used rather than a regex to stay consistent with the codebase's simple string operations. Full URLs (from browser `window.location.origin`) pass through unchanged.

Added 3 test cases to the existing test file:
1. Extracts subdomain from a full URL with protocol (existing behavior preserved)
2. Handles URL without protocol scheme (the bug fix)
3. Returns custom domain for non-front domain URLs
2026-03-27 20:21:58 +00:00
Charles BochetandGitHub cb44b22e15 Fix INDEX view showing labelPlural instead of resolved view name in nav (#19049)
## Summary
- Navigation sidebar was displaying "Notes" instead of "All Notes" for
INDEX views
- `getNavigationMenuItemLabel` had a special case for INDEX views that
returned `objectMetadataItem.labelPlural` instead of the
already-resolved `view.name`
- Since `viewsSelector` already resolves `{objectLabelPlural}` templates
via `resolveViewNamePlaceholders`, the INDEX special case was redundant
and incorrect — removed it from both `getNavigationMenuItemLabel` and
`getViewNavigationMenuItemLabel`
- Added unit tests covering all navigation menu item type branches

<img width="222" height="479" alt="image"
src="https://github.com/user-attachments/assets/de6f92b1-e0c2-445a-a145-cf2820434af7"
/>
2026-03-27 17:25:52 +00:00
6 changed files with 310 additions and 101 deletions
@@ -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',
);
});
});
});
@@ -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 '';
}
@@ -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 ?? '';
}
@@ -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;
};
@@ -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
@@ -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;