Fixed some issues with DataTables and unmounting extensions

When using ServerWidePluginData component for two different plugins that had a DataTablesTable
React reused the component, which lead to issues of DataTables trying to remount on the same
component with a different ID, with both jQuery and React trying to remove elements from DOM.

- Added div around table in DataTablesTable to allow React properly unmount the element after
  jQuery already removed the table element
- Added extra layer in ServerWidePluginData that re-renders the tab when plugin changes
- Sorted extensions in the endpoints
- Added /v1/networkMetadata endpoint for implementing server list navigation later
- Added network page to App.js with plugins and players tabs already implemented
This commit is contained in:
Aurora Lahtela 2022-09-03 14:01:15 +03:00
parent 813abd040a
commit 38ee84af67
15 changed files with 283 additions and 22 deletions

View File

@ -33,7 +33,7 @@ public class ExtensionsDto {
this.playerUUID = playerUUID;
this.serverUUID = serverUUID;
this.serverName = serverName;
this.extensionData = extensionData.stream().map(ExtensionDataDto::new).collect(Collectors.toList());
this.extensionData = extensionData.stream().sorted().map(ExtensionDataDto::new).collect(Collectors.toList());
}
public String getServerUUID() {

View File

@ -116,6 +116,7 @@ public class ExtensionJSONResolver implements Resolver {
List<ExtensionData> extensionData = dbSystem.getDatabase().query(new ExtensionServerDataQuery(serverUUID));
return Map.of(
"extensions", extensionData.stream()
.sorted()
.map(ExtensionDataDto::new)
.collect(Collectors.toList())
);

View File

@ -82,6 +82,7 @@ public class MetadataJSONResolver implements NoAuthResolver {
.put("playerHeadImageUrl", config.get(DisplaySettings.PLAYER_HEAD_IMG_URL))
.put("isProxy", serverInfo.getServer().isProxy())
.put("serverName", serverInfo.getServer().getIdentifiableName())
.put("serverUUID", serverInfo.getServer().getUuid().toString())
.put("networkName", serverInfo.getServer().isProxy() ? config.get(ProxySettings.NETWORK_NAME) : null)
.put("mainCommand", mainCommand)
.build())

View File

@ -0,0 +1,82 @@
/*
* This file is part of Player Analytics (Plan).
*
* Plan is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License v3 as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Plan is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
*/
package com.djrapitops.plan.delivery.webserver.resolver.json;
import com.djrapitops.plan.delivery.domain.datatransfer.ServerDto;
import com.djrapitops.plan.delivery.web.resolver.Resolver;
import com.djrapitops.plan.delivery.web.resolver.Response;
import com.djrapitops.plan.delivery.web.resolver.request.Request;
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
import com.djrapitops.plan.identification.ServerInfo;
import com.djrapitops.plan.storage.database.DBSystem;
import com.djrapitops.plan.storage.database.queries.objects.ServerQueries;
import com.djrapitops.plan.utilities.java.Maps;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* @author AuroraLS3
*/
@Singleton
@Path("/v1/networkMetadata")
public class NetworkMetadataJSONResolver implements Resolver {
private final ServerInfo serverInfo;
private final DBSystem dbSystem;
@Inject
public NetworkMetadataJSONResolver(ServerInfo serverInfo, DBSystem dbSystem) {
this.serverInfo = serverInfo;
this.dbSystem = dbSystem;
}
@Override
public boolean canAccess(Request request) {
return request.getUser().orElse(new WebUser("")).hasPermission("page.network");
}
@GET
@Operation(
description = "Get metadata about the network such as list of servers.",
requestBody = @RequestBody(content = @Content(examples = @ExampleObject()))
)
@Override
public Optional<Response> resolve(Request request) {
return Optional.of(getResponse());
}
private Response getResponse() {
return Response.builder()
.setJSONContent(Maps.builder(String.class, Object.class)
.put("servers", dbSystem.getDatabase().query(ServerQueries.fetchPlanServerInformationCollection())
.stream().map(ServerDto::fromServer)
.sorted()
.collect(Collectors.toList()))
.put("currentServer", ServerDto.fromServer(serverInfo.getServer()))
.build())
.build();
}
}

View File

@ -62,6 +62,7 @@ public class RootJSONResolver {
QueryJSONResolver queryJSONResolver,
VersionJSONResolver versionJSONResolver,
MetadataJSONResolver metadataJSONResolver,
NetworkMetadataJSONResolver networkMetadataJSONResolver,
WhoAmIJSONResolver whoAmIJSONResolver,
ServerIdentityJSONResolver serverIdentityJSONResolver,
ExtensionJSONResolver extensionJSONResolver
@ -89,6 +90,7 @@ public class RootJSONResolver {
.add("version", versionJSONResolver)
.add("locale", localeJSONResolver)
.add("metadata", metadataJSONResolver)
.add("networkMetadata", networkMetadataJSONResolver)
.add("serverIdentity", serverIdentityJSONResolver)
.add("whoami", whoAmIJSONResolver)
.add("extensionData", extensionJSONResolver)

View File

@ -33,6 +33,8 @@ const ServerPerformance = React.lazy(() => import("./views/server/ServerPerforma
const ServerPluginData = React.lazy(() => import("./views/server/ServerPluginData"));
const ServerWidePluginData = React.lazy(() => import("./views/server/ServerWidePluginData"));
const NetworkPage = React.lazy(() => import("./views/layout/NetworkPage"));
const PlayersPage = React.lazy(() => import("./views/layout/PlayersPage"));
const AllPlayers = React.lazy(() => import("./views/players/AllPlayers"));
@ -109,6 +111,17 @@ function App() {
icon: faMapSigns
}}/>}/>
</Route>
<Route path="/network" element={<Lazy><NetworkPage/></Lazy>}>
<Route path="" element={<Lazy><OverviewRedirect/></Lazy>}/>
<Route path="players" element={<Lazy><AllPlayers/></Lazy>}/>
<Route path="plugins-overview" element={<Lazy><ServerPluginData/></Lazy>}/>
<Route path="plugins/:plugin" element={<Lazy><ServerWidePluginData/></Lazy>}/>
<Route path="*" element={<ErrorView error={{
message: 'Unknown tab address, please correct the address',
title: 'No such tab',
icon: faMapSigns
}}/>}/>
</Route>
<Route path="/errors" element={<Lazy><ErrorsPage/></Lazy>}/>
<Route path="/docs" element={<Lazy><SwaggerView/></Lazy>}/>
</Routes>

View File

@ -2,14 +2,16 @@ import {FontAwesomeIcon as Fa} from '@fortawesome/react-fontawesome'
import {iconTypeToFontAwesomeClass} from "../../util/icons";
import React from "react";
const ExtensionIcon = ({icon}) => (
<Fa icon={[
iconTypeToFontAwesomeClass(icon.family),
icon.iconName
]}
className={icon.colorClass}
/>
)
const ExtensionIcon = ({icon}) => {
return (
<Fa icon={[
iconTypeToFontAwesomeClass(icon.family),
icon.iconName
]}
className={icon.colorClass}
/>
)
}
export const toExtensionIconHtmlString = ({icon}) => {
return icon ? `<i class="${iconTypeToFontAwesomeClass(icon.family)} ${icon.iconName} ${icon.colorClass}"></i>` : '';

View File

@ -67,7 +67,7 @@ const ExtensionColoredTable = ({table}) => {
const ExtensionTable = ({table}) => {
const tableLength = table.table.rows.length;
if (tableLength > 25) {
if (tableLength > 10) {
return <ExtensionDataTable table={table}/>
} else {
return <ExtensionColoredTable table={table}/>

View File

@ -24,6 +24,10 @@ const Divider = ({showMargin}) => (
)
const InnerItem = ({href, icon, name, nameShort}) => {
if (!href) {
return (<hr className={"nav-servers dropdown-divider mx-3 my-2"}/>)
}
if (href.startsWith('/')) {
return (
<a href={href} className="collapse-item nav-button">

View File

@ -25,9 +25,13 @@ const DataTablesTable = ({id, options}) => {
};
}, [id, options, dataTableRef]);
// The surrounding div is required: jquery Datatables changes the DOM around table and React needs to have a node
// that wasn't changed.
return (
<table id={id} className={"table table-bordered table-striped" + (nightModeEnabled ? " table-dark" : '')}
style={{width: "100%"}}/>
<div>
<table id={id} className={"table table-bordered table-striped" + (nightModeEnabled ? " table-dark" : '')}
style={{width: "100%"}}/>
</div>
)
};

View File

@ -1,13 +1,10 @@
import {createContext, useContext, useEffect, useState} from "react";
import {useDataRequest} from "./dataFetchHook";
import {fetchExtensionData} from "../service/serverService";
import {useParams} from "react-router-dom";
const ServerExtensionContext = createContext({});
export const ServerExtensionContextProvider = ({children}) => {
const {identifier} = useParams();
export const ServerExtensionContextProvider = ({identifier, children}) => {
const [extensionData, setExtensionData] = useState(undefined);
const [extensionDataLoadingError, setExtensionDataLoadingError] = useState(undefined);

View File

@ -18,4 +18,9 @@ export const fetchAvailableLocales = async () => {
export const fetchErrorLogs = async () => {
const url = '/v1/errors';
return doGetRequest(url);
}
export const fetchNetworkMetadata = async () => {
const url = '/v1/networkMetadata';
return doGetRequest(url);
}

View File

@ -0,0 +1,132 @@
import React, {useEffect} from "react";
import {useTranslation} from "react-i18next";
import {Outlet} from "react-router-dom";
import {useNavigation} from "../../hooks/navigationHook";
import {
faChartLine,
faCogs,
faCubes,
faGlobe,
faInfoCircle,
faNetworkWired,
faSearch,
faServer,
faUserGroup,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import {useAuth} from "../../hooks/authenticationHook";
import {NightModeCss} from "../../hooks/themeHook";
import Sidebar from "../../components/navigation/Sidebar";
import Header from "../../components/navigation/Header";
import ColorSelectorModal from "../../components/modal/ColorSelectorModal";
import {useMetadata} from "../../hooks/metadataHook";
import {faCalendarCheck} from "@fortawesome/free-regular-svg-icons";
import {SwitchTransition} from "react-transition-group";
import MainPageRedirect from "../../components/navigation/MainPageRedirect";
import ExtensionIcon from "../../components/extensions/ExtensionIcon";
import {ServerExtensionContextProvider, useServerExtensionContext} from "../../hooks/serverExtensionDataContext";
const NetworkSidebar = () => {
const {t, i18n} = useTranslation();
const {sidebarItems, setSidebarItems} = useNavigation();
const {extensionData} = useServerExtensionContext();
useEffect(() => {
const servers = []
const items = [
{name: 'html.label.networkOverview', icon: faInfoCircle, href: "overview"},
{},
{
name: 'html.label.servers',
icon: faServer,
contents: [
{name: 'html.label.overview', icon: faNetworkWired, href: "serversOverview"},
{name: 'html.label.sessions', icon: faCalendarCheck, href: "sessions"},
{name: 'html.label.performance', icon: faCogs, href: "performance"},
{},
...servers.map(server => {
return {name: server.serverName, icon: faServer, href: "/server/" + server.serverUUID}
})
]
},
{
name: 'html.label.playerbase',
icon: faUsers,
contents: [
{
nameShort: 'html.label.overview',
name: 'html.label.playerbaseOverview',
icon: faChartLine,
href: "playerbase"
},
// {name: 'html.label.playerRetention', icon: faUsersViewfinder, href: "retention"},
{name: 'html.label.playerList', icon: faUserGroup, href: "players"},
{name: 'html.label.geolocations', icon: faGlobe, href: "geolocations"},
]
},
{},
{name: 'html.label.plugins'},
{name: 'html.label.pluginsOverview', icon: faCubes, href: "plugins-overview"}
]
if (extensionData) {
extensionData.extensions.filter(extension => extension.wide)
.map(extension => extension.extensionInformation)
.map(info => {
return {
name: info.pluginName,
icon: <ExtensionIcon icon={info.icon}/>,
href: `plugins/${encodeURIComponent(info.pluginName)}`
}
}).forEach(item => items.push(item))
}
items.push(
{},
{name: 'html.label.links'},
{name: 'html.label.query', icon: faSearch, href: "/query"}
);
setSidebarItems(items);
window.document.title = `Plan | Network`;
}, [t, i18n, extensionData, setSidebarItems])
return (
<Sidebar items={sidebarItems} showBackButton={false}/>
)
}
const ServerPage = () => {
const {networkName, serverUUID} = useMetadata();
const {currentTab} = useNavigation();
const {authRequired, loggedIn} = useAuth();
if (authRequired && !loggedIn) return <MainPageRedirect/>
if (!serverUUID) return <></>
return (
<>
<NightModeCss/>
<ServerExtensionContextProvider identifier={serverUUID}>
<NetworkSidebar/>
<div className="d-flex flex-column" id="content-wrapper">
<Header page={networkName} tab={currentTab}/>
<div id="content" style={{display: 'flex'}}>
<main className="container-fluid mt-4">
<SwitchTransition>
<Outlet/>
</SwitchTransition>
</main>
<aside>
<ColorSelectorModal/>
</aside>
</div>
</div>
</ServerExtensionContextProvider>
</>
)
}
export default ServerPage;

View File

@ -145,7 +145,7 @@ const ServerPage = () => {
return (
<>
<NightModeCss/>
<ServerExtensionContextProvider>
<ServerExtensionContextProvider identifier={identifier}>
<ServerSidebar/>
<div className="d-flex flex-column" id="content-wrapper">
<Header page={displayedServerName} tab={currentTab}/>

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, {useEffect, useState} from 'react';
import ErrorView from "../ErrorView";
import LoadIn from "../../components/animation/LoadIn";
import {Card, Col, Row} from "react-bootstrap-v5";
@ -8,15 +8,17 @@ import {useTranslation} from "react-i18next";
import Loader from "../../components/navigation/Loader";
import {useServerExtensionContext} from "../../hooks/serverExtensionDataContext";
const ServerWidePluginData = () => {
const PluginData = ({plugin}) => {
const {t} = useTranslation();
const {plugin} = useParams();
const {extensionData, extensionDataLoadingError} = useServerExtensionContext();
const [extension, setExtension] = useState(undefined);
useEffect(() => {
setExtension(extensionData?.extensions?.find(extension => extension.extensionInformation.pluginName === plugin))
}, [setExtension, extensionData, plugin])
if (extensionDataLoadingError) return <ErrorView error={extensionDataLoadingError}/>;
const extension = extensionData?.find(extension => extension.extensionInformation.pluginName === plugin)
if (!extension) {
return (
<LoadIn>
@ -47,6 +49,22 @@ const ServerWidePluginData = () => {
</section>
</LoadIn>
)
}
const ServerWidePluginData = () => {
const {plugin} = useParams();
const [previousPlugin, setPreviousPlugin] = useState(undefined);
// Prevents React from reusing the extension component of two different plugins, leading to DataTables errors.
useEffect(() => {
setPreviousPlugin(plugin);
}, [plugin, setPreviousPlugin]);
if (plugin !== previousPlugin) {
return <></>
}
return (<PluginData plugin={plugin}/>)
};
export default ServerWidePluginData