Skip to content

Commit a9ef527

Browse files
authored
Merge pull request #868 from d-zero-dev/claude/sheettable-hidden-rows-XPpEX
Add row visibility metadata to sheet table data
2 parents 28b0e81 + 86aa661 commit a9ef527

File tree

3 files changed

+193
-14
lines changed

3 files changed

+193
-14
lines changed

packages/@d-zero/google-sheets/src/sheet-table.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ import { Sheets } from './sheets/sheets.js';
99
// Google Sheets epoch (December 30, 1899)
1010
const SHEETS_EPOCH = new Date(1899, 11, 30).getTime();
1111

12+
/**
13+
* Configuration for a single header column in a sheet table.
14+
*/
1215
export type HeaderCell = {
1316
readonly label: string;
1417
readonly conditionalFormatRules?: sheets_v4.Schema$ConditionalFormatRule[];
1518
};
1619

20+
/**
21+
* Options for creating a {@link SheetTable}.
22+
*/
1723
export type SheetTableOptions = {
1824
readonly bodyStartRow?: number;
1925
readonly frozen?: {
@@ -22,16 +28,30 @@ export type SheetTableOptions = {
2228
};
2329
};
2430

31+
/**
32+
* Header configuration that searches for existing header labels in the sheet.
33+
* @template T - Record type whose keys correspond to header labels in the sheet
34+
*/
2535
export type SearchTableHeaders<T> = {
2636
readonly headerRowNumber?: number;
2737
readonly search: readonly (keyof T)[];
2838
};
2939

40+
/**
41+
* Header configuration that explicitly defines column headers and their labels.
42+
* @template T - Record type whose keys are used as header identifiers
43+
*/
3044
export type DefineHeader<T> = {
3145
readonly headerRowNumber?: number;
3246
readonly define: Record<keyof T, string | HeaderCell>;
3347
};
3448

49+
/**
50+
* Typed table interface for reading from and writing to a Google Sheets worksheet.
51+
*
52+
* Use the static {@link SheetTable.create} factory method to instantiate.
53+
* @template T - Record type representing a single row of data
54+
*/
3555
export class SheetTable<T> {
3656
readonly #auth: OAuth2Client;
3757
readonly #bodyStartRow: number;
@@ -67,6 +87,10 @@ export class SheetTable<T> {
6787
this.#bodyStartRow = options?.bodyStartRow ?? 2;
6888
}
6989

90+
/**
91+
* Appends rows to the sheet.
92+
* @param records - Array of row data keyed by header identifiers
93+
*/
7094
async addRecords(records: ReadonlyArray<Record<keyof T, string | CellData>>) {
7195
if (!this.#sheet) {
7296
throw new Error('Sheet is not created');
@@ -84,6 +108,14 @@ export class SheetTable<T> {
84108
);
85109
}
86110

111+
/**
112+
* Retrieves all data rows with header-mapped values and row visibility state.
113+
*
114+
* Each returned record includes:
115+
* - `hiddenByUser` (`boolean`): `true` if the row is manually hidden by a user
116+
* - `hiddenByFilter` (`boolean`): `true` if the row is hidden by a filter view
117+
* @returns Array of records typed as `T & { hiddenByUser: boolean; hiddenByFilter: boolean }`
118+
*/
87119
async getData() {
88120
if (!this.#sheet) {
89121
throw new Error('Sheet is not created');
@@ -112,17 +144,21 @@ export class SheetTable<T> {
112144
typeMap.set(firstColIndex + cellType.index, cellType.type);
113145
}
114146

115-
// Get all data
116-
const data = await this.#sheet.getValues(
117-
`${headers.at(0)?.row ?? 'A'}${this.#bodyStartRow}`,
118-
headers.at(-1)?.row ?? 'A',
119-
);
147+
// Get all data and row metadata in parallel
148+
const [data, rowMetadata] = await Promise.all([
149+
this.#sheet.getValues(
150+
`${headers.at(0)?.row ?? 'A'}${this.#bodyStartRow}`,
151+
headers.at(-1)?.row ?? 'A',
152+
),
153+
this.#sheet.getRowMetadata(this.#bodyStartRow),
154+
]);
120155

121156
if (data == null) {
122157
return [];
123158
}
124159

125-
const list = data.map((_d) => {
160+
const list = data.map((_d, rowIndex) => {
161+
const meta = rowMetadata[rowIndex];
126162
const _data: Partial<T> = {};
127163

128164
for (const header of headers) {
@@ -133,7 +169,11 @@ export class SheetTable<T> {
133169
_data[header.key] = convertValue(rawValue, cellType) as T[keyof T];
134170
}
135171

136-
return _data as T;
172+
return {
173+
..._data,
174+
hiddenByUser: meta?.hiddenByUser ?? false,
175+
hiddenByFilter: meta?.hiddenByFilter ?? false,
176+
} as T & { hiddenByUser: boolean; hiddenByFilter: boolean };
137177
});
138178

139179
return list;
@@ -177,6 +217,16 @@ export class SheetTable<T> {
177217
}
178218
}
179219

220+
/**
221+
* Creates and initializes a new {@link SheetTable} instance.
222+
* @template T - Record type representing a single row of data
223+
* @param sheetUrl - Full URL of the Google Spreadsheet
224+
* @param sheetName - Name of the worksheet tab to operate on
225+
* @param auth - Authenticated OAuth2 client for Google Sheets API access
226+
* @param header - Header configuration (define columns explicitly or search existing ones)
227+
* @param options - Additional table options such as frozen panes and body start row
228+
* @returns Initialized SheetTable instance ready for read/write operations
229+
*/
180230
static async create<T>(
181231
sheetUrl: string,
182232
sheetName: string,
@@ -197,10 +247,11 @@ interface Header<T> {
197247
}
198248

199249
/**
200-
*
201-
* @param sheet
202-
* @param headerRowNumber
203-
* @param keys
250+
* Resolves header keys to their column positions by reading the header row from the sheet.
251+
* @param sheet - Sheet instance to read headers from
252+
* @param headerRowNumber - 1-based row number where headers are located
253+
* @param keys - Header keys to search for in the header row
254+
* @returns Resolved headers sorted by column index, excluding keys not found
204255
*/
205256
async function getHeaders<T>(
206257
sheet: Sheet,
@@ -225,9 +276,9 @@ async function getHeaders<T>(
225276
}
226277

227278
/**
228-
* Convert column number to alphabet column name.
229-
* Example: 5 => "E", 100 => "CV"
230-
* @param col
279+
* Converts a 1-based column number to an alphabetic column name (e.g. 5 → "E", 100 → "CV").
280+
* @param col - 1-based column number
281+
* @returns Alphabetic column name
231282
*/
232283
function getClmName(col: number): string {
233284
const COUNT_OF_ALPHABET = 26;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, test, expect, vi } from 'vitest';
2+
3+
import { Sheet } from './sheet.js';
4+
5+
/**
6+
*
7+
* @param rowMetadata
8+
*/
9+
function createMockParent(rowMetadata: Record<string, unknown>[]) {
10+
return {
11+
getWithGridData: vi.fn().mockResolvedValue({
12+
data: {
13+
sheets: [
14+
{
15+
data: [
16+
{
17+
rowMetadata,
18+
},
19+
],
20+
},
21+
],
22+
},
23+
}),
24+
};
25+
}
26+
27+
describe('getRowMetadata', () => {
28+
const mockSheet = {
29+
properties: { title: 'TestSheet', sheetId: 0 },
30+
};
31+
32+
test('returns hiddenByUser: true for user-hidden rows', async () => {
33+
const parent = createMockParent([{ hiddenByUser: true }, { hiddenByUser: false }]);
34+
const sheet = new Sheet(mockSheet as never, parent as never);
35+
36+
const result = await sheet.getRowMetadata(2);
37+
38+
expect(result).toEqual([
39+
{ hiddenByUser: true, hiddenByFilter: false },
40+
{ hiddenByUser: false, hiddenByFilter: false },
41+
]);
42+
});
43+
44+
test('returns hiddenByFilter: true for filter-hidden rows', async () => {
45+
const parent = createMockParent([
46+
{ hiddenByFilter: true },
47+
{ hiddenByFilter: false },
48+
]);
49+
const sheet = new Sheet(mockSheet as never, parent as never);
50+
51+
const result = await sheet.getRowMetadata(2);
52+
53+
expect(result).toEqual([
54+
{ hiddenByUser: false, hiddenByFilter: true },
55+
{ hiddenByUser: false, hiddenByFilter: false },
56+
]);
57+
});
58+
59+
test('returns both true when hidden by user and filter', async () => {
60+
const parent = createMockParent([{ hiddenByUser: true, hiddenByFilter: true }]);
61+
const sheet = new Sheet(mockSheet as never, parent as never);
62+
63+
const result = await sheet.getRowMetadata(2);
64+
65+
expect(result).toEqual([{ hiddenByUser: true, hiddenByFilter: true }]);
66+
});
67+
68+
test('returns both false for visible rows', async () => {
69+
const parent = createMockParent([{}]);
70+
const sheet = new Sheet(mockSheet as never, parent as never);
71+
72+
const result = await sheet.getRowMetadata(2);
73+
74+
expect(result).toEqual([{ hiddenByUser: false, hiddenByFilter: false }]);
75+
});
76+
77+
test('returns empty array when rowMetadata is empty', async () => {
78+
const parent = createMockParent([]);
79+
const sheet = new Sheet(mockSheet as never, parent as never);
80+
81+
const result = await sheet.getRowMetadata(2);
82+
83+
expect(result).toEqual([]);
84+
});
85+
86+
test('returns empty array when API returns no sheets data', async () => {
87+
const parent = {
88+
getWithGridData: vi.fn().mockResolvedValue({
89+
data: { sheets: [] },
90+
}),
91+
};
92+
const sheet = new Sheet(mockSheet as never, parent as never);
93+
94+
const result = await sheet.getRowMetadata(2);
95+
96+
expect(result).toEqual([]);
97+
});
98+
99+
test('passes correct range to getWithGridData', async () => {
100+
const parent = createMockParent([]);
101+
const sheet = new Sheet(mockSheet as never, parent as never);
102+
103+
await sheet.getRowMetadata(3);
104+
105+
expect(parent.getWithGridData).toHaveBeenCalledWith("'TestSheet'!A3:A");
106+
});
107+
});

packages/@d-zero/google-sheets/src/sheets/sheet.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,27 @@ export class Sheet {
168168
return index;
169169
}
170170

171+
/**
172+
* Retrieves row visibility metadata starting from the specified row.
173+
*
174+
* - `hiddenByUser`: The row is manually hidden by a user (right-click → "Hide row")
175+
* - `hiddenByFilter`: The row is hidden by a filter view or filter condition
176+
* @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#DimensionProperties
177+
* @param startRow - The 1-based row number to start fetching metadata from
178+
* @returns Array of row visibility objects, one per row from `startRow` onward
179+
*/
180+
async getRowMetadata(startRow: number) {
181+
const res = await this.#parent.getWithGridData(
182+
`'${this.props.title}'!A${startRow}:A`,
183+
);
184+
const sheet = res.data.sheets?.[0];
185+
const rowMetadataList = sheet?.data?.[0]?.rowMetadata ?? [];
186+
return rowMetadataList.map((metadata) => ({
187+
hiddenByUser: metadata.hiddenByUser === true,
188+
hiddenByFilter: metadata.hiddenByFilter === true,
189+
}));
190+
}
191+
171192
async getValues(row: string, col: string) {
172193
const res = await this.#parent.get({
173194
range: `'${this.props.title}'!${row}:${col}`,

0 commit comments

Comments
 (0)