From cbf89baf3168ab59315167fad36ef03dd9f4d03d Mon Sep 17 00:00:00 2001 From: Aart van Baren Date: Tue, 30 Dec 2025 13:41:29 +0200 Subject: [PATCH] webui: use RTK Query (WIP) --- .../java/org/eclipse/openvsx/UserAPI.java | 14 + .../java/org/eclipse/openvsx/UserService.java | 10 + .../org/eclipse/openvsx/json/UserJson.java | 26 +- webui/package.json | 4 +- webui/src/components/error-dialog.tsx | 33 +- webui/src/context.ts | 5 - webui/src/default/default-app.tsx | 48 +- webui/src/default/menu-content.tsx | 10 +- webui/src/default/page-settings.tsx | 19 +- webui/src/extension-registry-service.ts | 605 ------------------ webui/src/extension-registry-types.ts | 10 + webui/src/index.ts | 1 - webui/src/main.tsx | 113 +--- webui/src/other-pages.tsx | 14 +- .../pages/admin-dashboard/admin-dashboard.tsx | 14 +- .../pages/admin-dashboard/extension-admin.tsx | 52 +- .../extension-version-container.tsx | 57 +- .../pages/admin-dashboard/namespace-admin.tsx | 75 +-- .../namespace-change-dialog.tsx | 46 +- .../pages/admin-dashboard/publisher-admin.tsx | 34 +- .../publisher-revoke-dialog.tsx | 91 ++- .../publisher-revoke-tokens-button.tsx | 34 +- .../extension-detail-changes.tsx | 37 +- .../extension-detail-overview.tsx | 39 +- .../extension-detail-reviews.tsx | 90 +-- .../extension-detail/extension-detail.tsx | 242 +++---- .../extension-review-dialog.tsx | 32 +- .../extension-list/extension-list-header.tsx | 22 +- .../extension-list/extension-list-item.tsx | 30 +- .../pages/extension-list/extension-list.tsx | 96 ++- .../namespace-detail/namespace-detail.tsx | 52 +- .../user/add-namespace-member-dialog.tsx | 47 +- webui/src/pages/user/avatar.tsx | 7 +- .../pages/user/create-namespace-dialog.tsx | 34 +- .../src/pages/user/generate-token-dialog.tsx | 31 +- webui/src/pages/user/logout.tsx | 30 +- .../pages/user/publish-extension-dialog.tsx | 50 +- webui/src/pages/user/user-extension-list.tsx | 2 +- .../user/user-namespace-details-logo.tsx | 287 +++++++++ .../src/pages/user/user-namespace-details.tsx | 506 ++++----------- .../user-namespace-extension-list-item.tsx | 18 +- .../user/user-namespace-extension-list.tsx | 61 +- .../user/user-namespace-member-component.tsx | 18 +- .../pages/user/user-namespace-member-list.tsx | 55 +- .../pages/user/user-publisher-agreement.tsx | 69 +- .../user/user-settings-delete-extension.tsx | 47 +- .../pages/user/user-settings-extensions.tsx | 50 +- .../user/user-settings-namespace-detail.tsx | 5 +- .../pages/user/user-settings-namespaces.tsx | 54 +- webui/src/pages/user/user-settings-tokens.tsx | 80 +-- webui/src/pages/user/user-settings.tsx | 24 +- webui/src/store/api.ts | 477 ++++++++++++++ webui/src/store/error-middleware.ts | 37 ++ webui/src/store/error.ts | 48 ++ webui/src/store/hooks.ts | 5 + webui/src/store/store.ts | 17 + webui/yarn.lock | 120 +++- 57 files changed, 1778 insertions(+), 2356 deletions(-) delete mode 100644 webui/src/extension-registry-service.ts create mode 100644 webui/src/pages/user/user-namespace-details-logo.tsx create mode 100644 webui/src/store/api.ts create mode 100644 webui/src/store/error-middleware.ts create mode 100644 webui/src/store/error.ts create mode 100644 webui/src/store/hooks.ts create mode 100644 webui/src/store/store.ts diff --git a/server/src/main/java/org/eclipse/openvsx/UserAPI.java b/server/src/main/java/org/eclipse/openvsx/UserAPI.java index 49d295bad..bf8f7eca5 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserAPI.java +++ b/server/src/main/java/org/eclipse/openvsx/UserAPI.java @@ -121,6 +121,7 @@ public UserJson getUserData() { json.setRole(user.getRole()); json.setTokensUrl(createApiUrl(serverUrl, "user", "tokens")); json.setCreateTokenUrl(createApiUrl(serverUrl, "user", "token", "create")); + json.setDeleteAllTokensUrl(createApiUrl(serverUrl, "user", "tokens", "delete")); eclipse.enrichUserJsonWithPublisherAgreement(json, user); return json; } @@ -189,6 +190,19 @@ public ResponseEntity deleteAccessToken(@PathVariable long id) { } } + @PostMapping( + path = "/user/tokens/delete", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity deleteAllAccessTokens() { + var user = users.findLoggedInUser(); + if (user == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + + return ResponseEntity.ok(users.deleteAllAccessTokens(user)); + } + @GetMapping( path = "/user/extensions", produces = MediaType.APPLICATION_JSON_VALUE diff --git a/server/src/main/java/org/eclipse/openvsx/UserService.java b/server/src/main/java/org/eclipse/openvsx/UserService.java index 464ba3b8f..3f03239d7 100644 --- a/server/src/main/java/org/eclipse/openvsx/UserService.java +++ b/server/src/main/java/org/eclipse/openvsx/UserService.java @@ -285,6 +285,16 @@ public ResultJson deleteAccessToken(UserData user, long id) { return ResultJson.success("Deleted access token for user " + user.getLoginName() + "."); } + @Transactional + public ResultJson deleteAllAccessTokens(UserData user) { + var tokens = repositories.findAccessTokens(user); + for(var token : tokens) { + token.setActive(false); + } + + return ResultJson.success("Deleted access tokens for user " + user.getLoginName() + "."); + } + public boolean canLogin() { return !getLoginProviders().isEmpty(); } diff --git a/server/src/main/java/org/eclipse/openvsx/json/UserJson.java b/server/src/main/java/org/eclipse/openvsx/json/UserJson.java index ea522c077..26ee5ee2b 100644 --- a/server/src/main/java/org/eclipse/openvsx/json/UserJson.java +++ b/server/src/main/java/org/eclipse/openvsx/json/UserJson.java @@ -40,6 +40,9 @@ public static UserJson error(String message) { @Schema(hidden = true) private String createTokenUrl; + @Schema(hidden = true) + private String deleteAllTokensUrl; + @Schema(hidden = true) private String role; @@ -85,6 +88,14 @@ public void setCreateTokenUrl(String createTokenUrl) { this.createTokenUrl = createTokenUrl; } + public String getDeleteAllTokensUrl() { + return deleteAllTokensUrl; + } + + public void setDeleteAllTokensUrl(String deleteAllTokensUrl) { + this.deleteAllTokensUrl = deleteAllTokensUrl; + } + public String getRole() { return role; } @@ -200,6 +211,7 @@ public boolean equals(Object o) { return Objects.equals(loginName, userJson.loginName) && Objects.equals(tokensUrl, userJson.tokensUrl) && Objects.equals(createTokenUrl, userJson.createTokenUrl) + && Objects.equals(deleteAllTokensUrl, userJson.deleteAllTokensUrl) && Objects.equals(role, userJson.role) && Objects.equals(fullName, userJson.fullName) && Objects.equals(avatarUrl, userJson.avatarUrl) @@ -211,6 +223,18 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(loginName, tokensUrl, createTokenUrl, role, fullName, avatarUrl, homepage, provider, publisherAgreement, additionalLogins); + return Objects.hash( + loginName, + tokensUrl, + createTokenUrl, + deleteAllTokensUrl, + role, + fullName, + avatarUrl, + homepage, + provider, + publisherAgreement, + additionalLogins + ); } } \ No newline at end of file diff --git a/webui/package.json b/webui/package.json index 29766b09c..3a132f67c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "openvsx-webui", - "version": "0.17.1", + "version": "0.18.0", "description": "User interface for Eclipse Open VSX", "keywords": [ "react", @@ -41,6 +41,7 @@ "@mui/base": "^5.0.0-beta.9", "@mui/icons-material": "^5.13.7", "@mui/material": "^5.13.7", + "@reduxjs/toolkit": "^2.11.0", "clipboard-copy": "^4.0.1", "clsx": "^1.2.1", "dompurify": "^3.0.4", @@ -56,6 +57,7 @@ "react-dropzone": "^14.2.3", "react-helmet-async": "^1.3.0", "react-infinite-scroller": "^1.2.6", + "react-redux": "^9.2.0", "react-router": "^6.14.2", "react-router-dom": "^6.14.1" }, diff --git a/webui/src/components/error-dialog.tsx b/webui/src/components/error-dialog.tsx index 07dc01bb1..4c2e2990f 100644 --- a/webui/src/components/error-dialog.tsx +++ b/webui/src/components/error-dialog.tsx @@ -12,13 +12,17 @@ import React, { FunctionComponent, ReactNode, useContext, useEffect } from 'reac import { Dialog, DialogTitle, DialogContent, Button, DialogContentText, DialogActions, Box, Link } from '@mui/material'; import { MainContext } from '../context'; import { styled, Theme } from '@mui/material/styles'; +import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { hideError, selectError } from '../store/error'; const ErrorLink = styled(Link)(({ theme }: { theme: Theme }) => ({ textDecoration: 'underline', color: theme.palette.primary.contrastText })); -export const ErrorDialog: FunctionComponent = props => { +export const ErrorDialog: FunctionComponent = () => { + const dispatch = useAppDispatch(); + const error = useAppSelector(selectError); useEffect(() => { document.addEventListener('keydown', handleEnter); @@ -27,16 +31,14 @@ export const ErrorDialog: FunctionComponent = props => { const handleEnter = (event: KeyboardEvent): void => { if (event.code === 'Enter') { - props.handleCloseDialog(); + handleCloseDialog(); } }; - const getContentForCode = (): ReactNode => { - if (!props.errorCode) { - return null; - } + const handleCloseDialog = () => dispatch(hideError()); - switch (props.errorCode) { + const getContentForCode = (): ReactNode => { + switch (error.code) { case 'eclipse-missing-github-id': return <> Please fill in the “GitHub Username” field @@ -65,12 +67,12 @@ export const ErrorDialog: FunctionComponent = props => { const context = useContext(MainContext); const codeContent = getContentForCode(); return + open={error.show} + onClose={handleCloseDialog} > Error - {props.errorMessage} + {error.message} { codeContent ? @@ -81,16 +83,9 @@ export const ErrorDialog: FunctionComponent = props => { - ; -}; - -export interface ErrorDialogProps { - errorMessage: string; - errorCode?: number | string; - isErrorDialogOpen: boolean; - handleCloseDialog: () => void; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/context.ts b/webui/src/context.ts index deae63d70..1ecbea01b 100644 --- a/webui/src/context.ts +++ b/webui/src/context.ts @@ -10,16 +10,11 @@ import { createContext } from 'react'; import { PageSettings } from './page-settings'; -import { ExtensionRegistryService } from './extension-registry-service'; -import { UserData } from './extension-registry-types'; import { ErrorResponse } from './server-request'; export interface MainContext { - service: ExtensionRegistryService; pageSettings: PageSettings; handleError: (err: Error | Partial) => void; - user?: UserData; - updateUser: () => void; loginProviders?: Record; } diff --git a/webui/src/default/default-app.tsx b/webui/src/default/default-app.tsx index f324a30f0..d2aa535e0 100644 --- a/webui/src/default/default-app.tsx +++ b/webui/src/default/default-app.tsx @@ -14,40 +14,15 @@ import { HelmetProvider } from 'react-helmet-async'; import { BrowserRouter } from 'react-router-dom'; import { ThemeProvider } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { ExtensionRegistryService } from '../extension-registry-service'; import { Main } from '../main'; import createPageSettings from './page-settings'; import createDefaultTheme from './theme'; +import { store } from '../store/store'; +import { Provider } from 'react-redux'; // This is the default entry point for the webui Docker image and for development. // The production code for open-vsx.org is at https://github.com/eclipse/open-vsx.org - -let serverHost = location.host; -if (serverHost.startsWith('3000-')) { - // Gitpod dev environment: the frontend runs on port 3000, but the server runs on port 8080 - serverHost = '8080-' + serverHost.substring(5); -} else if (location.port === '3000') { - // Localhost dev environment - serverHost = location.hostname + ':8080'; -} else if (serverHost.includes('che-webui')) { - // Eclipse Che dev environment. - // If serverHost contains 'che-webui', replace it with 'che-server' - serverHost = serverHost.replace('che-webui', 'che-server'); -} -const service = new ExtensionRegistryService(`${location.protocol}//${serverHost}`); - -async function getServerVersion(): Promise { - const abortController = new AbortController(); - try { - const result = await service.getRegistryVersion(abortController); - return result.version; - } catch (error) { - console.error('Could not determine server version'); - return 'unknown'; - } -} - const App = () => { const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const theme = useMemo( @@ -55,16 +30,17 @@ const App = () => { [prefersDarkMode], ); - const pageSettings = createPageSettings(prefersDarkMode, service.serverUrl, getServerVersion()); + const pageSettings = createPageSettings(prefersDarkMode); return ( - - -
- - + + + +
+ + + ); }; diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index 6ea7b1bb7..fd492f1e9 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -29,6 +29,7 @@ import LogoutIcon from '@mui/icons-material/Logout'; import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard'; import { LogoutForm } from '../pages/user/logout'; import { LoginComponent } from './login'; +import { useGetUserQuery } from '../store/api'; //-------------------- Mobile View --------------------// @@ -54,8 +55,7 @@ export const MobileMenuItemText: FunctionComponent = ({ child }; export const MobileUserAvatar: FunctionComponent = () => { - const context = useContext(MainContext); - const user = context.user; + const { data: user } = useGetUserQuery(); if (!user) { return null; } @@ -118,7 +118,8 @@ export const MobileUserAvatar: FunctionComponent = () => { export const MobileMenuContent: FunctionComponent = () => { const location = useLocation(); - const { user, loginProviders } = useContext(MainContext); + const { data: user } = useGetUserQuery(); + const { loginProviders } = useContext(MainContext); return <> {loginProviders && ( @@ -205,7 +206,8 @@ export const MenuLink = styled(Link)(headerItem); export const MenuRouteLink = styled(RouteLink)(headerItem); export const DefaultMenuContent: FunctionComponent = () => { - const { user, loginProviders } = useContext(MainContext); + const { data: user } = useGetUserQuery(); + const { loginProviders } = useContext(MainContext); return <> Documentation diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx index d03eba95f..45ff7cf7a 100644 --- a/webui/src/default/page-settings.tsx +++ b/webui/src/default/page-settings.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, ReactNode, Suspense, lazy } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { Helmet } from 'react-helmet-async'; import { styled, Theme } from '@mui/material/styles'; import { Link, Typography, Box } from '@mui/material'; @@ -21,8 +21,9 @@ import { DefaultMenuContent, MobileMenuContent } from './menu-content'; import OpenVSXLogo from './openvsx-registry-logo'; import About from './about'; import { createAbsoluteURL } from '../utils'; +import { useGetRegistryVersionQuery, serverUrl } from '../store/api'; -export default function createPageSettings(prefersDarkMode: boolean, serverUrl: string, serverVersionPromise: Promise): PageSettings { +export default function createPageSettings(prefersDarkMode: boolean): PageSettings { const toolbarContent: FunctionComponent = () => @@ -39,10 +40,12 @@ export default function createPageSettings(prefersDarkMode: boolean, serverUrl: const StyledRouteLink = styled(RouteLink)(link); - const ServerVersion = lazy(async () => { - const version = await serverVersionPromise; - return { default: () => Server Version: {version} }; - }); + const ServerVersion: FunctionComponent = () => { + const { data } = useGetRegistryVersionQuery(); + return data != null + ? Server Version: {data.version} + :
Loading version...
; + }; const footerContent: FunctionComponent<{ expanded: boolean }> = () => @@ -65,9 +68,7 @@ export default function createPageSettings(prefersDarkMode: boolean, serverUrl: >  eclipse/openvsx - Loading version...}> - - + About This Service diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts deleted file mode 100644 index e5ea6fb52..000000000 --- a/webui/src/extension-registry-service.ts +++ /dev/null @@ -1,605 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2019 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { - Extension, UserData, ExtensionCategory, ExtensionReviewList, PersonalAccessToken, SearchResult, NewReview, - SuccessResult, ErrorResult, CsrfTokenJson, isError, Namespace, NamespaceDetails, MembershipRole, SortBy, - SortOrder, UrlString, NamespaceMembershipList, PublisherInfo, SearchEntry, RegistryVersion, - LoginProviders -} from './extension-registry-types'; -import { createAbsoluteURL, addQuery } from './utils'; -import { sendRequest, ErrorResponse } from './server-request'; - -export class ExtensionRegistryService { - - readonly admin: AdminService; - - constructor(readonly serverUrl: string = '', AdminConstructor: AdminServiceConstructor = AdminServiceImpl) { - this.admin = new AdminConstructor(this); - } - - getLoginProviders(abortController: AbortController): Promise> { - const endpoint = createAbsoluteURL([this.serverUrl, 'login-providers']); - return sendRequest({ abortController, endpoint }); - } - - getLogoutUrl(): string { - return createAbsoluteURL([this.serverUrl, 'logout']); - } - - getExtensionApiUrl(ext: { namespace: string, name: string, target?: string, version?: string }): string { - const arr = [this.serverUrl, 'api', ext.namespace, ext.name]; - if (ext.target) { - arr.push(ext.target); - } - if (ext.version) { - arr.push(ext.version); - } - - return createAbsoluteURL(arr); - } - - getNamespaceDetails(abortController: AbortController, name: string): Promise> { - const endpoint = createAbsoluteURL([this.serverUrl, 'api', name, 'details']); - return sendRequest({ abortController, endpoint }); - } - - async setNamespaceDetails(abortController: AbortController, endpoint: string, details: NamespaceDetails): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'POST', - payload: details, - credentials: true, - endpoint, - headers - }); - } - - async setNamespaceLogo(abortController: AbortController, endpoint: string, logoFile: Blob, logoName: string): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - const form = new FormData(); - form.append('file', logoFile, logoName); - endpoint = createAbsoluteURL([endpoint, 'logo']); - return sendRequest({ - abortController, - method: 'POST', - payload: form, - credentials: true, - endpoint, - headers - }); - } - - search(abortController: AbortController, filter?: ExtensionFilter): Promise> { - const query: { key: string, value: string | number }[] = []; - if (filter) { - if (filter.query) - query.push({ key: 'query', value: filter.query }); - if (filter.category) - query.push({ key: 'category', value: filter.category }); - if (filter.offset) - query.push({ key: 'offset', value: filter.offset }); - if (filter.size) - query.push({ key: 'size', value: filter.size }); - if (filter.sortBy) - query.push({ key: 'sortBy', value: filter.sortBy }); - if (filter.sortOrder) - query.push({ key: 'sortOrder', value: filter.sortOrder }); - } - const endpoint = createAbsoluteURL([this.serverUrl, 'api', '-', 'search'], query); - return sendRequest({ abortController, endpoint }); - } - - getExtensionDetail(abortController: AbortController, extensionUrl: UrlString): Promise> { - return sendRequest({ abortController, endpoint: extensionUrl }); - } - - getExtensionReadme(abortController: AbortController, extension: Extension): Promise { - return sendRequest({ - abortController, - endpoint: extension.files.readme, - headers: { 'Accept': 'text/plain' }, - followRedirect: true - }); - } - - getExtensionChangelog(abortController: AbortController, extension: Extension): Promise { - return sendRequest({ - abortController, - endpoint: extension.files.changelog, - headers: { 'Accept': 'text/plain' }, - followRedirect: true - }); - } - - getExtensionIcon(abortController: AbortController, extension: Extension | SearchEntry): Promise { - if (!extension.files.icon) { - return Promise.resolve(undefined); - } - - return sendRequest({ - abortController, - endpoint: extension.files.icon, - headers: { 'Accept': 'application/octet-stream' }, - followRedirect: true - }).then(value => { - const blob = value as Blob; - return URL.createObjectURL(blob); - }); - } - - getCategories(): ExtensionCategory[] { - return [ - 'Programming Languages', - 'Snippets', - 'Linters', - 'Themes', - 'Debuggers', - 'Formatters', - 'Keymaps', - 'SCM Providers', - 'Other', - 'Extension Packs', - 'Language Packs', - 'Data Science', - 'Machine Learning', - 'Visualization', - 'Notebooks' - ]; - } - - getExtensionReviews(abortController: AbortController, extension: Extension): Promise> { - return sendRequest({ abortController, endpoint: extension.reviewsUrl }); - } - - async postReview(abortController: AbortController, review: NewReview, postReviewUrl: UrlString): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - method: 'POST', - payload: review, - credentials: true, - endpoint: postReviewUrl, - headers - }); - } - - async deleteReview(abortController: AbortController, deleteReviewUrl: string): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: deleteReviewUrl, - headers - }); - } - - getUser(abortController: AbortController): Promise> { - return sendRequest({ - abortController, - endpoint: createAbsoluteURL([this.serverUrl, 'user']), - credentials: true - }); - } - - getUserAuthError(abortController: AbortController): Promise> { - return sendRequest({ - abortController, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'auth-error']), - credentials: true - }); - } - - getUserByName(abortController: AbortController, name: string): Promise[]> { - return sendRequest({ - abortController, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'search', name]), - credentials: true - }); - } - - getAccessTokens(abortController: AbortController, user: UserData): Promise[]> { - return sendRequest({ - abortController, - credentials: true, - endpoint: user.tokensUrl - }); - } - - async createAccessToken(abortController: AbortController, user: UserData, description: string): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - const endpoint = addQuery(user.createTokenUrl, [{ key: 'description', value: description }]); - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint, - headers - }); - } - - async deleteAccessToken(abortController: AbortController, token: PersonalAccessToken): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: token.deleteTokenUrl, - headers - }); - } - - async deleteAllAccessTokens(abortController: AbortController, tokens: PersonalAccessToken[]): Promise[]> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return await Promise.all(tokens.map(token => sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: token.deleteTokenUrl, - headers - }))); - } - - getCsrfToken(abortController: AbortController): Promise> { - return sendRequest({ - abortController, - credentials: true, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'csrf']) - }); - } - - getNamespaces(abortController: AbortController): Promise[]> { - return sendRequest({ - abortController, - credentials: true, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'namespaces']) - }); - } - - getNamespaceMembers(abortController: AbortController, namespace: Namespace): Promise> { - return sendRequest({ - abortController, - credentials: true, - endpoint: namespace.membersUrl - }); - } - - async setNamespaceMember(abortController: AbortController, endpoint: UrlString, user: UserData, role: MembershipRole | 'remove'): Promise[]> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - const query = [ - { key: 'user', value: user.loginName }, - { key: 'provider', value: user.provider }, - { key: 'role', value: role } - ]; - return sendRequest({ - abortController, - headers, - method: 'POST', - credentials: true, - endpoint: addQuery(endpoint, query) - }); - } - - async signPublisherAgreement(abortController: AbortController): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'publisher-agreement']), - headers - }); - } - - getStaticContent(abortController: AbortController, url: string): Promise { - return sendRequest({ - abortController, - endpoint: url, - headers: { 'Accept': 'text/plain' }, - followRedirect: true - }); - } - - async publishExtension(abortController: AbortController, extensionPackage: File): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/octet-stream' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - payload: extensionPackage, - headers: headers, - endpoint: createAbsoluteURL([this.serverUrl, 'api', 'user', 'publish']) - }); - } - - async createNamespace(abortController: AbortController, name: string): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - payload: { name: name }, - headers: headers, - endpoint: createAbsoluteURL([this.serverUrl, 'api', 'user', 'namespace', 'create']) - }); - } - - async getExtensions(abortController: AbortController): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'GET', - credentials: true, - headers: headers, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'extensions']) - }); - } - - async getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'GET', - credentials: true, - headers: headers, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'extension', namespace, extension]) - }); - } - - async deleteExtensions(abortController: AbortController, req: { namespace: string, extension: string, targetPlatformVersions?: object[] }): Promise> { - const csrfResponse = await this.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: createAbsoluteURL([this.serverUrl, 'user', 'extension', req.namespace, req.extension, 'delete']), - headers, - payload: req.targetPlatformVersions - }); - } - - async getRegistryVersion(abortController: AbortController): Promise> { - const endpoint = createAbsoluteURL([this.serverUrl, 'api', 'version']); - return sendRequest({ abortController, endpoint }); - } -} - -export interface AdminService { - getExtension(abortController: AbortController, namespace: string, extension: string): Promise> - deleteExtensions(abortController: AbortController, req: { namespace: string, extension: string, targetPlatformVersions?: object[] }): Promise> - getNamespace(abortController: AbortController, name: string): Promise> - createNamespace(abortController: AbortController, namespace: { name: string }): Promise> - changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> - getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> - revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> - revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> -} - -export interface AdminServiceConstructor { - new (registry: ExtensionRegistryService): AdminService -} - -export class AdminServiceImpl implements AdminService { - - constructor(readonly registry: ExtensionRegistryService) {} - - getExtension(abortController: AbortController, namespace: string, extension: string): Promise> { - return sendRequest({ - abortController, - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'extension', namespace, extension]) - }); - } - - async deleteExtensions(abortController: AbortController, req: { namespace: string, extension: string, targetPlatformVersions?: object[] }): Promise> { - const csrfResponse = await this.registry.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'extension', req.namespace, req.extension, 'delete']), - headers, - payload: req.targetPlatformVersions - }); - } - - getNamespace(abortController: AbortController, name: string): Promise> { - return sendRequest({ - abortController, - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'namespace', name]) - }); - } - - async createNamespace(abortController: AbortController, namespace: { name: string }): Promise> { - const csrfResponse = await this.registry.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'create-namespace']), - method: 'POST', - payload: namespace, - headers - }); - } - - async changeNamespace(abortController: AbortController, req: {oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean}): Promise> { - const csrfResponse = await this.registry.getCsrfToken(abortController); - const headers: Record = { - 'Content-Type': 'application/json;charset=UTF-8' - }; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'change-namespace']), - method: 'POST', - payload: req, - headers - }); - } - - async getPublisherInfo(abortController: AbortController, provider: string, login: string): Promise> { - return sendRequest({ - abortController, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'publisher', provider, login]), - credentials: true - }); - } - - async revokePublisherContributions(abortController: AbortController, provider: string, login: string): Promise> { - const csrfResponse = await this.registry.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'publisher', provider, login, 'revoke']), - headers - }); - } - - async revokeAccessTokens(abortController: AbortController, provider: string, login: string): Promise> { - const csrfResponse = await this.registry.getCsrfToken(abortController); - const headers: Record = {}; - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - headers[csrfToken.header] = csrfToken.value; - } - return sendRequest({ - abortController, - method: 'POST', - credentials: true, - endpoint: createAbsoluteURL([this.registry.serverUrl, 'admin', 'publisher', provider, login, 'tokens', 'revoke']), - headers - }); - } -} - -export interface ExtensionFilter { - query: string; - category: ExtensionCategory | ''; - size: number; - offset: number; - sortBy: SortBy; - sortOrder: SortOrder; -} diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 6d50efb48..a8eda9679 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -157,6 +157,7 @@ export interface UserData { loginName: string; tokensUrl: UrlString; createTokenUrl: UrlString; + deleteAllTokensUrl: UrlString; fullName?: string; avatarUrl?: UrlString; homepage?: string; @@ -258,3 +259,12 @@ export interface LoginProviders { export type MembershipRole = 'contributor' | 'owner'; export type SortBy = 'relevance' | 'timestamp' | 'rating' | 'downloadCount'; export type SortOrder = 'asc' | 'desc'; + +export interface ExtensionFilter { + query: string; + category: ExtensionCategory | ''; + size: number; + offset: number; + sortBy: SortBy; + sortOrder: SortOrder; +} \ No newline at end of file diff --git a/webui/src/index.ts b/webui/src/index.ts index 1b754e9e3..c8d4a65fe 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -10,7 +10,6 @@ export * from './main'; export * from './page-settings'; -export * from './extension-registry-service'; export * from './extension-registry-types'; export * from './pages/extension-detail/extension-detail'; export * from './pages/extension-list/extension-list'; diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 579efb4b9..d6e3b859b 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -8,128 +8,43 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, ReactNode, useEffect, useState, useRef } from 'react'; +import React, { FunctionComponent } from 'react'; import { CssBaseline } from '@mui/material'; import { Route, Routes } from 'react-router-dom'; import { AdminDashboard, AdminDashboardRoutes } from './pages/admin-dashboard/admin-dashboard'; import { ErrorDialog } from './components/error-dialog'; -import { handleError } from './utils'; -import { ExtensionRegistryService } from './extension-registry-service'; -import { UserData, isError, ReportedError, isSuccess, LoginProviders } from './extension-registry-types'; import { MainContext } from './context'; import { PageSettings } from './page-settings'; -import { ErrorResponse } from './server-request'; import '../src/main.css'; import { OtherPages } from './other-pages'; +import { useGetLoginProvidersQuery, useGetUserAuthErrorQuery } from './store/api'; export const Main: FunctionComponent = props => { - const [user, setUser] = useState(); - const [userLoading, setUserLoading] = useState(true); - const [loginProviders, setLoginProviders] = useState | undefined>(props.loginProviders); - const [error, setError] = useState<{message: string, code?: number | string}>(); - const [isErrorDialogOpen, setIsErrorDialogOpen] = useState(false); - const abortController = useRef(new AbortController()); - - useEffect(() => { - getLoginProviders(); - - // If there was an authentication error, get the message from the server and show it - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.has('auth-error')) { - props.service.getUserAuthError(abortController.current).then(onError); - } - - // Get data of the currently logged in user - updateUser(); - - return () => abortController.current.abort(); - }, []); - - const updateUser = async () => { - try { - setUserLoading(true); - const user = await props.service.getUser(abortController.current); - if (isError(user)) { - // An error result with HTTP OK status indicates that the user is not logged in. - setUser(undefined); - } else { - setUser(user as UserData); - } - } catch (err) { - onError(err); - } - - setUserLoading(false); - }; - - const getLoginProviders = async () => { - if (props.loginProviders != null) { - return; - } - - const data = await props.service.getLoginProviders(abortController.current); - if (isSuccess(data)) { - console.log(data.success); - } else { - setLoginProviders((data as LoginProviders).loginProviders); - } - }; - - const onError = (err: Error | Partial | ReportedError) => { - if (err instanceof DOMException && err.message.trim() === 'The operation was aborted.') { - // ignore error caused by AbortController.abort() - return; - } - - const message = handleError(err); - const code = (err as ReportedError).code; - setError({ message, code }); - setIsErrorDialogOpen(true); - }; - - const onErrorDialogClose = () => { - setIsErrorDialogOpen(false); - }; - - const renderPageContent = (): ReactNode => { - const { mainHeadTags: MainHeadTagsComponent } = props.pageSettings.elements; - return <> - { MainHeadTagsComponent ? : null } - - } /> - } /> - - { - error ? - - : null - } - ; - }; + const { data: loginProviders } = useGetLoginProvidersQuery(undefined, { skip: props.loginProviders != null }); + useGetUserAuthErrorQuery(undefined, { skip: !new URLSearchParams(window.location.search).has('auth-error') }); const mainContext: MainContext = { - service: props.service, + handleError: () => {}, pageSettings: props.pageSettings, - user, - updateUser, - loginProviders, - handleError: onError + loginProviders: props.loginProviders ?? loginProviders }; + + const { mainHeadTags: MainHeadTagsComponent } = props.pageSettings.elements; return <> - {renderPageContent()} + {MainHeadTagsComponent ? : null} + + } /> + } /> + + ; }; export interface MainProps { - service: ExtensionRegistryService; pageSettings: PageSettings; loginProviders?: Record; } \ No newline at end of file diff --git a/webui/src/other-pages.tsx b/webui/src/other-pages.tsx index b908c4a2b..8f736891f 100644 --- a/webui/src/other-pages.tsx +++ b/webui/src/other-pages.tsx @@ -10,7 +10,6 @@ import { UserSettings, UserSettingsRoutes } from './pages/user/user-settings'; import { NamespaceDetail, NamespaceDetailRoutes } from './pages/namespace-detail/namespace-detail'; import { ExtensionDetail, ExtensionDetailRoutes } from './pages/extension-detail/extension-detail'; import { getCookieValueByKey, setCookie } from './utils'; -import { UserData } from './extension-registry-types'; import { NotFound } from './not-found'; const ToolbarItem = styled(Box)({ @@ -35,7 +34,7 @@ const Footer = styled('footer')(({ theme }: { theme: Theme }) => ({ backgroundImage: theme.palette.mode == 'dark' ? 'linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08))' : undefined })); -export const OtherPages: FunctionComponent = (props) => { +export const OtherPages: FunctionComponent = () => { const { pageSettings } = useContext(MainContext); const { additionalRoutes: AdditionalRoutes, @@ -107,8 +106,8 @@ export const OtherPages: FunctionComponent = (props) => { } /> - } /> - } /> + } /> + } /> } /> } /> } /> @@ -126,9 +125,4 @@ export const OtherPages: FunctionComponent = (props) => { : null } ; -}; - -export interface OtherPagesProps { - user?: UserData; - userLoading: boolean; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/admin-dashboard.tsx b/webui/src/pages/admin-dashboard/admin-dashboard.tsx index 4df98e83f..e0b14803c 100644 --- a/webui/src/pages/admin-dashboard/admin-dashboard.tsx +++ b/webui/src/pages/admin-dashboard/admin-dashboard.tsx @@ -23,6 +23,7 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import { Welcome } from './welcome'; import { PublisherAdmin } from './publisher-admin'; import PersonIcon from '@mui/icons-material/Person'; +import { useGetUserQuery } from '../../store/api'; export namespace AdminDashboardRoutes { export const ROOT = 'admin-dashboard'; @@ -43,8 +44,9 @@ const Message: FunctionComponent<{message: string}> = ({ message }) => { ); }; -export const AdminDashboard: FunctionComponent = props => { - const { user, loginProviders } = useContext(MainContext); +export const AdminDashboard: FunctionComponent = () => { + const { data: user, isLoading: userLoading } = useGetUserQuery(); + const { loginProviders } = useContext(MainContext); const navigate = useNavigate(); const toMainPage = () => navigate('/'); @@ -76,7 +78,7 @@ export const AdminDashboard: FunctionComponent = props => { ; } else if (user) { content = ; - } else if (!props.userLoading && loginProviders) { + } else if (!userLoading && loginProviders) { content = ; } @@ -84,8 +86,4 @@ export const AdminDashboard: FunctionComponent = props => { {content} ; -}; - -export interface AdminDashboardProps { - userLoading: boolean; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/extension-admin.tsx b/webui/src/pages/admin-dashboard/extension-admin.tsx index e6dced536..41ddd1f86 100644 --- a/webui/src/pages/admin-dashboard/extension-admin.tsx +++ b/webui/src/pages/admin-dashboard/extension-admin.tsx @@ -8,42 +8,33 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { SearchListContainer } from './search-list-container'; import { ExtensionListSearchfield } from '../extension-list/extension-list-searchfield'; import { Button, Typography } from '@mui/material'; -import { MainContext } from '../../context'; -import { isError, Extension, TargetPlatformVersion } from '../../extension-registry-types'; +import { TargetPlatformVersion } from '../../extension-registry-types'; import { ExtensionVersionContainer } from './extension-version-container'; import { StyledInput } from './namespace-input'; +import { useAdminDeleteExtensionsMutation, useAdminGetExtensionQuery } from '../../store/api'; export const ExtensionAdmin: FunctionComponent = props => { - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [extensionValue, setExtensionValue] = useState(''); + const [error, setError] = useState(''); + const [extensionFieldError, setExtensionFieldError] = useState(false); + const [namespaceFieldError, setNamespaceFieldError] = useState(false); + const [namespaceValue, setNamespaceValue] = useState(''); - const [loading, setLoading] = useState(false); + const { data: extension, isLoading } = useAdminGetExtensionQuery({ namespace: namespaceValue, extension: extensionValue }, { skip: !namespaceValue || !extensionValue }); + const [deleteExtensions] = useAdminDeleteExtensionsMutation(); - const [extensionValue, setExtensionValue] = useState(''); const handleExtensionChange = (value: string) => { setExtensionValue(value); }; - const [namespaceValue, setNamespaceValue] = useState(''); const handleNamespaceChange = (value: string) => { setNamespaceValue(value); }; - const [error, setError] = useState(''); - - const [extensionFieldError, setExtensionFieldError] = useState(false); - const [namespaceFieldError, setNamespaceFieldError] = useState(false); - - const { service, handleError } = useContext(MainContext); - const [extension, setExtension] = useState(undefined); const findExtension = async () => { if (!namespaceValue) { setNamespaceFieldError(true); @@ -57,24 +48,6 @@ export const ExtensionAdmin: FunctionComponent = props => { return; } setExtensionFieldError(false); - try { - setLoading(true); - const extensionDetail = await service.admin.getExtension(abortController.current, namespaceValue, extensionValue); - if (isError(extensionDetail)) { - throw extensionDetail; - } - setExtension(extensionDetail); - setError(''); - setLoading(false); - } catch (err) { - if (err && err.status === 404) { - setError(`Extension not found: ${namespaceValue}.${extensionValue}`); - setExtension(undefined); - } else { - handleError(err); - } - setLoading(false); - } }; const onRemove = async (targetPlatformVersions?: TargetPlatformVersion[]) => { @@ -82,8 +55,7 @@ export const ExtensionAdmin: FunctionComponent = props => { return; } - await service.admin.deleteExtensions(abortController.current, { namespace: extension.namespace, extension: extension.name, targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ version, targetPlatform })) }); - await findExtension(); + await deleteExtensions({ namespace: extension.namespace, extension: extension.name, targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ version, targetPlatform })) }); }; return { : '' } - loading={loading} + loading={isLoading} />; }; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/extension-version-container.tsx b/webui/src/pages/admin-dashboard/extension-version-container.tsx index e49e95e77..95d4821e3 100644 --- a/webui/src/pages/admin-dashboard/extension-version-container.tsx +++ b/webui/src/pages/admin-dashboard/extension-version-container.tsx @@ -8,52 +8,57 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { ChangeEvent, FunctionComponent, useContext, useState, useEffect, useRef } from 'react'; +import React, { ChangeEvent, FunctionComponent, useState, useEffect } from 'react'; import { Extension, TargetPlatformVersion, VERSION_ALIASES } from '../../extension-registry-types'; import { Box, Grid, Typography, FormControl, FormGroup, FormControlLabel, Checkbox } from '@mui/material'; import WarningIcon from '@mui/icons-material/Warning'; import { ExtensionRemoveDialog } from './extension-remove-dialog'; import { getTargetPlatformDisplayName } from '../../utils'; -import { MainContext } from '../../context'; +import { useGetExtensionIconQuery } from '../../store/api'; + +const WILDCARD = '*'; +const getTargetPlatformVersions = (extension: Extension) => { + const versionMap: TargetPlatformVersion[] = []; + versionMap.push({ targetPlatform: WILDCARD, version: WILDCARD, checked: false }); + if (extension.allTargetPlatformVersions != null) { + extension.allTargetPlatformVersions + .filter(i => VERSION_ALIASES.indexOf(i.version) < 0) + .forEach(i => { + const { version, targetPlatforms } = i; + versionMap.push({ targetPlatform: WILDCARD, version, checked: false }); + targetPlatforms.forEach(targetPlatform => versionMap.push({ targetPlatform, version, checked: false })); + }); + } + + return versionMap; +}; export const ExtensionVersionContainer: FunctionComponent = props => { - const WILDCARD = '*'; const { extension } = props; - const { service } = useContext(MainContext); - const abortController = useRef(new AbortController()); - - const getTargetPlatformVersions = () => { - const versionMap: TargetPlatformVersion[] = []; - versionMap.push({ targetPlatform: WILDCARD, version: WILDCARD, checked: false }); - if (extension.allTargetPlatformVersions != null) { - extension.allTargetPlatformVersions - .filter(i => VERSION_ALIASES.indexOf(i.version) < 0) - .forEach(i => { - const { version, targetPlatforms } = i; - versionMap.push({ targetPlatform: WILDCARD, version, checked: false }); - targetPlatforms.forEach(targetPlatform => versionMap.push({ targetPlatform, version, checked: false })); - }); - } + const [targetPlatformVersions, setTargetPlatformVersions] = useState([]); + const [icon, setIcon] = useState(undefined); + const { data: iconBlob } = useGetExtensionIconQuery(extension); - return versionMap; - }; + useEffect(() => { + setTargetPlatformVersions(getTargetPlatformVersions(extension)); + }, [extension]); useEffect(() => { return () => { - abortController.current.abort(); + if (icon) { + URL.revokeObjectURL(icon); + } }; }, []); - const [targetPlatformVersions, setTargetPlatformVersions] = useState(getTargetPlatformVersions()); - const [icon, setIcon] = useState(undefined); useEffect(() => { if (icon) { URL.revokeObjectURL(icon); } - service.getExtensionIcon(abortController.current, props.extension).then(setIcon); - setTargetPlatformVersions(getTargetPlatformVersions()); - }, [props.extension]); + const newIcon = iconBlob ? URL.createObjectURL(iconBlob) : undefined; + setIcon(newIcon); + }, [iconBlob]); const handleChange = (event: ChangeEvent) => { const newTargetPlatformVersions: TargetPlatformVersion[] = []; diff --git a/webui/src/pages/admin-dashboard/namespace-admin.tsx b/webui/src/pages/admin-dashboard/namespace-admin.tsx index b0a966569..4b34e60d5 100644 --- a/webui/src/pages/admin-dashboard/namespace-admin.tsx +++ b/webui/src/pages/admin-dashboard/namespace-admin.tsx @@ -8,95 +8,60 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useState, useContext, useEffect, useRef, ReactNode } from 'react'; +import React, { FunctionComponent, useState, useContext, ReactNode } from 'react'; import { Typography, Box } from '@mui/material'; import { NamespaceDetail, NamespaceDetailConfigContext } from '../user/user-settings-namespace-detail'; import { ButtonWithProgress } from '../../components/button-with-progress'; -import { Namespace, isError } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { StyledInput } from './namespace-input'; import { SearchListContainer } from './search-list-container'; +import { useAdminCreateNamespaceMutation, useAdminGetNamespaceQuery, useGetUserQuery } from '../../store/api'; export const NamespaceAdmin: FunctionComponent = props => { - const { pageSettings, service, user, handleError } = useContext(MainContext); - - const [loading, setLoading] = useState(false); - const [currentNamespace, setCurrentNamespace] = useState(); - const [notFound, setNotFound] = useState(''); + const { pageSettings } = useContext(MainContext); + const [namespaceName, setNamespaceName] = useState(); + const [inputValue, setInputValue] = useState(''); + const [creating, setCreating] = useState(false); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [createNamespace] = useAdminCreateNamespaceMutation(); + const { data: user } = useGetUserQuery(); + const { data: currentNamespace, isLoading } = useAdminGetNamespaceQuery(namespaceName as string, { skip: namespaceName == null }); - const fetchNamespace = async (namespaceName: string) => { - if (!namespaceName) { - setCurrentNamespace(undefined); - setNotFound(''); - return; - } - try { - setLoading(true); - const namespace = await service.admin.getNamespace(abortController.current, namespaceName); - if (isError(namespace)) { - throw namespace; - } - setCurrentNamespace(namespace); - setNotFound(''); - setLoading(false); - } catch (err) { - if (err && err.status === 404) { - setNotFound(namespaceName); - setCurrentNamespace(undefined); - } else { - handleError(err); - } - setLoading(false); - } + const fetchNamespace = (namespaceName: string) => { + setNamespaceName(namespaceName ? namespaceName : undefined); }; - const [inputValue, setInputValue] = useState(''); const onChangeInput = (name: string) => { setInputValue(name); }; - const [creating, setCreating] = useState(false); const onCreate = async () => { - try { - setCreating(true); - await service.admin.createNamespace(abortController.current, { - name: inputValue - }); - await fetchNamespace(inputValue); - } catch (err) { - handleError(err); - } finally { - setCreating(false); - } + setCreating(true); + await createNamespace({ + name: inputValue + }); + setCreating(false); }; let listContainer: ReactNode = ''; if (currentNamespace && pageSettings && user) { listContainer = true} fixSelf={false} /> ; - } else if (notFound) { + } else if (namespaceName && currentNamespace == null && !isLoading) { listContainer = - Namespace {notFound} not found. Do you want to create it? + Namespace {namespaceName} not found. Do you want to create it? - Create Namespace {notFound} + Create Namespace {namespaceName} ; @@ -107,6 +72,6 @@ export const NamespaceAdmin: FunctionComponent = props => { [] } listContainer={listContainer} - loading={loading} + loading={isLoading} />; }; \ No newline at end of file diff --git a/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx b/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx index a62a12521..0965719cb 100644 --- a/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx +++ b/webui/src/pages/admin-dashboard/namespace-change-dialog.tsx @@ -8,38 +8,30 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ - import React, { ChangeEvent, FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; + import React, { ChangeEvent, FunctionComponent, useState, useEffect } from 'react'; import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, FormControlLabel, TextField } from '@mui/material'; import { ButtonWithProgress } from '../../components/button-with-progress'; - import { Namespace, SuccessResult, isError } from '../../extension-registry-types'; - import { MainContext } from '../../context'; + import { Namespace, SuccessResult } from '../../extension-registry-types'; import { InfoDialog } from '../../components/info-dialog'; +import { useAdminChangeNamespaceMutation } from '../../store/api'; export interface NamespaceChangeDialogProps { open: boolean; onClose: () => void; namespace: Namespace; - setLoadingState: (loading: boolean) => void; } export const NamespaceChangeDialog: FunctionComponent = props => { const { open } = props; - const { service, handleError } = useContext(MainContext); const [working, setWorking] = useState(false); const [newNamespace, setNewNamespace] = useState(''); const [removeOldNamespace, setRemoveOldNamespace] = useState(false); const [mergeIfNewNamespaceAlreadyExists, setMergeIfNewNamespaceAlreadyExists] = useState(false); const [infoDialogIsOpen, setInfoDialogIsOpen] = useState(false); const [infoDialogMessage, setInfoDialogMessage] = useState(''); - - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [changeNamespace] = useAdminChangeNamespaceMutation(); useEffect(() => { if (open) { @@ -63,28 +55,16 @@ setMergeIfNewNamespaceAlreadyExists(checked); }; const handleChangeNamespace = async () => { - try { - if (!props.namespace) { - return; - } - setWorking(true); - props.setLoadingState(true); - const oldNamespace = props.namespace.name; - const result = await service.admin.changeNamespace(abortController.current, { oldNamespace, newNamespace, removeOldNamespace, mergeIfNewNamespaceAlreadyExists }); - if (isError(result)) { - throw result; - } - - const successResult = result as SuccessResult; - props.setLoadingState(false); - setWorking(false); - setInfoDialogIsOpen(true); - setInfoDialogMessage(successResult.success); - } catch (err) { - props.setLoadingState(false); - setWorking(false); - handleError(err); + if (!props.namespace) { + return; } + setWorking(true); + const oldNamespace = props.namespace.name; + const { data: result } = await changeNamespace({ oldNamespace, newNamespace, removeOldNamespace, mergeIfNewNamespaceAlreadyExists }); + const successResult = result as SuccessResult; + setWorking(false); + setInfoDialogIsOpen(true); + setInfoDialogMessage(successResult.success); }; return <> diff --git a/webui/src/pages/admin-dashboard/publisher-admin.tsx b/webui/src/pages/admin-dashboard/publisher-admin.tsx index 7595ab14e..66d27368e 100644 --- a/webui/src/pages/admin-dashboard/publisher-admin.tsx +++ b/webui/src/pages/admin-dashboard/publisher-admin.tsx @@ -8,24 +8,21 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useState, useContext, createContext, useEffect, useRef, ReactNode } from 'react'; +import React, { FunctionComponent, useState, useContext, createContext, ReactNode } from 'react'; import { Typography, Box } from '@mui/material'; import { PublisherInfo } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { StyledInput } from './namespace-input'; import { SearchListContainer } from './search-list-container'; import { PublisherDetails } from './publisher-details'; +import { apiSlice, useGetUserQuery } from '../../store/api'; export const UpdateContext = createContext({ handleUpdate: () => { } }); export const PublisherAdmin: FunctionComponent = props => { - const { pageSettings, service, user, handleError } = useContext(MainContext); + const { data: user } = useGetUserQuery(); + const { pageSettings } = useContext(MainContext); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [getPublisherInfo] = apiSlice.useLazyAdminGetPublisherInfoQuery(); const [loading, setLoading] = useState(false); const [inputValue, setInputValue] = useState(''); @@ -37,26 +34,21 @@ export const PublisherAdmin: FunctionComponent = props => { const [notFound, setNotFound] = useState(''); const fetchPublisher = async () => { const publisherName = inputValue; - try { - setLoading(true); - if (publisherName !== '') { - const publisher = await service.admin.getPublisherInfo(abortController.current, 'github', publisherName); + setLoading(true); + if (publisherName !== '') { + const { data: publisher } = await getPublisherInfo({ provider: 'github', login: publisherName }); + if (publisher != null) { setNotFound(''); setPublisher(publisher); } else { - setNotFound(''); - setPublisher(undefined); - } - setLoading(false); - } catch (err) { - if (err?.status === 404) { setNotFound(publisherName); setPublisher(undefined); - } else { - handleError(err); } - setLoading(false); + } else { + setNotFound(''); + setPublisher(undefined); } + setLoading(false); }; const handleUpdate = () => { diff --git a/webui/src/pages/admin-dashboard/publisher-revoke-dialog.tsx b/webui/src/pages/admin-dashboard/publisher-revoke-dialog.tsx index 2fce104c9..bd0ddb6d2 100644 --- a/webui/src/pages/admin-dashboard/publisher-revoke-dialog.tsx +++ b/webui/src/pages/admin-dashboard/publisher-revoke-dialog.tsx @@ -8,33 +8,25 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { Button, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Typography, Link } from '@mui/material'; -import { createAbsoluteURL } from '../../utils'; import { ButtonWithProgress } from '../../components/button-with-progress'; -import { PublisherInfo, isError } from '../../extension-registry-types'; -import { MainContext } from '../../context'; -import { UpdateContext } from './publisher-admin'; +import { PublisherInfo } from '../../extension-registry-types'; +import { eclipseLoginUrl, useAdminRevokePublisherContributionsMutation, useGetUserQuery } from '../../store/api'; export const PublisherRevokeDialog: FunctionComponent = props => { - const { user, service, handleError } = useContext(MainContext); - const updateContext = useContext(UpdateContext); + const { data: user } = useGetUserQuery(); const [dialogOpen, setDialogOpen] = useState(false); const [working, setWorking] = useState(false); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [revokePublisherContributions] = useAdminRevokePublisherContributionsMutation(); if (props.publisherInfo.user.publisherAgreement - && !(user?.additionalLogins?.find(login => login.provider === 'eclipse'))) { + && !(user?.additionalLogins?.find(login => login.provider === 'eclipse'))) { // If a publisher agreement is required, the admin must be logged in with Eclipse to revoke it - return + return @@ -42,20 +34,11 @@ export const PublisherRevokeDialog: FunctionComponent { - try { - setWorking(true); - const user = props.publisherInfo.user; - const result = await service.admin.revokePublisherContributions(abortController.current, user.provider as string, user.loginName); - if (isError(result)) { - throw result; - } - updateContext.handleUpdate(); - setDialogOpen(false); - } catch (err) { - handleError(err); - } finally { - setWorking(false); - } + setWorking(true); + const user = props.publisherInfo.user; + await revokePublisherContributions({ provider: user.provider as string, login: user.loginName }); + setDialogOpen(false); + setWorking(false); }; const tokenCount = props.publisherInfo.activeAccessTokenNum; @@ -77,31 +60,31 @@ export const PublisherRevokeDialog: FunctionComponent { !tokenCount && !extensionCount && !hasAgreement ? - <> - Publisher {props.publisherInfo.user.loginName} currently has no contributions to revoke. - Send the request anyway? - - : - <> - The following actions will be executed: -
    - { - tokenCount > 0 ? -
  • Deactivate {tokenCount} access token{tokenCount > 1 ? 's' : ''} of {props.publisherInfo.user.loginName}
  • - : null - } - { - extensionCount > 0 ? -
  • Deactivate {extensionCount} published extension version{extensionCount > 1 ? 's' : ''}
  • - : null - } - { - hasAgreement ? -
  • Revoke the Publisher Agreement of {props.publisherInfo.user.loginName}
  • - : null - } -
