@nospt/plugin-dev-ai-hub 0.2.4 โ 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/DevAiHubResourceClient.esm.js +97 -0
- package/dist/api/DevAiHubResourceClient.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/AiResourcesPage.esm.js +150 -0
- package/dist/components/AiResourcesPage/AiResourcesPage.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/AiResourcesPage.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceCard.esm.js +112 -0
- package/dist/components/AiResourcesPage/ResourceCard.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/ResourceCard.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js +238 -0
- package/dist/components/AiResourcesPage/ResourceDetailPanel.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceDetailPanel.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/ResourceDetailPanel.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceFilters.esm.js +242 -0
- package/dist/components/AiResourcesPage/ResourceFilters.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceFilters.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/ResourceFilters.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js +366 -0
- package/dist/components/AiResourcesPage/ResourceInstallDialog.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/ResourceInstallDialog.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/ResourceInstallDialog.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/StatTiles.aurora.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/StatTiles.aurora.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/StatTiles.console.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/StatTiles.console.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/StatTiles.esm.js +82 -0
- package/dist/components/AiResourcesPage/StatTiles.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/StatTiles.module.css.esm.js +8 -0
- package/dist/components/AiResourcesPage/StatTiles.module.css.esm.js.map +1 -0
- package/dist/components/AiResourcesPage/index.esm.js +6 -0
- package/dist/components/AiResourcesPage/typeMeta.esm.js +34 -0
- package/dist/components/AiResourcesPage/typeMeta.esm.js.map +1 -0
- package/dist/components/ToolIcon/ToolIcon.esm.js +28 -10
- package/dist/components/ToolIcon/ToolIcon.esm.js.map +1 -1
- package/dist/hooks/useResourceBody.esm.js +39 -0
- package/dist/hooks/useResourceBody.esm.js.map +1 -0
- package/dist/hooks/useResources.esm.js +26 -0
- package/dist/hooks/useResources.esm.js.map +1 -0
- package/dist/hooks/useTelemetryCounts.esm.js +24 -0
- package/dist/hooks/useTelemetryCounts.esm.js.map +1 -0
- package/dist/index.d.ts +1 -38
- package/dist/index.esm.js +1 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/plugin.esm.js +15 -24
- package/dist/plugin.esm.js.map +1 -1
- package/dist/routes.esm.js +1 -2
- package/dist/routes.esm.js.map +1 -1
- package/package.json +17 -10
- package/.eslintrc.js +0 -1
- package/dev/index.tsx +0 -32
- package/dist/api/DevAiHubClient.esm.js +0 -78
- package/dist/api/DevAiHubClient.esm.js.map +0 -1
- package/dist/components/AssetCard/AssetCard.esm.js +0 -120
- package/dist/components/AssetCard/AssetCard.esm.js.map +0 -1
- package/dist/components/AssetCard/AssetCard.module.css.esm.js +0 -8
- package/dist/components/AssetCard/AssetCard.module.css.esm.js.map +0 -1
- package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js +0 -218
- package/dist/components/AssetDetailPanel/AssetDetailPanel.esm.js.map +0 -1
- package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js +0 -8
- package/dist/components/AssetDetailPanel/AssetDetailPanel.module.css.esm.js.map +0 -1
- package/dist/components/AssetFilters/AssetFilters.esm.js +0 -176
- package/dist/components/AssetFilters/AssetFilters.esm.js.map +0 -1
- package/dist/components/AssetFilters/AssetFilters.module.css.esm.js +0 -8
- package/dist/components/AssetFilters/AssetFilters.module.css.esm.js.map +0 -1
- package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js +0 -138
- package/dist/components/AssetInstallDialog/AssetInstallDialog.esm.js.map +0 -1
- package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js +0 -8
- package/dist/components/AssetInstallDialog/AssetInstallDialog.module.css.esm.js.map +0 -1
- package/dist/components/DevAiHubPage/DevAiHubPage.esm.js +0 -195
- package/dist/components/DevAiHubPage/DevAiHubPage.esm.js.map +0 -1
- package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js +0 -8
- package/dist/components/DevAiHubPage/DevAiHubPage.module.css.esm.js.map +0 -1
- package/dist/components/DevAiHubPage/index.esm.js +0 -6
- package/dist/components/McpConfigPage/McpConfigPage.esm.js +0 -200
- package/dist/components/McpConfigPage/McpConfigPage.esm.js.map +0 -1
- package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js +0 -8
- package/dist/components/McpConfigPage/McpConfigPage.module.css.esm.js.map +0 -1
- package/dist/components/McpConfigPage/index.esm.js +0 -6
- package/dist/components/McpConfigPage/index.esm.js.map +0 -1
- package/dist/hooks/index.esm.js +0 -114
- package/dist/hooks/index.esm.js.map +0 -1
- package/dist/pluginLegacy.esm.js +0 -27
- package/dist/pluginLegacy.esm.js.map +0 -1
- package/package.json-prepack +0 -85
- package/src/api/DevAiHubClient.ts +0 -110
- package/src/components/AssetCard/AssetCard.module.css +0 -153
- package/src/components/AssetCard/AssetCard.test.tsx +0 -182
- package/src/components/AssetCard/AssetCard.tsx +0 -152
- package/src/components/AssetCard/index.ts +0 -1
- package/src/components/AssetDetailPanel/AssetDetailPanel.module.css +0 -207
- package/src/components/AssetDetailPanel/AssetDetailPanel.tsx +0 -313
- package/src/components/AssetDetailPanel/index.ts +0 -1
- package/src/components/AssetFilters/AssetFilters.module.css +0 -67
- package/src/components/AssetFilters/AssetFilters.tsx +0 -219
- package/src/components/AssetFilters/index.ts +0 -2
- package/src/components/AssetInstallDialog/AssetInstallDialog.module.css +0 -72
- package/src/components/AssetInstallDialog/AssetInstallDialog.tsx +0 -176
- package/src/components/AssetInstallDialog/index.ts +0 -1
- package/src/components/DevAiHubPage/DevAiHubPage.module.css +0 -105
- package/src/components/DevAiHubPage/DevAiHubPage.tsx +0 -197
- package/src/components/DevAiHubPage/index.ts +0 -1
- package/src/components/McpConfigDialog/McpConfigDialog.module.css +0 -111
- package/src/components/McpConfigDialog/McpConfigDialog.tsx +0 -271
- package/src/components/McpConfigDialog/index.ts +0 -1
- package/src/components/McpConfigPage/McpConfigPage.module.css +0 -121
- package/src/components/McpConfigPage/McpConfigPage.tsx +0 -242
- package/src/components/McpConfigPage/index.ts +0 -1
- package/src/components/ToolIcon/ToolIcon.tsx +0 -65
- package/src/components/ToolIcon/index.ts +0 -1
- package/src/css-modules.d.ts +0 -4
- package/src/hooks/index.ts +0 -152
- package/src/index.ts +0 -10
- package/src/plugin.tsx +0 -60
- package/src/pluginLegacy.ts +0 -42
- package/src/routes.ts +0 -4
- package/tests/e2e/.auth.json +0 -14
- package/tests/e2e/asset-detail.spec.ts +0 -90
- package/tests/e2e/asset-filtering.spec.ts +0 -145
- package/tests/e2e/asset-installation.spec.ts +0 -74
- package/tests/e2e/dev-ai-hub-page.spec.ts +0 -89
- package/tests/e2e/fixtures/base.ts +0 -61
- package/tests/e2e/fixtures/mock-api.ts +0 -221
- package/tests/e2e/mcp-config.spec.ts +0 -88
- package/tsconfig.json +0 -10
- /package/dist/components/{DevAiHubPage โ AiResourcesPage}/index.esm.js.map +0 -0
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createApiRef,
|
|
3
|
-
DiscoveryApi,
|
|
4
|
-
FetchApi,
|
|
5
|
-
} from '@backstage/core-plugin-api';
|
|
6
|
-
import type {
|
|
7
|
-
AiAsset,
|
|
8
|
-
AiAssetListResponse,
|
|
9
|
-
AiHubProvider,
|
|
10
|
-
AiHubStats,
|
|
11
|
-
AssetListFilter,
|
|
12
|
-
} from '@nospt/plugin-dev-ai-hub-common';
|
|
13
|
-
|
|
14
|
-
export const devAiHubApiRef = createApiRef<DevAiHubApi>({
|
|
15
|
-
id: 'plugin.dev-ai-hub.service',
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
export interface DevAiHubApi {
|
|
19
|
-
listAssets(filter?: AssetListFilter): Promise<AiAssetListResponse>;
|
|
20
|
-
getAsset(id: string): Promise<AiAsset>;
|
|
21
|
-
getAssetRaw(id: string): Promise<string>;
|
|
22
|
-
/** Returns the absolute URL for the download endpoint (zip for skills, md for others). */
|
|
23
|
-
getDownloadUrl(id: string): Promise<string>;
|
|
24
|
-
trackInstall(id: string): Promise<void>;
|
|
25
|
-
listProviders(): Promise<AiHubProvider[]>;
|
|
26
|
-
getProviderStatus(id: string): Promise<AiHubProvider>;
|
|
27
|
-
triggerSync(id: string): Promise<void>;
|
|
28
|
-
getStats(): Promise<AiHubStats>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class DevAiHubClient implements DevAiHubApi {
|
|
32
|
-
constructor(
|
|
33
|
-
private readonly discoveryApi: DiscoveryApi,
|
|
34
|
-
private readonly fetchApi: FetchApi,
|
|
35
|
-
) {}
|
|
36
|
-
|
|
37
|
-
private async baseUrl(): Promise<string> {
|
|
38
|
-
return this.discoveryApi.getBaseUrl('dev-ai-hub');
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
42
|
-
const base = await this.baseUrl();
|
|
43
|
-
const response = await this.fetchApi.fetch(`${base}${path}`, init);
|
|
44
|
-
if (!response.ok) {
|
|
45
|
-
const text = await response.text();
|
|
46
|
-
throw new Error(
|
|
47
|
-
`Dev AI Hub API error ${response.status}: ${text}`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return response.json() as Promise<T>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async listAssets(filter?: AssetListFilter): Promise<AiAssetListResponse> {
|
|
54
|
-
const params = new URLSearchParams();
|
|
55
|
-
if (filter?.type) params.set('type', filter.type);
|
|
56
|
-
if (filter?.tool) params.set('tool', filter.tool);
|
|
57
|
-
if (filter?.search) params.set('search', filter.search);
|
|
58
|
-
if (filter?.providerId) params.set('provider', filter.providerId);
|
|
59
|
-
if (filter?.tags?.length) params.set('tags', filter.tags.join(','));
|
|
60
|
-
if (filter?.page) params.set('page', String(filter.page));
|
|
61
|
-
if (filter?.pageSize) params.set('pageSize', String(filter.pageSize));
|
|
62
|
-
|
|
63
|
-
const qs = params.toString();
|
|
64
|
-
return this.fetch<AiAssetListResponse>(`/assets${qs ? `?${qs}` : ''}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async getAsset(id: string): Promise<AiAsset> {
|
|
68
|
-
return this.fetch<AiAsset>(`/assets/${encodeURIComponent(id)}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async getAssetRaw(id: string): Promise<string> {
|
|
72
|
-
const base = await this.baseUrl();
|
|
73
|
-
const response = await this.fetchApi.fetch(
|
|
74
|
-
`${base}/assets/${encodeURIComponent(id)}/raw`,
|
|
75
|
-
);
|
|
76
|
-
if (!response.ok) throw new Error(`Failed to fetch raw asset: ${response.status}`);
|
|
77
|
-
return response.text();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async getDownloadUrl(id: string): Promise<string> {
|
|
81
|
-
const base = await this.baseUrl();
|
|
82
|
-
return `${base}/assets/${encodeURIComponent(id)}/download`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async trackInstall(id: string): Promise<void> {
|
|
86
|
-
await this.fetch(`/assets/${encodeURIComponent(id)}/track-install`, {
|
|
87
|
-
method: 'POST',
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async listProviders(): Promise<AiHubProvider[]> {
|
|
92
|
-
return this.fetch<AiHubProvider[]>('/providers');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async getProviderStatus(id: string): Promise<AiHubProvider> {
|
|
96
|
-
return this.fetch<AiHubProvider>(
|
|
97
|
-
`/providers/${encodeURIComponent(id)}/status`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async triggerSync(id: string): Promise<void> {
|
|
102
|
-
await this.fetch(`/providers/${encodeURIComponent(id)}/sync`, {
|
|
103
|
-
method: 'POST',
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async getStats(): Promise<AiHubStats> {
|
|
108
|
-
return this.fetch<AiHubStats>('/stats');
|
|
109
|
-
}
|
|
110
|
-
}
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
@layer components {
|
|
2
|
-
.card {
|
|
3
|
-
height: 100%;
|
|
4
|
-
display: flex;
|
|
5
|
-
flex-direction: column;
|
|
6
|
-
border-radius: var(--bui-radius-2);
|
|
7
|
-
border: 1px solid var(--bui-border-1);
|
|
8
|
-
border-left: 3px solid var(--card-accent);
|
|
9
|
-
background-color: var(--bui-bg-neutral-1);
|
|
10
|
-
transition: all 0.18s ease;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
.card:hover {
|
|
14
|
-
box-shadow: 0 6px 24px color-mix(in srgb, var(--card-accent) 19%, transparent);
|
|
15
|
-
border-color: var(--card-accent);
|
|
16
|
-
transform: translateY(-2px);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
.cardContent {
|
|
20
|
-
padding: var(--bui-space-3);
|
|
21
|
-
padding-bottom: 0;
|
|
22
|
-
flex: 1;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
.header {
|
|
26
|
-
display: flex;
|
|
27
|
-
align-items: flex-start;
|
|
28
|
-
gap: var(--bui-space-2);
|
|
29
|
-
margin-bottom: var(--bui-space-2);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
.iconBox {
|
|
33
|
-
width: 36px;
|
|
34
|
-
height: 36px;
|
|
35
|
-
border-radius: var(--bui-radius-2);
|
|
36
|
-
display: flex;
|
|
37
|
-
align-items: center;
|
|
38
|
-
justify-content: center;
|
|
39
|
-
flex-shrink: 0;
|
|
40
|
-
box-shadow: 0 2px 8px color-mix(in srgb, var(--card-accent) 15%, transparent);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.iconImage {
|
|
44
|
-
width: 20px;
|
|
45
|
-
height: 20px;
|
|
46
|
-
object-fit: contain;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
.headerInfo {
|
|
50
|
-
flex: 1;
|
|
51
|
-
min-width: 0;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
.titleRow {
|
|
55
|
-
display: flex;
|
|
56
|
-
align-items: center;
|
|
57
|
-
gap: var(--bui-space-1);
|
|
58
|
-
margin-bottom: 2px;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
.title {
|
|
62
|
-
line-height: 1.2;
|
|
63
|
-
flex: 1;
|
|
64
|
-
overflow: hidden;
|
|
65
|
-
white-space: nowrap;
|
|
66
|
-
text-overflow: ellipsis;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
.newBadge {
|
|
70
|
-
height: 16px;
|
|
71
|
-
flex-shrink: 0;
|
|
72
|
-
font-size: 0.58rem;
|
|
73
|
-
font-weight: 700;
|
|
74
|
-
background-color: #6AB04C;
|
|
75
|
-
color: #fff;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.description {
|
|
79
|
-
margin-bottom: var(--bui-space-2);
|
|
80
|
-
display: -webkit-box;
|
|
81
|
-
-webkit-line-clamp: 2;
|
|
82
|
-
-webkit-box-orient: vertical;
|
|
83
|
-
overflow: hidden;
|
|
84
|
-
line-height: 1.4;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.toolsRow :global(.bui-TagList) {
|
|
88
|
-
display: flex;
|
|
89
|
-
gap: var(--bui-space-1);
|
|
90
|
-
flex-wrap: wrap;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
.toolsRowWithTags {
|
|
94
|
-
margin-bottom: 6px;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
.toolChip {
|
|
98
|
-
height: 18px;
|
|
99
|
-
font-size: 0.65rem;
|
|
100
|
-
font-weight: 600;
|
|
101
|
-
background-color: var(--bui-bg-neutral-1-hover);
|
|
102
|
-
color: var(--bui-fg-secondary);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.tagsRow :global(.bui-TagList) {
|
|
106
|
-
display: flex;
|
|
107
|
-
gap: var(--bui-space-1);
|
|
108
|
-
flex-wrap: wrap;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
.tagChip {
|
|
112
|
-
height: 16px;
|
|
113
|
-
font-size: 0.6rem;
|
|
114
|
-
color: var(--bui-fg-secondary);
|
|
115
|
-
background-color: transparent;
|
|
116
|
-
border: 1px solid var(--bui-border-1);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
.cardActions {
|
|
120
|
-
padding: var(--bui-space-2) var(--bui-space-3);
|
|
121
|
-
display: flex;
|
|
122
|
-
justify-content: space-between;
|
|
123
|
-
align-items: center;
|
|
124
|
-
margin-top: auto;
|
|
125
|
-
border-top: 1px solid var(--bui-border-1);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.metaRow {
|
|
129
|
-
display: flex;
|
|
130
|
-
align-items: center;
|
|
131
|
-
gap: var(--bui-space-2);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.metaText {
|
|
135
|
-
font-size: 0.65rem;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
.installCountRow {
|
|
139
|
-
display: flex;
|
|
140
|
-
align-items: center;
|
|
141
|
-
gap: 2px;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
.installEmoji {
|
|
145
|
-
font-size: 0.65rem;
|
|
146
|
-
line-height: 1;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
.actionsRow {
|
|
150
|
-
display: flex;
|
|
151
|
-
gap: 2px;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import '@testing-library/jest-dom';
|
|
2
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
-
import { AssetCard } from './AssetCard';
|
|
4
|
-
import type { AiAssetSummary } from '@nospt/plugin-dev-ai-hub-common';
|
|
5
|
-
|
|
6
|
-
jest.mock('../ToolIcon', () => ({
|
|
7
|
-
ToolIcon: ({ tool }: { tool: string }) => <span data-testid={`tool-icon-${tool}`} />,
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
const NOW = new Date('2026-03-14T12:00:00Z').getTime();
|
|
11
|
-
|
|
12
|
-
function makeAsset(overrides?: Partial<AiAssetSummary>): AiAssetSummary {
|
|
13
|
-
return {
|
|
14
|
-
id: 'asset-1',
|
|
15
|
-
providerId: 'provider-1',
|
|
16
|
-
name: 'My Instruction',
|
|
17
|
-
description: 'A helpful coding instruction',
|
|
18
|
-
type: 'instruction',
|
|
19
|
-
tools: ['claude-code'],
|
|
20
|
-
tags: ['python', 'linting'],
|
|
21
|
-
author: 'Test Author',
|
|
22
|
-
version: '1.0.0',
|
|
23
|
-
installCount: 0,
|
|
24
|
-
syncedAt: '2026-03-14T12:00:00Z',
|
|
25
|
-
createdAt: '2026-03-01T00:00:00Z',
|
|
26
|
-
updatedAt: '2026-03-13T00:00:00Z', // 1 day ago โ "New"
|
|
27
|
-
...overrides,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe('AssetCard', () => {
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
jest.spyOn(Date, 'now').mockReturnValue(NOW);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
afterEach(() => {
|
|
37
|
-
jest.restoreAllMocks();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('renders asset name and description', () => {
|
|
41
|
-
render(<AssetCard asset={makeAsset()} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
42
|
-
expect(screen.getByText('My Instruction')).toBeInTheDocument();
|
|
43
|
-
expect(screen.getByText('A helpful coding instruction')).toBeInTheDocument();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('renders the type label', () => {
|
|
47
|
-
render(<AssetCard asset={makeAsset({ type: 'instruction' })} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
48
|
-
expect(screen.getByText('Instruction')).toBeInTheDocument();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('renders the correct type label for each type', () => {
|
|
52
|
-
const types = ['agent', 'skill', 'workflow'] as const;
|
|
53
|
-
const labels = ['Agent', 'Skill', 'Workflow'];
|
|
54
|
-
|
|
55
|
-
types.forEach((type, i) => {
|
|
56
|
-
const { unmount } = render(
|
|
57
|
-
<AssetCard asset={makeAsset({ type })} onView={jest.fn()} onInstall={jest.fn()} />,
|
|
58
|
-
);
|
|
59
|
-
expect(screen.getByText(labels[i])).toBeInTheDocument();
|
|
60
|
-
unmount();
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('renders author and version in the footer', () => {
|
|
65
|
-
render(<AssetCard asset={makeAsset()} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
66
|
-
expect(screen.getByText(/v1\.0\.0/)).toBeInTheDocument();
|
|
67
|
-
expect(screen.getByText(/Test Author/)).toBeInTheDocument();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('"New" badge', () => {
|
|
71
|
-
it('shows "New" badge when updatedAt is within 14 days', () => {
|
|
72
|
-
const recentDate = new Date(NOW - 5 * 24 * 60 * 60 * 1000).toISOString(); // 5 days ago
|
|
73
|
-
render(
|
|
74
|
-
<AssetCard asset={makeAsset({ updatedAt: recentDate })} onView={jest.fn()} onInstall={jest.fn()} />,
|
|
75
|
-
);
|
|
76
|
-
expect(screen.getByText('New')).toBeInTheDocument();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('does not show "New" badge when updatedAt is older than 14 days', () => {
|
|
80
|
-
const oldDate = new Date(NOW - 20 * 24 * 60 * 60 * 1000).toISOString(); // 20 days ago
|
|
81
|
-
render(
|
|
82
|
-
<AssetCard asset={makeAsset({ updatedAt: oldDate })} onView={jest.fn()} onInstall={jest.fn()} />,
|
|
83
|
-
);
|
|
84
|
-
expect(screen.queryByText('New')).not.toBeInTheDocument();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe('install count display', () => {
|
|
89
|
-
it('does not show install count when installCount is 0', () => {
|
|
90
|
-
render(<AssetCard asset={makeAsset({ installCount: 0 })} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
91
|
-
expect(screen.queryByText('โ')).not.toBeInTheDocument();
|
|
92
|
-
expect(screen.queryByText('๐ฅ')).not.toBeInTheDocument();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('shows "โ" arrow with count when installCount > 0 and < 5', () => {
|
|
96
|
-
render(<AssetCard asset={makeAsset({ installCount: 3 })} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
97
|
-
expect(screen.getByText('โ')).toBeInTheDocument();
|
|
98
|
-
expect(screen.getByText('3')).toBeInTheDocument();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('shows ๐ฅ emoji when installCount >= 5 (popular threshold)', () => {
|
|
102
|
-
render(<AssetCard asset={makeAsset({ installCount: 5 })} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
103
|
-
expect(screen.getByText('๐ฅ')).toBeInTheDocument();
|
|
104
|
-
expect(screen.getByText('5')).toBeInTheDocument();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('shows ๐ฅ emoji for installCount > 5', () => {
|
|
108
|
-
render(<AssetCard asset={makeAsset({ installCount: 42 })} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
109
|
-
expect(screen.getByText('๐ฅ')).toBeInTheDocument();
|
|
110
|
-
expect(screen.queryByText('โ')).not.toBeInTheDocument();
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('callbacks', () => {
|
|
115
|
-
it('calls onView with the asset id when "View details" button is clicked', () => {
|
|
116
|
-
const onView = jest.fn();
|
|
117
|
-
render(<AssetCard asset={makeAsset()} onView={onView} onInstall={jest.fn()} />);
|
|
118
|
-
fireEvent.click(screen.getByRole('button', { name: 'View details' }));
|
|
119
|
-
expect(onView).toHaveBeenCalledTimes(1);
|
|
120
|
-
expect(onView).toHaveBeenCalledWith('asset-1');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('calls onInstall with the asset id when "Install in editor" button is clicked', () => {
|
|
124
|
-
const onInstall = jest.fn();
|
|
125
|
-
render(<AssetCard asset={makeAsset()} onView={jest.fn()} onInstall={onInstall} />);
|
|
126
|
-
fireEvent.click(screen.getByRole('button', { name: 'Install in editor' }));
|
|
127
|
-
expect(onInstall).toHaveBeenCalledTimes(1);
|
|
128
|
-
expect(onInstall).toHaveBeenCalledWith('asset-1');
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe('tags', () => {
|
|
133
|
-
it('renders tags as chips prefixed with #', () => {
|
|
134
|
-
render(
|
|
135
|
-
<AssetCard asset={makeAsset({ tags: ['python', 'linting'] })} onView={jest.fn()} onInstall={jest.fn()} />,
|
|
136
|
-
);
|
|
137
|
-
expect(screen.getByText('#python')).toBeInTheDocument();
|
|
138
|
-
expect(screen.getByText('#linting')).toBeInTheDocument();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('renders at most 3 tags and shows "+N" for the rest', () => {
|
|
142
|
-
render(
|
|
143
|
-
<AssetCard
|
|
144
|
-
asset={makeAsset({ tags: ['a', 'b', 'c', 'd', 'e'] })}
|
|
145
|
-
onView={jest.fn()}
|
|
146
|
-
onInstall={jest.fn()}
|
|
147
|
-
/>,
|
|
148
|
-
);
|
|
149
|
-
expect(screen.getByText('#a')).toBeInTheDocument();
|
|
150
|
-
expect(screen.getByText('#b')).toBeInTheDocument();
|
|
151
|
-
expect(screen.getByText('#c')).toBeInTheDocument();
|
|
152
|
-
expect(screen.queryByText('#d')).not.toBeInTheDocument();
|
|
153
|
-
expect(screen.getByText('+2')).toBeInTheDocument();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('renders no tags section when tags is empty', () => {
|
|
157
|
-
render(<AssetCard asset={makeAsset({ tags: [] })} onView={jest.fn()} onInstall={jest.fn()} />);
|
|
158
|
-
expect(screen.queryByText(/#/)).not.toBeInTheDocument();
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe('tools', () => {
|
|
163
|
-
it('renders a tool icon for each tool', () => {
|
|
164
|
-
render(
|
|
165
|
-
<AssetCard
|
|
166
|
-
asset={makeAsset({ tools: ['claude-code', 'github-copilot'] })}
|
|
167
|
-
onView={jest.fn()}
|
|
168
|
-
onInstall={jest.fn()}
|
|
169
|
-
/>,
|
|
170
|
-
);
|
|
171
|
-
expect(screen.getByTestId('tool-icon-claude-code')).toBeInTheDocument();
|
|
172
|
-
expect(screen.getByTestId('tool-icon-github-copilot')).toBeInTheDocument();
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('shows "Universal" label for the "all" tool', () => {
|
|
176
|
-
render(
|
|
177
|
-
<AssetCard asset={makeAsset({ tools: ['all'] })} onView={jest.fn()} onInstall={jest.fn()} />,
|
|
178
|
-
);
|
|
179
|
-
expect(screen.getByText('Universal')).toBeInTheDocument();
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
});
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import type { ElementType } from 'react';
|
|
2
|
-
import { Box, Flex, Text, Card, CardBody, CardFooter, Tag, TagGroup, ButtonIcon, Tooltip, TooltipTrigger } from '@backstage/ui';
|
|
3
|
-
import { RiDownloadLine, RiExternalLinkLine } from '@remixicon/react';
|
|
4
|
-
import { RiArticleLine, RiRobot2Line, RiToolsLine, RiGitBranchLine } from '@remixicon/react';
|
|
5
|
-
import type { AiAssetSummary, AssetType, AiTool } from '@nospt/plugin-dev-ai-hub-common';
|
|
6
|
-
import { ToolIcon } from '../ToolIcon';
|
|
7
|
-
import styles from './AssetCard.module.css';
|
|
8
|
-
|
|
9
|
-
const POPULAR_THRESHOLD = 5;
|
|
10
|
-
const NEW_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
|
11
|
-
|
|
12
|
-
const TOOL_LABELS: Record<AiTool, string> = {
|
|
13
|
-
'all': 'Universal',
|
|
14
|
-
'claude-code': 'Claude Code',
|
|
15
|
-
'github-copilot': 'GitHub Copilot',
|
|
16
|
-
'google-gemini': 'Google Gemini',
|
|
17
|
-
'cursor': 'Cursor',
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const TYPE_CONFIG: Record<AssetType, { label: string; color: string; bg: string; Icon: ElementType }> = {
|
|
21
|
-
instruction: { label: 'Instruction', color: '#54A0FF', bg: 'rgba(84, 160, 255, 0.15)', Icon: RiArticleLine },
|
|
22
|
-
agent: { label: 'Agent', color: '#FF6B9D', bg: 'rgba(255, 107, 157, 0.15)', Icon: RiRobot2Line },
|
|
23
|
-
skill: { label: 'Skill', color: '#6AB04C', bg: 'rgba(106, 176, 76, 0.15)', Icon: RiToolsLine },
|
|
24
|
-
workflow: { label: 'Workflow', color: '#F9CA24', bg: 'rgba(249, 202, 36, 0.15)', Icon: RiGitBranchLine },
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
interface AssetCardProps {
|
|
28
|
-
asset: AiAssetSummary;
|
|
29
|
-
onView: (id: string) => void;
|
|
30
|
-
onInstall: (id: string) => void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function AssetCard({ asset, onView, onInstall }: AssetCardProps) {
|
|
34
|
-
const cfg = TYPE_CONFIG[asset.type];
|
|
35
|
-
const TypeIcon = cfg.Icon;
|
|
36
|
-
const isPopular = asset.installCount >= POPULAR_THRESHOLD;
|
|
37
|
-
const isNew = Date.now() - new Date(asset.updatedAt).getTime() < NEW_DAYS_MS;
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<Card
|
|
41
|
-
className={styles.card}
|
|
42
|
-
style={{ '--card-accent': cfg.color } as React.CSSProperties}
|
|
43
|
-
>
|
|
44
|
-
<CardBody className={styles.cardContent}>
|
|
45
|
-
{/* Header */}
|
|
46
|
-
<Flex className={styles.header}>
|
|
47
|
-
<Box
|
|
48
|
-
className={styles.iconBox}
|
|
49
|
-
style={{ backgroundColor: cfg.bg }}
|
|
50
|
-
>
|
|
51
|
-
{asset.icon ? (
|
|
52
|
-
<img
|
|
53
|
-
src={asset.icon}
|
|
54
|
-
alt={asset.label ?? asset.name}
|
|
55
|
-
className={styles.iconImage}
|
|
56
|
-
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
57
|
-
/>
|
|
58
|
-
) : (
|
|
59
|
-
<TypeIcon size={18} style={{ color: cfg.color }} />
|
|
60
|
-
)}
|
|
61
|
-
</Box>
|
|
62
|
-
|
|
63
|
-
<Box className={styles.headerInfo}>
|
|
64
|
-
<Flex className={styles.titleRow}>
|
|
65
|
-
<Text variant="body-medium" weight="bold" className={styles.title} title={asset.label ?? asset.name}>
|
|
66
|
-
{asset.label ?? asset.name}
|
|
67
|
-
</Text>
|
|
68
|
-
{isNew && (
|
|
69
|
-
<TagGroup aria-label="Status">
|
|
70
|
-
<Tag id="new" size="small" className={styles.newBadge}>New</Tag>
|
|
71
|
-
</TagGroup>
|
|
72
|
-
)}
|
|
73
|
-
</Flex>
|
|
74
|
-
<Text variant="body-x-small" style={{ color: cfg.color, fontWeight: 600 }}>
|
|
75
|
-
{cfg.label}
|
|
76
|
-
</Text>
|
|
77
|
-
</Box>
|
|
78
|
-
</Flex>
|
|
79
|
-
|
|
80
|
-
{/* Description */}
|
|
81
|
-
<Text variant="body-x-small" color="secondary" className={styles.description}>
|
|
82
|
-
{asset.description}
|
|
83
|
-
</Text>
|
|
84
|
-
|
|
85
|
-
{/* Tools */}
|
|
86
|
-
<TagGroup aria-label="Compatible tools" className={`${styles.toolsRow} ${asset.tags.length > 0 ? styles.toolsRowWithTags : ''}`}>
|
|
87
|
-
{asset.tools.map(tool => (
|
|
88
|
-
<Tag key={tool} id={tool} size="small" className={styles.toolChip} icon={<ToolIcon tool={tool as AiTool} size={12} />}>
|
|
89
|
-
{TOOL_LABELS[tool as AiTool] ?? tool}
|
|
90
|
-
</Tag>
|
|
91
|
-
))}
|
|
92
|
-
</TagGroup>
|
|
93
|
-
|
|
94
|
-
{/* Tags */}
|
|
95
|
-
{asset.tags.length > 0 && (
|
|
96
|
-
<>
|
|
97
|
-
<TagGroup aria-label="Tags" className={styles.tagsRow}>
|
|
98
|
-
{asset.tags.slice(0, 3).map(tag => (
|
|
99
|
-
<Tag key={tag} id={tag} size="small" className={styles.tagChip}>
|
|
100
|
-
#{tag}
|
|
101
|
-
</Tag>
|
|
102
|
-
))}
|
|
103
|
-
</TagGroup>
|
|
104
|
-
{asset.tags.length > 3 && (
|
|
105
|
-
<Text variant="body-x-small" color="secondary" style={{ alignSelf: 'center' }}>
|
|
106
|
-
+{asset.tags.length - 3}
|
|
107
|
-
</Text>
|
|
108
|
-
)}
|
|
109
|
-
</>
|
|
110
|
-
)}
|
|
111
|
-
</CardBody>
|
|
112
|
-
|
|
113
|
-
<CardFooter className={styles.cardActions}>
|
|
114
|
-
<Flex className={styles.metaRow}>
|
|
115
|
-
<Text variant="body-x-small" color="secondary" className={styles.metaText}>
|
|
116
|
-
v{asset.version} ยท {asset.author}
|
|
117
|
-
</Text>
|
|
118
|
-
{asset.installCount > 0 && (
|
|
119
|
-
<Flex className={styles.installCountRow}>
|
|
120
|
-
<Text className={styles.installEmoji}>
|
|
121
|
-
{isPopular ? '๐ฅ' : 'โ'}
|
|
122
|
-
</Text>
|
|
123
|
-
<Text variant="body-x-small" color="secondary" className={styles.metaText}>
|
|
124
|
-
{asset.installCount}
|
|
125
|
-
</Text>
|
|
126
|
-
</Flex>
|
|
127
|
-
)}
|
|
128
|
-
</Flex>
|
|
129
|
-
<Flex className={styles.actionsRow}>
|
|
130
|
-
<TooltipTrigger>
|
|
131
|
-
<ButtonIcon
|
|
132
|
-
aria-label="Install in editor"
|
|
133
|
-
icon={<RiDownloadLine size={16} />}
|
|
134
|
-
variant="tertiary"
|
|
135
|
-
onPress={() => onInstall(asset.id)}
|
|
136
|
-
/>
|
|
137
|
-
<Tooltip>Install in editor</Tooltip>
|
|
138
|
-
</TooltipTrigger>
|
|
139
|
-
<TooltipTrigger>
|
|
140
|
-
<ButtonIcon
|
|
141
|
-
aria-label="View details"
|
|
142
|
-
icon={<RiExternalLinkLine size={16} />}
|
|
143
|
-
variant="tertiary"
|
|
144
|
-
onPress={() => onView(asset.id)}
|
|
145
|
-
/>
|
|
146
|
-
<Tooltip>View details</Tooltip>
|
|
147
|
-
</TooltipTrigger>
|
|
148
|
-
</Flex>
|
|
149
|
-
</CardFooter>
|
|
150
|
-
</Card>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { AssetCard } from './AssetCard';
|