Skip to content

Commit 7f8ce78

Browse files
Fran McDadeFran McDade
authored andcommitted
feat!: create table cell for displaying links (#468)
1 parent 992a0f2 commit 7f8ce78

10 files changed

Lines changed: 403 additions & 8 deletions

File tree

package-lock.json

Lines changed: 78 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"devDependencies": {
2525
"@commitlint/cli": "^17.4.2",
2626
"@commitlint/config-conventional": "^17.4.2",
27+
"@emotion/jest": "^11.13.0",
2728
"@mui/types": "^7.4.0",
2829
"@next/eslint-plugin-next": "^14.2.28",
2930
"@storybook/addon-actions": "^8.6.4",
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { LinkProps, Link as MLink, Typography } from "@mui/material";
2+
import { CellContext, RowData } from "@tanstack/react-table";
3+
import { BaseComponentProps } from "components/types";
4+
import React from "react";
5+
import { isValidUrl } from "../../../../../../common/utils";
6+
import { TYPOGRAPHY_PROPS } from "../../../../../../styles/common/mui/typography";
7+
import { isClientSideNavigation } from "../../../../../Links/common/utils";
8+
import { getComponent, getRelAttribute, getTargetAttribute } from "./utils";
9+
10+
export const LinkCell = <
11+
T extends RowData,
12+
TValue extends LinkProps = LinkProps
13+
>({
14+
getValue,
15+
}: BaseComponentProps & CellContext<T, TValue>): JSX.Element | null => {
16+
const props = getValue();
17+
const {
18+
children,
19+
className,
20+
color,
21+
href = "",
22+
rel,
23+
target,
24+
underline,
25+
...linkProps
26+
} = props;
27+
28+
// Determine if the href should use client-side navigation.
29+
const isClientSide = isClientSideNavigation(href);
30+
31+
// Determine if the href is valid.
32+
const isValid = isClientSide || isValidUrl(href);
33+
34+
// If the href is invalid, return a Typography component.
35+
if (!isValid)
36+
return (
37+
<Typography
38+
className={className}
39+
color={TYPOGRAPHY_PROPS.COLOR.INHERIT}
40+
component="span"
41+
variant={TYPOGRAPHY_PROPS.VARIANT.INHERIT}
42+
{...linkProps}
43+
>
44+
{children}
45+
</Typography>
46+
);
47+
48+
// If the href is valid, return a Link component.
49+
return (
50+
<MLink
51+
className={className}
52+
color={color}
53+
component={getComponent(href, isClientSide)}
54+
href={href}
55+
rel={getRelAttribute(rel, isClientSide)}
56+
target={getTargetAttribute(target, isClientSide)}
57+
underline={underline}
58+
{...linkProps}
59+
>
60+
{children}
61+
</MLink>
62+
);
63+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ComponentProps } from "react";
2+
import { TYPOGRAPHY_PROPS } from "../../../../../../../styles/common/mui/typography";
3+
import { LinkCell } from "../linkCell";
4+
import { GetValue } from "./types";
5+
6+
export const CLIENT_SIDE_ARGS: Partial<ComponentProps<typeof LinkCell>> = {
7+
getValue: (() => ({
8+
children: "Explore",
9+
href: "/",
10+
})) as GetValue,
11+
};
12+
13+
export const EXTERNAL_ARGS: Partial<ComponentProps<typeof LinkCell>> = {
14+
getValue: (() => ({
15+
children: "Explore",
16+
href: "https://www.example.com",
17+
})) as GetValue,
18+
};
19+
20+
export const INVALID_ARGS: Partial<ComponentProps<typeof LinkCell>> = {
21+
getValue: (() => ({
22+
children: "Explore",
23+
})) as GetValue,
24+
};
25+
26+
export const WITH_CUSTOM_STYLE_ARGS: Partial<ComponentProps<typeof LinkCell>> =
27+
{
28+
getValue: (() => ({
29+
children: "Explore",
30+
color: "success",
31+
href: "/",
32+
underline: "none",
33+
variant: TYPOGRAPHY_PROPS.VARIANT.TEXT_BODY_SMALL_400,
34+
})) as GetValue,
35+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Meta, StoryObj } from "@storybook/react";
2+
import { LinkCell } from "../linkCell";
3+
import {
4+
CLIENT_SIDE_ARGS,
5+
EXTERNAL_ARGS,
6+
INVALID_ARGS,
7+
WITH_CUSTOM_STYLE_ARGS,
8+
} from "./args";
9+
10+
const meta: Meta<typeof LinkCell> = {
11+
component: LinkCell,
12+
};
13+
14+
export default meta;
15+
16+
type Story = StoryObj<typeof meta>;
17+
18+
export const ClientSide: Story = {
19+
args: CLIENT_SIDE_ARGS,
20+
};
21+
22+
export const External: Story = {
23+
args: EXTERNAL_ARGS,
24+
};
25+
26+
export const Invalid: Story = {
27+
args: INVALID_ARGS,
28+
};
29+
30+
export const WithCustomStyle: Story = {
31+
args: WITH_CUSTOM_STYLE_ARGS,
32+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { LinkProps } from "@mui/material";
2+
import { CellContext, RowData } from "@tanstack/react-table";
3+
4+
export type GetValue = CellContext<RowData, LinkProps>["getValue"];
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Link from "next/link";
2+
import { ElementType } from "react";
3+
import { isValidUrl } from "../../../../../../common/utils";
4+
import {
5+
ANCHOR_TARGET,
6+
REL_ATTRIBUTE,
7+
} from "../../../../../Links/common/entities";
8+
import {
9+
assertAnchorRelAttribute,
10+
assertAnchorTargetAttribute,
11+
} from "../../../../../common/Link/typeGuards";
12+
13+
/**
14+
* Returns the component to use for a link based on the given href and client-side navigation flag.
15+
* @param href - The href attribute to use.
16+
* @param isClientSide - Whether the link is a client-side navigation.
17+
* @returns The component to use for the link.
18+
*/
19+
export function getComponent(href: string, isClientSide: boolean): ElementType {
20+
if (isClientSide) return Link; // Use Next/Link for client-side navigation.
21+
if (isValidUrl(href)) return "a"; // Use anchor tag for external links.
22+
return "span"; // Use span for invalid links.
23+
}
24+
25+
/**
26+
* Returns the rel attribute for a link based on the given rel and client-side navigation flag.
27+
* @param rel - The rel attribute to use.
28+
* @param isClientSideNavigation - Whether the link is a client-side navigation.
29+
* @returns The rel attribute for the link.
30+
*/
31+
export function getRelAttribute(
32+
rel: string | undefined,
33+
isClientSideNavigation: boolean
34+
): string {
35+
if (rel) {
36+
assertAnchorRelAttribute(rel);
37+
return rel;
38+
}
39+
return isClientSideNavigation
40+
? REL_ATTRIBUTE.NO_OPENER
41+
: REL_ATTRIBUTE.NO_OPENER_NO_REFERRER;
42+
}
43+
44+
/**
45+
* Returns the target attribute for a link based on the given target and client-side navigation flag.
46+
* @param target - The target attribute to use.
47+
* @param isClientSideNavigation - Whether the link is a client-side navigation.
48+
* @returns The target attribute for the link.
49+
*/
50+
export function getTargetAttribute(
51+
target: string | undefined,
52+
isClientSideNavigation: boolean
53+
): string {
54+
if (target) {
55+
assertAnchorTargetAttribute(target);
56+
return target;
57+
}
58+
return isClientSideNavigation ? ANCHOR_TARGET.SELF : ANCHOR_TARGET.BLANK;
59+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ANCHOR_TARGET, REL_ATTRIBUTE } from "../../Links/common/entities";
2+
3+
/**
4+
* Asserts that the given value is a valid REL_ATTRIBUTE.
5+
* @param value - Value to assert.
6+
* @throws Error if the value is not a valid REL_ATTRIBUTE.
7+
*/
8+
export function assertAnchorRelAttribute(
9+
value: string
10+
): asserts value is REL_ATTRIBUTE {
11+
if (!Object.values(REL_ATTRIBUTE).includes(value as REL_ATTRIBUTE)) {
12+
throw new Error(
13+
`Expecting rel attribute: ${value} to be one of ${Object.values(
14+
REL_ATTRIBUTE
15+
)}`
16+
);
17+
}
18+
}
19+
20+
/**
21+
* Asserts that the given value is a valid ANCHOR_TARGET.
22+
* @param value - Value to assert.
23+
* @throws Error if the value is not a valid ANCHOR_TARGET.
24+
*/
25+
export function assertAnchorTargetAttribute(
26+
value: string
27+
): asserts value is ANCHOR_TARGET {
28+
if (!Object.values(ANCHOR_TARGET).includes(value as ANCHOR_TARGET)) {
29+
throw new Error(
30+
`Expecting anchor target: ${value} to be one of ${Object.values(
31+
ANCHOR_TARGET
32+
)}`
33+
);
34+
}
35+
}

0 commit comments

Comments
 (0)