- + <> + Publisher {props.publisherInfo.user.loginName} currently has no contributions to revoke. + Send the request anyway? + + : + <> + The following actions will be executed: +
    + { + tokenCount > 0 ? +
  • Deactivate {tokenCount} access token{tokenCount > 1 ? 's' : ''} of {props.publisherInfo.user.loginName}
  • + : null + } + { + extensionCount > 0 ? +
  • Deactivate {extensionCount} published extension version{extensionCount > 1 ? 's' : ''}
  • + : null + } + { + hasAgreement ? +
  • Revoke the Publisher Agreement of {props.publisherInfo.user.loginName}
  • + : null + } +
+ } diff --git a/webui/src/pages/admin-dashboard/publisher-revoke-tokens-button.tsx b/webui/src/pages/admin-dashboard/publisher-revoke-tokens-button.tsx index 9b20bbb69..5557edf1e 100644 --- a/webui/src/pages/admin-dashboard/publisher-revoke-tokens-button.tsx +++ b/webui/src/pages/admin-dashboard/publisher-revoke-tokens-button.tsx @@ -8,39 +8,21 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { Box } from '@mui/material'; import { ButtonWithProgress } from '../../components/button-with-progress'; -import { PublisherInfo, isError } from '../../extension-registry-types'; -import { MainContext } from '../../context'; -import { UpdateContext } from './publisher-admin'; +import { PublisherInfo } from '../../extension-registry-types'; +import { useAdminRevokeAccessTokensMutation } from '../../store/api'; export const PublisherRevokeTokensButton: FunctionComponent = props => { - const { service, handleError } = useContext(MainContext); - const updateContext = useContext(UpdateContext); - const [working, setWorking] = useState(false); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [revokeAccessTokens] = useAdminRevokeAccessTokensMutation(); const doRevoke = async () => { - try { - setWorking(true); - const user = props.publisherInfo.user; - const result = await service.admin.revokeAccessTokens(abortController.current, user.provider as string, user.loginName); - if (isError(result)) { - throw result; - } - updateContext.handleUpdate(); - } catch (err) { - handleError(err); - } finally { - setWorking(false); - } + setWorking(true); + const user = props.publisherInfo.user; + await revokeAccessTokens({ provider: user.provider as string, login: user.loginName }); + setWorking(false); }; return diff --git a/webui/src/pages/extension-detail/extension-detail-changes.tsx b/webui/src/pages/extension-detail/extension-detail-changes.tsx index 4967f9bc8..c4feeaf93 100644 --- a/webui/src/pages/extension-detail/extension-detail-changes.tsx +++ b/webui/src/pages/extension-detail/extension-detail-changes.tsx @@ -8,47 +8,18 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; +import React, { FunctionComponent } from 'react'; import { Box, Divider, Typography } from '@mui/material'; -import { MainContext } from '../../context'; import { SanitizedMarkdown } from '../../components/sanitized-markdown'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { Extension } from '../../extension-registry-types'; +import { useGetStaticContentQuery } from '../../store/api'; export const ExtensionDetailChanges: FunctionComponent = props => { - const [changelog, setChangelog] = useState(); - const [loading, setLoading] = useState(true); - const context = useContext(MainContext); - const abortController = useRef(new AbortController()); - - useEffect(() => { - updateChanges(); - return () => abortController.current.abort(); - }, []); - - useEffect(() => { - setLoading(true); - updateChanges(); - }, [props.extension.namespace, props.extension.name, props.extension.version]); - - const updateChanges = async (): Promise => { - if (props.extension.files.changelog) { - try { - const changelog = await context.service.getExtensionChangelog(abortController.current, props.extension); - setChangelog(changelog); - } catch (err) { - context.handleError(err); - setChangelog(undefined); - } - } else { - setChangelog(''); - } - - setLoading(false); - }; + const { data: changelog, isLoading } = useGetStaticContentQuery(props.extension.files.changelog); if (typeof changelog === 'undefined') { - return ; + return ; } if (changelog.length === 0) { return <> diff --git a/webui/src/pages/extension-detail/extension-detail-overview.tsx b/webui/src/pages/extension-detail/extension-detail-overview.tsx index be940438f..c766f11bd 100644 --- a/webui/src/pages/extension-detail/extension-detail-overview.tsx +++ b/webui/src/pages/extension-detail/extension-detail-overview.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef, useMemo } from 'react'; +import React, { FunctionComponent, ReactNode, useContext, useMemo } from 'react'; import { Box, Theme, Typography, Button, Link, NativeSelect, SxProps, styled, Grid, Stack } from '@mui/material'; import { Link as RouteLink, useNavigate, useParams } from 'react-router-dom'; import HomeIcon from '@mui/icons-material/Home'; @@ -24,15 +24,14 @@ import { Extension, ExtensionReference, VERSION_ALIASES } from '../../extension- import { ExtensionListRoutes } from '../extension-list/extension-list-container'; import { ExtensionDetailRoutes } from './extension-detail'; import { ExtensionDetailDownloadsMenu } from './extension-detail-downloads-menu'; +import { useGetStaticContentQuery } from '../../store/api'; export const ExtensionDetailOverview: FunctionComponent = props => { - const [loading, setLoading] = useState(true); - const [readme, setReadme] = useState(''); - const { pageSettings, service, handleError } = useContext(MainContext); + const { data: readme, isLoading } = useGetStaticContentQuery(props.extension.files.readme); + const { pageSettings } = useContext(MainContext); const params = useParams(); const navigate = useNavigate(); - const abortController = useRef(new AbortController()); const worksWithEngines = useMemo(() => { const engines = props.extension.engines; @@ -78,34 +77,6 @@ export const ExtensionDetailOverview: FunctionComponent); }, [props.extension.downloads]); - useEffect(() => { - updateReadme(); - return () => { - abortController.current.abort(); - }; - }, []); - - useEffect(() => { - setLoading(true); - updateReadme(); - }, [props.extension.namespace, props.extension.name, props.extension.version]); - - const updateReadme = async (): Promise => { - if (props.extension.files.readme) { - try { - const readme = await service.getExtensionReadme(abortController.current, props.extension); - setReadme(readme); - setLoading(false); - } catch (err) { - handleError(err); - setLoading(false); - } - } else { - setReadme('## No README available'); - setLoading(false); - } - }; - const renderVersionSection = (): ReactNode => { const { extension } = props; const allVersions = Object.keys(extension.allVersions) @@ -244,7 +215,7 @@ export const ExtensionDetailOverview: FunctionComponent; + return ; } const { extension } = props; diff --git a/webui/src/pages/extension-detail/extension-detail-reviews.tsx b/webui/src/pages/extension-detail/extension-detail-reviews.tsx index 0fe8105ce..d8d32ab4b 100644 --- a/webui/src/pages/extension-detail/extension-detail-reviews.tsx +++ b/webui/src/pages/extension-detail/extension-detail-reviews.tsx @@ -8,78 +8,45 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { Fragment, FunctionComponent, ReactNode, useContext, useState, useEffect, useRef } from 'react'; +import React, { Fragment, FunctionComponent, ReactNode, useState } from 'react'; import { Box, Typography, Divider, Link } from '@mui/material'; -import { MainContext } from '../../context'; import { toLocalTime } from '../../utils'; -import { ExtensionReview, Extension, ExtensionReviewList, isEqualUser, isError, UserData } from '../../extension-registry-types'; +import { ExtensionReview, Extension, ExtensionReviewList, isEqualUser } from '../../extension-registry-types'; import { TextDivider } from '../../components/text-divider'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { ButtonWithProgress } from '../../components/button-with-progress'; import { Timestamp } from '../../components/timestamp'; import { ExportRatingStars } from './extension-rating-stars'; import { ExtensionReviewDialog } from './extension-review-dialog'; +import { useDeleteReviewMutation, useGetExtensionReviewsQuery, useGetUserQuery } from '../../store/api'; export const ExtensionDetailReviews: FunctionComponent = props => { - const [reviewList, setReviewList] = useState(); - const [loading, setLoading] = useState(true); + const { data: reviewList, isLoading } = useGetExtensionReviewsQuery(props.extension); + const [deleteReview] = useDeleteReviewMutation(); const [revoked, setRevoked] = useState(false); - const context = useContext(MainContext); - const abortController = useRef(new AbortController()); - - useEffect(() => { - updateReviews(); - return () => abortController.current.abort(); - }, []); - - const updateReviews = async () => { - try { - const reviewList = await context.service.getExtensionReviews(abortController.current, props.extension); - setReviewList(reviewList); - } catch (err) { - context.handleError(err); - } - - setLoading(false); - setRevoked(false); - }; - - const saveCompleted = () => { - setLoading(true); - updateReviews(); - props.reviewsDidUpdate(); - }; + const { data: user } = useGetUserQuery(); const handleRevokeButton = async () => { setRevoked(true); - try { - const result = await context.service.deleteReview(abortController.current, reviewList!.deleteUrl); - if (isError(result)) { - throw result; - } - saveCompleted(); - } catch (err) { - context.handleError(err); - } + await deleteReview({ deleteReviewUrl: reviewList!.deleteUrl, extension: props.extension }); }; const renderButton = (): ReactNode => { - if (!context.user || !reviewList) { - return ''; + if (!user || !reviewList) { + return ''; } - const existingReview = reviewList.reviews.find(r => isEqualUser(r.user, context.user as UserData)); + const existingReview = reviewList.reviews.find(r => isEqualUser(r.user, user)); if (existingReview) { const localTime = toLocalTime(existingReview.timestamp); return + working={revoked} + onClick={handleRevokeButton} + title={`Revoke review written by ${user.loginName} on ${localTime}`} > Revoke my Review ; } else { return @@ -105,24 +72,24 @@ export const ExtensionDetailReviews: FunctionComponent { r.timestamp ? - <> - - - - : null + <> + + + + : null } { r.user.homepage ? - - {r.user.loginName} - - : - r.user.loginName + + {r.user.loginName} + + : + r.user.loginName } @@ -163,7 +130,7 @@ export const ExtensionDetailReviews: FunctionComponent - + {renderReviewList(reviewList)} ; @@ -172,5 +139,4 @@ export const ExtensionDetailReviews: FunctionComponent void; } \ No newline at end of file diff --git a/webui/src/pages/extension-detail/extension-detail.tsx b/webui/src/pages/extension-detail/extension-detail.tsx index af34e20a3..4174fd703 100644 --- a/webui/src/pages/extension-detail/extension-detail.tsx +++ b/webui/src/pages/extension-detail/extension-detail.tsx @@ -9,7 +9,7 @@ ********************************************************************************/ import * as React from 'react'; -import { ChangeEvent, FunctionComponent, ReactElement, ReactNode, useContext, useEffect, useState, useRef } from 'react'; +import { ChangeEvent, FunctionComponent, ReactElement, ReactNode, useContext, useEffect, useState } from 'react'; import { Typography, Box, Theme, Container, Link, Avatar, Paper, Badge, SxProps, Tabs, Tab, Stack, useTheme, PaletteMode, decomposeColor @@ -30,6 +30,7 @@ import { ExtensionDetailOverview } from './extension-detail-overview'; import { ExtensionDetailChanges } from './extension-detail-changes'; import { ExtensionDetailReviews } from './extension-detail-reviews'; import styled from '@mui/material/styles/styled'; +import { useGetExtensionDetailQuery, useGetExtensionIconQuery } from '../../store/api'; export namespace ExtensionDetailRoutes { export namespace Parameters { @@ -72,20 +73,16 @@ const StyledHoverPopover = styled(HoverPopover)(alignVertically); export const ExtensionDetail: FunctionComponent = () => { const theme = useTheme(); - const [loading, setLoading] = useState(true); - const [notFoundError, setNotFoundError] = useState(); - const [extension, setExtension] = useState(); const [icon, setIcon] = useState(); const navigate = useNavigate(); const { namespace, name, target, version } = useParams(); - const { handleError, pageSettings, service } = useContext(MainContext); + const { pageSettings } = useContext(MainContext); + const { data: extension, isLoading, refetch } = useGetExtensionDetailQuery({ namespace: namespace as string, name: name as string, target, version }); + const { data: iconBlob } = useGetExtensionIconQuery(extension as Extension); - const abortController = useRef(new AbortController()); useEffect(() => { - updateExtension(); return () => { - abortController.current.abort(); if (icon) { URL.revokeObjectURL(icon); } @@ -97,46 +94,17 @@ export const ExtensionDetail: FunctionComponent = () => { return; } - setLoading(true); - updateExtension(); + refetch(); }, [namespace, name, target, version]); - const updateExtension = async (): Promise => { - const extensionUrl = getExtensionApiUrl(); - try { - const response = await service.getExtensionDetail(abortController.current, extensionUrl); - if (isError(response)) { - throw response; - } - const extension = response as Extension; - const icon = await updateIcon(extension); - setExtension(extension); - setIcon(icon); - setLoading(false); - } catch (err) { - if (err && err.status === 404) { - setNotFoundError(`Extension Not Found: ${namespace}.${name}`); - setLoading(false); - } else { - handleError(err); - } - setLoading(false); - } - }; - - const getExtensionApiUrl = (): string => { - return versionPointsToTab(version) - ? service.getExtensionApiUrl({ namespace: namespace as string, name: name as string }) - : service.getExtensionApiUrl({ namespace: namespace as string, name: name as string, target: target, version: version }); - }; - - const updateIcon = async (extension: Extension): Promise => { + useEffect(() => { if (icon) { URL.revokeObjectURL(icon); } - return await service.getExtensionIcon(abortController.current, extension); - }; + const newIcon = iconBlob ? URL.createObjectURL(iconBlob) : undefined; + setIcon(newIcon); + }, [iconBlob]); const onVersionSelect = (version: string): void => { const arr = [ExtensionDetailRoutes.ROOT, namespace as string, name as string]; @@ -150,10 +118,6 @@ export const ExtensionDetail: FunctionComponent = () => { navigate(createRoute(arr)); }; - const onReviewUpdate = (): void => { - updateExtension(); - }; - const handleTabChange = (event: ChangeEvent, newTab: string): void => { const previousTab = versionPointsToTab(version) ? version : 'overview'; if (newTab !== previousTab) { @@ -185,8 +149,8 @@ export const ExtensionDetail: FunctionComponent = () => { const renderHeaderTags = (extension?: Extension): ReactNode => { const { extensionHeadTags: ExtensionHeadTagsComponent } = pageSettings.elements; return <> - { ExtensionHeadTagsComponent - ? + {ExtensionHeadTagsComponent + ? : null } ; @@ -195,13 +159,13 @@ export const ExtensionDetail: FunctionComponent = () => { const renderNotFound = (): ReactNode => { return <> { - notFoundError ? - - - {notFoundError} - - - : null + !isLoading ? + + + Extension Not Found: {namespace}.{name} + + + : null } ; }; @@ -211,7 +175,7 @@ export const ExtensionDetail: FunctionComponent = () => { case 'changes': return ; case 'reviews': - return ; + return ; default: return ; } @@ -255,7 +219,7 @@ export const ExtensionDetail: FunctionComponent = () => { > { }); return ( - - - - { extension.displayName ?? extension.name} - - - { extension.deprecated && - - - - This extension has been deprecated.{extension.replacement && <> Use - {extension.replacement.displayName} - instead.} + + + + {extension.displayName ?? extension.name} - - } - - - {renderAccessInfo(extension, headerTextColor)}  - - {extension.namespaceDisplayName} - - - - - Published by {renderUser(extension.publishedBy, headerTextColor, alignVertically)} - - - - {renderLicense(extension, headerTextColor)} + + {extension.deprecated && + + + + This extension has been deprecated.{extension.replacement && <> Use + {extension.replacement.displayName} + instead.} + + + } + + + {renderAccessInfo(extension, headerTextColor)}  + + {extension.namespaceDisplayName} + + + + + Published by {renderUser(extension.publishedBy, headerTextColor, alignVertically)} + + + + {renderLicense(extension, headerTextColor)} + - - - {extension.description} - - - = 1000 ? `${extension.downloadCount} downloads` : undefined}> -  {downloadCountFormatted} {extension.downloadCount === 1 ? 'download' : 'downloads'} + + {extension.description} - - - - ({reviewCountFormatted}) - + > + = 1000 ? `${extension.downloadCount} downloads` : undefined}> +  {downloadCountFormatted} {extension.downloadCount === 1 ? 'download' : 'downloads'} + + + + + ({reviewCountFormatted}) + ); @@ -434,18 +398,18 @@ export const ExtensionDetail: FunctionComponent = () => { const popupContent = { user.avatarUrl ? - - : null + + : null } { user.fullName ? - {user.fullName} - : null + {user.fullName} + : null } {user.loginName} @@ -457,13 +421,13 @@ export const ExtensionDetail: FunctionComponent = () => { { user.avatarUrl ? - <> - {user.loginName}  - - : user.loginName + <> + {user.loginName}  + + : user.loginName } ; @@ -485,11 +449,11 @@ export const ExtensionDetail: FunctionComponent = () => { }; return <> - { renderHeaderTags(extension) } - + {renderHeaderTags(extension)} + { - extension - ? renderExtension(extension) + extension && !isError(extension) + ? renderExtension(extension as Extension) : renderNotFound() } ; diff --git a/webui/src/pages/extension-detail/extension-review-dialog.tsx b/webui/src/pages/extension-detail/extension-review-dialog.tsx index 329d8457b..bf332e55b 100644 --- a/webui/src/pages/extension-detail/extension-review-dialog.tsx +++ b/webui/src/pages/extension-detail/extension-review-dialog.tsx @@ -8,12 +8,12 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { ChangeEvent, FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; +import React, { ChangeEvent, FunctionComponent, useEffect, useState, useRef } from 'react'; import { Box, Button, Dialog, DialogTitle, DialogContent, DialogContentText, TextField, DialogActions } from '@mui/material'; import { ButtonWithProgress } from '../../components/button-with-progress'; -import { Extension, StarRating, isError } from '../../extension-registry-types'; +import { Extension, StarRating } from '../../extension-registry-types'; import { ExtensionRatingStarSetter } from './extension-rating-star-setter'; -import { MainContext } from '../../context'; +import { useGetUserQuery, usePostReviewMutation } from '../../store/api'; const REVIEW_COMMENT_SIZE = 2048; @@ -23,8 +23,9 @@ export const ExtensionReviewDialog: FunctionComponent(1); const [comment, setComment] = useState(''); const [commentError, setCommentError] = useState(); - const context = useContext(MainContext); const abortController = useRef(new AbortController()); + const { data: user } = useGetUserQuery(); + const [postReview] = usePostReviewMutation(); useEffect(() => { document.addEventListener('keydown', handleEnter); @@ -35,7 +36,7 @@ export const ExtensionReviewDialog: FunctionComponent { - if (context.user) { + if (user) { setOpen(true); setPosted(false); } @@ -45,18 +46,10 @@ export const ExtensionReviewDialog: FunctionComponent { setPosted(true); - try { - const result = await context.service.postReview(abortController.current, { rating, comment }, props.reviewPostUrl); - if (isError(result)) { - throw result; - } - - setOpen(false); - setComment(''); - props.saveCompleted(); - } catch (err) { - context.handleError(err); - } + const postReviewUrl = props.reviewPostUrl; + await postReview({ review: { rating, comment }, postReviewUrl, extension: props.extension }); + setOpen(false); + setComment(''); }; const handleCommentChange = (event: ChangeEvent) => { @@ -76,7 +69,7 @@ export const ExtensionReviewDialog: FunctionComponent @@ -87,7 +80,7 @@ export const ExtensionReviewDialog: FunctionComponent{props.extension.displayName ?? props.extension.name} Review - Your review will be posted publicly as {context.user.loginName} + Your review will be posted publicly as {user.loginName} void; } \ No newline at end of file diff --git a/webui/src/pages/extension-list/extension-list-header.tsx b/webui/src/pages/extension-list/extension-list-header.tsx index 4c97e3ec3..99f00165e 100644 --- a/webui/src/pages/extension-list/extension-list-header.tsx +++ b/webui/src/pages/extension-list/extension-list-header.tsx @@ -16,6 +16,26 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import { ExtensionListSearchfield } from './extension-list-searchfield'; import { MainContext } from '../../context'; +const getCategories = (): ExtensionCategory[] => { + return [ + 'Programming Languages', + 'Snippets', + 'Linters', + 'Themes', + 'Debuggers', + 'Formatters', + 'Keymaps', + 'SCM Providers', + 'Other', + 'Extension Packs', + 'Language Packs', + 'Data Science', + 'Machine Learning', + 'Visualization', + 'Notebooks' + ]; +}; + export const ExtensionListHeader: FunctionComponent = props => { const [categories, setCategories] = useState([]); const [category, setCategory] = useState(''); @@ -24,7 +44,7 @@ export const ExtensionListHeader: FunctionComponent = const context = useContext(MainContext); useEffect(() => { - const categories = Array.from(context.service.getCategories()); + const categories = Array.from(getCategories()); categories.sort((a, b) => { if (a === b) return 0; diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx index 7c0b69fd2..7ff44e865 100644 --- a/webui/src/pages/extension-list/extension-list-item.tsx +++ b/webui/src/pages/extension-list/extension-list-item.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useState, useEffect, useRef } from 'react'; +import React, { FunctionComponent, useContext, useEffect, useState } from 'react'; import { Link as RouteLink } from 'react-router-dom'; import { Paper, Typography, Box, Grid, Fade } from '@mui/material'; import SaveAltIcon from '@mui/icons-material/SaveAlt'; @@ -17,37 +17,21 @@ import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; import { SearchEntry } from '../../extension-registry-types'; import { ExportRatingStars } from '../extension-detail/extension-rating-stars'; import { createRoute } from '../../utils'; +import { useGetExtensionIconQuery } from '../../store/api'; export const ExtensionListItem: FunctionComponent = props => { const [icon, setIcon] = useState(); + const { data } = useGetExtensionIconQuery(props.extension); const context = useContext(MainContext); - const abortController = useRef(new AbortController()); useEffect(() => { - updateChanges(); - return () => { - abortController.current.abort(); - if (icon) { - URL.revokeObjectURL(icon); - } - }; - }, []); - - useEffect(() => { - updateChanges(); - }, [props.extension.namespace, props.extension.name, props.extension.version]); - - const updateChanges = async (): Promise => { if (icon) { URL.revokeObjectURL(icon); } - try { - const icon = await context.service.getExtensionIcon(abortController.current, props.extension); - setIcon(icon); - } catch (err) { - context.handleError(err); - } - }; + + const newIcon = data ? URL.createObjectURL(data) : undefined; + setIcon(newIcon); + }, [data]); const { extension, filterSize, idx } = props; const route = createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]); diff --git a/webui/src/pages/extension-list/extension-list.tsx b/webui/src/pages/extension-list/extension-list.tsx index b5d01a58d..5f2bb6861 100644 --- a/webui/src/pages/extension-list/extension-list.tsx +++ b/webui/src/pages/extension-list/extension-list.tsx @@ -8,34 +8,31 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useEffect, useRef, useState } from 'react'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; import * as InfiniteScroll from 'react-infinite-scroller'; import { Box, Grid, CircularProgress, Container } from '@mui/material'; import { ExtensionListItem } from './extension-list-item'; -import { isError, SearchEntry, SearchResult } from '../../extension-registry-types'; -import { ExtensionFilter } from '../../extension-registry-service'; +import { ExtensionFilter, SearchEntry, SearchResult } from '../../extension-registry-types'; import { debounce } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; -import { MainContext } from '../../context'; +import { apiSlice } from '../../store/api'; export const ExtensionList: FunctionComponent = props => { - const abortController = useRef(new AbortController()); const cancellationToken = useRef<{ timeout?: number }>({}); const enableLoadMore = useRef(false); const lastRequestedPage = useRef(0); const pageOffset = useRef(0); const filterSize = useRef(props.filter.size ?? 10); - const context = useContext(MainContext); const [extensions, setExtensions] = useState([]); const [extensionKeys, setExtensionKeys] = useState>(new Set()); const [appliedFilter, setAppliedFilter] = useState(); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); + const [search] = apiSlice.useLazySearchQuery(); useEffect(() => { enableLoadMore.current = true; return () => { - abortController.current.abort(); clearTimeout(cancellationToken.current.timeout); enableLoadMore.current = false; }; @@ -45,30 +42,21 @@ export const ExtensionList: FunctionComponent = props => { filterSize.current = props.filter.size ?? filterSize.current; debounce( async () => { - try { - const result = await context.service.search(abortController.current, props.filter); - if (isError(result)) { - throw result; - } - - const searchResult = result as SearchResult; - props.onUpdate(searchResult.totalSize); - const actualSize = searchResult.extensions.length; - pageOffset.current = lastRequestedPage.current; - const extensionKeys = new Set(); - for (const ext of searchResult.extensions) { - extensionKeys.add(`${ext.namespace}.${ext.name}`); - } - - setExtensions(searchResult.extensions); - setExtensionKeys(extensionKeys); - setAppliedFilter(props.filter); - setHasMore(actualSize < searchResult.totalSize && actualSize > 0); - } catch (err) { - context.handleError(err); - } finally { - setLoading(false); + const { data: result } = await search(props.filter); + const searchResult = result as SearchResult; + props.onUpdate(searchResult.totalSize); + const actualSize = searchResult.extensions.length; + pageOffset.current = lastRequestedPage.current; + const extensionKeys = new Set(); + for (const ext of searchResult.extensions) { + extensionKeys.add(`${ext.namespace}.${ext.name}`); } + + setExtensions(searchResult.extensions); + setExtensionKeys(extensionKeys); + setAppliedFilter(props.filter); + setHasMore(actualSize < searchResult.totalSize && actualSize > 0); + setLoading(false); }, cancellationToken.current, props.debounceTime @@ -83,37 +71,31 @@ export const ExtensionList: FunctionComponent = props => { if (!isSameFilter(props.filter, filter)) { return; } - try { - filter.offset = (p - pageOffset.current) * filterSize.current; - const result = await context.service.search(abortController.current, filter); - if (isError(result)) { - throw result; - } - const newExtensions: SearchEntry[] = []; - const newExtensionKeys = new Set(); - newExtensions.push(...extensions); - extensionKeys.forEach((key) => newExtensionKeys.add(key)); - const searchResult = result as SearchResult; - if (enableLoadMore.current && isSameFilter(props.filter, filter)) { - // Check for duplicate keys to avoid problems due to asynchronous user edit / loadMore call - for (const ext of searchResult.extensions) { - const key = `${ext.namespace}.${ext.name}`; - if (!extensionKeys.has(key)) { - newExtensions.push(ext); - newExtensionKeys.add(key); - } + filter.offset = (p - pageOffset.current) * filterSize.current; + const { data: result } = await search(filter); + const newExtensions: SearchEntry[] = []; + const newExtensionKeys = new Set(); + newExtensions.push(...extensions); + extensionKeys.forEach((key) => newExtensionKeys.add(key)); + const searchResult = result as SearchResult; + if (enableLoadMore.current && isSameFilter(props.filter, filter)) { + // TODO test if this is needed + // Check for duplicate keys to avoid problems due to asynchronous user edit / loadMore call + for (const ext of searchResult.extensions) { + const key = `${ext.namespace}.${ext.name}`; + if (!extensionKeys.has(key)) { + newExtensions.push(ext); + newExtensionKeys.add(key); } - - setExtensions(newExtensions); - setExtensionKeys(newExtensionKeys); - setHasMore(extensions.length < searchResult.totalSize && searchResult.extensions.length > 0); } - } catch (err) { - context.handleError(err); - } finally { - setLoading(false); + + setExtensions(newExtensions); + setExtensionKeys(newExtensionKeys); + setHasMore(extensions.length < searchResult.totalSize && searchResult.extensions.length > 0); } + + setLoading(false); }; const isSameFilter = (f1: ExtensionFilter, f2: ExtensionFilter): boolean => { diff --git a/webui/src/pages/namespace-detail/namespace-detail.tsx b/webui/src/pages/namespace-detail/namespace-detail.tsx index 4094485ed..1e7f8d9da 100644 --- a/webui/src/pages/namespace-detail/namespace-detail.tsx +++ b/webui/src/pages/namespace-detail/namespace-detail.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react'; +import React, { FunctionComponent, ReactNode, useContext, useEffect, useState } from 'react'; import { Typography, Box, Container, Grid, Link, Divider } from '@mui/material'; import GitHubIcon from '@mui/icons-material/GitHub'; import LinkedInIcon from '@mui/icons-material/LinkedIn'; @@ -18,7 +18,8 @@ import { ExtensionListItem } from '../extension-list/extension-list-item'; import { MainContext } from '../../context'; import { createRoute } from '../../utils'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; -import { NamespaceDetails, isError, UrlString } from '../../extension-registry-types'; +import { NamespaceDetails, UrlString } from '../../extension-registry-types'; +import { useGetNamespaceDetailsQuery } from '../../store/api'; export namespace NamespaceDetailRoutes { export namespace Parameters { @@ -30,50 +31,17 @@ export namespace NamespaceDetailRoutes { } export const NamespaceDetail: FunctionComponent = () => { - const [loading, setLoading] = useState(true); + const { name } = useParams(); + const { data: namespaceDetails, isLoading } = useGetNamespaceDetailsQuery(name as string); const [truncateReadMore, setTruncateReadMore] = useState(true); const [showReadMore, setShowReadMore] = useState(false); - const [namespaceDetails, setNamespaceDetails] = useState>(); - const [notFoundError, setNotFoundError] = useState(''); - - const { name } = useParams(); - const { pageSettings, service, handleError } = useContext(MainContext); - const abortController = useRef(new AbortController()); - useEffect(() => { - updateNamespaceDetails(name as string); - return () => { - abortController.current.abort(); - }; - }, []); + const { pageSettings } = useContext(MainContext); useEffect(() => { - setNamespaceDetails(undefined); - setLoading(true); - updateNamespaceDetails(name as string); + setTruncateReadMore(true); }, [name]); - const updateNamespaceDetails = async(name: string): Promise => { - try { - const namespaceDetails = await service.getNamespaceDetails(abortController.current, name); - if (isError(namespaceDetails)) { - throw namespaceDetails; - } - - setNamespaceDetails(namespaceDetails); - setLoading(false); - setTruncateReadMore(true); - } catch (err) { - if (err && err.status === 404) { - setNotFoundError(`Namespace Not Found: ${name}`); - } else { - handleError(err); - } - - setLoading(false); - } - }; - const readMore = () => { setTruncateReadMore(false); }; @@ -95,10 +63,10 @@ export const NamespaceDetail: FunctionComponent = () => { const renderNotFound = (): ReactNode => { return <> { - notFoundError ? + !isLoading ? - {notFoundError} + Namespace Not Found: {name} : null @@ -222,7 +190,7 @@ export const NamespaceDetail: FunctionComponent = () => { return <> { renderHeaderTags(name as string, namespaceDetails) } - + { namespaceDetails ? renderNamespaceDetails(namespaceDetails, truncateReadMore) diff --git a/webui/src/pages/user/add-namespace-member-dialog.tsx b/webui/src/pages/user/add-namespace-member-dialog.tsx index 8a5a252b6..d4d0c7446 100644 --- a/webui/src/pages/user/add-namespace-member-dialog.tsx +++ b/webui/src/pages/user/add-namespace-member-dialog.tsx @@ -8,15 +8,16 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { ChangeEvent, FunctionComponent, KeyboardEvent, useState, useContext, useEffect, useRef } from 'react'; +import React, { ChangeEvent, FunctionComponent, KeyboardEvent, useState, useContext } from 'react'; import { UserData } from '../..'; import { Dialog, DialogTitle, DialogContent, DialogContentText, TextField, DialogActions, Button, Popper, Fade, Paper, Box, Avatar } from '@mui/material'; -import { Namespace, NamespaceMembership, isError } from '../../extension-registry-types'; +import { Namespace, NamespaceMembership } from '../../extension-registry-types'; import { NamespaceDetailConfigContext } from './user-settings-namespace-detail'; import { MainContext } from '../../context'; +import { apiSlice, useSetNamespaceMemberMutation } from '../../store/api'; export interface AddMemberDialogProps { open: boolean; @@ -24,46 +25,30 @@ export interface AddMemberDialogProps { filterUsers: (user: UserData) => boolean; namespace: Namespace; members: NamespaceMembership[]; - setLoadingState: (loading: boolean) => void; } export const AddMemberDialog: FunctionComponent = props => { const { open } = props; const config = useContext(NamespaceDetailConfigContext); - const { service, handleError } = useContext(MainContext); + const { handleError } = useContext(MainContext); const [foundUsers, setFoundUsers] = useState([]); const [showUserPopper, setShowUserPopper] = useState(false); const [popperTarget, setPopperTarget] = useState(undefined); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const [setNamespaceMember] = useSetNamespaceMemberMutation(); + const [getUserByName] = apiSlice.useLazyGetUserByNameQuery(); const addUser = async (user: UserData) => { - try { - if (!props.namespace) { - return; - } - if (props.members.find(m => m.user.loginName === user.loginName && m.user.provider === user.provider)) { - setShowUserPopper(false); - handleError({ message: `User ${user.loginName} is already a member of ${props.namespace.name}.` }); - return; - } - props.setLoadingState(true); - const endpoint = props.namespace.roleUrl; - const result = await service.setNamespaceMember(abortController.current, endpoint, user, config.defaultMemberRole ?? 'contributor'); - if (isError(result)) { - throw result; - } - props.setLoadingState(false); - onClose(); - } catch (err) { + if (!props.namespace) { + return; + } + if (props.members.find(m => m.user.loginName === user.loginName && m.user.provider === user.provider)) { setShowUserPopper(false); - props.setLoadingState(false); - handleError(err); + handleError({ message: `User ${user.loginName} is already a member of ${props.namespace.name}.` }); + return; } + const endpoint = props.namespace.roleUrl; + await setNamespaceMember({ endpoint, user, role: config.defaultMemberRole ?? 'contributor' }); + onClose(); }; const onClose = () => { @@ -78,7 +63,7 @@ export const AddMemberDialog: FunctionComponent = props => let showUserPopper = false; let foundUsers: UserData[] = []; if (val) { - const users = await service.getUserByName(abortController.current, val); + const { data: users } = await getUserByName(val); if (users) { showUserPopper = true; foundUsers = users; diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx index 43428912c..b31d07045 100644 --- a/webui/src/pages/user/avatar.tsx +++ b/webui/src/pages/user/avatar.tsx @@ -8,14 +8,14 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useRef, useState } from 'react'; +import React, { FunctionComponent, useRef, useState } from 'react'; import { styled } from '@mui/material/styles'; import { Avatar, Menu, Typography, MenuItem, Link, Divider, IconButton } from '@mui/material'; import { Link as RouteLink } from 'react-router-dom'; import { UserSettingsRoutes } from './user-settings'; import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard'; -import { MainContext } from '../../context'; import { LogoutForm } from './logout'; +import { useGetUserQuery } from '../../store/api'; const AvatarRouteLink = styled(RouteLink)({ cursor: 'pointer', @@ -27,8 +27,8 @@ const AvatarMenuItem = styled(MenuItem)({ cursor: 'auto' }); export const UserAvatar: FunctionComponent = () => { const [open, setOpen] = useState(false); - const context = useContext(MainContext); const avatarButton = useRef(); + const { data: user } = useGetUserQuery(); const handleAvatarClick = () => { setOpen(!open); @@ -38,7 +38,6 @@ export const UserAvatar: FunctionComponent = () => { setOpen(false); }; - const user = context.user; if (!user) { return null; } diff --git a/webui/src/pages/user/create-namespace-dialog.tsx b/webui/src/pages/user/create-namespace-dialog.tsx index 404601b26..512fef132 100644 --- a/webui/src/pages/user/create-namespace-dialog.tsx +++ b/webui/src/pages/user/create-namespace-dialog.tsx @@ -8,27 +8,25 @@ * SPDX-License-Identifier: EPL-2.0 * ****************************************************************************** */ -import React, { ChangeEvent, FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; +import React, { ChangeEvent, FunctionComponent, useEffect, useState } from 'react'; import { Button, Dialog, DialogTitle, DialogContent, Box, TextField, DialogActions } from '@mui/material'; import { ButtonWithProgress } from '../../components/button-with-progress'; -import { isError } from '../../extension-registry-types'; -import { MainContext } from '../../context'; +import { useCreateNamespaceMutation, useGetUserQuery } from '../../store/api'; const NAMESPACE_NAME_SIZE = 255; -export const CreateNamespaceDialog: FunctionComponent = props => { +export const CreateNamespaceDialog: FunctionComponent = () => { const [open, setOpen] = useState(false); const [posted, setPosted] = useState(false); const [name, setName] = useState(''); const [nameError, setNameError] = useState(); - const context = useContext(MainContext); - const abortController = useRef(new AbortController()); + const { data: user } = useGetUserQuery(); + const [createNamespace] = useCreateNamespaceMutation(); useEffect(() => { document.addEventListener('keydown', handleEnter); return () => { - abortController.current.abort(); document.removeEventListener('keydown', handleEnter); }; }, []); @@ -55,23 +53,13 @@ export const CreateNamespaceDialog: FunctionComponent { - if (!context.user) { + if (!user) { return; } setPosted(true); - try { - const response = await context.service.createNamespace(abortController.current, name); - if (isError(response)) { - throw response; - } - - setOpen(false); - props.namespaceCreated(); - } catch (err) { - context.handleError(err); - } - + await createNamespace(name); + setOpen(false); setPosted(false); }; @@ -110,8 +98,4 @@ export const CreateNamespaceDialog: FunctionComponent ; -}; - -export interface CreateNamespaceDialogProps { - namespaceCreated: () => void; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/pages/user/generate-token-dialog.tsx b/webui/src/pages/user/generate-token-dialog.tsx index 20df5841e..b604c09da 100644 --- a/webui/src/pages/user/generate-token-dialog.tsx +++ b/webui/src/pages/user/generate-token-dialog.tsx @@ -8,24 +8,25 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { ChangeEvent, FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; +import React, { ChangeEvent, FunctionComponent, useEffect, useState, useRef } from 'react'; import { Button, Dialog, DialogTitle, DialogContent, DialogContentText, Box, TextField, DialogActions, Typography } from '@mui/material'; import { ButtonWithProgress } from '../../components/button-with-progress'; import { CopyToClipboard } from '../../components/copy-to-clipboard'; -import { PersonalAccessToken, isError } from '../../extension-registry-types'; -import { MainContext } from '../../context'; +import { PersonalAccessToken } from '../../extension-registry-types'; +import { useCreateAccessTokenMutation, useGetUserQuery } from '../../store/api'; const TOKEN_DESCRIPTION_SIZE = 255; -export const GenerateTokenDialog: FunctionComponent = props => { +export const GenerateTokenDialog: FunctionComponent = props => { const [open, setOpen] = useState(false); const [posted, setPosted] = useState(false); const [description, setDescription] = useState(''); const [descriptionError, setDescriptionError] = useState(); const [token, setToken] = useState(); - const context = useContext(MainContext); const abortController = useRef(new AbortController()); + const { data: user } = useGetUserQuery(); + const [createAccessToken] = useCreateAccessTokenMutation(); useEffect(() => { document.addEventListener('keydown', handleEnter); @@ -56,20 +57,12 @@ export const GenerateTokenDialog: FunctionComponent = }; const handleGenerate = async () => { - if (!context.user) { + if (!user) { return; } setPosted(true); - try { - const token = await context.service.createAccessToken(abortController.current, context.user, description); - if (isError(token)) { - throw token; - } - setToken(token); - props.handleTokenGenerated(); - } catch (err) { - context.handleError(err); - } + const { data: token } = await createAccessToken({ user, description }); + setToken(token); }; const handleEnter = (e: KeyboardEvent) => { @@ -150,8 +143,4 @@ export const GenerateTokenDialog: FunctionComponent = ; -}; - -export interface GenerateTokenDialogProps { - handleTokenGenerated: () => void; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/pages/user/logout.tsx b/webui/src/pages/user/logout.tsx index a628a93fa..ff1b6f609 100644 --- a/webui/src/pages/user/logout.tsx +++ b/webui/src/pages/user/logout.tsx @@ -8,11 +8,11 @@ * SPDX-License-Identifier: EPL-2.0 * ****************************************************************************** */ -import React, { FunctionComponent, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'; +import React, { FunctionComponent, PropsWithChildren } from 'react'; import { Button } from '@mui/material'; import { styled } from '@mui/material/styles'; import { isError, CsrfTokenJson } from '../../extension-registry-types'; -import { MainContext } from '../../context'; +import { logoutUrl, useGetCsrfTokenQuery } from '../../store/api'; const LogoutButton = styled(Button)({ cursor: 'pointer', @@ -23,29 +23,11 @@ const LogoutButton = styled(Button)({ }); export const LogoutForm: FunctionComponent = ({ children }) => { - const [csrf, setCsrf] = useState(); - const context = useContext(MainContext); + const { data } = useGetCsrfTokenQuery(); - const abortController = useRef(new AbortController()); - useEffect(() => { - updateCsrf(); - return () => abortController.current.abort(); - }, []); - - const updateCsrf = async () => { - try { - const csrfResponse = await context.service.getCsrfToken(abortController.current); - if (!isError(csrfResponse)) { - const csrfToken = csrfResponse as CsrfTokenJson; - setCsrf(csrfToken.value); - } - } catch (err) { - context.handleError(err); - } - }; - - return
- {csrf ? : null} + const csrf = data && !isError(data) ? (data as CsrfTokenJson).value : undefined; + return + {csrf && } {children} diff --git a/webui/src/pages/user/publish-extension-dialog.tsx b/webui/src/pages/user/publish-extension-dialog.tsx index 32f514ef9..d9f6ab967 100644 --- a/webui/src/pages/user/publish-extension-dialog.tsx +++ b/webui/src/pages/user/publish-extension-dialog.tsx @@ -16,6 +16,7 @@ import { ButtonWithProgress } from '../../components/button-with-progress'; import { ErrorResult, isError } from '../../extension-registry-types'; import { MainContext } from '../../context'; import { styled, Theme } from '@mui/material/styles'; +import { useCreateNamespaceMutation, useGetUserQuery, usePublishExtensionMutation } from '../../store/api'; const getColor = (isFocused: boolean, isDragAccept: boolean, isDragReject: boolean) => { if (isDragAccept) { @@ -44,7 +45,7 @@ const DropzoneDiv = styled('div')(({ theme }: { theme: Theme }) => ({ transition: 'border .24s ease-in-out' })); -export const PublishExtensionDialog: FunctionComponent = props => { +export const PublishExtensionDialog: FunctionComponent = () => { const [open, setOpen] = useState(false); const [publishing, setPublishing] = useState(false); const [fileToPublish, setFileToPublish] = useState(); @@ -52,6 +53,9 @@ export const PublishExtensionDialog: FunctionComponent(new AbortController()); + const [createNamespace] = useCreateNamespaceMutation(); + const [publishExtension] = usePublishExtensionMutation(); + const { data: user } = useGetUserQuery(); useEffect(() => { document.addEventListener('keydown', handleEnter); @@ -99,32 +103,21 @@ export const PublishExtensionDialog: FunctionComponent setOldFileToPublish(undefined); const handlePublish = async () => { - if (!context.user || !fileToPublish) { + if (!user || !fileToPublish) { return; } setPublishing(true); - let published = false; let retryPublish = false; - try { - published = await tryPublishExtension(fileToPublish); - } catch (err) { - try { - await tryResolveNamespaceError(err); - retryPublish = true; - } catch (namespaceError) { - context.handleError(namespaceError); - } + let publishResponse = await publishExtension(fileToPublish); + if (isError(publishResponse)) { + await tryResolveNamespaceError(publishResponse); + retryPublish = true; } if (retryPublish) { - try { - published = await tryPublishExtension(fileToPublish); - } catch (err) { - context.handleError(err); - } + publishResponse = await publishExtension(fileToPublish); } - if (published) { - props.extensionPublished(); + if (!isError(publishResponse)) { setOpen(false); setFileToPublish(undefined); setOldFileToPublish(undefined); @@ -139,17 +132,6 @@ export const PublishExtensionDialog: FunctionComponent => { - let published = false; - const publishResponse = await context.service.publishExtension(abortController.current, fileToPublish); - if (isError(publishResponse)) { - throw publishResponse; - } - - published = true; - return published; - }; - const tryResolveNamespaceError = async (publishResponse: Readonly) => { const namespaceError = 'Unknown publisher: '; if (!isError(publishResponse) || !publishResponse.error.startsWith(namespaceError)) { @@ -162,7 +144,7 @@ export const PublishExtensionDialog: FunctionComponent ; -}; - -export interface PublishExtensionDialogProps { - extensionPublished: () => void; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/pages/user/user-extension-list.tsx b/webui/src/pages/user/user-extension-list.tsx index aecb2b963..065d4a544 100644 --- a/webui/src/pages/user/user-extension-list.tsx +++ b/webui/src/pages/user/user-extension-list.tsx @@ -15,7 +15,7 @@ import { UserNamespaceExtensionListItem } from './user-namespace-extension-list- import { Extension } from '../../extension-registry-types'; interface UserExtensionListProps { - extensions?: Extension[]; + extensions?: Readonly; loading: boolean; canDelete?: boolean } diff --git a/webui/src/pages/user/user-namespace-details-logo.tsx b/webui/src/pages/user/user-namespace-details-logo.tsx new file mode 100644 index 000000000..d769e2322 --- /dev/null +++ b/webui/src/pages/user/user-namespace-details-logo.tsx @@ -0,0 +1,287 @@ +/******************************************************************************** + * Copyright (c) 2025 Precies. Software OU and others + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import React, { FunctionComponent, useContext, useRef, useState } from 'react'; +import { + Box, Grid, Button, IconButton, Slider, Stack, Dialog, DialogActions, DialogTitle, + DialogContent +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import RotateLeftIcon from '@mui/icons-material/RotateLeft'; +import RotateRightIcon from '@mui/icons-material/RotateRight'; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import ZoomOutIcon from '@mui/icons-material/ZoomOut'; +import { MainContext } from '../../context'; +import { Namespace } from '../../extension-registry-types'; +import Dropzone from 'react-dropzone'; +import AvatarEditor, { Position } from 'react-avatar-editor'; +import _ from 'lodash'; +import { styled, Theme } from '@mui/material/styles'; + +const getColor = (isFocused: boolean, isDragAccept: boolean, isDragReject: boolean) => { + if (isDragAccept) { + return 'success.main'; + } else if (isDragReject) { + return 'error.main'; + } else if (isFocused) { + return 'secondary.main'; + } else { + return 'text.primary'; + } +}; + +const DropzoneDiv = styled('div')(({ theme }: { theme: Theme }) => ({ + gridRow: 1, + gridColumn: 1, + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: theme.spacing(3), + borderWidth: 2, + borderRadius: 2, + borderStyle: 'dashed', + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, + outline: 'none', + transition: 'border .24s ease-in-out', + '&:hover + $avatarButtons': { + display: 'flex' + } +})); + +export const UserNamespaceDetailsLogo: FunctionComponent = props => { + const editor = useRef(null); + + const context = useContext(MainContext); + const [dropzoneFile, setDropzoneFile] = useState(); + const [logoPreview, setLogoPreview] = useState(); + const [editing, setEditing] = useState(false); + const [editorScale, setEditorScale] = useState(1); + const [editorScaleAdjusted, setEditorScaleAdjusted] = useState(1); + const [editorRotation, setEditorRotation] = useState(0); + const [editorPosition, setEditorPosition] = useState(); + const [prevEditorScale, setPrevEditorScale] = useState(1); + const [prevEditorRotation, setPrevEditorRotation] = useState(0); + const [prevEditorPosition, setPrevEditorPosition] = useState(); + + const resetLogoPreview = () => { + if (logoPreview) { + URL.revokeObjectURL(logoPreview); + } + setLogoPreview(undefined); + }; + + const handleDrop = (acceptedFiles: T[]) => { + const file = acceptedFiles[0]; + if (file.type !== 'image/png' && file.type != 'image/jpeg') { + context.handleError(new Error(`Unsupported file type '${file.type}'`)); + return; + } + + setDropzoneFile(file); + setEditing(true); + setEditorScale(1); + setEditorScaleAdjusted(1); + setEditorRotation(0); + setEditorPosition(undefined); + }; + + const handleFileDialogOpen = () => { + setDropzoneFile(undefined); + resetLogoPreview(); + }; + + const rotateLeft = () => setEditorRotation(editorRotation - 90); + const rotateRight = () => setEditorRotation(editorRotation + 90); + + const handleEditorScaleChange = (event: Event, value: number | number[]) => { + setEditorScale((typeof value === 'number') ? value : value[0]); + setEditorScaleAdjusted(adjustScale(editorScale)); + }; + + const handleCancelEditLogo = () => { + setEditorScale(prevEditorScale); + setEditorScaleAdjusted(adjustScale(prevEditorScale)); + setEditorRotation(prevEditorRotation); + setEditorPosition(prevEditorPosition); + setEditing(false); + }; + + const handleApplyLogo = () => { + const avatarEditor = editor.current as AvatarEditor; + const canvasScaled = avatarEditor.getImageScaledToCanvas(); + canvasScaled.toBlob(async (blob) => { + if (blob) { + if (logoPreview) { + URL.revokeObjectURL(logoPreview); + } + setLogoPreview(URL.createObjectURL(blob)); + props.onLogoChange({ file: blob, name: dropzoneFile!.name }); + } + }); + setEditing(false); + }; + + const adjustScale = (x: number) => { + return x < 1 ? (0.5 + (x / 2)) : x; + }; + + const percentageLabelFormat = (value: number) => { + return `${Math.round(value * 100)}%`; + }; + + const deleteLogo = () => { + resetLogoPreview(); + props.onLogoChange(undefined); + }; + + const editLogo = () => { + setPrevEditorScale(editorScale); + setPrevEditorRotation(editorRotation); + setPrevEditorPosition(editorPosition); + setEditing(true); + }; + + const handleEditorPositionChange = (editorPosition: Position) => setEditorPosition(editorPosition); + + const isDropzoneDisabled = (): boolean => { + return logoPreview !== undefined || dropzoneFile?.name != null; + }; + + return <> + setEditing(false)} > + + Edit namespace logo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {({ getRootProps, getInputProps, isFocused, isDragAccept, isDragReject }) => ( + + + + + + {logoPreview || dropzoneFile?.name ? + + {logoPreview ? + + + + : null + } + + + + + : null + } + + )} + + ; +}; + +export interface UserNamespaceDetailsLogoProps { + namespace: Namespace; + onLogoChange: (logo?: NamespaceDetailsLogo) => void +} + +export interface NamespaceDetailsLogo { + file: Blob + name: string +} \ No newline at end of file diff --git a/webui/src/pages/user/user-namespace-details.tsx b/webui/src/pages/user/user-namespace-details.tsx index 35a9557be..e39c47878 100644 --- a/webui/src/pages/user/user-namespace-details.tsx +++ b/webui/src/pages/user/user-namespace-details.tsx @@ -8,61 +8,22 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { ChangeEvent, FunctionComponent, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, TextField, Typography, Grid, Button, IconButton, Slider, Stack, Dialog, DialogActions, DialogTitle, - DialogContent, InputAdornment, Select, MenuItem, Paper, SelectChangeEvent } from '@mui/material'; +import React, { ChangeEvent, FunctionComponent, useContext, useEffect, useMemo, useState } from 'react'; +import { TextField, Typography, Grid, Button, IconButton, InputAdornment, Select, MenuItem, Paper, SelectChangeEvent } from '@mui/material'; import { CheckCircleOutline } from '@mui/icons-material'; import BusinessIcon from '@mui/icons-material/Business'; -import DeleteIcon from '@mui/icons-material/Delete'; -import EditIcon from '@mui/icons-material/Edit'; import GitHubIcon from '@mui/icons-material/GitHub'; import LinkedInIcon from '@mui/icons-material/LinkedIn'; import PersonIcon from '@mui/icons-material/Person'; -import RotateLeftIcon from '@mui/icons-material/RotateLeft'; -import RotateRightIcon from '@mui/icons-material/RotateRight'; import TwitterIcon from '@mui/icons-material/Twitter'; -import ZoomInIcon from '@mui/icons-material/ZoomIn'; -import ZoomOutIcon from '@mui/icons-material/ZoomOut'; import CloseIcon from '@mui/icons-material/Close'; import { MainContext } from '../../context'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; -import { Namespace, NamespaceDetails, isError } from '../../extension-registry-types'; -import Dropzone from 'react-dropzone'; -import AvatarEditor, { Position } from 'react-avatar-editor'; +import { Namespace, NamespaceDetails } from '../../extension-registry-types'; import _ from 'lodash'; -import { styled, Theme } from '@mui/material/styles'; - -const getColor = (isFocused: boolean, isDragAccept: boolean, isDragReject: boolean) => { - if (isDragAccept) { - return 'success.main'; - } else if (isDragReject) { - return 'error.main'; - } else if (isFocused) { - return 'secondary.main'; - } else { - return 'text.primary'; - } -}; - -const DropzoneDiv = styled('div')(({ theme }: { theme: Theme }) => ({ - gridRow: 1, - gridColumn: 1, - flex: 1, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: theme.spacing(3), - borderWidth: 2, - borderRadius: 2, - borderStyle: 'dashed', - backgroundColor: theme.palette.background.default, - color: theme.palette.text.primary, - outline: 'none', - transition: 'border .24s ease-in-out', - '&:hover + $avatarButtons': { - display: 'flex' - } -})); +import { styled } from '@mui/material/styles'; +import { useGetNamespaceDetailsQuery, useSetNamespaceDetailsMutation, useSetNamespaceLogoMutation } from '../../store/api'; +import { NamespaceDetailsLogo, UserNamespaceDetailsLogo } from './user-namespace-details-logo'; const GridIconItem = styled(Grid)({ display: 'flex', @@ -80,140 +41,87 @@ export const UserNamespaceDetails: FunctionComponent const LINKED_IN_PERSONAL = 'in'; const LINKED_IN_COMPANY = 'company'; - const abortController = useRef(new AbortController()); - const editor = useRef(null); - const context = useContext(MainContext); const [currentDetails, setCurrentDetails] = useState(); const [newDetails, setNewDetails] = useState(); + const [newDetailsLogo, setNewDetailsLogo] = useState(); const [detailsUpdated, setDetailsUpdated] = useState(false); const [bannerNamespaceName, setBannerNamespaceName] = useState(''); - const [loading, setLoading] = useState(true); - const [dropzoneFile, setDropzoneFile] = useState(); - const [logoPreview, setLogoPreview] = useState(); - const [editing, setEditing] = useState(false); - const [editorScale, setEditorScale] = useState(1); - const [editorScaleAdjusted, setEditorScaleAdjusted] = useState(1); - const [editorRotation, setEditorRotation] = useState(0); - const [editorPosition, setEditorPosition] = useState(); - const [prevEditorScale, setPrevEditorScale] = useState(1); - const [prevEditorRotation, setPrevEditorRotation] = useState(0); - const [prevEditorPosition, setPrevEditorPosition] = useState(); const [linkedInAccountType, setLinkedInAccountType] = useState(LINKED_IN_PERSONAL); - - const noChanges = useMemo(() => { - const isFalsy = (x: unknown) => !!x === false; - return _.isEqual(_.omitBy(currentDetails, isFalsy), _.omitBy(newDetails, isFalsy)); - }, [currentDetails, newDetails]); + const { data: details, isLoading } = useGetNamespaceDetailsQuery(props.namespace.name); + const [setNamespaceDetails] = useSetNamespaceDetailsMutation(); + const [setNamespaceLogo] = useSetNamespaceLogoMutation(); useEffect(() => { - getNamespaceDetails(); - return () => abortController.current.abort(); - }, []); - - useEffect(() => { - setLoading(true); - getNamespaceDetails(); - }, [props.namespace]); - - const resetLogoPreview = () => { - if (logoPreview) { - URL.revokeObjectURL(logoPreview); + if (isLoading) { + return; } - setLogoPreview(undefined); - }; - - const getNamespaceDetails = async (): Promise => { - if (!props.namespace.name) { - resetLogoPreview(); + if (details == null) { setCurrentDetails(undefined); setNewDetails(undefined); - setLoading(false); + setLinkedInAccountType(LINKED_IN_PERSONAL); return; } - try { - const details = await context.service.getNamespaceDetails(abortController.current, props.namespace.name); - if (isError(details)) { - throw details; - } + let linkedInAccountType = LINKED_IN_PERSONAL; + const linkedin = details.socialLinks.linkedin; + if (linkedin) { + const linkedinPath = linkedin.split('/'); + details.socialLinks.linkedin = linkedinPath[linkedinPath.length - 1]; + linkedInAccountType = linkedinPath[linkedinPath.length - 2]; + } - let linkedInAccountType = LINKED_IN_PERSONAL; - const linkedin = details.socialLinks.linkedin; - if (linkedin) { - const linkedinPath = linkedin.split('/'); - details.socialLinks.linkedin = linkedinPath[linkedinPath.length - 1]; - linkedInAccountType = linkedinPath[linkedinPath.length - 2]; - } + const github = details.socialLinks.github; + if (github) { + details.socialLinks.github = github.substring(github.lastIndexOf('/') + 1); + } - const github = details.socialLinks.github; - if (github) { - details.socialLinks.github = github.substring(github.lastIndexOf('/') + 1); - } + const twitter = details.socialLinks.twitter; + if (twitter) { + details.socialLinks.twitter = twitter.substring(twitter.lastIndexOf('/') + 1); + } - const twitter = details.socialLinks.twitter; - if (twitter) { - details.socialLinks.twitter = twitter.substring(twitter.lastIndexOf('/') + 1); - } + setCurrentDetails(copy(details)); + setNewDetails(copy(details)); + setLinkedInAccountType(linkedInAccountType); + }, [details, isLoading]); - setCurrentDetails(copy(details)); - setNewDetails(copy(details)); - setLinkedInAccountType(linkedInAccountType); - resetLogoPreview(); - setLoading(false); - } catch (err) { - context.handleError(err); - setLoading(false); - } finally { - setDetailsUpdated(false); - } - }; + const noChanges = useMemo(() => { + const isFalsy = (x: unknown) => !!x === false; + return _.isEqual(_.omitBy(currentDetails, isFalsy), _.omitBy(newDetails, isFalsy)) && newDetailsLogo == null; + }, [currentDetails, newDetails, newDetailsLogo]); const copy = (arg: NamespaceDetails): NamespaceDetails => { return JSON.parse(JSON.stringify(arg)); }; - const setNamespaceDetails = async () => { + const saveNamespaceDetails = async () => { if (!newDetails) { return; } - setLoading(true); setDetailsUpdated(false); - try { - const details = copy(newDetails); - details.socialLinks.linkedin = details.socialLinks.linkedin - ? `https://www.linkedin.com/${linkedInAccountType}/${details.socialLinks.linkedin}` - : undefined; - - details.socialLinks.github = details.socialLinks.github - ? 'https://github.com/' + details.socialLinks.github - : undefined; - - details.socialLinks.twitter = details.socialLinks.twitter - ? 'https://twitter.com/' + details.socialLinks.twitter - : undefined; - - const result = await context.service.setNamespaceDetails(abortController.current, props.namespace.detailsUrl, details); - if (isError(result)) { - throw result; - } - - if (logoPreview) { - const logoFile = await (await fetch(logoPreview)).blob(); - await context.service.setNamespaceLogo(abortController.current, props.namespace.detailsUrl, logoFile, details.logo as string); - await getNamespaceDetails(); - } else { - setCurrentDetails(copy(newDetails)); - } - - setDetailsUpdated(true); - setBannerNamespaceName(details.displayName || details.name); - } catch (err) { - context.handleError(err); - } finally { - setLoading(false); - } + const details = copy(newDetails); + details.socialLinks.linkedin = details.socialLinks.linkedin + ? `https://www.linkedin.com/${linkedInAccountType}/${details.socialLinks.linkedin}` + : undefined; + + details.socialLinks.github = details.socialLinks.github + ? 'https://github.com/' + details.socialLinks.github + : undefined; + + details.socialLinks.twitter = details.socialLinks.twitter + ? 'https://twitter.com/' + details.socialLinks.twitter + : undefined; + + const detailsPromise = setNamespaceDetails({ endpoint: props.namespace.detailsUrl, details }); + const logoPromise = newDetailsLogo != null + ? setNamespaceLogo({ endpoint: props.namespace.detailsUrl, name: props.namespace.name, logoFile: newDetailsLogo.file, logoName: newDetailsLogo.name }) + : Promise.resolve(); + + await Promise.all([detailsPromise, logoPromise]); + setDetailsUpdated(true); + setBannerNamespaceName(details.displayName || details.name); }; const handleInputChange = (event: ChangeEvent) => { @@ -278,173 +186,20 @@ export const UserNamespaceDetails: FunctionComponent }; const handleSelectChange = (event: SelectChangeEvent) => setLinkedInAccountType(event.target.value); - - const handleDrop = (acceptedFiles: T[]) => { - const file = acceptedFiles[0]; - if (file.type !== 'image/png' && file.type != 'image/jpeg') { - context.handleError(new Error(`Unsupported file type '${file.type}'`)); - return; - } - - setDropzoneFile(file); - setEditing(true); - setEditorScale(1); - setEditorScaleAdjusted(1); - setEditorRotation(0); - setEditorPosition(undefined); - }; - - const handleFileDialogOpen = () => { - setDropzoneFile(undefined); - resetLogoPreview(); - }; - - const rotateLeft = () => setEditorRotation(editorRotation - 90); - const rotateRight = () => setEditorRotation(editorRotation + 90); - - const handleEditorScaleChange = (event: Event, value: number | number[]) => { - setEditorScale((typeof value === 'number') ? value : value[0]); - setEditorScaleAdjusted(adjustScale(editorScale)); - }; - - const handleCancelEditLogo = () => { - setEditorScale(prevEditorScale); - setEditorScaleAdjusted(adjustScale(prevEditorScale)); - setEditorRotation(prevEditorRotation); - setEditorPosition(prevEditorPosition); - setEditing(false); - }; - - const handleApplyLogo = () => { - const avatarEditor = editor.current as AvatarEditor; - const canvasScaled = avatarEditor.getImageScaledToCanvas(); - canvasScaled.toBlob(async (blob) => { - if (blob) { - if (logoPreview) { - URL.revokeObjectURL(logoPreview); - } - setLogoPreview(URL.createObjectURL(blob)); - - if (newDetails) { - const details = copy(newDetails); - details.logo = dropzoneFile!.name; - setNewDetails(details); - } - } - }); - setEditing(false); - }; - - const adjustScale = (x: number) => { - return x < 1 ? (0.5 + (x / 2)) : x; - }; - - const percentageLabelFormat = (value: number) => { - return `${Math.round(value * 100)}%`; - }; - - const deleteLogo = () => { - resetLogoPreview(); - if (newDetails) { - const details = copy(newDetails); - details.logo = undefined; - setNewDetails(details); - } - }; - - const editLogo = () => { - setPrevEditorScale(editorScale); - setPrevEditorRotation(editorRotation); - setPrevEditorPosition(editorPosition); - setEditing(true); - }; - - const handleEditorPositionChange = (editorPosition: Position) => setEditorPosition(editorPosition); - - const isDropzoneDisabled = (): boolean => { - return logoPreview !== undefined || (newDetails !== undefined && newDetails.logo !== undefined); - }; - const handleClose = () => setDetailsUpdated(false); + const handleLogoChange = (logo: NamespaceDetailsLogo) => setNewDetailsLogo(logo); if (!newDetails) { - return ; + return ; } const successColor = context.pageSettings.themeType === 'dark' ? '#fff' : '#000'; return <> - setEditing(false)} > - - Edit namespace logo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Details - { detailsUpdated + {detailsUpdated ? : null } - - {({ getRootProps, getInputProps, isFocused, isDragAccept, isDragReject }) => ( - - - - - - { logoPreview || newDetails?.logo ? - - { logoPreview ? - - - - : null - } - - - - - : null - } - - )} - + @@ -521,8 +229,8 @@ export const UserNamespaceDetails: FunctionComponent + value={newDetails.displayName ?? ''} + onChange={handleInputChange} /> variant='outlined' label='Description' name={INPUT_DESCRIPTION} - value={ newDetails.description ?? '' } - onChange={ handleInputChange } /> + value={newDetails.description ?? ''} + onChange={handleInputChange} /> + label='Website' + type='url' + name={INPUT_WEBSITE} + value={newDetails.website ?? ''} + onChange={handleInputChange} /> + value={newDetails.supportLink ?? ''} + onChange={handleInputChange} /> - + - - - - - https://www.linkedin.com/{linkedInAccountType}/ - }}/> + }} + > + + + + https://www.linkedin.com/{linkedInAccountType}/ + + }} /> - + https://github.com/ }}/> + value={newDetails.socialLinks.github ?? ''} + onChange={handleInputChange} + InputProps={{ startAdornment: https://github.com/ }} /> - + https://twitter.com/ }}/> + value={newDetails.socialLinks.twitter ?? ''} + onChange={handleInputChange} + InputProps={{ startAdornment: https://twitter.com/ }} /> - diff --git a/webui/src/pages/user/user-namespace-extension-list-item.tsx b/webui/src/pages/user/user-namespace-extension-list-item.tsx index b63e76994..8bc219ba8 100644 --- a/webui/src/pages/user/user-namespace-extension-list-item.tsx +++ b/webui/src/pages/user/user-namespace-extension-list-item.tsx @@ -8,7 +8,7 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { useContext, FunctionComponent, useState, useEffect, useRef, ReactNode, MouseEvent } from 'react'; +import React, { useContext, FunctionComponent, useState, useEffect, ReactNode, MouseEvent } from 'react'; import { Extension } from '../../extension-registry-types'; import { Paper, Typography, Box, styled, IconButton } from '@mui/material'; import { Link as RouteLink, useNavigate } from 'react-router-dom'; @@ -18,6 +18,7 @@ import { Timestamp } from '../../components/timestamp'; import { ExtensionDetailRoutes } from '../extension-detail/extension-detail'; import DeleteIcon from '@mui/icons-material/Delete'; import { UserSettingsRoutes } from './user-settings'; +import { useGetExtensionIconQuery } from '../../store/api'; const getOpacity = (extension: Extension) => { if (extension.deprecated) { @@ -42,26 +43,31 @@ const Paragraph = styled(Box)({ }); export const UserNamespaceExtensionListItem: FunctionComponent = props => { - const { pageSettings, service } = useContext(MainContext); + const { pageSettings } = useContext(MainContext); const [icon, setIcon] = useState(undefined); const { extension } = props; const route = extension && createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]) || ''; const deleteRoute = extension && createRoute([UserSettingsRoutes.EXTENSIONS, extension.namespace, extension.name, 'delete']) || ''; const inactive = extension.active === false; - const abortController = useRef(new AbortController()); const navigate = useNavigate(); + const { data: iconBlob } = useGetExtensionIconQuery(extension); + useEffect(() => { return () => { - abortController.current.abort(); + if (icon) { + URL.revokeObjectURL(icon); + } }; }, []); + useEffect(() => { if (icon) { URL.revokeObjectURL(icon); } - service.getExtensionIcon(abortController.current, extension).then(setIcon); - }, [extension]); + const newIcon = iconBlob ? URL.createObjectURL(iconBlob) : undefined; + setIcon(newIcon); + }, [iconBlob]); let status: ReactNode = null; if (inactive) { diff --git a/webui/src/pages/user/user-namespace-extension-list.tsx b/webui/src/pages/user/user-namespace-extension-list.tsx index e5eaab55e..8298f508e 100644 --- a/webui/src/pages/user/user-namespace-extension-list.tsx +++ b/webui/src/pages/user/user-namespace-extension-list.tsx @@ -8,61 +8,40 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; -import { Namespace, isError, Extension, ErrorResult } from '../../extension-registry-types'; -import { MainContext } from '../../context'; +import React, { FunctionComponent, useEffect, useState } from 'react'; +import { Namespace, Extension } from '../../extension-registry-types'; import { UserExtensionList } from './user-extension-list'; import { Typography } from '@mui/material'; +import { apiSlice } from '../../store/api'; export const UserNamespaceExtensionListContainer: FunctionComponent = props => { const [extensions, setExtensions] = useState(); - const [loading, setLoading] = useState(true); - const context = useContext(MainContext); + const [loading, setLoading] = useState(false); + const [getExtensionDetail] = apiSlice.useLazyGetExtensionDetailQuery(); - const abortController = useRef(new AbortController()); useEffect(() => { - updateExtensions(); - return () => abortController.current.abort(); - }, []); - - useEffect(() => { - setExtensions(undefined); setLoading(true); - updateExtensions(); - }, [props.namespace.name]); - - const updateExtensions = async (): Promise => { - const extensionsURLs: string[] = Object.keys(props.namespace.extensions).map((key: string) => props.namespace.extensions[key]); - - const getExtension = async (url: string) => { - let result: Extension | ErrorResult; - try { - result = await context.service.getExtensionDetail(abortController.current, url); - if (isError(result)) { - throw result; - } - return result; - } catch (error) { - context.handleError(error); - return undefined; - } - }; - - const extensionUnfiltered = await Promise.all( - extensionsURLs.map((url: string) => getExtension(url)) - ); - const extensions = extensionUnfiltered.filter(e => e != null) as Extension[]; - - setExtensions(extensions); - setLoading(false); - }; + const namespace = props.namespace.name; + const promises = Object.keys(props.namespace.extensions) + .map(async (name: string) => { + const { data } = await getExtensionDetail({ namespace, name }); + return data; + }); + + Promise.all(promises) + .then((response) => { + const extensions = response.filter((extension) => extension != null) as Extension[]; + setExtensions(extensions); + setLoading(false); + }); + }, [props.namespace.name, props.namespace.extensions]); return <> Extensions { extensions && extensions.length > 0 ? - : No extensions published under this namespace yet. + : No extensions published under this namespace yet. } ; }; diff --git a/webui/src/pages/user/user-namespace-member-component.tsx b/webui/src/pages/user/user-namespace-member-component.tsx index a30273223..6ff01dde6 100644 --- a/webui/src/pages/user/user-namespace-member-component.tsx +++ b/webui/src/pages/user/user-namespace-member-component.tsx @@ -8,19 +8,19 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext } from 'react'; +import React, { FunctionComponent } from 'react'; import { Box, Typography, Avatar, Select, MenuItem, Button, SelectChangeEvent } from '@mui/material'; import { NamespaceMembership, MembershipRole, Namespace, UserData } from '../../extension-registry-types'; -import { MainContext } from '../../context'; +import { useGetUserQuery } from '../../store/api'; -export const UserNamespaceMember: FunctionComponent = props => { - const equalUser = (user1: UserData | undefined, user2: UserData | undefined) => { - return user1?.loginName === user2?.loginName && user1?.provider === user2?.provider; - }; +const equalUser = (user1: UserData | undefined, user2: UserData | undefined) => { + return user1?.loginName === user2?.loginName && user1?.provider === user2?.provider; +}; +export const UserNamespaceMember: FunctionComponent = props => { + const { data: user } = useGetUserQuery(); const memberUser = props.member.user; - const context = useContext(MainContext); - const contextUser = context.user; + return {memberUser.loginName} @@ -43,7 +43,7 @@ export const UserNamespaceMember: FunctionComponent = }} > { - props.fixSelf && equalUser(memberUser, contextUser) ? + props.fixSelf && equalUser(memberUser, user) ? = props => { - const { service, user, handleError } = useContext(MainContext); - const [members, setMembers] = useState([]); const [addDialogIsOpen, setAddDialogIsOpen] = useState(false); - const abortController = useRef(new AbortController()); + const { data: user } = useGetUserQuery(); + const { data: membershipList } = useGetNamespaceMembersQuery(props.namespace); + const [setNamespaceMember] = useSetNamespaceMemberMutation(); - useEffect(() => { - fetchMembers(); - }, [props.namespace]); - - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); const handleCloseAddDialog = async () => { setAddDialogIsOpen(false); - fetchMembers(); }; const handleOpenAddDialog = () => { setAddDialogIsOpen(true); }; - const fetchMembers = async () => { - try { - const membershipList = await service.getNamespaceMembers(abortController.current, props.namespace); - const members = membershipList.namespaceMemberships; - setMembers(members); - } catch (err) { - handleError(err); - } - }; - const changeRole = async (membership: NamespaceMembership, role: MembershipRole | 'remove') => { - try { - props.setLoadingState(true); - const endpoint = props.namespace.roleUrl; - const result = await service.setNamespaceMember(abortController.current, endpoint, membership.user, role); - if (isError(result)) { - throw result; - } - await fetchMembers(); - props.setLoadingState(false); - } catch (err) { - handleError(err); - props.setLoadingState(false); - } + const endpoint = props.namespace.roleUrl; + await setNamespaceMember({ endpoint, user: membership.user, role }); }; if (!user) { @@ -83,9 +52,9 @@ export const UserNamespaceMemberList: FunctionComponent - {members.length ? + {membershipList?.namespaceMemberships.length ? - {members.map(member => + {membershipList?.namespaceMemberships.map(member => : There are no members assigned yet.} ; }; export interface UserNamespaceMemberListProps { namespace: Namespace; - setLoadingState: (loadingState: boolean) => void; filterUsers: (user: UserData) => boolean; fixSelf: boolean; } \ No newline at end of file diff --git a/webui/src/pages/user/user-publisher-agreement.tsx b/webui/src/pages/user/user-publisher-agreement.tsx index 4fb00234b..e7473a5aa 100644 --- a/webui/src/pages/user/user-publisher-agreement.tsx +++ b/webui/src/pages/user/user-publisher-agreement.tsx @@ -8,54 +8,30 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useState, useEffect, useRef, ReactNode } from 'react'; +import React, { FunctionComponent, useContext, useState, ReactNode } from 'react'; import { Box, Typography, Paper, Button, Dialog, DialogContent, DialogContentText, Link } from '@mui/material'; -import { UserData, isError, ReportedError } from '../../extension-registry-types'; import { SanitizedMarkdown } from '../../components/sanitized-markdown'; import { Timestamp } from '../../components/timestamp'; import { ButtonWithProgress } from '../../components/button-with-progress'; -import { createAbsoluteURL } from '../../utils'; import { MainContext } from '../../context'; import CircularProgress from '@mui/material/CircularProgress'; +import { eclipseLoginUrl, useGetStaticContentQuery, useSignPublisherAgreementMutation } from '../../store/api'; +import { UserData } from '../../extension-registry-types'; -export const UserPublisherAgreement: FunctionComponent = props => { - const { service, pageSettings, updateUser, handleError } = useContext(MainContext); +export const UserPublisherAgreement: FunctionComponent = ({ user }) => { + const { pageSettings, handleError } = useContext(MainContext); const [dialogOpen, setDialogOpen] = useState(false); const [working, setWorking] = useState(false); - const [agreementText, setAgreementText] = useState(''); - const abortController = useRef(new AbortController()); + const { data: agreementText } = useGetStaticContentQuery(pageSettings.urls.publisherAgreement as string, { skip: pageSettings.urls.publisherAgreement == null }); + const [signPublisherAgreement] = useSignPublisherAgreementMutation(); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); - - useEffect(() => { - if (dialogOpen) { - onDialogOpened(); - } - }, [dialogOpen]); - - const signPublisherAgreement = async (): Promise => { - try { - setWorking(true); - const result = await service.signPublisherAgreement(abortController.current); - if (isError(result)) { - throw result; - } - updateUser(); - setDialogOpen(false); - } catch (err) { - if (!(err as ReportedError).code) { - Object.assign(err, { code: 'publisher-agreement-problem' }); - } - handleError(err); - } finally { - setWorking(false); - } + const onSignPublisherAgreement = async (): Promise => { + setWorking(true); + await signPublisherAgreement(); + setDialogOpen(false); + setWorking(false); }; const openPublisherAgreement = () => { @@ -66,26 +42,11 @@ export const UserPublisherAgreement: FunctionComponent { - const agreementURL = pageSettings.urls.publisherAgreement; - if (agreementURL) { - try { - const agreementMd = await service.getStaticContent(abortController.current, agreementURL); - setAgreementText(agreementMd); - } catch (err) { - handleError(err); - } - } else { - setAgreementText('Publisher agreement text is not available.'); - } - }; - const onClose = () => { setDialogOpen(false); }; - const user = props.user; - if (!user.publisherAgreement) { + if (!user?.publisherAgreement) { return null; } @@ -118,7 +79,7 @@ export const UserPublisherAgreement: FunctionComponent - + @@ -143,7 +104,7 @@ export const UserPublisherAgreement: FunctionComponent - + Agree diff --git a/webui/src/pages/user/user-settings-delete-extension.tsx b/webui/src/pages/user/user-settings-delete-extension.tsx index 977eb5a6f..ab269bbb6 100644 --- a/webui/src/pages/user/user-settings-delete-extension.tsx +++ b/webui/src/pages/user/user-settings-delete-extension.tsx @@ -8,64 +8,37 @@ * SPDX-License-Identifier: EPL-2.0 * ****************************************************************************** */ -import React, { FunctionComponent, useState, useContext, useEffect, useRef } from 'react'; +import React, { FunctionComponent, useEffect } from 'react'; import { Box } from '@mui/material'; -import { MainContext } from '../../context'; -import { isError, Extension, TargetPlatformVersion } from '../../extension-registry-types'; +import { TargetPlatformVersion } from '../../extension-registry-types'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { ExtensionVersionContainer } from '../admin-dashboard/extension-version-container'; import { useNavigate } from 'react-router'; +import { useDeleteExtensionsMutation, useGetExtensionQuery } from '../../store/api'; import { UserSettingsRoutes } from './user-settings'; export const UserSettingsDeleteExtension: FunctionComponent = props => { const navigate = useNavigate(); - const abortController = useRef(new AbortController()); - useEffect(() => { - return () => { - abortController.current.abort(); - }; - }, []); + const { data: extension, isLoading } = useGetExtensionQuery(props); + const [deleteExtensions] = useDeleteExtensionsMutation(); useEffect(() => { - findExtension(); - }, [props.namespace, props.extension]); - - const [loading, setLoading] = useState(false); - - const { service, handleError } = useContext(MainContext); - const [extension, setExtension] = useState(undefined); - const findExtension = async () => { - try { - setLoading(true); - const extensionDetail = await service.getExtension(abortController.current, props.namespace, props.extension); - if (isError(extensionDetail)) { - throw extensionDetail; - } - setExtension(extensionDetail); - setLoading(false); - } catch (err) { - setLoading(false); - setExtension(undefined); - if (err && err.status === 404) { - navigate(UserSettingsRoutes.EXTENSIONS); - } else { - handleError(err); - } + if (!isLoading && extension == null) { + navigate(UserSettingsRoutes.EXTENSIONS); } - }; + }, [extension, isLoading]); const onRemove = async (targetPlatformVersions?: TargetPlatformVersion[]) => { if (extension == null) { return; } - await service.deleteExtensions(abortController.current, { namespace: extension.namespace, extension: extension.name, targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ version, targetPlatform })) }); - await findExtension(); + await deleteExtensions({ namespace: extension.namespace, extension: extension.name, targetPlatformVersions: targetPlatformVersions?.map(({ version, targetPlatform }) => ({ version, targetPlatform })) }); }; return ( - + { extension ? diff --git a/webui/src/pages/user/user-settings-extensions.tsx b/webui/src/pages/user/user-settings-extensions.tsx index f1b091452..27f25b73b 100644 --- a/webui/src/pages/user/user-settings-extensions.tsx +++ b/webui/src/pages/user/user-settings-extensions.tsx @@ -8,52 +8,16 @@ * SPDX-License-Identifier: EPL-2.0 * ****************************************************************************** */ -import React, { FunctionComponent, useContext, useEffect, useState, useRef } from 'react'; -import { Extension } from '../../extension-registry-types'; +import React, { FunctionComponent } from 'react'; import { Box, Typography } from '@mui/material'; import { PublishExtensionDialog } from './publish-extension-dialog'; import { UserExtensionList } from './user-extension-list'; -import { isError } from '../../extension-registry-types'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; -import { MainContext } from '../../context'; +import { useGetExtensionsQuery } from '../../store/api'; export const UserSettingsExtensions: FunctionComponent = () => { - const [loading, setLoading] = useState(true); - const [extensions, setExtensions] = useState(Array()); - const { user, service, handleError } = useContext(MainContext); - const abortController = useRef(new AbortController()); - - useEffect(() => { - updateExtensions(); - return () => { - abortController.current.abort(); - }; - }, []); - - const handleExtensionPublished = () => { - setLoading(true); - updateExtensions(); - }; - - const updateExtensions = async (): Promise => { - if (!user) { - return; - } - try { - const response = await service.getExtensions(abortController.current); - if (isError(response)) { - throw response; - } - - const extensions = response as Extension[]; - setExtensions(extensions); - setLoading(false); - } catch (err) { - handleError(err); - setLoading(false); - } - }; + const { data: extensions, isLoading } = useGetExtensionsQuery(); return <> { }} > - + - + { - extensions && extensions.length > 0 - ? + extensions != null && extensions.length > 0 + ? : No extensions published under this namespace yet. } diff --git a/webui/src/pages/user/user-settings-namespace-detail.tsx b/webui/src/pages/user/user-settings-namespace-detail.tsx index 68f752863..81152cd93 100644 --- a/webui/src/pages/user/user-settings-namespace-detail.tsx +++ b/webui/src/pages/user/user-settings-namespace-detail.tsx @@ -110,7 +110,6 @@ export const NamespaceDetail: FunctionComponent = props => props.namespace.membersUrl ? @@ -133,8 +132,7 @@ export const NamespaceDetail: FunctionComponent = props => + namespace={props.namespace} /> ; }; @@ -142,7 +140,6 @@ export interface NamespaceDetailProps { namespace: Namespace; filterUsers: (user: UserData) => boolean; fixSelf: boolean; - setLoadingState: (loading: boolean) => void; namespaceAccessUrl?: string; theme?: string; } \ No newline at end of file diff --git a/webui/src/pages/user/user-settings-namespaces.tsx b/webui/src/pages/user/user-settings-namespaces.tsx index 4395e38e9..3d1d0c66d 100644 --- a/webui/src/pages/user/user-settings-namespaces.tsx +++ b/webui/src/pages/user/user-settings-namespaces.tsx @@ -8,13 +8,14 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, useContext, useEffect, useState, useRef, ReactNode } from 'react'; +import React, { FunctionComponent, useContext, useEffect, useState, ReactNode } from 'react'; import { Box, Typography, Tabs, Tab, useTheme, useMediaQuery, Link } from '@mui/material'; import { Namespace, UserData } from '../../extension-registry-types'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { MainContext } from '../../context'; import { NamespaceDetail } from './user-settings-namespace-detail'; import { CreateNamespaceDialog } from './create-namespace-dialog'; +import { useGetNamespacesQuery, useGetUserQuery } from '../../store/api'; interface NamespaceTabProps { chosenNamespace: Namespace, @@ -56,48 +57,24 @@ const NamespacesTabs = (props: NamespaceTabProps) => { export const UserSettingsNamespaces: FunctionComponent = () => { - const [loading, setLoading] = useState(true); - const [namespaces, setNamespaces] = useState>([]); const [chosenNamespace, setChosenNamespace] = useState(); - const { pageSettings, service, user, handleError } = useContext(MainContext); - const abortController = useRef(new AbortController()); + const { pageSettings } = useContext(MainContext); + const { data: user } = useGetUserQuery(); + const { data: namespaces, isLoading } = useGetNamespacesQuery(); useEffect(() => { - initNamespaces(); - return () => { - abortController.current.abort(); - }; - }, []); + if (chosenNamespace == null && namespaces != null && namespaces.length > 0) { + setChosenNamespace(namespaces[0]); + } + }, [namespaces, chosenNamespace]); const handleChangeNamespace = (value: Namespace): void => { - doHandleChangeNamespace(value); - }; - - const doHandleChangeNamespace = async(chosenNamespace: Namespace): Promise => { setChosenNamespace(chosenNamespace); }; - const initNamespaces = async(): Promise => { - try { - const namespaces = await service.getNamespaces(abortController.current); - const chosenNamespace = namespaces.length ? namespaces[0] : undefined; - setNamespaces(namespaces); - setChosenNamespace(chosenNamespace); - setLoading(false); - } catch (err) { - handleError(err); - setLoading(false); - } - }; - - const handleNamespaceCreated = () => { - setLoading(true); - initNamespaces(); - }; - let namespaceContainer: ReactNode = null; const namespaceAccessUrl = pageSettings.urls.namespaceAccessInfo; - if (namespaces.length > 0 && chosenNamespace) { + if ((namespaces?.length ?? 0) > 0 && chosenNamespace) { namespaceContainer = { > setLoading(loading)} filterUsers={(foundUser: UserData) => foundUser.provider !== user?.provider || foundUser.loginName !== user?.loginName} fixSelf={true} namespaceAccessUrl={namespaceAccessUrl} theme={pageSettings.themeType}/> ; - } else if (!loading) { + } else if (!isLoading) { namespaceContainer = No namespaces available. Read here about claiming namespaces.; } @@ -143,14 +119,12 @@ export const UserSettingsNamespaces: FunctionComponent = () => { }} > - + - + {namespaceContainer} ; diff --git a/webui/src/pages/user/user-settings-tokens.tsx b/webui/src/pages/user/user-settings-tokens.tsx index ebc39c30a..a722d1028 100644 --- a/webui/src/pages/user/user-settings-tokens.tsx +++ b/webui/src/pages/user/user-settings-tokens.tsx @@ -8,16 +8,16 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import React, { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react'; +import React, { FunctionComponent, ReactNode, useState } from 'react'; import { Theme, Typography, Box, Paper, Button, Link, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'; import { Link as RouteLink } from 'react-router-dom'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; import { Timestamp } from '../../components/timestamp'; -import { PersonalAccessToken } from '../../extension-registry-types'; -import { MainContext } from '../../context'; +import { PersonalAccessToken, UserData } from '../../extension-registry-types'; import { GenerateTokenDialog } from './generate-token-dialog'; import { UserSettingsRoutes } from './user-settings'; import styled from '@mui/material/styles/styled'; +import { useDeleteAccessTokenMutation, useDeleteAllAccessTokensMutation, useGetAccessTokensQuery, useGetUserQuery } from '../../store/api'; const link = ({ theme }: { theme: Theme }) => ({ color: theme.palette.secondary.main, @@ -43,42 +43,15 @@ const DeleteButton = styled(Button)(({ theme }: { theme: Theme }) => ({ export const UserSettingsTokens: FunctionComponent = () => { - const { service, user, handleError } = useContext(MainContext); - - const [tokens, setTokens] = useState(new Array()); - const [loading, setLoading] = useState(true); const [showDeleteAll, setShowDeleteAll] = useState(false); - const abortController = useRef(new AbortController()); - useEffect(() => { - updateTokens(); - return () => { - abortController.current.abort(); - }; - }, []); - - const updateTokens = async() => { - if (!user) { - return; - } - try { - const tokens = await service.getAccessTokens(abortController.current, user); - setTokens(tokens); - setLoading(false); - } catch (err) { - handleError(err); - setLoading(false); - } - }; + const { data: user } = useGetUserQuery(); + const { data: tokens, isLoading } = useGetAccessTokensQuery(user as UserData, { skip: user == null }); + const [deleteAccessToken] = useDeleteAccessTokenMutation(); + const [deleteAllAccessTokens] = useDeleteAllAccessTokensMutation(); const handleDelete = async (token: PersonalAccessToken) => { - setLoading(true); - try { - await service.deleteAccessToken(abortController.current, token); - updateTokens(); - } catch (err) { - handleError(err); - } + await deleteAccessToken(token); }; const onShowDeleteAll = () => setShowDeleteAll(true); @@ -86,32 +59,21 @@ export const UserSettingsTokens: FunctionComponent = () => { const handleDeleteAll = async () => { onHideDeleteAll(); - setLoading(true); - try { - await service.deleteAllAccessTokens(abortController.current, tokens); - updateTokens(); - } catch (err) { - handleError(err); - } - }; - - const handleTokenGenerated = () => { - setLoading(true); - updateTokens(); + await deleteAllAccessTokens(user as UserData); }; const renderToken = (token: PersonalAccessToken): ReactNode => { return {token.description} - Created: - Accessed: {token.accessedTimestamp ? : 'never'} + Created: + Accessed: {token.accessedTimestamp ? : 'never'} handleDelete(token)} - disabled={loading}> + disabled={isLoading}> Delete @@ -151,15 +113,13 @@ export const UserSettingsTokens: FunctionComponent = () => { }} > - + + disabled={isLoading || (tokens?.length ?? 0) === 0}> Delete all @@ -167,16 +127,16 @@ export const UserSettingsTokens: FunctionComponent = () => { { - tokens.length === 0 && !loading ? - - You currently have no tokens. - : null + (tokens?.length ?? 0) === 0 && !isLoading ? + + You currently have no tokens. + : null } - + - {tokens.map(token => renderToken(token))} + {tokens?.map(token => renderToken(token))} = props => { +export const UserSettings: FunctionComponent = () => { - const { pageSettings, user, loginProviders } = useContext(MainContext); + const { pageSettings, loginProviders } = useContext(MainContext); const { tab, namespace, extension } = useParams(); + const { data: user, isLoading: userLoading } = useGetUserQuery(); const renderTab = (user: UserData, tab?: string, namespace?: string, extension?: string): ReactNode => { if (tab == null && namespace != null && extension != null) { - return ; + return ; } switch (tab) { @@ -59,7 +61,7 @@ export const UserSettings: FunctionComponent = props => { }; const renderContent = (): ReactNode => { - if (props.userLoading) { + if (userLoading) { return ; } @@ -70,8 +72,8 @@ export const UserSettings: FunctionComponent = props => { Please { -return (log in); -}}/> to + return (log in); + }} /> to access your account settings. @@ -105,12 +107,8 @@ return (log in); return <> - Settings – { pageSettings.pageTitle } + Settings – {pageSettings.pageTitle} - { renderContent() } + {renderContent()} ; -}; - -export interface UserSettingsProps { - userLoading: boolean; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/webui/src/store/api.ts b/webui/src/store/api.ts new file mode 100644 index 000000000..59f4805e7 --- /dev/null +++ b/webui/src/store/api.ts @@ -0,0 +1,477 @@ +import { BaseQueryFn, createApi, FetchArgs, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query/react'; +import { CsrfTokenJson, ErrorResult, Extension, ExtensionFilter, ExtensionReviewList, isError, isSuccess, LoginProviders, MembershipRole, Namespace, NamespaceDetails, NamespaceMembershipList, NewReview, PersonalAccessToken, PublisherInfo, RegistryVersion, SearchEntry, SearchResult, SuccessResult, UrlString, UserData } from '../extension-registry-types'; +import { createAbsoluteURL } from '../utils'; + +let serverHost = location.host; +if (serverHost.startsWith('3000-')) { + // Gitpod dev environment: the frontend runs on port 3000, but the server runs on port 8080 + serverHost = '8080-' + serverHost.substring(5); +} else if (location.port === '3000') { + // Localhost dev environment + serverHost = location.hostname + ':8080'; +} else if (serverHost.includes('che-webui')) { + // Eclipse Che dev environment. + // If serverHost contains 'che-webui', replace it with 'che-server' + serverHost = serverHost.replace('che-webui', 'che-server'); +} + +export const serverUrl = `${location.protocol}//${serverHost}`; +export const logoutUrl = createAbsoluteURL([serverUrl, 'logout']); +export const eclipseLoginUrl = createAbsoluteURL([serverUrl, 'oauth2', 'authorization', 'eclipse']); + +const baseQueryWithCsrf: BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError +> = async (args, api, extraOptions) => { + let token: CsrfTokenJson | undefined = undefined; + // TODO double-check when to use CSRF, not only for mutations + if (api.type === 'mutation') { + const { data } = await api.dispatch(apiSlice.endpoints.getCsrfToken.initiate(undefined, { forceRefetch: true })); + if (data && !isError(data)) { + token = data as CsrfTokenJson; + } + } + return await fetchBaseQuery({ + baseUrl: serverUrl, + prepareHeaders: (headers) => { + if (token != null) { + headers.set(token.header, token.value); + } + + return headers; + }, + })(args, api, extraOptions); +}; + +export const apiSlice = createApi({ + reducerPath: 'api', + baseQuery: baseQueryWithCsrf, + tagTypes: [ + 'RegistryVersion', + 'LoginProviders', + 'User', + 'UserByName', + 'UserAuthError', + 'NamespaceDetails', + 'Extension', + 'ExtensionReadme', + 'ExtensionChangelog', + 'ExtensionIcon', + 'ExtensionReviews', + 'AccessTokens', + 'UserNamespaces', + 'NamespaceMembers', + 'StaticContent', + 'AdminExtension', + 'AdminNamespace', + 'AdminPublisherInfo' + ], + endpoints: builder => ({ + getRegistryVersion: builder.query, void>({ + query: () => '/api/version', + transformErrorResponse: () => { + console.error('Could not determine server version'); + return 'unknown'; + }, + providesTags: ['RegistryVersion'] + }), + getLoginProviders: builder.query>, void>({ + query: () => '/login-providers', + transformResponse: (response: Readonly) => { + if (isSuccess(response)) { + console.log(response.success); + return {}; + } else { + return (response as LoginProviders).loginProviders; + } + }, + providesTags: ['LoginProviders'] + }), + getUser: builder.query, void>({ + query: () => ({ + url: '/user', + credentials: 'include' + }), + transformResponse: (response: Readonly) => { + return !isError(response) ? response as UserData : undefined; + }, + providesTags: ['User'] + }), + getUserAuthError: builder.query, void>({ + query: () => ({ + url: '/user/auth-error', + credentials: 'include' + }), + providesTags: ['UserAuthError'] + }), + getCsrfToken: builder.query, void>({ + query: () => ({ + url: '/user/csrf', + credentials: 'include' + }), + }), + signPublisherAgreement: builder.mutation, void>({ + query: () => ({ + url: '/user/publisher-agreement', + method: 'POST', + credentials: 'include' + }), + invalidatesTags: ['User'] + }), + getNamespaceDetails: builder.query, string>({ + query: (name) => `/api/${name}/details`, + providesTags: (details) => details != null ? [{ type: 'NamespaceDetails', id: details.name }] : [] + }), + setNamespaceDetails: builder.mutation, { endpoint: string, details: NamespaceDetails }>({ + query: (input) => ({ + url: input.endpoint, + body: input.details, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result, error, input) => isSuccess(result) ? [{ type: 'NamespaceDetails', id: input.details.name }] : [] + }), + setNamespaceLogo: builder.mutation, { endpoint: string, name: string, logoFile: Blob, logoName: string }>({ + query: (input) => { + const form = new FormData(); + form.append('file', input.logoFile, input.logoName); + return { + url: input.endpoint, + body: form, + method: 'POST', + credentials: 'include' + }; + }, + invalidatesTags: (result, error, input) => isSuccess(result) ? [{ type: 'NamespaceDetails', id: input.name }] : [] + }), + search: builder.query, ExtensionFilter | undefined>({ + query: (filter) => { + let params: Record | undefined = undefined; + if (filter) { + params = {}; + if (filter.query) + params['query'] = filter.query; + if (filter.category) + params['category'] = filter.category; + if (filter.offset) + params['offset'] = filter.offset; + if (filter.size) + params['size'] = filter.size; + if (filter.sortBy) + params['sortBy'] = filter.sortBy; + if (filter.sortOrder) + params['sortOrder'] = filter.sortOrder; + } + + return { + url: '/api/-/search', + params + }; + } + }), + getExtensionDetail: builder.query, {namespace: string, name: string, target?: string, version?: string}>({ + query: ({ namespace, name, target, version }) => { + const params = [namespace, name]; + if (target) { + params.push(target); + } + if (version) { + params.push(version); + } + return { url: `/api/${params.join('/')}` }; + }, + transformResponse: (response: Readonly) => { + return !isError(response) ? response as Extension : undefined; + }, + providesTags: (result) => { + if (result != null && !isError(result)) { + const extension = result as Extension; + const id = `${extension.namespace}.${extension.name}`; + return [{ type: 'Extension', id }]; + } else { + return []; + } + } + }), + getExtensionIcon: builder.query({ + query: (extension) => ({ + url: extension.files.readme, + headers: { 'Accept': 'application/octet-stream' }, + redirect: 'follow' + }), + providesTags: (result, error, extension) => result != null ? [{ type: 'ExtensionIcon', id: `${extension.namespace}.${extension.name}` }] : [] + }), + getExtensionReviews: builder.query, Extension>({ + query: (extension) => extension.reviewsUrl, + providesTags: (result, error, extension) => result != null ? [{ type: 'ExtensionReviews', id: `${extension.namespace}.${extension.name}` }] : [] + }), + postReview: builder.mutation, { review: NewReview, postReviewUrl: UrlString, extension: Extension }>({ + query: ({ review, postReviewUrl }) => ({ + url: postReviewUrl, + method: 'POST', + body: review, + credentials: 'include' + }), + invalidatesTags: (result, error, { extension }) => { + if (isSuccess(result)) { + const id = `${extension.namespace}.${extension.name}`; + return [{ type: 'ExtensionReviews', id }, { type: 'Extension', id }]; + } else { + return []; + } + } + }), + deleteReview: builder.mutation, { deleteReviewUrl: UrlString, extension: Extension }>({ + query: ({ deleteReviewUrl }) => ({ + url: deleteReviewUrl, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result, error, { extension }) => { + if (isSuccess(result)) { + const id = `${extension.namespace}.${extension.name}`; + return [{ type: 'ExtensionReviews', id }, { type: 'Extension', id }]; + } else { + return []; + } + } + }), + getUserByName: builder.query[], string>({ + query: (name) => ({ + url: `/user/search/${name}`, + credentials: 'include' + }), + providesTags: (result, error, name) => result != null ? [{ type: 'UserByName', id: name }] : [] + }), + getAccessTokens: builder.query[], UserData>({ + query: (user) => ({ + url: user.tokensUrl, + credentials: 'include' + }), + providesTags: ['AccessTokens'] + }), + createAccessToken: builder.mutation, { user: UserData, description: string }>({ + query: ({ user, description }) => ({ + url: user.createTokenUrl, + params: { description }, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result) => result != null ? ['AccessTokens'] : [] + }), + deleteAccessToken: builder.mutation, PersonalAccessToken>({ + query: (token) => ({ + url: token.deleteTokenUrl, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result) => result != null && isSuccess(result) ? ['AccessTokens'] : [] + }), + deleteAllAccessTokens: builder.mutation[], UserData>({ + query: (user) => ({ + url: user.deleteAllTokensUrl, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result) => result != null ? ['AccessTokens'] : [] + }), + getNamespaces: builder.query[], void>({ + query: () => ({ + url: '/user/namespaces', + credentials: 'include' + }), + providesTags: ['UserNamespaces'] + }), + getNamespaceMembers: builder.query, Namespace>({ + query: (namespace) => ({ + url: namespace.membersUrl, + credentials: 'include' + }), + providesTags: ['NamespaceMembers'] + }), + setNamespaceMember: builder.mutation[], { endpoint: UrlString, user: UserData, role: MembershipRole | 'remove' }>({ + query: ({ endpoint, user, role }) => ({ + url: endpoint, + params: { + user: user.loginName, + provider: user.provider, + role + }, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (results) => results != null && results.some((result) => isSuccess(result)) ? ['NamespaceMembers'] : [] + }), + getStaticContent: builder.query({ + query: (url) => ({ + url, + headers: { 'Accept': 'text/plain' }, + redirect: 'follow' + }), + providesTags: (result, error, url) => result != null ? [{ type: 'StaticContent', id: url }] : [] + }), + publishExtension: builder.mutation, File>({ + query: (extensionPackage) => ({ + url: '/api/user/publish', + method: 'POST', + payload: extensionPackage, + headers: { 'Content-Type': 'application/octet-stream' }, + credentials: 'include' + }), + invalidatesTags: [{ type: 'Extension', id: 'List' }] + }), + createNamespace: builder.mutation, string>({ + query: (name) => ({ + url: '/api/user/namespace/create', + method: 'POST', + payload: { name }, + credentials: 'include' + }), + invalidatesTags: ['UserNamespaces'] + }), + getExtensions: builder.query, void>({ + query: () => ({ + url: '/user/extensions', + credentials: 'include' + }), + transformResponse: (response: Readonly) => { + return !isError(response) ? response as Extension[] : undefined; + }, + providesTags: [{ type: 'Extension', id: 'List' }] + }), + getExtension: builder.query, { namespace: string, extension: string }>({ + query: ({ namespace, extension }) => ({ + url: `/user/extension/${namespace}/${extension}`, + credentials: 'include' + }), + providesTags: (result) => result != null ? [{ type: 'Extension', id: `${result.namespace}.${result.name}` }] : [] + }), + deleteExtensions: builder.mutation, { namespace: string, extension: string, targetPlatformVersions?: object[] }>({ + query: ({ namespace, extension, targetPlatformVersions }) => ({ + url: `/user/extension/${namespace}/${extension}/delete`, + method: 'POST', + credentials: 'include', + payload: targetPlatformVersions + }), + invalidatesTags: (result, error, { namespace, extension }) => { + if (result != null && isSuccess(result)) { + const id = `${namespace}.${extension}`; + return [{ type: 'Extension', id }, { type: 'AdminExtension', id }]; + } else { + return []; + } + } + }), + adminGetExtension: builder.query, { namespace: string, extension: string }>({ + query: ({ namespace, extension }) => ({ + url: `/admin/extension/${namespace}/${extension}`, + credentials: 'include' + }), + providesTags: (result) => result != null ? [{ type: 'AdminExtension', id: `${result.namespace}.${result.name}` }] : [] + }), + adminDeleteExtensions: builder.mutation, { namespace: string, extension: string, targetPlatformVersions?: object[] }>({ + query: ({ namespace, extension, targetPlatformVersions }) => ({ + url: `/admin/extension/${namespace}/${extension}/delete`, + method: 'POST', + credentials: 'include', + payload: targetPlatformVersions + }), + invalidatesTags: (result, error, { namespace, extension }) => { + if (result != null && isSuccess(result)) { + const id = `${namespace}.${extension}`; + return [{ type: 'Extension', id }, { type: 'AdminExtension', id }]; + } else { + return []; + } + } + }), + adminGetNamespace: builder.query, string>({ + query: (name) => ({ + url: `/admin/namespace/${name}`, + credentials: 'include' + }), + providesTags: (result) => result != null ? [{ type: 'AdminNamespace', id: result.name }] : [] + }), + adminCreateNamespace: builder.mutation, { name: string }>({ + query: (namespace) => ({ + url: '/admin/create-namespace', + method: 'POST', + payload: namespace, + credentials: 'include' + }) + }), + adminChangeNamespace: builder.mutation, { oldNamespace: string, newNamespace: string, removeOldNamespace: boolean, mergeIfNewNamespaceAlreadyExists: boolean }>({ + query: (req) => ({ + url: '/admin/change-namespace', + method: 'POST', + payload: req, + credentials: 'include' + }), + invalidatesTags: (result, error, req) => result != null && isSuccess(result) ? [{ type: 'AdminNamespace', id: req.oldNamespace }, { type: 'AdminNamespace', id: req.newNamespace }, { type: 'NamespaceDetails', id: req.oldNamespace }, { type: 'NamespaceDetails', id: req.newNamespace }] : [] + }), + adminGetPublisherInfo: builder.query, { provider: string, login: string }>({ + query: ({ provider, login }) => ({ + url: `'/admin/publisher/${provider}/${login}`, + credentials: 'include' + }), + providesTags: (result) => result != null ? [{ type: 'AdminPublisherInfo', id: `${result.user.provider}/${result.user.loginName}` }] : [] + }), + adminRevokePublisherContributions: builder.mutation, { provider: string, login: string }>({ + query: ({ provider, login }) => ({ + url: `/admin/publisher/${provider}/${login}/revoke`, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result, error, { provider, login }) => result != null && isSuccess(result) ? [{ type: 'AdminPublisherInfo', id: `${provider}/${login}` }] : [] + }), + adminRevokeAccessTokens: builder.mutation, { provider: string, login: string }>({ + query: ({ provider, login }) => ({ + url: `/admin/publisher/${provider}/${login}/tokens/revoke`, + method: 'POST', + credentials: 'include' + }), + invalidatesTags: (result, error, { provider, login }) => result != null && isSuccess(result) ? [{ type: 'AdminPublisherInfo', id: `${provider}/${login}` }] : [] + }) + }) +}); + +export const { + useGetCsrfTokenQuery, + useGetRegistryVersionQuery, + useGetLoginProvidersQuery, + useGetUserQuery, + useGetUserAuthErrorQuery, + useSignPublisherAgreementMutation, + useGetNamespaceDetailsQuery, + useSetNamespaceDetailsMutation, + useSetNamespaceLogoMutation, + useSearchQuery, + useGetExtensionDetailQuery, + useGetExtensionIconQuery, + useGetExtensionReviewsQuery, + usePostReviewMutation, + useDeleteReviewMutation, + useGetUserByNameQuery, + useGetAccessTokensQuery, + useCreateAccessTokenMutation, + useDeleteAccessTokenMutation, + useDeleteAllAccessTokensMutation, + useGetNamespacesQuery, + useGetNamespaceMembersQuery, + useSetNamespaceMemberMutation, + useGetStaticContentQuery, + usePublishExtensionMutation, + useCreateNamespaceMutation, + useGetExtensionsQuery, + useGetExtensionQuery, + useDeleteExtensionsMutation, + useAdminGetExtensionQuery, + useAdminDeleteExtensionsMutation, + useAdminGetNamespaceQuery, + useAdminCreateNamespaceMutation, + useAdminChangeNamespaceMutation, + useAdminGetPublisherInfoQuery, + useAdminRevokePublisherContributionsMutation, + useAdminRevokeAccessTokensMutation +} = apiSlice; \ No newline at end of file diff --git a/webui/src/store/error-middleware.ts b/webui/src/store/error-middleware.ts new file mode 100644 index 000000000..c2b3dff61 --- /dev/null +++ b/webui/src/store/error-middleware.ts @@ -0,0 +1,37 @@ +import { isFulfilled, isRejectedWithValue, Middleware, MiddlewareAPI } from "@reduxjs/toolkit"; +import { isError, ReportedError } from "../extension-registry-types"; +import { ErrorPayload, setError } from "./error"; +import { handleError } from "../utils"; + +const getEndpointName = (action: any) => { + let meta; + if ('meta' in action) { + meta = action.meta; + } + let arg; + if (meta != null && 'arg' in meta) { + arg = meta.arg; + } + + return arg != null && 'endpointName' in arg ? arg.endpointName : ''; +}; + +export const errorMiddleware: Middleware = + (api: MiddlewareAPI) => (next) => (action) => { + console.log('MW', action); + let error: ErrorPayload | undefined = undefined; + if (isRejectedWithValue(action)) { + // TODO handle rejected + console.error('rejected'); + } else if (isFulfilled(action) && isError(action.payload)) { + error = { code: '', message: handleError(action.payload) }; + } + + if (error != null && getEndpointName(action) === 'signPublisherAgreement' && !(error as ReportedError).code) { + error.code = 'publisher-agreement-problem'; + } + if (error != null) { + api.dispatch(setError(error)); + } + return next(action); + }; \ No newline at end of file diff --git a/webui/src/store/error.ts b/webui/src/store/error.ts new file mode 100644 index 000000000..6274d4941 --- /dev/null +++ b/webui/src/store/error.ts @@ -0,0 +1,48 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import type { RootState } from './store'; +import { apiSlice } from './api'; +import { handleError } from '../utils'; + +export interface ErrorPayload { + code: string + message: string +} + +export interface ErrorState extends ErrorPayload { + show: boolean +} + +const initialState: ErrorState = { + code: '', + message: '', + show: false +}; + +export const errorSlice = createSlice({ + name: 'error', + initialState, + reducers: { + setError: (state: ErrorState, action: PayloadAction) => { + state = { ...action.payload, show: true }; + }, + hideError: (state: ErrorState) => { + state.show = false; + } + }, + extraReducers(builder) { + builder.addMatcher(apiSlice.endpoints.getUserAuthError.matchFulfilled, (state, action) => { + state = { + code: '', + message: handleError(action.payload), + show: true + }; + }); + }, +}); + +export const { setError, hideError } = errorSlice.actions; + +export const selectError = (state: RootState) => state.error; + +export default errorSlice.reducer; \ No newline at end of file diff --git a/webui/src/store/hooks.ts b/webui/src/store/hooks.ts new file mode 100644 index 000000000..78d5f9c57 --- /dev/null +++ b/webui/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { useDispatch, useSelector } from 'react-redux'; +import type { AppDispatch, RootState } from './store'; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); \ No newline at end of file diff --git a/webui/src/store/store.ts b/webui/src/store/store.ts new file mode 100644 index 000000000..84d01a9d0 --- /dev/null +++ b/webui/src/store/store.ts @@ -0,0 +1,17 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { apiSlice } from './api'; +import errorReducer from './error'; +import { errorMiddleware } from './error-middleware'; + +export const store = configureStore({ + reducer: { + [apiSlice.reducerPath]: apiSlice.reducer, + error: errorReducer + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware().concat(apiSlice.middleware, errorMiddleware) +}); + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch +export type AppStore = typeof store \ No newline at end of file diff --git a/webui/yarn.lock b/webui/yarn.lock index 050e326b1..6b69e4f83 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -1040,6 +1040,28 @@ __metadata: languageName: node linkType: hard +"@reduxjs/toolkit@npm:^2.11.0": + version: 2.11.0 + resolution: "@reduxjs/toolkit@npm:2.11.0" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/utils": "npm:^0.3.0" + immer: "npm:^11.0.0" + redux: "npm:^5.0.1" + redux-thunk: "npm:^3.1.0" + reselect: "npm:^5.1.0" + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 10/99f32cc627ea0204549ec4b93436eefa1dbf6d4f095dc3beb4010bcc1962e62f4f0ad2f7b295544e764c96d3ad24c5b16aa4b3f5bdef459a6f5abbf4b07ebb01 + languageName: node + linkType: hard + "@remix-run/router@npm:1.16.1": version: 1.16.1 resolution: "@remix-run/router@npm:1.16.1" @@ -1047,6 +1069,20 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10/aee780cc1431888ca4b9aba9b24ffc8f3073fc083acc105e3951481478a2f4dc957796931b2da9e2d8329584cf211e4542275f188296c1cdff3ed44fd93a8bc8 + languageName: node + linkType: hard + +"@standard-schema/utils@npm:^0.3.0": + version: 0.3.0 + resolution: "@standard-schema/utils@npm:0.3.0" + checksum: 10/7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51 + languageName: node + linkType: hard + "@stylistic/eslint-plugin@npm:^2.11.0": version: 2.11.0 resolution: "@stylistic/eslint-plugin@npm:2.11.0" @@ -1409,6 +1445,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/use-sync-external-store@npm:0.0.6" + checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^8.15.0": version: 8.15.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.15.0" @@ -3593,7 +3636,22 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.5": +"glob@npm:^10.2.2, glob@npm:^10.3.10": + version: 10.4.1 + resolution: "glob@npm:10.4.1" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10/d7bb49d2b413f77bdd59fea4ca86dcc12450deee221af0ca93e09534b81b9ef68fe341345751d8ff0c5b54bad422307e0e44266ff8ad7fbbd0c200e8ec258b16 + languageName: node + linkType: hard + +"glob@npm:^10.4.5": version: 10.5.0 resolution: "glob@npm:10.5.0" dependencies: @@ -3828,6 +3886,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^11.0.0": + version: 11.0.1 + resolution: "immer@npm:11.0.1" + checksum: 10/94c715fff8752bc653f7b93db4a906778b780b42a1079d70c73f7d9655c415fea890e6cbb406bb6d6ad7fe9593390c650e04b538b59c60304b06fac744ccfe4f + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -4952,6 +5017,7 @@ __metadata: "@mui/icons-material": "npm:^5.13.7" "@mui/material": "npm:^5.13.7" "@playwright/test": "npm:^1.55.1" + "@reduxjs/toolkit": "npm:^2.11.0" "@stylistic/eslint-plugin": "npm:^2.11.0" "@types/babel__core": "npm:^7" "@types/chai": "npm:^4.3.5" @@ -4993,6 +5059,7 @@ __metadata: react-dropzone: "npm:^14.2.3" react-helmet-async: "npm:^1.3.0" react-infinite-scroller: "npm:^1.2.6" + react-redux: "npm:^9.2.0" react-router: "npm:^6.14.2" react-router-dom: "npm:^6.14.1" rimraf: "npm:^6.1.2" @@ -5499,6 +5566,25 @@ __metadata: languageName: node linkType: hard +"react-redux@npm:^9.2.0": + version: 9.2.0 + resolution: "react-redux@npm:9.2.0" + dependencies: + "@types/use-sync-external-store": "npm:^0.0.6" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + "@types/react": ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + redux: + optional: true + checksum: 10/b3d2f89f469169475ab0a9f8914d54a336ac9bc6a31af6e8dcfe9901e6fe2cfd8c1a3f6ce7a2f7f3e0928a93fbab833b668804155715598b7f2ad89927d3ff50 + languageName: node + linkType: hard + "react-router-dom@npm:^6.14.1": version: 6.23.1 resolution: "react-router-dom@npm:6.23.1" @@ -5563,6 +5649,22 @@ __metadata: languageName: node linkType: hard +"redux-thunk@npm:^3.1.0": + version: 3.1.0 + resolution: "redux-thunk@npm:3.1.0" + peerDependencies: + redux: ^5.0.0 + checksum: 10/38c563db5f0bbec90d2e65cc27f3c870c1b6102e0c071258734fac41cb0e51d31d894125815c2f4133b20aff231f51f028ad99bccc05a7e3249f1a5d5a959ed3 + languageName: node + linkType: hard + +"redux@npm:^5.0.1": + version: 5.0.1 + resolution: "redux@npm:5.0.1" + checksum: 10/a373f9ed65693ead58bea5ef61c1d6bef39da9f2706db3be6f84815f3a1283230ecd1184efb1b3daa7f807d8211b0181564ca8f336fc6ee0b1e2fa0ba06737c2 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.4": version: 1.0.6 resolution: "reflect.getprototypeof@npm:1.0.6" @@ -5616,6 +5718,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:^5.1.0": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 10/1fdae11a39ed9c8d85a24df19517c8372ee24fefea9cce3fae9eaad8e9cefbba5a3d4940c6fe31296b6addf76e035588c55798f7e6e147e1b7c0855f119e7fa5 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -6527,6 +6636,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.4.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2"