From 6928a9b00ab52ff5ef66897376ecd5ea39d5540c Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Thu, 20 Aug 2026 12:48:37 +0200 Subject: [PATCH] Hide card fields that render no content (#4523) Co-authored-by: Claude Opus 5 --- .changeset/hide-empty-card-fields.md | 5 + .../DocumentView/Table/RecordCard.tsx | 7 + .../Table/isRecordColumnEmpty.test.ts | 164 ++++++++++++++++++ .../DocumentView/Table/isRecordColumnEmpty.ts | 60 +++++++ packages/gitbook/src/lib/document.test.ts | 84 +++++++++ packages/gitbook/src/lib/document.tsx | 23 ++- 6 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 .changeset/hide-empty-card-fields.md create mode 100644 packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.test.ts create mode 100644 packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.ts diff --git a/.changeset/hide-empty-card-fields.md b/.changeset/hide-empty-card-fields.md new file mode 100644 index 000000000..132664631 --- /dev/null +++ b/.changeset/hide-empty-card-fields.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Hide card fields that render no content, along with their title diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx index ebce4b180..ddedaf3bb 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordCard.tsx @@ -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 ( diff --git a/packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.test.ts b/packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.test.ts new file mode 100644 index 000000000..bcc7760a0 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.test.ts @@ -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); + }); +}); diff --git a/packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.ts b/packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.ts new file mode 100644 index 000000000..62b4dc47a --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/isRecordColumnEmpty.ts @@ -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); + } +} diff --git a/packages/gitbook/src/lib/document.test.ts b/packages/gitbook/src/lib/document.test.ts index a0c10adda..d1a8da527 100644 --- a/packages/gitbook/src/lib/document.test.ts +++ b/packages/gitbook/src/lib/document.test.ts @@ -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({ diff --git a/packages/gitbook/src/lib/document.tsx b/packages/gitbook/src/lib/document.tsx index a580d66e8..7ddc988d2 100644 --- a/packages/gitbook/src/lib/document.tsx +++ b/packages/gitbook/src/lib/document.tsx @@ -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([ + '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)); }