Skip to content

Commit 3023ef9

Browse files
committed
wip: show data connectors on namespace page
1 parent ed29ee2 commit 3023ef9

18 files changed

+857
-11
lines changed
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*!
2+
* Copyright 2024 - Swiss Data Science Center (SDSC)
3+
* A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and
4+
* Eidgenössische Technische Hochschule Zürich (ETHZ).
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License
17+
*/
18+
19+
import cx from "classnames";
20+
import { ReactNode, useCallback, useEffect, useMemo } from "react";
21+
import { Globe2, Lock } from "react-bootstrap-icons";
22+
import {
23+
Link,
24+
generatePath,
25+
useSearchParams,
26+
} from "react-router-dom-v5-compat";
27+
import { Card, CardBody, Col, Row } from "reactstrap";
28+
29+
import { Loader } from "../../../components/Loader";
30+
import Pagination from "../../../components/Pagination";
31+
import { TimeCaption } from "../../../components/TimeCaption";
32+
import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert";
33+
import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants";
34+
import type { DataConnector } from "../../projectsV2/api/data-connectors.api";
35+
import {
36+
useGetNamespacesByNamespaceSlugQuery,
37+
useGetDataConnectorsQuery,
38+
} from "../../projectsV2/api/projectV2.enhanced-api";
39+
import ClampedParagraph from "../../../components/clamped/ClampedParagraph";
40+
41+
const DEFAULT_PER_PAGE = 12;
42+
const DEFAULT_PAGE_PARAM = "page";
43+
44+
interface DataConnectorListDisplayProps {
45+
namespace?: string;
46+
pageParam?: string;
47+
perPage?: number;
48+
emptyListElement?: ReactNode;
49+
}
50+
51+
export default function DataConnectorListDisplay({
52+
namespace: ns,
53+
pageParam: pageParam_,
54+
perPage: perPage_,
55+
emptyListElement,
56+
}: DataConnectorListDisplayProps) {
57+
const pageParam = useMemo(
58+
() => (pageParam_ ? pageParam_ : DEFAULT_PAGE_PARAM),
59+
[pageParam_]
60+
);
61+
const perPage = useMemo(
62+
() => (perPage_ ? perPage_ : DEFAULT_PER_PAGE),
63+
[perPage_]
64+
);
65+
66+
const [searchParams, setSearchParams] = useSearchParams();
67+
const onPageChange = useCallback(
68+
(pageNumber: number) => {
69+
setSearchParams((prevParams) => {
70+
if (pageNumber == 1) {
71+
prevParams.delete(pageParam);
72+
} else {
73+
prevParams.set(pageParam, `${pageNumber}`);
74+
}
75+
return prevParams;
76+
});
77+
},
78+
[pageParam, setSearchParams]
79+
);
80+
81+
const page = useMemo(() => {
82+
const pageRaw = searchParams.get(pageParam);
83+
if (!pageRaw) {
84+
return 1;
85+
}
86+
try {
87+
const page = parseInt(pageRaw, 10);
88+
return page > 0 ? page : 1;
89+
} catch {
90+
return 1;
91+
}
92+
}, [pageParam, searchParams]);
93+
94+
const { data, error, isLoading } = useGetDataConnectorsQuery({
95+
params: {
96+
namespace: ns,
97+
page,
98+
per_page: perPage,
99+
},
100+
});
101+
102+
useEffect(() => {
103+
if (data?.totalPages && page > data.totalPages) {
104+
setSearchParams(
105+
(prevParams) => {
106+
if (data.totalPages == 1) {
107+
prevParams.delete(pageParam);
108+
} else {
109+
prevParams.set(pageParam, `${data.totalPages}`);
110+
}
111+
return prevParams;
112+
},
113+
{ replace: true }
114+
);
115+
}
116+
}, [data?.totalPages, page, pageParam, setSearchParams]);
117+
118+
if (isLoading)
119+
return (
120+
<div className={cx("d-flex", "justify-content-center", "w-100")}>
121+
<div className={cx("d-flex", "flex-column")}>
122+
<Loader />
123+
<div>Retrieving projects...</div>
124+
</div>
125+
</div>
126+
);
127+
128+
if (error || data == null) {
129+
return <RtkOrNotebooksError error={error} dismissible={false} />;
130+
}
131+
132+
if (!data.total) {
133+
return emptyListElement ?? <p>The project list is empty.</p>;
134+
}
135+
136+
return (
137+
<div className={cx("d-flex", "flex-column", "gap-3")}>
138+
<Row
139+
className={cx("row-cols-1", "row-cols-md-2", "row-cols-xxl-3", "g-3")}
140+
>
141+
{data.dataConnectors?.map((dc) => (
142+
<ListDisplayDataConnector key={dc.id} dataConnector={dc} />
143+
))}
144+
</Row>
145+
<Pagination
146+
currentPage={data.page}
147+
perPage={perPage}
148+
totalItems={data.total}
149+
onPageChange={onPageChange}
150+
className={cx(
151+
"d-flex",
152+
"justify-content-center",
153+
"rk-search-pagination"
154+
)}
155+
/>
156+
</div>
157+
);
158+
}
159+
160+
interface ListDisplayDataConnectorProps {
161+
dataConnector: DataConnector;
162+
}
163+
function ListDisplayDataConnector({
164+
dataConnector,
165+
}: ListDisplayDataConnectorProps) {
166+
const { data: namespaceData } = useGetNamespacesByNamespaceSlugQuery({
167+
namespaceSlug: dataConnector.namespace,
168+
});
169+
170+
const {
171+
name,
172+
namespace,
173+
description,
174+
visibility,
175+
creation_date: creationDate,
176+
} = dataConnector;
177+
178+
const namespaceUrl =
179+
namespaceData && namespaceData.namespace_kind === "group"
180+
? generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, { slug: namespace })
181+
: generatePath(ABSOLUTE_ROUTES.v2.users.show, {
182+
username: dataConnector.namespace,
183+
});
184+
185+
return (
186+
<Col>
187+
<Card className="h-100" data-cy="project-card">
188+
<CardBody className={cx("d-flex", "flex-column")}>
189+
<h5>{name}</h5>
190+
<p>
191+
<Link to={namespaceUrl}>
192+
{"@"}
193+
{namespace}
194+
</Link>
195+
</p>
196+
{description && <ClampedParagraph>{description}</ClampedParagraph>}
197+
<div
198+
className={cx(
199+
"align-items-center",
200+
"d-flex",
201+
"flex-wrap",
202+
"gap-2",
203+
"justify-content-between",
204+
"mt-auto"
205+
)}
206+
>
207+
<div>
208+
{visibility.toLowerCase() === "private" ? (
209+
<>
210+
<Lock className={cx("bi", "me-1")} />
211+
Private
212+
</>
213+
) : (
214+
<>
215+
<Globe2 className={cx("bi", "me-1")} />
216+
Public
217+
</>
218+
)}
219+
</div>
220+
<TimeCaption
221+
datetime={creationDate}
222+
prefix="Created"
223+
enableTooltip
224+
/>
225+
</div>
226+
</CardBody>
227+
</Card>
228+
</Col>
229+
);
230+
}

