Hide card fields that render no content (#4523)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zeno Kapitein
2026-08-20 12:48:37 +02:00
committed by GitHub
parent 7bab574c63
commit 6928a9b00a
6 changed files with 340 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Hide card fields that render no content, along with their title
@@ -5,6 +5,7 @@ import {
SiteInsightsLinkPosition,
} from '@gitbook/api';
import { isRecordColumnEmpty } from './isRecordColumnEmpty';
import { RecordColumnValue } from './RecordColumnValue';
import { RecordCardStyles } from './styles';
import type { TableRecordKV, TableViewProps } from './Table';
@@ -153,6 +154,12 @@ export async function RecordCard(
return null;
}
// A field can hold nothing at all (an `if` block that didn't match, an
// empty value); rendering it would leave a gap and a dangling title.
if (isRecordColumnEmpty(block, record[1], column)) {
return null;
}
if (!view.hideColumnTitle && definition.title) {
const ariaLabelledBy = `${block.key}-${column}-title`;
return (
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'bun:test';
import type {
DocumentBlock,
DocumentBlockParagraph,
DocumentTableDefinition,
DocumentTableRecord,
} from '@gitbook/api';
import { isRecordColumnEmpty } from './isRecordColumnEmpty';
type Value = DocumentTableRecord['values'][string];
/**
* Check a single-column table holding `value`, with `fragmentNodes` as the column's text fragment.
*/
function isEmpty(
definition: DocumentTableDefinition,
value: Value,
fragmentNodes?: DocumentBlock[],
column = 'column'
): boolean {
return isRecordColumnEmpty(
{
object: 'block',
type: 'table',
isVoid: true,
data: {
view: { type: 'cards', cardSize: 'medium', columns: ['column'] },
records: {},
definition: { column: definition },
},
fragments: fragmentNodes
? [{ object: 'fragment', fragment: 'fragment', nodes: fragmentNodes }]
: [],
},
{ orderIndex: 'a', values: { column: value } },
column
);
}
function paragraph(text: string): DocumentBlockParagraph {
return {
object: 'block',
type: 'paragraph',
nodes: [{ object: 'text', leaves: [{ object: 'leaf', text, marks: [] }] }],
};
}
const ifBlock: DocumentBlock = {
object: 'block',
type: 'if',
data: { expression: 'visitor.claims.enabled' },
nodes: [paragraph('Hidden')],
};
const TEXT: DocumentTableDefinition = {
id: 'column',
title: 'Text',
type: 'text',
textAlignment: 'left',
};
describe('isRecordColumnEmpty', () => {
describe('text', () => {
it('keeps a fragment with content', () => {
expect(isEmpty(TEXT, 'fragment', [paragraph('Hello')])).toBe(false);
});
it('drops a fragment with no nodes', () => {
expect(isEmpty(TEXT, 'fragment', [])).toBe(true);
});
it('drops a missing fragment', () => {
expect(isEmpty(TEXT, 'fragment')).toBe(true);
});
it('drops blank paragraphs, if blocks, and a mix of the two', () => {
expect(isEmpty(TEXT, 'fragment', [paragraph(''), paragraph(' ')])).toBe(true);
expect(isEmpty(TEXT, 'fragment', [ifBlock])).toBe(true);
expect(isEmpty(TEXT, 'fragment', [ifBlock, paragraph('')])).toBe(true);
});
it('keeps a fragment whose blocks paint something of their own', () => {
expect(
isEmpty(TEXT, 'fragment', [
paragraph(''),
{ object: 'block', type: 'divider', isVoid: true, data: {} },
])
).toBe(false);
// A hint renders its coloured box however blank its content is.
expect(
isEmpty(TEXT, 'fragment', [
{
object: 'block',
type: 'hint',
data: { style: 'info' },
nodes: [paragraph('')],
},
])
).toBe(false);
});
});
describe('other column types', () => {
it('keeps an unchecked checkbox, drops a missing one', () => {
const checkbox: DocumentTableDefinition = { id: 'column', title: '', type: 'checkbox' };
expect(isEmpty(checkbox, false)).toBe(false);
expect(isEmpty(checkbox, null)).toBe(true);
});
it('keeps a zero number, drops an unrated rating', () => {
const rating: DocumentTableDefinition = {
id: 'column',
title: '',
type: 'rating',
max: 5,
};
expect(isEmpty({ id: 'column', title: '', type: 'number' }, 0)).toBe(false);
expect(isEmpty(rating, 3)).toBe(false);
expect(isEmpty(rating, 0)).toBe(true);
});
it('drops a select with no matching option', () => {
const select: DocumentTableDefinition = {
id: 'column',
title: '',
type: 'select',
multiple: true,
options: [{ value: 'a', label: 'A', color: 'blue' }],
};
expect(isEmpty(select, ['a'])).toBe(false);
expect(isEmpty(select, ['b'])).toBe(true);
expect(isEmpty(select, [])).toBe(true);
});
it('drops empty file and user lists', () => {
const files: DocumentTableDefinition = { id: 'column', title: '', type: 'files' };
const users: DocumentTableDefinition = {
id: 'column',
title: '',
type: 'users',
multiple: true,
};
expect(isEmpty(files, ['file-1'])).toBe(false);
expect(isEmpty(files, [])).toBe(true);
expect(isEmpty(users, ['user-1'])).toBe(false);
expect(isEmpty(users, [])).toBe(true);
});
it('drops a missing content ref and image', () => {
const ref: DocumentTableDefinition = { id: 'column', title: '', type: 'content-ref' };
const image: DocumentTableDefinition = { id: 'column', title: '', type: 'image' };
expect(isEmpty(ref, { kind: 'url', url: 'https://a.co' })).toBe(false);
expect(isEmpty(ref, null)).toBe(true);
expect(isEmpty(image, { ref: { kind: 'file', file: 'file-1' } })).toBe(false);
expect(isEmpty(image, null)).toBe(true);
});
});
it('drops a column without a definition', () => {
expect(isEmpty(TEXT, 'fragment', [paragraph('Hello')], 'unknown')).toBe(true);
});
});
@@ -0,0 +1,60 @@
import assertNever from 'assert-never';
import type { DocumentBlockTable, DocumentTableRecord } from '@gitbook/api';
import { isContentRef, isDocumentTableImageRecord, isStringArray } from './utils';
import { getNodeFragmentByName, isNodeEmpty } from '@/lib/document';
/**
* Check if a column of a record renders nothing at all, mirroring `RecordColumnValue`.
* Used by cards to drop a field (and its title) instead of leaving a hole in the layout.
*
* Reference-like columns (files, users, content-ref, image) are only checked against their raw
* value: whether a ref actually resolves is only known asynchronously, at render time.
*/
export function isRecordColumnEmpty(
block: DocumentBlockTable,
record: DocumentTableRecord,
column: string
): boolean {
const definition = block.data.definition[column];
const value = record.values[column];
if (!definition) {
return true;
}
switch (definition.type) {
case 'checkbox':
// An unchecked box is still rendered.
return typeof value !== 'boolean';
case 'rating':
// Mirror the renderer, which paints the stars on a truthy rating only.
return typeof value !== 'number' || !value;
case 'number':
return typeof value !== 'number';
case 'text': {
if (typeof value !== 'string') {
return true;
}
const fragment = getNodeFragmentByName(block, value);
return !fragment || isNodeEmpty(fragment);
}
case 'files':
case 'users':
return !isStringArray(value) || value.length === 0;
case 'select':
return (
!isStringArray(value) ||
!value.some((selectId) =>
definition.options.some((option) => option.value === selectId)
)
);
case 'content-ref':
return !isContentRef(value);
case 'image':
return !isDocumentTableImageRecord(value);
default:
assertNever(definition);
}
}
+84
View File
@@ -1,7 +1,15 @@
import { describe, expect, it } from 'bun:test';
import type { DocumentBlockParagraph } from '@gitbook/api';
import { getBlockTitle, isNodeEmpty } from './document';
const emptyParagraph: DocumentBlockParagraph = {
object: 'block',
type: 'paragraph',
nodes: [{ object: 'text', leaves: [{ object: 'leaf', text: '', marks: [] }] }],
};
describe('isNodeEmpty', () => {
it('should return true for a document with an empty paragraph', () => {
expect(
@@ -30,6 +38,82 @@ describe('isNodeEmpty', () => {
).toEqual(true);
});
it('should return true for a document with several empty paragraphs', () => {
expect(
isNodeEmpty({
object: 'document',
data: {},
nodes: [emptyParagraph, emptyParagraph],
})
).toEqual(true);
});
it('should return false for a paragraph whose children are not all blank', () => {
expect(
isNodeEmpty({
object: 'block',
type: 'paragraph',
nodes: [
{ object: 'text', leaves: [{ object: 'leaf', text: '', marks: [] }] },
{ object: 'text', leaves: [{ object: 'leaf', text: 'Hello', marks: [] }] },
],
})
).toEqual(false);
});
it('should return true for a document with only an if block', () => {
expect(
isNodeEmpty({
object: 'document',
data: {},
nodes: [
{
object: 'block',
type: 'if',
data: { expression: 'visitor.claims.enabled' },
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{
object: 'text',
leaves: [{ object: 'leaf', text: 'Hidden', marks: [] }],
},
],
},
],
},
emptyParagraph,
],
})
).toEqual(true);
});
it('should return false for a document with a tabs block whose panes are blank', () => {
expect(
isNodeEmpty({
object: 'document',
data: {},
nodes: [
{
object: 'block',
type: 'tabs',
nodes: [
{
object: 'block',
type: 'tabs-item',
data: { title: 'Shown in the tab bar' },
nodes: [emptyParagraph],
},
],
data: {},
},
],
})
).toEqual(false);
});
it('should return false for a document with an api block', () => {
expect(
isNodeEmpty({
+20 -3
View File
@@ -177,6 +177,14 @@ export function getNodeFragmentByName(
return fragment ?? null;
}
/** Blocks that paint nothing of their own: only the nodes they hold. */
const TEXT_ONLY_BLOCKS = new Set<DocumentBlock['type']>([
'paragraph',
'heading-1',
'heading-2',
'heading-3',
]);
/**
* Test if a node is empty.
*/
@@ -187,11 +195,20 @@ export function isNodeEmpty(
return false;
}
if (node.object !== 'text' && 'nodes' in node) {
if (node.nodes.length > 1) {
return false;
if (node.object === 'block') {
// `if` blocks are resolved by the API, one reaching us is never rendered.
if (node.type === 'if') {
return true;
}
// Any other block paints something of its own however blank its nodes are: a hint its box,
// a list its markers, a tabs-item the title and icon from its `data`.
if (!TEXT_ONLY_BLOCKS.has(node.type)) {
return false;
}
}
if (node.object !== 'text' && 'nodes' in node) {
return node.nodes.every((child) => isNodeEmpty(child));
}