Skip to content

Commit

Permalink
Merge branch 'development' into make-url-in-license-optional
Browse files Browse the repository at this point in the history
  • Loading branch information
jan-zon-brainhub committed Nov 29, 2024
2 parents 1bff780 + 68f30de commit 878f857
Show file tree
Hide file tree
Showing 14 changed files with 531 additions and 43 deletions.
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
"cli": "node dist/cli.js",
"cli:init": "node dist/cli.js init",
"render-mock": "tsx --watch src/mock-render.tsx",
"typecheck": "tsc --noEmit true"
"typecheck": "tsc --noEmit true",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage"
},
"files": ["dist"],
"dependencies": {
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/components/__snapshots__/table.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Table Component with mocked terminal dimensions > renders the table with insufficient terminal width 1`] = `
"┌─────────┬────────────────────────────────┬─────┐
│ Name │ Description │ Age │
├─────────┼────────────────────────────────┼─────┤
│ Alice │ Develops and maintains │ 20 │
│ │ software, writes code, tests │ │
│ │ new features, and fixes bugs. │ │
│ Bob │ Creates robust applications by │ 30 │
│ │ programming, testing, and │ │
│ │ deploying software solutions. │ │
│ Charlie │ Responsible for designing and │ 27 │
│ │ implementing user interfaces, │ │
│ │ ensuring a seamless user │ │
│ │ experience. │ │
└─────────┴────────────────────────────────┴─────┘
"
`;

exports[`Table Component with mocked terminal dimensions > renders the table with sufficient terminal width 1`] = `
"┌─────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬─────┐
│ Name │ Description │ Age │
├─────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼─────┤
│ Alice │ Develops and maintains software, writes code, tests new features, and fixes bugs. │ 20 │
│ Bob │ Creates robust applications by programming, testing, and deploying software solutions. │ 30 │
│ Charlie │ Responsible for designing and implementing user interfaces, ensuring a seamless user experience. │ 27 │
└─────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴─────┘
"
`;
4 changes: 2 additions & 2 deletions packages/cli/src/components/audit-licenses/verbose-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const columns: Column<VerboseViewData>[] = [
{
title: "status",
accessor: "status",
cell: (rowData: VerboseViewData) => (
<Text color={getColorForStatus(rowData.status)}>{rowData.status}</Text>
cell: (content) => (
<Text color={getColorForStatus(content as LicenseStatus)}>{content}</Text>
),
},
{
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/components/table.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Text } from "ink";
import { render } from "ink-testing-library";
import { describe, expect, it, vi } from "vitest";
import { type Column, Table } from "./table.js";

type RowData = {
name: string;
description: string;
age: string;
};

vi.mock("../hooks/use-terminal-dimensions.js", async () => {
const actual = await vi.importActual("../hooks/use-terminal-dimensions.js");
return {
...actual,
useTerminalDimensions: vi.fn(),
};
});

describe("Table Component with mocked terminal dimensions", () => {
const columns: Column<RowData>[] = [
{ title: "Name", accessor: "name" },
{
title: "Description",
accessor: "description",
cell: (content) => <Text color="red">{content}</Text>,
},
{ title: "Age", accessor: "age" },
];

const data: RowData[] = [
{
name: "Alice",
description:
"Develops and maintains software, writes code, tests new features, and fixes bugs.",
age: "20",
},
{
name: "Bob",
description:
"Creates robust applications by programming, testing, and deploying software solutions.",
age: "30",
},
{
name: "Charlie",
description:
"Responsible for designing and implementing user interfaces, ensuring a seamless user experience.",
age: "27",
},
];

it("renders the table with sufficient terminal width", async () => {
const { useTerminalDimensions } = await import(
"../hooks/use-terminal-dimensions.js"
);
// @ts-expect-error
useTerminalDimensions.mockReturnValue([350, 20]);

const { lastFrame } = render(<Table columns={columns} data={data} />);
const output = lastFrame();

expect(output).toMatchSnapshot();
});

it("renders the table with insufficient terminal width", async () => {
const { useTerminalDimensions } = await import(
"../hooks/use-terminal-dimensions.js"
);
// @ts-expect-error
useTerminalDimensions.mockReturnValue([50, 20]);

const { lastFrame } = render(<Table columns={columns} data={data} />);
const output = lastFrame();

expect(output).toMatchSnapshot();
});
});
96 changes: 60 additions & 36 deletions packages/cli/src/components/table.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Box, Static, Text } from "ink";
import { type ReactNode, useMemo } from "react";
import { useTerminalDimensions } from "../hooks/use-terminal-dimensions.js";
import { calculateColumnWidths, splitIntoLines } from "../utils/table-utils.js";

export interface Column<T extends Record<string, string>> {
title: string;
accessor: keyof T;
cell?: (rowData: T) => ReactNode;
cell?: (value: string) => ReactNode; // This should not change length of the content in cell, only styling
}

interface TableProps<T extends Record<string, string>> {
Expand All @@ -16,17 +18,14 @@ export function Table<T extends Record<string, string>>({
columns,
data,
}: TableProps<T>) {
const columnsWithWidth = useMemo(() => {
const columnsWithWidth = [];
for (const column of columns) {
const columnWidth = Math.max(
...data.map((row) => (row[column.accessor] ?? "").length),
column.title.length,
);
columnsWithWidth.push({ ...column, width: columnWidth });
}
return columnsWithWidth;
}, [columns, data]);
const [terminalWidth] = useTerminalDimensions();

const availableSpace = terminalWidth - (3 * columns.length + 1);

const columnsWithWidth = useMemo(
() => calculateColumnWidths<T>(columns, data, availableSpace),
[columns, data, availableSpace],
);

const createHorizontalLine = (
leftChar: string,
Expand All @@ -52,14 +51,15 @@ export function Table<T extends Record<string, string>>({
<Box flexDirection="row" key="header-row">
<Text></Text>
{columnsWithWidth.map((columnWithWidth) => {
const content = columnWithWidth.title.padEnd(
columnWithWidth.width,
" ",
);
const content = columnWithWidth.title;
const padding = " ".repeat(columnWithWidth.width - content.length + 1);

return (
<Text key={columnWithWidth.title}>
{" "}
{content} {"│"}
{content}
{padding}
{"│"}
</Text>
);
})}
Expand All @@ -70,25 +70,49 @@ export function Table<T extends Record<string, string>>({
<Text key="top-line">{topLine}</Text>,
headerRow,
<Text key="header-separator">{headerSeparator}</Text>,
...data.map((row, rowIndex) => (
<Box key={Object.values(row).join(",")} flexDirection="row">
<Text></Text>
{columnsWithWidth.map((columnWithWidth, colIndex) => {
const cellValue = row[columnWithWidth.accessor]?.toString() ?? "";
const cellContent = columnWithWidth.cell ? (
columnWithWidth.cell(row)
) : (
<Text>{cellValue.padEnd(columnWithWidth.width, " ")}</Text>
);
return (
<Text key={`${columnWithWidth.title}-${rowIndex}-${colIndex}`}>
{" "}
{cellContent} {"│"}
</Text>
);
})}
</Box>
)),
...data.flatMap((row, rowIndex) => {
const rowLines = columnsWithWidth.map((columnWithWidth) => {
const cellValue = row[columnWithWidth.accessor]?.toString() ?? "";
return splitIntoLines(cellValue, columnWithWidth.width);
});

const maxLines = rowLines.reduce(
(max, lines) => Math.max(max, lines.length),
0,
);

return Array.from({ length: maxLines }, (_, lineIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: statically rendered component
<Box key={`row-${rowIndex}-line-${lineIndex}`} flexDirection="row">
<Text></Text>
{columnsWithWidth.map((columnWithWidth, colIndex) => {
const lines = rowLines[colIndex];
const lineText = lines?.[lineIndex] || "";

const cellContent = columnWithWidth.cell ? (
columnWithWidth.cell(lineText)
) : (
<Text>{lineText}</Text>
);

const padding = " ".repeat(
columnWithWidth.width - lineText.length + 1,
);

return (
<Text
key={`${columnWithWidth.title}-${rowIndex}-${colIndex}-${lineIndex}`}
>
{" "}
{cellContent}
{padding}
{"│"}
</Text>
);
})}
</Box>
));
}),
<Text key="bottom-line">{bottomLine}</Text>,
];

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/hooks/use-terminal-dimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useStdout } from "ink";
import { useCallback, useEffect, useState } from "react";

export function useTerminalDimensions() {
const { stdout } = useStdout();
const [dimensions, setDimensions] = useState<[number, number]>([
stdout.columns,
stdout.rows,
]);

const handleResize = useCallback(() => {
setDimensions([stdout.columns, stdout.rows]);
}, [stdout]);

useEffect(() => {
stdout.on("resize", handleResize);
return () => {
stdout.off("resize", handleResize);
};
}, [stdout, handleResize]);

return dimensions;
}
100 changes: 100 additions & 0 deletions packages/cli/src/utils/table-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { Column } from "../components/table.js";
import { calculateColumnWidths, splitIntoLines } from "./table-utils.js";

describe("table-utils", () => {
describe("calculateColumnWidths", () => {
it("should calculate the correct widths for simple input", () => {
const columns: Column<{ name: string; age: string }>[] = [
{ title: "Name", accessor: "name" },
{ title: "Age", accessor: "age" },
];
const data = [
{ name: "Alice", age: "30" },
{ name: "Bob", age: "25" },
];
const availableSpace = 20;

const expected = [
{ ...columns[0], width: 5, contentWidth: 5, titleWidth: 4 },
{ ...columns[1], width: 3, contentWidth: 2, titleWidth: 3 },
];

const result = calculateColumnWidths(columns, data, availableSpace);

expect(result).toEqual(expected);
});

it("should handle empty data", () => {
const columns: Column<{ name: string; age: string }>[] = [
{ title: "Name", accessor: "name" },
{ title: "Age", accessor: "age" },
];
const data: [] = [];
const availableSpace = 20;

const expected = [
{ ...columns[0], width: 4, contentWidth: 0, titleWidth: 4 },
{ ...columns[1], width: 3, contentWidth: 0, titleWidth: 3 },
];

const result = calculateColumnWidths(columns, data, availableSpace);

expect(result).toEqual(expected);
});

it("should shrink columns correctly when total width exceeds terminal width", () => {
const columns: Column<{ id: string; username: string; email: string }>[] =
[
{ title: "ID", accessor: "id" },
{ title: "Username", accessor: "username" },
{ title: "Email", accessor: "email" },
];
const data = [
{ id: "1", username: "johnsmith", email: "[email protected]" },
{ id: "2", username: "janedoe", email: "[email protected]" },
];
const availableSpace = 20;

const result = calculateColumnWidths(columns, data, availableSpace);

const totalWidth = result.reduce((acc, column) => acc + column.width, 0);
expect(totalWidth).toBeLessThanOrEqual(availableSpace);

expect(result[0]?.width).toBe(2); // same as "ID".length
expect(result[1]?.width).toBe(9); // same as "johnsmith".length
expect(result[2]?.width).toBe(9); // fill remaining space
});
});

describe("splitIntoLines", () => {
it("handles empty string", () => {
expect(splitIntoLines("", 10)).toEqual([]);
});

it("handles string shorter than width", () => {
const text = "Hello";
expect(splitIntoLines(text, 10)).toEqual([text]);
});

it("splits string at exact width when no spaces near boundary", () => {
const text = "HelloWorld";
expect(splitIntoLines(text, 5)).toEqual(["Hello", "World"]);
});

it("splits string without cutting words", () => {
const text = "Hello beautiful world";
expect(splitIntoLines(text, 10)).toEqual(["Hello", "beautiful", "world"]);
});

it("handles string where width ends at a space", () => {
const text = "Hello beautiful world";
expect(splitIntoLines(text, 16)).toEqual(["Hello beautiful", "world"]);
});

it("cuts to width when no spaces are found", () => {
const text = "HelloBeautifulWorld";
expect(splitIntoLines(text, 10)).toEqual(["HelloBeaut", "ifulWorld"]);
});
});
});
Loading

0 comments on commit 878f857

Please sign in to comment.