client/src/features/groupsV2/show/GroupV2Show.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { Loader } from "../../../components/Loader";
3232
import ContainerWrap from "../../../components/container/ContainerWrap";
3333
import LazyNotFound from "../../../not-found/LazyNotFound";
3434
import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants";
35+
36+
import DataConnectorListDisplay from "../../dataConnectorsV2/components/DataConnectorListDisplay";
3537
import MembershipGuard from "../../ProjectPageV2/utils/MembershipGuard";
3638
import type { GroupResponse } from "../../projectsV2/api/namespace.api";
3739
import {
@@ -134,6 +136,15 @@ export default function GroupV2Show() {
134136
emptyListElement={<p>No visible projects.</p>}
135137
/>
136138
</section>
139+
140+
<section className="mt-3">
141+
<h4>Group Data Connectors</h4>
142+
<DataConnectorListDisplay
143+
namespace={slug}
144+
pageParam="projects_page"
145+
emptyListElement={<p>No visible data connectors.</p>}
146+
/>
147+
</section>
137148
</ContainerWrap>
138149
);
139150
}

client/src/features/projectsV2/api/projectV2.enhanced-api.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { AbstractKgPaginatedResponse } from "../../../utils/types/pagination.typ
22
import { processPaginationHeaders } from "../../../utils/helpers/kgPagination.utils";
33

