Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 65 additions & 14 deletions packages/@d-zero/google-sheets/src/sheet-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ import { Sheets } from './sheets/sheets.js';
// Google Sheets epoch (December 30, 1899)
const SHEETS_EPOCH = new Date(1899, 11, 30).getTime();

/**
* Configuration for a single header column in a sheet table.
*/
export type HeaderCell = {
readonly label: string;
readonly conditionalFormatRules?: sheets_v4.Schema$ConditionalFormatRule[];
};

/**
* Options for creating a {@link SheetTable}.
*/
export type SheetTableOptions = {
readonly bodyStartRow?: number;
readonly frozen?: {
Expand All @@ -22,16 +28,30 @@ export type SheetTableOptions = {
};
};

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

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

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

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

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

// Get all data
const data = await this.#sheet.getValues(
`${headers.at(0)?.row ?? 'A'}${this.#bodyStartRow}`,
headers.at(-1)?.row ?? 'A',
);
// Get all data and row metadata in parallel
const [data, rowMetadata] = await Promise.all([
this.#sheet.getValues(
`${headers.at(0)?.row ?? 'A'}${this.#bodyStartRow}`,
headers.at(-1)?.row ?? 'A',
),
this.#sheet.getRowMetadata(this.#bodyStartRow),
]);

if (data == null) {
return [];
}

const list = data.map((_d) => {
const list = data.map((_d, rowIndex) => {
const meta = rowMetadata[rowIndex];
const _data: Partial<T> = {};

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

return _data as T;
return {
..._data,
hiddenByUser: meta?.hiddenByUser ?? false,
hiddenByFilter: meta?.hiddenByFilter ?? false,
} as T & { hiddenByUser: boolean; hiddenByFilter: boolean };
});

return list;
Expand Down Expand Up @@ -177,6 +217,16 @@ export class SheetTable<T> {
}
}

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

/**
*
* @param sheet
* @param headerRowNumber
* @param keys
* Resolves header keys to their column positions by reading the header row from the sheet.
* @param sheet - Sheet instance to read headers from
* @param headerRowNumber - 1-based row number where headers are located
* @param keys - Header keys to search for in the header row
* @returns Resolved headers sorted by column index, excluding keys not found
*/
async function getHeaders<T>(
sheet: Sheet,
Expand All @@ -225,9 +276,9 @@ async function getHeaders<T>(
}

/**
* Convert column number to alphabet column name.
* Example: 5 => "E", 100 => "CV"
* @param col
* Converts a 1-based column number to an alphabetic column name (e.g. 5 → "E", 100 → "CV").
* @param col - 1-based column number
* @returns Alphabetic column name
*/
function getClmName(col: number): string {
const COUNT_OF_ALPHABET = 26;
Expand Down
107 changes: 107 additions & 0 deletions packages/@d-zero/google-sheets/src/sheets/sheet.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, test, expect, vi } from 'vitest';

import { Sheet } from './sheet.js';

/**
*
* @param rowMetadata
*/
function createMockParent(rowMetadata: Record<string, unknown>[]) {
return {
getWithGridData: vi.fn().mockResolvedValue({
data: {
sheets: [
{
data: [
{
rowMetadata,
},
],
},
],
},
}),
};
}

describe('getRowMetadata', () => {
const mockSheet = {
properties: { title: 'TestSheet', sheetId: 0 },
};

test('returns hiddenByUser: true for user-hidden rows', async () => {
const parent = createMockParent([{ hiddenByUser: true }, { hiddenByUser: false }]);
const sheet = new Sheet(mockSheet as never, parent as never);

const result = await sheet.getRowMetadata(2);

expect(result).toEqual([
{ hiddenByUser: true, hiddenByFilter: false },
{ hiddenByUser: false, hiddenByFilter: false },
]);
});

test('returns hiddenByFilter: true for filter-hidden rows', async () => {
const parent = createMockParent([
{ hiddenByFilter: true },
{ hiddenByFilter: false },
]);
const sheet = new Sheet(mockSheet as never, parent as never);

const result = await sheet.getRowMetadata(2);

expect(result).toEqual([
{ hiddenByUser: false, hiddenByFilter: true },
{ hiddenByUser: false, hiddenByFilter: false },
]);
});

test('returns both true when hidden by user and filter', async () => {
const parent = createMockParent([{ hiddenByUser: true, hiddenByFilter: true }]);
const sheet = new Sheet(mockSheet as never, parent as never);

const result = await sheet.getRowMetadata(2);

expect(result).toEqual([{ hiddenByUser: true, hiddenByFilter: true }]);
});

test('returns both false for visible rows', async () => {
const parent = createMockParent([{}]);
const sheet = new Sheet(mockSheet as never, parent as never);

const result = await sheet.getRowMetadata(2);

expect(result).toEqual([{ hiddenByUser: false, hiddenByFilter: false }]);
});

test('returns empty array when rowMetadata is empty', async () => {
const parent = createMockParent([]);
const sheet = new Sheet(mockSheet as never, parent as never);

const result = await sheet.getRowMetadata(2);

expect(result).toEqual([]);
});

test('returns empty array when API returns no sheets data', async () => {
const parent = {
getWithGridData: vi.fn().mockResolvedValue({
data: { sheets: [] },
}),
};
const sheet = new Sheet(mockSheet as never, parent as never);

const result = await sheet.getRowMetadata(2);

expect(result).toEqual([]);
});

test('passes correct range to getWithGridData', async () => {
const parent = createMockParent([]);
const sheet = new Sheet(mockSheet as never, parent as never);

await sheet.getRowMetadata(3);

expect(parent.getWithGridData).toHaveBeenCalledWith("'TestSheet'!A3:A");
});
});
21 changes: 21 additions & 0 deletions packages/@d-zero/google-sheets/src/sheets/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,27 @@ export class Sheet {
return index;
}

/**
* Retrieves row visibility metadata starting from the specified row.
*
* - `hiddenByUser`: The row is manually hidden by a user (right-click → "Hide row")
* - `hiddenByFilter`: The row is hidden by a filter view or filter condition
* @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#DimensionProperties
* @param startRow - The 1-based row number to start fetching metadata from
* @returns Array of row visibility objects, one per row from `startRow` onward
*/
async getRowMetadata(startRow: number) {
const res = await this.#parent.getWithGridData(
`'${this.props.title}'!A${startRow}:A`,
);
const sheet = res.data.sheets?.[0];
const rowMetadataList = sheet?.data?.[0]?.rowMetadata ?? [];
return rowMetadataList.map((metadata) => ({
hiddenByUser: metadata.hiddenByUser === true,
hiddenByFilter: metadata.hiddenByFilter === true,
}));
}

async getValues(row: string, col: string) {
const res = await this.#parent.get({
range: `'${this.props.title}'!${row}:${col}`,
Expand Down
Loading