Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/dashboard/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ 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

- [Search and filter components](./component-catalog.md)
- [Component tags](./tags.md)
- [Dependency tree](./dependency-tree.md)
- [Props tracking](./props-tracking.md)
- [Subcomponents](./subcomponents.md)

---

Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard/components/props-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) →
17 changes: 17 additions & 0 deletions docs/dashboard/components/subcomponents.md
Original file line number Diff line number Diff line change
@@ -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 used**, which reflects the total usage of the component family (the root component plus all of its subcomponents).

---

← [Props tracking](./props-tracking.md)
68 changes: 68 additions & 0 deletions webapp/src/backend/router/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
analyseTimeSeriesDataAsCSV,
findLatestComponentsByDefinitionId,
getComponentProps,
getComponentSubcomponents,
getComponentUsagesWithParentComponent,
getCustomProperties,
getDependenciesFor,
Expand Down Expand Up @@ -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({
Expand Down
82 changes: 82 additions & 0 deletions webapp/src/backend/service/component/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentAggregationResult>([
{
$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");
Expand Down
14 changes: 14 additions & 0 deletions webapp/src/frontend/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,20 @@ export async function getLatestAnalysisComponentProps(workspaceSlug: string, com
return handleResponse<ComponentProps>(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<SubcomponentsResponse>(response);
return {
subcomponents: subcomponents.map(transformComponent),
familyUsage,
};
}

export async function getMembers(workspaceSlug: string): Promise<Member[]> {
const response = await http.get(`${base}/workspaces/${workspaceSlug}/members`);
return handleResponse<Member[]>(response);
Expand Down
53 changes: 52 additions & 1 deletion webapp/src/frontend/pages/componentDetail/ComponentDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -82,6 +85,8 @@ export function ComponentDetail() {
const workspace = getWorkspace()!;

const [component, setComponent] = useState<Component | undefined>(undefined);
const [subcomponents, setSubcomponents] = useState<Component[] | undefined>(undefined);
const [familyUsage, setFamilyUsage] = useState<number | undefined>(undefined);
const [, definitionId] = useMemo(() => componentSlug?.split("::") ?? [], [componentSlug]);

const { data: customProperties } = useQuery({
Expand All @@ -95,6 +100,20 @@ export function ComponentDetail() {
},
});

const detailInfoCustomProperties = useMemo(() => {
if (!customProperties) {
return undefined;
}
const result: Record<string, (string | number | boolean | Date)[]> = { ...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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -208,7 +242,7 @@ export function ComponentDetail() {
<div className={classes.leftPanel}>
<ComponentDetailInfo
component={component}
customProperties={customProperties}/>
customProperties={detailInfoCustomProperties}/>
</div>
{(
selectedProp
Expand Down Expand Up @@ -237,6 +271,23 @@ export function ComponentDetail() {
onPropClick={handlePropClick} />
),
},
{
key: "subcomponents",
label: (
<>
<IconComponents />
<div>
Subcomponents ({subcomponents?.length ?? 0})
</div>
</>
),
content: (
<SubcomponentsTable
loading={subcomponents === undefined}
subcomponents={subcomponents ?? []}
workspaceSlug={workspaceSlug!} />
),
},
{
key: "dependency-tree",
label: (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ 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 birthday = (() => {
if (!createdAt) {
return null;
Expand Down Expand Up @@ -139,8 +141,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 (
<section>
Expand All @@ -149,7 +157,7 @@ export function ComponentDetailInfo({ component, customProperties }: Props) {
<span>CUSTOM PROPERTIES</span>
</H4>
{customPropertyNames.map(name =>
<ComponentField key={name} name={name} type={customPropertyTypes[name]} value={metadata[name]}/>
<ComponentField key={name} name={name} type={customPropertyTypes[name]} value={metadata[name]}/>,
)}
</section>
);
Expand Down Expand Up @@ -178,6 +186,9 @@ export function ComponentDetailInfo({ component, customProperties }: Props) {
<span>{numOfDependencies}</span>
</ComponentField>
<ComponentField name="# Used" value={numOfUsages}/>
{rootComponentUsage !== undefined && (
<ComponentField name="# Root used" value={rootComponentUsage}/>
)}
<ComponentField
name="Created"
value={createdAt ? formatDate(createdAt) : "Over a year"}
Expand Down
Loading