mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-28 21:18:57 +00:00
Hide card fields that render no content (#4523)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user