From b2dec563d8502c2461ba39d4c6e91984a2f3e3b9 Mon Sep 17 00:00:00 2001 From: storchk Date: Fri, 11 Sep 2026 09:21:03 +0200 Subject: [PATCH 1/5] docs(dashboard): add subcomponents tab documentation --- docs/dashboard/components/README.md | 3 ++- docs/dashboard/components/props-tracking.md | 2 +- docs/dashboard/components/subcomponents.md | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 docs/dashboard/components/subcomponents.md diff --git a/docs/dashboard/components/README.md b/docs/dashboard/components/README.md index 7fcee32..a59c93d 100644 --- a/docs/dashboard/components/README.md +++ b/docs/dashboard/components/README.md @@ -4,7 +4,7 @@ The Components page lists all your components, where you can [search and filter] ![Components page](../../images/components-page.png) -You can also get deeper insights into individual components: see the [Dependency tree](./dependency-tree.md) to understand relationships, and [Props tracking](./props-tracking.md) to see how each prop is used. +You can also get deeper insights into individual components: see the [Dependency tree](./dependency-tree.md) to understand relationships, [Props tracking](./props-tracking.md) to see how each prop is used, and [Subcomponents](./subcomponents.md) to explore component families. ## Sections @@ -12,6 +12,7 @@ You can also get deeper insights into individual components: see the [Dependency - [Component tags](./tags.md) - [Dependency tree](./dependency-tree.md) - [Props tracking](./props-tracking.md) +- [Subcomponents](./subcomponents.md) --- diff --git a/docs/dashboard/components/props-tracking.md b/docs/dashboard/components/props-tracking.md index 88c95ed..c20d88a 100644 --- a/docs/dashboard/components/props-tracking.md +++ b/docs/dashboard/components/props-tracking.md @@ -30,4 +30,4 @@ Under **Popular Charts**, Omlet displays unused component props that can be remo --- -← [Dependency tree](./dependency-tree.md) +← [Dependency tree](./dependency-tree.md) · [Subcomponents](./subcomponents.md) → diff --git a/docs/dashboard/components/subcomponents.md b/docs/dashboard/components/subcomponents.md new file mode 100644 index 0000000..3a37cf0 --- /dev/null +++ b/docs/dashboard/components/subcomponents.md @@ -0,0 +1,17 @@ +# Subcomponents + +The **Subcomponents** tab on the **Component Detail** page lists all subcomponents belonging to a component family — components that share the same root name and package as the selected component. + +For example, if a design system exports `Button.Primary`, `Button.Secondary`, and `Button.Link`, visiting the detail page for `Button` shows these as subcomponents. + +## View subcomponents + +Open the **Subcomponents** tab on a component's detail page to see the list. Each row shows the subcomponent name and how many times it is used across projects. Click a subcomponent to open its detail page. + +## Family usage + +The component detail panel also shows **# Root component used**, which reflects the total usage of the component family (the root component plus all of its subcomponents). + +--- + +← [Props tracking](./props-tracking.md) From d396881b3bb18ffad5b6acc244d4e48afc280114 Mon Sep 17 00:00:00 2001 From: storchk Date: Fri, 11 Sep 2026 09:21:58 +0200 Subject: [PATCH 2/5] feat(webapp): create subcomponents view, update component detail infromation --- .../pages/componentDetail/ComponentDetail.tsx | 53 ++++++++++- .../ComponentDetailInfo.tsx | 21 ++++- .../SubcomponentsTable.module.css | 92 +++++++++++++++++++ .../subcomponentsTable/SubcomponentsTable.tsx | 76 +++++++++++++++ 4 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 webapp/src/frontend/pages/componentDetail/subcomponentsTable/SubcomponentsTable.module.css create mode 100644 webapp/src/frontend/pages/componentDetail/subcomponentsTable/SubcomponentsTable.tsx diff --git a/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx b/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx index 994af5b..eb396cc 100644 --- a/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx +++ b/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx @@ -9,7 +9,9 @@ import { getLatestAnalysisComponent, getLatestAnalysisComponentDependencies, getLatestAnalysisComponentProps, + getLatestAnalysisComponentSubcomponents, } from "../../api/api"; +import { IconComponents } from "../../library/icons/IconComponents"; import { IconDependencyTree } from "../../library/icons/IconDependencyTree"; import { IconProps } from "../../library/icons/IconProps"; import { logError } from "../../logger"; @@ -20,6 +22,7 @@ import { useStore } from "../../providers/StoreProvider/StoreProvider"; import { ComponentDetailInfo } from "./componentDetailInfo/ComponentDetailInfo"; import { PropsTable } from "./propsTable/PropsTable"; import { PropUsages } from "./propUsages/PropUsages"; +import { SubcomponentsTable } from "./subcomponentsTable/SubcomponentsTable"; import { Tabs } from "./tabs/Tabs"; import { TreeViewWithReactFlowProvider as TreeView } from "./treeView/TreeView"; @@ -82,6 +85,8 @@ export function ComponentDetail() { const workspace = getWorkspace()!; const [component, setComponent] = useState(undefined); + const [subcomponents, setSubcomponents] = useState(undefined); + const [familyUsage, setFamilyUsage] = useState(undefined); const [, definitionId] = useMemo(() => componentSlug?.split("::") ?? [], [componentSlug]); const { data: customProperties } = useQuery({ @@ -95,6 +100,20 @@ export function ComponentDetail() { }, }); + const detailInfoCustomProperties = useMemo(() => { + if (!customProperties) { + return undefined; + } + const result: Record = { ...customProperties }; + if (subcomponents !== undefined) { + result.subcomponents = [subcomponents.length]; + } + if (familyUsage !== undefined) { + result.rootComponent = [familyUsage]; + } + return result; + }, [customProperties, familyUsage, subcomponents]); + useEffect(() => { if (activeTab === "dependency-tree" || componentProps?.definitionId === definitionId) { return; @@ -129,6 +148,21 @@ export function ComponentDetail() { fetchComponent(); }, [workspace, definitionId]); + useEffect(() => { + async function fetchSubcomponents() { + try { + const { subcomponents, familyUsage } = await getLatestAnalysisComponentSubcomponents(workspaceSlug!, encodeURIComponent(definitionId)); + setSubcomponents(subcomponents); + setFamilyUsage(familyUsage); + } catch (error) { + logError(error); + } + } + setSubcomponents(undefined); + setFamilyUsage(undefined); + fetchSubcomponents(); + }, [workspace, definitionId]); + useEffect(() => { if (activeTab !== "dependency-tree") { return; @@ -208,7 +242,7 @@ export function ComponentDetail() {
+ customProperties={detailInfoCustomProperties}/>
{( selectedProp @@ -237,6 +271,23 @@ export function ComponentDetail() { onPropClick={handlePropClick} /> ), }, + { + key: "subcomponents", + label: ( + <> + +
+ Subcomponents +
+ + ), + content: ( + + ), + }, { key: "dependency-tree", label: ( diff --git a/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx b/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx index c469b22..2b65374 100644 --- a/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx +++ b/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx @@ -98,6 +98,9 @@ export function ComponentDetailInfo({ component, customProperties }: Props) { const tags = getTags().filter(({ slug }) => tagSlugs.has(slug)); const spacedPath = path.split("/").join(`/${ZERO_WIDTH_SPACE}`); + const rootComponentUsage = customProperties?.rootComponent?.[0] as number | undefined; + const subcomponentsCount = customProperties?.subcomponents?.[0] as number | undefined; + const birthday = (() => { if (!createdAt) { return null; @@ -139,8 +142,14 @@ export function ComponentDetailInfo({ component, customProperties }: Props) { return null; } - const customPropertyNames = Object.keys(customProperties); - const customPropertyTypes = getCustomPropertyTypes(customProperties); + const filteredCustomProperties = Object.fromEntries( + Object.entries(customProperties).filter(([key]) => key !== "rootComponent" && key !== "subcomponents"), + ); + const customPropertyNames = Object.keys(filteredCustomProperties); + if (customPropertyNames.length === 0) { + return null; + } + const customPropertyTypes = getCustomPropertyTypes(filteredCustomProperties); return (
@@ -149,7 +158,7 @@ export function ComponentDetailInfo({ component, customProperties }: Props) { CUSTOM PROPERTIES {customPropertyNames.map(name => - + , )}
); @@ -177,7 +186,13 @@ export function ComponentDetailInfo({ component, customProperties }: Props) { {numOfDependencies} + {subcomponentsCount !== undefined && ( + + )} + {rootComponentUsage !== undefined && ( + + )} +
+
Name
+
# Used
+
+ {[...range(1, 5)].map(i => ( +
+ {i > 1 &&
} +
+ +
+
+ +
+
+ ))} +
+ ); + } + + if (subcomponents.length === 0) { + return ( + + ); + } + + return ( +
+
+
Name
+
# Used
+
+ {subcomponents.map(({ name, definitionId, numOfUsages }, i) => { + const componentSlug = encodeURIComponent(`${name}::${definitionId}`); + const to = generatePath(RoutePath.ComponentDetail, { + workspaceSlug, + componentSlug, + }); + + return ( +
+ {i > 0 &&
} + + {name} + +
+ {numOfUsages} +
+
+ ); + })} +
+ ); +} From a95341ae0c2ac0a5bff8f941fb3bf5e2214a6a82 Mon Sep 17 00:00:00 2001 From: storchk Date: Fri, 11 Sep 2026 09:22:46 +0200 Subject: [PATCH 3/5] feat(api): integrate get subcomponents api --- webapp/src/backend/router/api.ts | 68 +++++++++++++++ .../backend/service/component/component.ts | 82 +++++++++++++++++++ webapp/src/frontend/api/api.ts | 14 ++++ 3 files changed, 164 insertions(+) diff --git a/webapp/src/backend/router/api.ts b/webapp/src/backend/router/api.ts index c42f4e0..91bb68d 100644 --- a/webapp/src/backend/router/api.ts +++ b/webapp/src/backend/router/api.ts @@ -41,6 +41,7 @@ import { analyseTimeSeriesDataAsCSV, findLatestComponentsByDefinitionId, getComponentProps, + getComponentSubcomponents, getComponentUsagesWithParentComponent, getCustomProperties, getDependenciesFor, @@ -1733,6 +1734,73 @@ apiRouter.get("/workspaces/:workspaceSlug/components/:definitionId/props", } ); +apiRouter.get("/workspaces/:workspaceSlug/components/:definitionId/subcomponents", + authMiddleware({ credentialsRequired: false }), + requestValidator({ + params: { + schema: joi.object({ + workspaceSlug: joi.string(), + definitionId: joi.string(), + }), + }, + }), + async (req: Request<{ workspaceSlug: string; definitionId: string; }>, res: Response) => { + try { + const { + auth, + params: { + workspaceSlug, + definitionId, + }, + } = req; + + const workspace = await getWorkspaceIfAuthorized(workspaceSlug, UserPermission.READ, { auth }); + + const dataRevisionId = await getWorkspaceDataRevisionId(workspace.id); + const computedEtag = etag(JSON.stringify({ + cacheVersion: COMPONENTS_ETAG_CACHE_VERSION, + workspaceId: workspace.id, + dataRevisionId, + definitionId, + url: req.originalUrl, + })); + + const clientEtag = req.get("If-None-Match"); + if (clientEtag === computedEtag) { + return res.status(httpStatus.NOT_MODIFIED) + .set("ETag", computedEtag) + .set("Cache-Control", "private") + .set("Content-Type", "application/json") + .send(); + } + + const { subcomponents, familyUsage } = await getComponentSubcomponents(workspace.id, definitionId); + const projectMap = Object.fromEntries(workspace.projects.map(p => [p.packageName, p])); + subcomponents.forEach(component => { + component.packageName = projectMap[component.packageName]?.alias || component.packageName; + }); + + res.status(httpStatus.OK) + .set("ETag", computedEtag) + .set("Cache-Control", "private") + .json({ + subcomponents: subcomponents.map(component => component.toResponse()), + familyUsage, + }); + } catch (error) { + if (error instanceof WorkspaceNotFound || error instanceof MemberNotFound) { + throw new ClientError(httpStatus.NOT_FOUND, ErrorResponseCode.WORKSPACE_NOT_FOUND); + } + + if (error instanceof ComponentNotFound) { + throw new ClientError(httpStatus.NOT_FOUND, ErrorResponseCode.COMPONENT_NOT_FOUND); + } + + throw error; + } + } +); + apiRouter.get("/workspaces/:workspaceSlug/invites", authMiddleware({ credentialsRequired: false }), requestValidator({ diff --git a/webapp/src/backend/service/component/component.ts b/webapp/src/backend/service/component/component.ts index aa7f983..aeeb075 100644 --- a/webapp/src/backend/service/component/component.ts +++ b/webapp/src/backend/service/component/component.ts @@ -740,6 +740,88 @@ export async function findLatestComponentsByDefinitionId(workspaceId: string, de return result.map(doc => Component.fromAggregationResult(doc)); } +export async function getComponentSubcomponents( + workspaceId: string, + componentDefinitionId: string, +): Promise<{ subcomponents: Component[]; familyUsage: number; }> { + const latestAnalysisId = await getLatestIndexAnalysisId(workspaceId); + if (!latestAnalysisId) { + return { subcomponents: [], familyUsage: 0 }; + } + + const [parent] = await findLatestComponentsByDefinitionId(workspaceId, [componentDefinitionId]); + if (!parent) { + throw new ComponentNotFound(); + } + + const escapedName = escapeRegex(parent.name); + const regexPattern = new RegExp(`^${escapedName}\\.`); + + const result = await HistoricComponentIndexModel.aggregate([ + { + $match: { + workspace: new MongooseTypes.ObjectId(workspaceId), + lastAnalysis: new MongooseTypes.ObjectId(latestAnalysisId), + }, + }, + { + $unwind: { + path: "$entries", + }, + }, + { + $match: { + "entries.definitionId": { $ne: componentDefinitionId }, + "entries.component.name": regexPattern, + "entries.component.packageName": parent.packageName, + }, + }, + { + $addFields: { + "entries.component.numOfUsages": { + $size: "$entries.usingComponents", + }, + "entries.component.tags": "$entries.tags", + "entries.component.lastUsageChangedAt": "$entries.lastUsageChangedAt", + }, + }, + { + $replaceRoot: { + newRoot: { + $mergeObjects: [ + "$entries.component", + { + id: "$entries.component._id", + tags: { + $cond: { + if: { $eq: ["$entries.component.isInternal", false] }, + then: { + $concatArrays: ["$entries.tags", [RESERVED_TAGS.EXTERNAL.slug]], + }, + else: "$entries.tags", + }, + }, + numOfUsages: { + $size: "$entries.usingComponents", + }, + }, + ], + }, + }, + }, + { + $sort: { + name: 1, + }, + }, + ], {}); + + const subcomponents = result.map(doc => Component.fromAggregationResult(doc)); + const familyUsage = parent.numOfUsages + subcomponents.reduce((sum, c) => sum + c.numOfUsages, 0); + + return { subcomponents, familyUsage }; +} + export class CustomPropertyRequiredForAnalysis extends ServiceError { constructor() { super("Custom property value required for given analysis subject"); diff --git a/webapp/src/frontend/api/api.ts b/webapp/src/frontend/api/api.ts index 5190c04..fdf1c0a 100644 --- a/webapp/src/frontend/api/api.ts +++ b/webapp/src/frontend/api/api.ts @@ -457,6 +457,20 @@ export async function getLatestAnalysisComponentProps(workspaceSlug: string, com return handleResponse(response); } +interface SubcomponentsResponse { + subcomponents: RawComponent[]; + familyUsage: number; +} + +export async function getLatestAnalysisComponentSubcomponents(workspaceSlug: string, componentId: string): Promise<{ subcomponents: Component[]; familyUsage: number; }> { + const response = await http.get(`${base}/workspaces/${workspaceSlug}/components/${componentId}/subcomponents`); + const { subcomponents, familyUsage } = await handleResponse(response); + return { + subcomponents: subcomponents.map(transformComponent), + familyUsage, + }; +} + export async function getMembers(workspaceSlug: string): Promise { const response = await http.get(`${base}/workspaces/${workspaceSlug}/members`); return handleResponse(response); From c56afabf4273949f2985ab441f3720cc3a20b64b Mon Sep 17 00:00:00 2001 From: storchk Date: Fri, 11 Sep 2026 09:41:41 +0200 Subject: [PATCH 4/5] chore(webapp): simplify ComponentDetailInfo and wording --- docs/dashboard/components/subcomponents.md | 2 +- .../componentDetailInfo/ComponentDetailInfo.tsx | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/dashboard/components/subcomponents.md b/docs/dashboard/components/subcomponents.md index 3a37cf0..313e152 100644 --- a/docs/dashboard/components/subcomponents.md +++ b/docs/dashboard/components/subcomponents.md @@ -10,7 +10,7 @@ Open the **Subcomponents** tab on a component's detail page to see the list. Eac ## Family usage -The component detail panel also shows **# Root component used**, which reflects the total usage of the component family (the root component plus all of its subcomponents). +The component detail panel also shows **# Root used**, which reflects the total usage of the component family (the root component plus all of its subcomponents). --- diff --git a/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx b/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx index 2b65374..85aa66e 100644 --- a/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx +++ b/webapp/src/frontend/pages/componentDetail/componentDetailInfo/ComponentDetailInfo.tsx @@ -99,7 +99,6 @@ export function ComponentDetailInfo({ component, customProperties }: Props) { const spacedPath = path.split("/").join(`/${ZERO_WIDTH_SPACE}`); const rootComponentUsage = customProperties?.rootComponent?.[0] as number | undefined; - const subcomponentsCount = customProperties?.subcomponents?.[0] as number | undefined; const birthday = (() => { if (!createdAt) { @@ -186,12 +185,9 @@ export function ComponentDetailInfo({ component, customProperties }: Props) { {numOfDependencies} - {subcomponentsCount !== undefined && ( - - )} {rootComponentUsage !== undefined && ( - + )} Date: Fri, 11 Sep 2026 09:47:37 +0200 Subject: [PATCH 5/5] feat(webapp): display subcomponent count in ComponentDetail --- webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx b/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx index eb396cc..181d9ed 100644 --- a/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx +++ b/webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx @@ -277,7 +277,7 @@ export function ComponentDetail() { <>
- Subcomponents + Subcomponents ({subcomponents?.length ?? 0})
),