Skip to content

Commit 61d1a01

Browse files
authored
Fix null contentRef handling in RecordColumnValue (#3566)
Co-authored-by: Nicolas Dorseuil <[email protected]>
1 parent f9a2977 commit 61d1a01

File tree

2 files changed

+87
-16
lines changed

2 files changed

+87
-16
lines changed

packages/gitbook/src/components/DocumentView/Table/RecordColumnValue.tsx

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ import { getSimplifiedContentType } from '@/lib/files';
1515
import { resolveContentRef } from '@/lib/references';
1616
import { tcls } from '@/lib/tailwind';
1717
import { filterOutNullable } from '@/lib/typescript';
18-
1918
import type { BlockProps } from '../Block';
2019
import { Blocks } from '../Blocks';
2120
import { FileIcon } from '../FileIcon';
2221
import type { TableRecordKV } from './Table';
23-
import { type VerticalAlignment, getColumnAlignment } from './utils';
22+
import {
23+
type VerticalAlignment,
24+
getColumnAlignment,
25+
isContentRef,
26+
isDocumentTableImageRecord,
27+
isStringArray,
28+
} from './utils';
2429

2530
const alignmentMap: Record<'text-left' | 'text-center' | 'text-right', string> = {
2631
'text-left': '**:text-left text-left',
@@ -58,18 +63,28 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
5863
return null;
5964
}
6065

66+
// Because definition and value depends on column, we have to check typing in each case at runtime.
67+
// Validation should have been done at the API level, but we can't know typing based on `definition.type`.
68+
// OpenAPI types cannot really handle discriminated unions based on a dynamic key.
6169
switch (definition.type) {
62-
case 'checkbox':
70+
case 'checkbox': {
71+
if (value === null || typeof value !== 'boolean') {
72+
return null;
73+
}
6374
return (
6475
<Checkbox
6576
className={tcls('w-5', 'h-5')}
66-
checked={value as boolean}
77+
checked={value}
6778
disabled={true}
6879
aria-labelledby={ariaLabelledBy}
6980
/>
7081
);
82+
}
7183
case 'rating': {
72-
const rating = value as number;
84+
if (typeof value !== 'number') {
85+
return null;
86+
}
87+
const rating = value;
7388
const max = definition.max;
7489

7590
return (
@@ -108,15 +123,21 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
108123
</Tag>
109124
);
110125
}
111-
case 'number':
126+
case 'number': {
127+
if (typeof value !== 'number') {
128+
return null;
129+
}
112130
return (
113131
<Tag
114132
className={tcls('text-base', 'tabular-nums', 'tracking-tighter')}
115133
aria-labelledby={ariaLabelledBy}
116134
>{`${value}`}</Tag>
117135
);
136+
}
118137
case 'text': {
119-
// @ts-ignore
138+
if (typeof value !== 'string') {
139+
return null;
140+
}
120141
const fragment = getNodeFragmentByName(block, value);
121142
if (!fragment) {
122143
return <Tag className={tcls(['w-full', verticalAlignment])}>{''}</Tag>;
@@ -149,8 +170,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
149170
);
150171
}
151172
case 'files': {
173+
if (!isStringArray(value)) {
174+
return null;
175+
}
152176
const files = await Promise.all(
153-
(value as string[]).map((fileId) =>
177+
value.map((fileId) =>
154178
context.contentContext
155179
? resolveContentRef(
156180
{
@@ -221,10 +245,12 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
221245
);
222246
}
223247
case 'content-ref': {
224-
const contentRef = value ? (value as ContentRef) : null;
248+
if (value === null || !isContentRef(value)) {
249+
return null;
250+
}
225251
const resolved =
226-
contentRef && context.contentContext
227-
? await resolveContentRef(contentRef, context.contentContext, {
252+
value && context.contentContext
253+
? await resolveContentRef(value, context.contentContext, {
228254
resolveAnchorText: true,
229255
iconStyle: ['mr-2', 'text-tint-subtle'],
230256
})
@@ -239,11 +265,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
239265
<StyledLink
240266
href={resolved.href}
241267
insights={
242-
contentRef
268+
value
243269
? {
244270
type: 'link_click',
245271
link: {
246-
target: contentRef,
272+
target: value,
247273
position: SiteInsightsLinkPosition.Content,
248274
},
249275
}
@@ -257,8 +283,11 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
257283
);
258284
}
259285
case 'users': {
286+
if (!isStringArray(value)) {
287+
return null;
288+
}
260289
const resolved = await Promise.all(
261-
(value as string[]).map(async (userId) => {
290+
value.map(async (userId) => {
262291
const contentRef: ContentRefUser = {
263292
kind: 'user',
264293
user: userId,
@@ -295,10 +324,13 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
295324
);
296325
}
297326
case 'select': {
327+
if (!isStringArray(value)) {
328+
return null;
329+
}
298330
return (
299331
<Tag aria-labelledby={ariaLabelledBy}>
300332
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
301-
{(value as string[]).map((selectId) => {
333+
{value.map((selectId) => {
302334
const option = definition.options.find(
303335
(option) => option.value === selectId
304336
);
@@ -329,8 +361,15 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
329361
);
330362
}
331363
case 'image': {
364+
if (!isDocumentTableImageRecord(value)) {
365+
return null;
366+
}
367+
332368
const image = context.contentContext
333-
? await resolveContentRef(value as ContentRef, context.contentContext)
369+
? await resolveContentRef(
370+
'ref' in value ? value.ref : value,
371+
context.contentContext
372+
)
334373
: null;
335374

336375
if (!image) {

packages/gitbook/src/components/DocumentView/Table/utils.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,35 @@ export function getColumnVerticalAlignment(column: DocumentTableDefinition): Ver
123123

124124
return 'self-center';
125125
}
126+
127+
/**
128+
* Check if a value is a ContentRef.
129+
* @param ref The value to check.
130+
* @returns True if the value is a ContentRef, false otherwise.
131+
*/
132+
export function isContentRef(ref?: DocumentTableRecord['values'][string]): ref is ContentRef {
133+
return Boolean(ref && typeof ref === 'object' && 'kind' in ref);
134+
}
135+
136+
/**
137+
* Check if a value is an array of strings.
138+
* @param value The value to check.
139+
* @returns True if the value is an array of strings, false otherwise.
140+
*/
141+
export function isStringArray(value?: DocumentTableRecord['values'][string]): value is string[] {
142+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
143+
}
144+
145+
/**
146+
* Check if a value is a DocumentTableImageRecord.
147+
* @param value The value to check.
148+
* @returns True if the value is a DocumentTableImageRecord, false otherwise.
149+
*/
150+
export function isDocumentTableImageRecord(
151+
value?: DocumentTableRecord['values'][string]
152+
): value is DocumentTableImageRecord {
153+
if (isContentRef(value) && (value.kind === 'file' || value.kind === 'url')) {
154+
return true;
155+
}
156+
return Boolean(value && typeof value === 'object' && 'ref' in value && isContentRef(value.ref));
157+
}

0 commit comments

Comments
 (0)