44
import { dataConnectorsApi as api } from "./data-connectors.api";
5+
6+
import type {
7+
GetDataConnectorsApiArg,
8+
GetDataConnectorsApiResponse as GetDataConnectorsApiResponseOrig,
9+
} from "./data-connectors.api";
510
import type {
611
GetProjectsApiArg,
712
GetProjectsApiResponse as GetProjectsApiResponseOrig,
@@ -28,6 +33,11 @@ import type {
2833
PostStoragesV2ByStorageIdSecretsApiResponse,
2934
} from "./storagesV2.api";
3035

36+
export interface GetDataConnectorsApiResponse
37+
extends AbstractKgPaginatedResponse {
38+
dataConnectors: GetDataConnectorsApiResponseOrig;
39+
}
40+
3141
interface GetGroupsApiResponse extends AbstractKgPaginatedResponse {
3242
groups: GetGroupsApiResponseOrig;
3343
}
@@ -55,6 +65,38 @@ interface GetStoragesV2StorageIdSecretsApiArg {
5565

5666
const injectedApi = api.injectEndpoints({
5767
endpoints: (builder) => ({
68+
getDataConnectorsPaged: builder.query<
69+
GetDataConnectorsApiResponse,
70+
GetDataConnectorsApiArg
71+
>({
72+
query: (queryArg) => ({
73+
url: "/data_connectors",
74+
params: {
75+
namespace: queryArg.params?.namespace,
76+
page: queryArg.params?.page,
77+
per_page: queryArg.params?.per_page,
78+
},
79+
}),
80+
transformResponse: (response, meta, queryArg) => {
81+
const dataConnectors = response as GetDataConnectorsApiResponseOrig;
82+
const headers = meta?.response?.headers;
83+
const headerResponse = processPaginationHeaders(
84+
headers,
85+
queryArg.params == null
86+
? {}
87+
: { page: queryArg.params.page, perPage: queryArg.params.per_page },
88+
dataConnectors
89+
);
90+
91+
return {
92+
dataConnectors,
93+
page: headerResponse.page,
94+
perPage: headerResponse.perPage,
95+
total: headerResponse.total,
96+
totalPages: headerResponse.totalPages,
97+
};
98+
},
99+
}),
58100
getGroupsPaged: builder.query<GetGroupsApiResponse, GetGroupsApiArg>({
59101
query: (queryArg) => ({
60102
url: "/groups",
@@ -178,6 +220,7 @@ const injectedApi = api.injectEndpoints({
178220

179221
const enhancedApi = injectedApi.enhanceEndpoints({
180222
addTagTypes: [
223+
"DataConnectors",
181224
"Group",
182225
"GroupMembers",
183226
"Namespace",
@@ -205,6 +248,9 @@ const enhancedApi = injectedApi.enhanceEndpoints({
205248
deleteStoragesV2ByStorageIdSecrets: {
206249
invalidatesTags: ["Storages", "StorageSecrets"],
207250
},
251+
getDataConnectors: {
252+
providesTags: ["DataConnectors"],
253+
},
208254
getGroups: {
209255
providesTags: ["Group"],
210256
},
@@ -317,4 +363,7 @@ export const {
317363
usePostStoragesV2Mutation,
318364
usePostStoragesV2ByStorageIdSecretsMutation,
319365
usePostStoragesV2SecretsForSessionLaunchMutation,
366+
367+
// data connectors hooks
368+
useGetDataConnectorsPagedQuery: useGetDataConnectorsQuery,
320369
} = enhancedApi;

client/src/features/usersV2/show/UserShow.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
} from "../../user/dataServicesUser.api";
3939
import UserAvatar from "./UserAvatar";
4040

41+
// TODO: Add data connectors
42+
4143
export default function UserShow() {
4244
const { username } = useParams<{ username: string }>();
4345

tests/cypress/e2e/groupV2.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ describe("Edit v2 group", () => {
9393
cy.visit("/v2/groups");
9494
});
9595

96+
it.only("shows a group", () => {
97+
fixtures
98+
.readGroupV2()
99+
.readGroupV2Namespace()
100+
.listGroupV2Members()
101+
.listProjectV2ByNamespace()
102+
.listDataConnectors({ namespace: "test-2-group-v2" });
103+
cy.contains("List Groups").should("be.visible");
104+
cy.contains("test 2 group-v2").should("be.visible").click();
105+
cy.wait("@readGroupV2");
106+
cy.contains("test 2 group-v2").should("be.visible");
107+
});
108+
96109
it("allows editing group metadata", () => {
97110
fixtures
98111
.readGroupV2()

0 commit comments

Comments
 (0)