refactor(ui): organize component source tree #35
1 changed files with 110 additions and 91 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { basename, join, relative } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
|
|
@ -9,89 +9,13 @@ const packageRoot = existsSync(join(root, "packages/ui/package.json"))
|
|||
const componentsDir = join(packageRoot, "src/components");
|
||||
const storiesDir = join(packageRoot, "src/stories");
|
||||
|
||||
const componentDomains = {
|
||||
foundation: [
|
||||
"Badge",
|
||||
"Button",
|
||||
"IconButton",
|
||||
"IconGlyph",
|
||||
"ProgressMeter",
|
||||
"Separator",
|
||||
"StatusBadge",
|
||||
"ThemeToggle",
|
||||
],
|
||||
frames: [
|
||||
"CornerBracketFrame",
|
||||
"DashboardFrame",
|
||||
"DashboardHeader",
|
||||
"DiagonalStripeField",
|
||||
"FooterCell",
|
||||
"FooterStatusCell",
|
||||
"GridFrame",
|
||||
"ModuleCard",
|
||||
"Panel",
|
||||
"ScanlineField",
|
||||
],
|
||||
operations: [
|
||||
"ServiceGroupPanel",
|
||||
"ServicePanel",
|
||||
"ServiceRow",
|
||||
"StatusStrip",
|
||||
"SystemState",
|
||||
"WeatherModule",
|
||||
],
|
||||
telemetry: [
|
||||
"LineChart",
|
||||
"SignalTrace",
|
||||
"Sparkline",
|
||||
"TelemetryCard",
|
||||
"TelemetryGrid",
|
||||
"TelemetryStrip",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const componentFiles: ReadonlyMap<string, string> = new Map(
|
||||
Object.entries(componentDomains).flatMap(([domain, components]) =>
|
||||
components.map((component) => [
|
||||
component,
|
||||
join(componentsDir, domain, `${component}.tsx`),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const requiredStoryFiles = [
|
||||
"Badge.stories.tsx",
|
||||
"Button.stories.tsx",
|
||||
"DashboardFrame.stories.tsx",
|
||||
"DashboardHeader.stories.tsx",
|
||||
"DashboardOnePager.stories.tsx",
|
||||
"CornerBracketFrame.stories.tsx",
|
||||
"DiagonalStripeField.stories.tsx",
|
||||
"FooterCell.stories.tsx",
|
||||
"FooterStatusCell.stories.tsx",
|
||||
"GridFrame.stories.tsx",
|
||||
"IconButton.stories.tsx",
|
||||
"IconGlyph.stories.tsx",
|
||||
"LineChart.stories.tsx",
|
||||
"ModuleCard.stories.tsx",
|
||||
"Panel.stories.tsx",
|
||||
"ProgressMeter.stories.tsx",
|
||||
"Separator.stories.tsx",
|
||||
"ServiceGroupPanel.stories.tsx",
|
||||
"ServicePanel.stories.tsx",
|
||||
"ServiceRow.stories.tsx",
|
||||
"SignalTrace.stories.tsx",
|
||||
"Sparkline.stories.tsx",
|
||||
"StatusBadge.stories.tsx",
|
||||
"StatusStrip.stories.tsx",
|
||||
"SystemState.stories.tsx",
|
||||
"ScanlineField.stories.tsx",
|
||||
"TelemetryCard.stories.tsx",
|
||||
"TelemetryGrid.stories.tsx",
|
||||
"TelemetryStrip.stories.tsx",
|
||||
"ThemeToggle.stories.tsx",
|
||||
"WeatherModule.stories.tsx",
|
||||
const allowedComponentDomains = [
|
||||
"foundation",
|
||||
"frames",
|
||||
"operations",
|
||||
"telemetry",
|
||||
] as const;
|
||||
const compositionStoryFiles = ["DashboardOnePager.stories.tsx"] as const;
|
||||
|
||||
const forbiddenStoryContent = [
|
||||
"dimension lab",
|
||||
|
|
@ -112,11 +36,23 @@ describe("Storybook inventory", () => {
|
|||
});
|
||||
|
||||
test("has a story for every reusable dashboard UI component", () => {
|
||||
for (const filename of requiredStoryFiles) {
|
||||
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true);
|
||||
for (const component of componentInventory()) {
|
||||
expect(
|
||||
existsSync(join(storiesDir, component.storyFile)),
|
||||
`${component.storyFile} is missing`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses only approved component domain folders", () => {
|
||||
const actualDomains = readdirSync(componentsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
expect(actualDomains).toEqual([...allowedComponentDomains].sort());
|
||||
});
|
||||
|
||||
test("loads dashboard component styles through the global app stylesheet", () => {
|
||||
const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8");
|
||||
const dashboardFrame = readFileSync(requiredComponentPath("DashboardFrame"), "utf8");
|
||||
|
|
@ -134,12 +70,33 @@ describe("Storybook inventory", () => {
|
|||
});
|
||||
|
||||
test("keeps component and story files paired as the UI inventory changes", () => {
|
||||
const componentStoryFiles = [...componentFiles.keys()].map(
|
||||
(component) => `${component}.stories.tsx`,
|
||||
const componentStoryFiles = new Set(
|
||||
componentInventory().map((component) => component.storyFile),
|
||||
);
|
||||
|
||||
for (const filename of componentStoryFiles) {
|
||||
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true);
|
||||
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const unpairedStoryFiles = storyFiles().filter(
|
||||
(filename) =>
|
||||
!componentStoryFiles.has(filename) &&
|
||||
!compositionStoryFiles.includes(
|
||||
filename as (typeof compositionStoryFiles)[number],
|
||||
),
|
||||
);
|
||||
expect(unpairedStoryFiles).toEqual([]);
|
||||
});
|
||||
|
||||
test("exports every reusable component through the package barrel", () => {
|
||||
const indexSource = readFileSync(join(packageRoot, "src/index.ts"), "utf8");
|
||||
|
||||
for (const component of componentInventory()) {
|
||||
expect(indexSource).toContain(
|
||||
`export { ${component.name} } from "${component.relativeExportPath}";`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -183,8 +140,12 @@ describe("Storybook inventory", () => {
|
|||
});
|
||||
|
||||
test("does not add deferred form/navigation primitives", () => {
|
||||
const componentNames = new Set(
|
||||
componentInventory().map((component) => component.name),
|
||||
);
|
||||
|
||||
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
|
||||
expect(componentFiles.has(component)).toBe(false);
|
||||
expect(componentNames.has(component)).toBe(false);
|
||||
expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false);
|
||||
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
|
||||
}
|
||||
|
|
@ -201,11 +162,69 @@ describe("Storybook inventory", () => {
|
|||
});
|
||||
|
||||
function requiredComponentPath(component: string): string {
|
||||
const componentPath = componentFiles.get(component);
|
||||
const componentPath = componentInventory().find(
|
||||
(entry) => entry.name === component,
|
||||
)?.path;
|
||||
|
||||
expect(componentPath, `${component} is missing from the UI component map`).toBeTypeOf(
|
||||
expect(componentPath, `${component} is missing from the UI component tree`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
|
||||
return componentPath as string;
|
||||
}
|
||||
|
||||
interface ComponentInventoryItem {
|
||||
name: string;
|
||||
path: string;
|
||||
relativeExportPath: string;
|
||||
storyFile: string;
|
||||
}
|
||||
|
||||
function componentInventory(): ComponentInventoryItem[] {
|
||||
const components = allowedComponentDomains
|
||||
.flatMap((domain) => collectComponentFiles(join(componentsDir, domain)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const names = components.map((component) => component.name);
|
||||
|
||||
expect(names).toEqual([...new Set(names)]);
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
function collectComponentFiles(directory: string): ComponentInventoryItem[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return collectComponentFiles(entryPath);
|
||||
}
|
||||
|
||||
if (
|
||||
!entry.isFile() ||
|
||||
!entry.name.endsWith(".tsx") ||
|
||||
entry.name.endsWith(".test.tsx")
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = basename(entry.name, ".tsx");
|
||||
const relativeExportPath = `./${relative(join(packageRoot, "src"), entryPath)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\.tsx$/, "")}`;
|
||||
|
||||
return [
|
||||
{
|
||||
name,
|
||||
path: entryPath,
|
||||
relativeExportPath,
|
||||
storyFile: `${name}.stories.tsx`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function storyFiles(): string[] {
|
||||
return readdirSync(storiesDir)
|
||||
.filter((filename) => filename.endsWith(".stories.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue