[UI] Add link rendering helper for plugin (#13193)

- Adds reusable helpers for rendering sanitized links
- Can be used by plugins
This commit is contained in:
Oliver authored and GitHub committed 2026-11-05 17:35:25 +11:00
1 parent 9416b81799
commit 5fe105b948
4 files changed
+116 -39

No files matched your search

+7
View File
@@ -2,6 +2,13 @@
This file contains historical changelog information for the InvenTree UI components library.
### 1.8.0 - Unreleased
#### New Features
- Adds `renderLink` helper function, which renders a validated external URL (`http` / `https` only) as an `<Anchor>` element with a link icon
- Adds `sanitizeLinkUrl` helper function, which validates a URL for use as an external link
### 1.7.0 - November 2026
Updated UI components library to match 1.7.0 InvenTree release.
+98
View File
@@ -0,0 +1,98 @@
import { Anchor, type AnchorProps } from '@mantine/core';
import { IconLink } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { cancelEvent } from './Events';
// Only these URL protocols are permitted for rendered links
const ALLOWED_LINK_PROTOCOLS: string[] = ['http:', 'https:'];
/**
* Validate a user-supplied URL for use as an external link.
*
* - The URL must be absolute and parseable
* - Only http and https protocols are permitted
* (this rejects e.g. javascript:, data:, vbscript:, file: URLs)
*
* @param url - The URL to validate
* @returns The normalized URL string if valid, otherwise null
*/
export function sanitizeLinkUrl(url: any): string | null {
if (typeof url !== 'string') {
return null;
}
const value = url.trim();
if (!value) {
return null;
}
try {
const parsed = new URL(value);
if (!ALLOWED_LINK_PROTOCOLS.includes(parsed.protocol.toLowerCase())) {
return null;
}
return parsed.href;
} catch {
return null;
}
}
export type RenderLinkOptions = {
/** Icon size (default = 18) */
size?: number;
/** Optional content to render instead of the link icon */
children?: ReactNode;
/** Content to render if the URL is empty (default = '-') */
placeholder?: ReactNode;
/** Additional props passed through to the Anchor component */
anchorProps?: AnchorProps;
};
/**
* Render an external link as an <Anchor> element with a link icon.
*
* The URL is validated before rendering - only absolute http / https URLs
* are rendered as clickable links. Invalid or unsafe URLs are rendered
* as plain (escaped) text, so that the value is still visible to the user.
*
* Links are opened in a new tab, with "noopener" and "noreferrer" set.
*
* @param url - The URL to render
* @param options - Optional rendering options
*/
export function renderLink(
url: string | null | undefined,
options: RenderLinkOptions = {}
): ReactNode {
const placeholder = options.placeholder ?? '-';
if (!url || (typeof url === 'string' && !url.trim())) {
return placeholder;
}
const href = sanitizeLinkUrl(url);
if (!href) {
// Not a valid / permitted URL - render as plain text (React escapes this)
return String(url);
}
return (
<Anchor
{...options.anchorProps}
href={href}
target='_blank'
rel='noreferrer noopener'
title={href}
onClick={(event: any) => {
cancelEvent(event);
window.open(href, '_blank', 'noopener,noreferrer');
}}
>
{options.children ?? <IconLink size={options.size ?? 18} />}
</Anchor>
);
}
+6
View File
@@ -75,6 +75,12 @@ export {
navigateToLink
} from './functions/Navigation';
export {
renderLink,
sanitizeLinkUrl,
type RenderLinkOptions
} from './functions/Links';
export {
permissionDenied,
invalidResponse,
@@ -2,27 +2,14 @@
* Common rendering functions for table column data.
*/
import { t } from '@lingui/core/macro';
import {
Anchor,
Badge,
Center,
Group,
Skeleton,
Text,
Tooltip
} from '@mantine/core';
import {
IconBell,
IconExclamationCircle,
IconLink,
IconLock
} from '@tabler/icons-react';
import { Badge, Center, Group, Skeleton, Text, Tooltip } from '@mantine/core';
import { IconBell, IconExclamationCircle, IconLock } from '@tabler/icons-react';
import { ProgressBar } from '@lib/components/ProgressBar';
import { YesNoButton } from '@lib/components/YesNoButton';
import type { ModelType } from '@lib/enums/ModelType';
import { resolveItem } from '@lib/functions/Conversion';
import { cancelEvent } from '@lib/functions/Events';
import { renderLink } from '@lib/functions/Links';
import type { TableColumn, TableColumnProps } from '@lib/types/Tables';
import type { ReactNode } from 'react';
import {
@@ -508,29 +495,8 @@ export function LinkColumn(props: TableColumnProps): TableColumn {
defaultVisible: false,
copyable: true,
copyAccessor: props.accessor ?? 'link',
render: (record: any) => {
const url = resolveItem(record, props.accessor ?? 'link');
if (!url) {
return '-';
}
return (
<Anchor
href={url}
target='_blank'
rel='noreferrer noopener'
onClick={(event: any) => {
cancelEvent(event);
window.open(url, '_blank', 'noopener,noreferrer');
}}
title={url}
>
<IconLink size={18} />
</Anchor>
);
},
render: (record: any) =>
renderLink(resolveItem(record, props.accessor ?? 'link')),
...props
};
}