fix(links): preserve percent-encoded URLs during normalization (#18792)
## Summary This preserves percent-encoded payloads when normalizing links fields. `lowercaseUrlOriginAndRemoveTrailingSlash` was decoding the path and query string while lowercasing the URL origin. That changes URLs where encoded payloads are semantically significant, such as Google Maps links containing `%2F` segments. Closes #18698. ## Changes - stop decoding the path/query payload in `lowercaseUrlOriginAndRemoveTrailingSlash` - preserve the raw path, query, and hash while still lowercasing the origin and trimming a trailing slash - update shared URL normalization tests to assert encoded payloads stay encoded - add a server-side regression test covering imported links field normalization ## Validation - `corepack yarn jest --config packages/twenty-shared/jest.config.mjs packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts --runInBand` - `corepack yarn jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts --runInBand` - `corepack yarn nx test twenty-server --runInBand --testFile=src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts` --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
-14
@@ -1,14 +0,0 @@
|
||||
import { normalizeUrl } from '@/navigation-menu-item/display/link/utils/normalizeUrl';
|
||||
|
||||
describe('normalizeUrl', () => {
|
||||
it('should leave url unchanged when it has protocol, otherwise prepend https', () => {
|
||||
expect(normalizeUrl('https://example.com')).toBe('https://example.com');
|
||||
expect(normalizeUrl('example.com')).toBe('https://example.com');
|
||||
expect(normalizeUrl(' example.com ')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should return empty string for empty or whitespace input', () => {
|
||||
expect(normalizeUrl('')).toBe('');
|
||||
expect(normalizeUrl(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
export const normalizeUrl = (url: string) => {
|
||||
const trimmedUrl = url.trim();
|
||||
|
||||
if (trimmedUrl === '') {
|
||||
return trimmedUrl;
|
||||
}
|
||||
|
||||
return trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')
|
||||
? trimmedUrl
|
||||
: `https://${trimmedUrl}`;
|
||||
};
|
||||
+1
-2
@@ -1,12 +1,11 @@
|
||||
import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
|
||||
|
||||
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
|
||||
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
|
||||
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
|
||||
import { normalizeUrl } from '@/navigation-menu-item/display/link/utils/normalizeUrl';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
export const useAddLinkToNavigationMenuDraft = () => {
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { getAbsoluteUrl } from 'twenty-shared/utils';
|
||||
import { ensureAbsoluteUrl } from 'twenty-shared/utils';
|
||||
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
|
||||
|
||||
import { extractDomainFromUrl } from '@/navigation-menu-item/display/link/utils/extractDomainFromUrl';
|
||||
@@ -45,7 +45,7 @@ export const SidePanelEditLinkItemView = ({
|
||||
|
||||
const currentName = selectedItem.name ?? defaultLabel;
|
||||
const currentDomain = selectedItem.link
|
||||
? extractDomainFromUrl(getAbsoluteUrl(selectedItem.link))
|
||||
? extractDomainFromUrl(ensureAbsoluteUrl(selectedItem.link))
|
||||
: undefined;
|
||||
const canAutoUpdateName =
|
||||
currentName === defaultLabel ||
|
||||
@@ -57,7 +57,7 @@ export const SidePanelEditLinkItemView = ({
|
||||
if (!canAutoUpdateName) return;
|
||||
const trimmed = value.trim();
|
||||
if (!isNonEmptyString(trimmed)) return;
|
||||
const domain = extractDomainFromUrl(getAbsoluteUrl(trimmed));
|
||||
const domain = extractDomainFromUrl(ensureAbsoluteUrl(trimmed));
|
||||
if (domain !== undefined) {
|
||||
setLastAutoSetName(domain);
|
||||
onUpdateLink(selectedItem.id, { name: domain });
|
||||
@@ -67,7 +67,7 @@ export const SidePanelEditLinkItemView = ({
|
||||
const handleUrlBlur = (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value.trim();
|
||||
if (isNonEmptyString(value)) {
|
||||
onUpdateLink(selectedItem.id, { link: getAbsoluteUrl(value) });
|
||||
onUpdateLink(selectedItem.id, { link: ensureAbsoluteUrl(value) });
|
||||
setUrlEditInput('');
|
||||
}
|
||||
};
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecor
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
|
||||
import { getAbsoluteUrl, isDefined } from 'twenty-shared/utils';
|
||||
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
|
||||
import { IconArrowUpRight, IconCopy } from 'twenty-ui/display';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
@@ -69,7 +69,7 @@ export const useGetSecondaryRecordTableCellButton = () => {
|
||||
if (isFieldLinks(fieldDefinition)) {
|
||||
const url = (fieldValue as FieldLinksValue).primaryLinkUrl ?? '';
|
||||
openLinkOnClick = () => {
|
||||
window.open(getAbsoluteUrl(url), '_blank');
|
||||
window.open(ensureAbsoluteUrl(url), '_blank');
|
||||
};
|
||||
copyOnClick = () => {
|
||||
copyToClipboard(url, t`Link copied to clipboard`);
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import {
|
||||
assertUnreachable,
|
||||
isDefined,
|
||||
isEmptyObject,
|
||||
lowercaseUrlOriginAndRemoveTrailingSlash,
|
||||
normalizeUrlOrigin,
|
||||
} from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
|
||||
@@ -185,7 +185,7 @@ export const buildRecordFromImportedStructuredRow = ({
|
||||
},
|
||||
[FieldMetadataType.LINKS]: {
|
||||
primaryLinkLabel: castToString,
|
||||
primaryLinkUrl: lowercaseUrlOriginAndRemoveTrailingSlash,
|
||||
primaryLinkUrl: normalizeUrlOrigin,
|
||||
secondaryLinks: linkArrayJSONSchema.parse,
|
||||
},
|
||||
|
||||
|
||||
+2
-4
@@ -13,7 +13,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import {
|
||||
getUniqueConstraintsFields,
|
||||
isDefined,
|
||||
lowercaseUrlOriginAndRemoveTrailingSlash,
|
||||
normalizeUrlOrigin,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type Column = {
|
||||
@@ -104,9 +104,7 @@ const getUniqueValues = (
|
||||
.primaryLinkUrl,
|
||||
)
|
||||
) {
|
||||
return lowercaseUrlOriginAndRemoveTrailingSlash(
|
||||
row?.[columnName]?.toString().trim() || '',
|
||||
);
|
||||
return normalizeUrlOrigin(row?.[columnName]?.toString().trim() || '');
|
||||
}
|
||||
|
||||
return row?.[columnName]?.toString().trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user