diff --git a/Src/Witsml/Extensions/StringExtensions.cs b/Src/Witsml/Extensions/StringExtensions.cs index fbcf0cf9e..40302c753 100644 --- a/Src/Witsml/Extensions/StringExtensions.cs +++ b/Src/Witsml/Extensions/StringExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; namespace Witsml.Extensions diff --git a/Src/Witsml/Extensions/UriExtensions.cs b/Src/Witsml/Extensions/UriExtensions.cs new file mode 100644 index 000000000..fa59d57ac --- /dev/null +++ b/Src/Witsml/Extensions/UriExtensions.cs @@ -0,0 +1,17 @@ +using System; + +namespace Witsml.Extensions; + +public static class UriExtensions +{ + /// + /// Determines whether two specified Uri objects have the same value ignore case. + /// + /// The first Uri to compare. + /// The second Uri to compare. + /// True if the value of the parameter is equal to the value of the , otherwise return false. + public static bool EqualsIgnoreCase(this Uri firstUri, Uri secondUri) + { + return string.Equals(firstUri?.AbsoluteUri, secondUri?.AbsoluteUri, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Src/Witsml/QueryLogger.cs b/Src/Witsml/QueryLogger.cs index 3dbae387c..8c6f30342 100644 --- a/Src/Witsml/QueryLogger.cs +++ b/Src/Witsml/QueryLogger.cs @@ -1,11 +1,30 @@ +using System; + using Serilog; using Serilog.Core; +using Witsml.Data; +using Witsml.ServiceReference; + namespace Witsml; public interface IQueryLogger { - void LogQuery(string querySent, bool isSuccessful, string xmlReceived = null); + /// + /// This method will be invoked after every call to Witsml server. It will contain information about the call, response and error (if any) + /// + /// Name of the Witsml function call + /// Witsml server Url + /// Witsml query + /// OptionsIn if applicable (could be null) + /// Xml request to the Witsml server (QueryIn/XMLin) + /// if true - request was successful (resultCode > 0) + /// Xml response from server (could be null) + /// Result code from the witsml server (negative number means error code) + /// Result message from the witsml server + /// IWitsmlQueryType + void LogQuery(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, + string xmlReceived, short resultCode, string suppMsgOut) where T : IWitsmlQueryType; } public class DefaultQueryLogger : IQueryLogger @@ -19,7 +38,8 @@ public DefaultQueryLogger() .CreateLogger(); } - public void LogQuery(string querySent, bool isSuccessful, string xmlReceived = null) + public void LogQuery(string function, Uri serverUrl, T query, OptionsIn optionsIn, string querySent, bool isSuccessful, + string xmlReceived, short resultCode, string suppMsgOut) where T : IWitsmlQueryType { if (xmlReceived != null) { diff --git a/Src/Witsml/WitsmlClient.cs b/Src/Witsml/WitsmlClient.cs index 8dc8378fc..5424a45b4 100644 --- a/Src/Witsml/WitsmlClient.cs +++ b/Src/Witsml/WitsmlClient.cs @@ -125,7 +125,9 @@ private async Task GetFromStoreInnerAsync(T query, OptionsIn optionsIn) wh }; WMLS_GetFromStoreResponse response = await _client.WMLS_GetFromStoreAsync(request); - LogQueriesSentAndReceived(request.QueryIn, response.IsSuccessful(), response.XMLout); + + LogQueriesSentAndReceived(nameof(_client.WMLS_GetFromStoreAsync), this._serverUrl, query, optionsIn, request.QueryIn, + response.IsSuccessful(), response.XMLout, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) return XmlHelper.Deserialize(response.XMLout, query); @@ -155,7 +157,9 @@ private async Task GetFromStoreInnerAsync(T query, OptionsIn optionsIn) wh }; WMLS_GetFromStoreResponse response = await _client.WMLS_GetFromStoreAsync(request); - LogQueriesSentAndReceived(request.QueryIn, response.IsSuccessful(), response.XMLout); + + LogQueriesSentAndReceived(nameof(_client.WMLS_GetFromStoreAsync), this._serverUrl, query, optionsIn, + request.QueryIn, response.IsSuccessful(), response.XMLout, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) return (XmlHelper.Deserialize(response.XMLout, query), response.Result); @@ -211,7 +215,9 @@ public async Task AddToStoreAsync(T query) where T : IWitsmlQuer }; WMLS_AddToStoreResponse response = await _client.WMLS_AddToStoreAsync(request); - LogQueriesSentAndReceived(request.XMLin, response.IsSuccessful()); + + LogQueriesSentAndReceived(nameof(_client.WMLS_AddToStoreAsync), this._serverUrl, query, optionsIn, + request.XMLin, response.IsSuccessful(), null, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) return new QueryResult(true); @@ -241,7 +247,9 @@ public async Task UpdateInStoreAsync(T query) where T : IWitsmlQ }; WMLS_UpdateInStoreResponse response = await _client.WMLS_UpdateInStoreAsync(request); - LogQueriesSentAndReceived(request.XMLin, response.IsSuccessful()); + + LogQueriesSentAndReceived(nameof(_client.WMLS_UpdateInStoreAsync), this._serverUrl, query, null, + request.XMLin, response.IsSuccessful(), null, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) return new QueryResult(true); @@ -271,7 +279,9 @@ public async Task DeleteFromStoreAsync(T query) where T : IWitsm }; WMLS_DeleteFromStoreResponse response = await _client.WMLS_DeleteFromStoreAsync(request); - LogQueriesSentAndReceived(request.QueryIn, response.IsSuccessful()); + + LogQueriesSentAndReceived(nameof(_client.WMLS_DeleteFromStoreAsync), this._serverUrl, query, null, + request.QueryIn, response.IsSuccessful(), null, response.Result, response.SuppMsgOut); if (response.IsSuccessful()) return new QueryResult(true); @@ -304,9 +314,10 @@ public async Task TestConnectionAsync() return new QueryResult(true); } - private void LogQueriesSentAndReceived(string querySent, bool isSuccessful, string xmlReceived = null) + private void LogQueriesSentAndReceived(string function, Uri serverUrl, T query, OptionsIn optionsIn, + string querySent, bool isSuccessful, string xmlReceived, short resultCode, string suppMsgOut = null) where T : IWitsmlQueryType { - _queryLogger?.LogQuery(querySent, isSuccessful, xmlReceived); + _queryLogger?.LogQuery(function, serverUrl, query, optionsIn, querySent, isSuccessful, xmlReceived, resultCode, suppMsgOut); } public Uri GetServerHostname() => _serverUrl; diff --git a/Src/WitsmlExplorer.Api/Configuration/ServerCredentials.cs b/Src/WitsmlExplorer.Api/Configuration/ServerCredentials.cs index 0b8aba3eb..d0c1bc0c2 100644 --- a/Src/WitsmlExplorer.Api/Configuration/ServerCredentials.cs +++ b/Src/WitsmlExplorer.Api/Configuration/ServerCredentials.cs @@ -1,4 +1,7 @@ using System; + +using Witsml.Extensions; + namespace WitsmlExplorer.Api.Configuration { public class ServerCredentials : ICredentials, IEquatable @@ -26,9 +29,9 @@ public bool IsCredsNullOrEmpty() } public bool Equals(ServerCredentials other) { - return (Host.ToString() == other.Host.ToString()) && - (UserId == other.UserId) && - (Password == other.Password); + return (Host.EqualsIgnoreCase(other.Host)) && + (UserId == other.UserId) && + (Password == other.Password); } public override bool Equals(object obj) diff --git a/Src/WitsmlExplorer.Api/Jobs/CreateTrajectoryJob.cs b/Src/WitsmlExplorer.Api/Jobs/CreateTrajectoryJob.cs new file mode 100644 index 000000000..2ba40901a --- /dev/null +++ b/Src/WitsmlExplorer.Api/Jobs/CreateTrajectoryJob.cs @@ -0,0 +1,50 @@ +using WitsmlExplorer.Api.Models; + +namespace WitsmlExplorer.Api.Jobs; + +/// +/// Job for create trajectory with jobInfo. +/// +public record CreateTrajectoryJob : Job +{ + /// + /// Trajectory API model. + /// + public Trajectory Trajectory { get; init; } + + /// + /// Getting description of created trajectory. + /// + /// String of job info which provide Trajectory Uid and Name, WellUid, WellboreUid. + public override string Description() + { + return $"Create Trajectory - Uid: {Trajectory.Uid}; Name: {Trajectory.Name}; WellUid: {Trajectory.WellUid}; WellboreUid: {Trajectory.WellboreUid};"; + } + + /// + /// Getting name of trajectory. + /// + /// String of trajectory name. + public override string GetObjectName() + { + return Trajectory.Name; + } + + /// + /// Getting name of wellbore. + /// + /// String of wellbore name. + public override string GetWellboreName() + { + return Trajectory.WellboreName; + } + + /// + /// Getting name of well. + /// + /// String of well name. + public override string GetWellName() + { + return Trajectory.WellName; + } +} diff --git a/Src/WitsmlExplorer.Api/Models/JobType.cs b/Src/WitsmlExplorer.Api/Models/JobType.cs index 50557921f..82e2f2f29 100644 --- a/Src/WitsmlExplorer.Api/Models/JobType.cs +++ b/Src/WitsmlExplorer.Api/Models/JobType.cs @@ -37,6 +37,7 @@ public enum JobType CreateRisk, CreateMudLog, CreateRig, + CreateTrajectory, CreateWbGeometry, BatchModifyWell, ImportLogData, diff --git a/Src/WitsmlExplorer.Api/Models/Trajectory.cs b/Src/WitsmlExplorer.Api/Models/Trajectory.cs index 313220934..da8eb9bbf 100644 --- a/Src/WitsmlExplorer.Api/Models/Trajectory.cs +++ b/Src/WitsmlExplorer.Api/Models/Trajectory.cs @@ -6,14 +6,14 @@ namespace WitsmlExplorer.Api.Models { public class Trajectory : ObjectOnWellbore { - public decimal? MdMin { get; internal init; } - public decimal? MdMax { get; internal init; } - public string AziRef { get; internal init; } - public string DTimTrajStart { get; internal init; } - public string DTimTrajEnd { get; internal init; } - public List TrajectoryStations { get; internal init; } - public string ServiceCompany { get; internal init; } - public string DateTimeCreation { get; internal init; } - public string DateTimeLastChange { get; internal init; } + public decimal? MdMin { get; init; } + public decimal? MdMax { get; init; } + public string AziRef { get; init; } + public string DTimTrajStart { get; init; } + public string DTimTrajEnd { get; init; } + public List TrajectoryStations { get; init; } + public string ServiceCompany { get; init; } + public string DateTimeCreation { get; init; } + public string DateTimeLastChange { get; init; } } } diff --git a/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs b/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs index 87afdf3f1..b78ced85f 100644 --- a/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs +++ b/Src/WitsmlExplorer.Api/Query/TrajectoryQueries.cs @@ -90,6 +90,34 @@ public static WitsmlTrajectories DeleteTrajectoryStations(string wellUid, string }.AsSingletonList() }; } + + /// + /// Create trajectories witsml model. + /// + /// API model of trajectory data. + /// New instance of WitsmlTrajectories model with added trajectory data. + public static WitsmlTrajectories CreateTrajectory(Trajectory trajectory) + { + return new() + { + Trajectories = new WitsmlTrajectory + { + UidWell = trajectory.WellUid, + NameWell = trajectory.WellName, + NameWellbore = trajectory.WellboreName, + Uid = trajectory.Uid, + Name = trajectory.Name, + UidWellbore = trajectory.WellboreUid, + MdMin = trajectory.MdMin != null ? new WitsmlMeasuredDepthCoord() { Value = trajectory.MdMin.Value.ToString(CultureInfo.InvariantCulture) } : null, + MdMax = trajectory.MdMax != null ? new WitsmlMeasuredDepthCoord() { Value = trajectory.MdMax.Value.ToString(CultureInfo.InvariantCulture) } : null, + AziRef = trajectory.AziRef.NullIfEmpty(), + ServiceCompany = trajectory.ServiceCompany.NullIfEmpty(), + DTimTrajStart = StringHelpers.ToUniversalDateTimeString(trajectory.DTimTrajStart), + DTimTrajEnd = StringHelpers.ToUniversalDateTimeString(trajectory.DTimTrajEnd), + }.AsSingletonList() + }; + } + public static WitsmlTrajectories UpdateTrajectoryStation(TrajectoryStation trajectoryStation, ObjectReference trajectoryReference) { WitsmlTrajectoryStation ts = new() diff --git a/Src/WitsmlExplorer.Api/Services/CredentialsService.cs b/Src/WitsmlExplorer.Api/Services/CredentialsService.cs index 9b60391ff..ad35a3ca8 100644 --- a/Src/WitsmlExplorer.Api/Services/CredentialsService.cs +++ b/Src/WitsmlExplorer.Api/Services/CredentialsService.cs @@ -13,6 +13,7 @@ using Witsml; using Witsml.Data; +using Witsml.Extensions; using WitsmlExplorer.Api.Configuration; using WitsmlExplorer.Api.Extensions; @@ -87,8 +88,8 @@ private async Task UserHasRoleForHost(string[] roles, Uri host) bool result = true; IEnumerable allServers = await _allServers; - bool systemCredsExists = _witsmlServerCredentials.WitsmlCreds.Any(n => n.Host == host); - IEnumerable hostServer = allServers.Where(n => n.Url.ToString() == host.ToString()); + bool systemCredsExists = _witsmlServerCredentials.WitsmlCreds.Any(n => n.Host.EqualsIgnoreCase(host)); + IEnumerable hostServer = allServers.Where(n => n.Url.EqualsIgnoreCase(host)); bool validRole = hostServer.Any(n => n.Roles != null && n.Roles.Intersect(roles).Any() ); @@ -139,7 +140,7 @@ private async Task GetSystemCredentialsByToken(string token, _logger.LogDebug("User roles in JWT: {roles}", string.Join(",", userRoles)); if (await UserHasRoleForHost(userRoles, server)) { - result = _witsmlServerCredentials.WitsmlCreds.Single(n => n.Host == server); + result = _witsmlServerCredentials.WitsmlCreds.Single(n => n.Host.EqualsIgnoreCase(server)); if (!result.IsCredsNullOrEmpty()) { CacheCredentials(GetClaimFromToken(token, SUBJECT), result, 1.0); diff --git a/Src/WitsmlExplorer.Api/Workers/Copy/CopyLogDataWorker.cs b/Src/WitsmlExplorer.Api/Workers/Copy/CopyLogDataWorker.cs index a3d3d62bf..7d1dfbed4 100644 --- a/Src/WitsmlExplorer.Api/Workers/Copy/CopyLogDataWorker.cs +++ b/Src/WitsmlExplorer.Api/Workers/Copy/CopyLogDataWorker.cs @@ -41,8 +41,8 @@ public CopyLogDataWorker(IWitsmlClientProvider witsmlClientProvider, ILogger servers = _witsmlServerRepository == null ? new List() : await _witsmlServerRepository.GetDocumentsAsync(); - int targetDepthLogDecimals = servers.FirstOrDefault((server) => server.Url == targetHostname)?.DepthLogDecimals ?? 0; - int sourceDepthLogDecimals = servers.FirstOrDefault((server) => server.Url == sourceHostname)?.DepthLogDecimals ?? 0; + int targetDepthLogDecimals = servers.FirstOrDefault((server) => server.Url.EqualsIgnoreCase(targetHostname))?.DepthLogDecimals ?? 0; + int sourceDepthLogDecimals = servers.FirstOrDefault((server) => server.Url.EqualsIgnoreCase(sourceHostname))?.DepthLogDecimals ?? 0; (WitsmlLog sourceLog, WitsmlLog targetLog) = await GetLogs(job); List mnemonicsToCopy = job.Source.ComponentUids.Any() diff --git a/Src/WitsmlExplorer.Api/Workers/Create/CreateTrajectoryWorker.cs b/Src/WitsmlExplorer.Api/Workers/Create/CreateTrajectoryWorker.cs new file mode 100644 index 000000000..a10481a6f --- /dev/null +++ b/Src/WitsmlExplorer.Api/Workers/Create/CreateTrajectoryWorker.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using Witsml; +using Witsml.Data; + +using WitsmlExplorer.Api.Jobs; +using WitsmlExplorer.Api.Models; +using WitsmlExplorer.Api.Query; +using WitsmlExplorer.Api.Services; + +namespace WitsmlExplorer.Api.Workers.Create; + +/// +/// Worker for creating new trajectory by specific well and wellbore. +/// +public class CreateTrajectoryWorker : BaseWorker, IWorker +{ + public CreateTrajectoryWorker(ILogger logger, IWitsmlClientProvider witsmlClientProvider) : base(witsmlClientProvider, logger) { } + public JobType JobType => JobType.CreateTrajectory; + + /// + /// Create new trajectory on wellbore for witsml client. + /// + /// Job info of created trajectory. + /// Task of workerResult with refresh objects. + public override async Task<(WorkerResult, RefreshAction)> Execute(CreateTrajectoryJob job) + { + Verify(job.Trajectory); + + WitsmlTrajectories trajectoryToCreate = TrajectoryQueries.CreateTrajectory(job.Trajectory); + + QueryResult addToStoreResult = await GetTargetWitsmlClientOrThrow().AddToStoreAsync(trajectoryToCreate); + + if (!addToStoreResult.IsSuccessful) + { + string errorMessage = "Failed to create trajectory."; + Logger.LogError("{ErrorMessage}. {jobDescription}", errorMessage, job.Description()); + return (new WorkerResult(GetTargetWitsmlClientOrThrow().GetServerHostname(), false, errorMessage, addToStoreResult.Reason), null); + } + + Logger.LogInformation("Trajectory created. {jobDescription}", job.Description()); + RefreshObjects refreshAction = new(GetTargetWitsmlClientOrThrow().GetServerHostname(), job.Trajectory.WellUid, job.Trajectory.WellboreUid, EntityType.Trajectory); + WorkerResult workerResult = new(GetTargetWitsmlClientOrThrow().GetServerHostname(), true, $"Trajectory {job.Trajectory.Name} add for {job.Trajectory.WellboreName}"); + + return (workerResult, refreshAction); + } + + private static void Verify(Trajectory trajectory) + { + if (string.IsNullOrEmpty(trajectory.Uid)) + { + throw new InvalidOperationException($"{nameof(trajectory.Uid)} cannot be empty"); + } + + if (string.IsNullOrEmpty(trajectory.Name)) + { + throw new InvalidOperationException($"{nameof(trajectory.Name)} cannot be empty"); + } + } +} diff --git a/Src/WitsmlExplorer.Frontend/components/Alerts.tsx b/Src/WitsmlExplorer.Frontend/components/Alerts.tsx index 7c6db8734..d2695ff11 100644 --- a/Src/WitsmlExplorer.Frontend/components/Alerts.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Alerts.tsx @@ -5,8 +5,9 @@ import { capitalize } from "lodash"; import React, { useContext, useEffect, useState } from "react"; import styled from "styled-components"; import NavigationContext from "../contexts/navigationContext"; +import OperationContext from "../contexts/operationContext"; import NotificationService from "../services/notificationService"; -import { colors } from "../styles/Colors"; +import { Colors } from "../styles/Colors"; interface AlertState { severity?: AlertSeverity; @@ -18,6 +19,9 @@ export type AlertSeverity = "error" | "info" | "success" | "warning"; const Alerts = (): React.ReactElement => { const [alert, setAlert] = useState(null); const { navigationState } = useContext(NavigationContext); + const { + operationState: { colors } + } = useContext(OperationContext); useEffect(() => { const unsubscribeOnConnectionStateChanged = NotificationService.Instance.onConnectionStateChanged.subscribe((connected) => { @@ -28,7 +32,7 @@ const Alerts = (): React.ReactElement => { } }); const unsubscribeOnJobFinished = NotificationService.Instance.alertDispatcherAsEvent.subscribe((notification) => { - const shouldNotify = notification.serverUrl == null || notification.serverUrl.toString() === navigationState.selectedServer?.url; + const shouldNotify = notification.serverUrl == null || notification.serverUrl.toString().toLowerCase() === navigationState.selectedServer?.url?.toLowerCase(); if (!shouldNotify) { return; } @@ -67,7 +71,7 @@ const Alerts = (): React.ReactElement => { return ( - + { ); }; -const AlertContainer = styled.div` +const AlertContainer = styled.div<{ colors: Colors }>` & .MuiAlert-root { - background-color: ${colors.ui.backgroundDefault}; + background-color: ${(props) => props.colors.ui.backgroundDefault}; + color: ${(props) => props.colors.text.staticIconsDefault}; } & .MuiAlert-action { align-items: start; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx index 60b6da8a6..bd8bdce31 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/CurveValuesView.tsx @@ -1,4 +1,4 @@ -import { Button, LinearProgress } from "@material-ui/core"; +import { Button } from "@material-ui/core"; import orderBy from "lodash/orderBy"; import React, { useCallback, useContext, useEffect, useState } from "react"; import styled from "styled-components"; @@ -6,28 +6,18 @@ import NavigationContext from "../../contexts/navigationContext"; import OperationContext from "../../contexts/operationContext"; import OperationType from "../../contexts/operationType"; import useExport from "../../hooks/useExport"; -import { DeleteLogCurveValuesJob } from "../../models/jobs/deleteLogCurveValuesJob"; +import { DeleteLogCurveValuesJob, IndexRange } from "../../models/jobs/deleteLogCurveValuesJob"; import { CurveSpecification, LogData, LogDataRow } from "../../models/logData"; -import LogObject from "../../models/logObject"; +import LogObject, { indexToNumber } from "../../models/logObject"; import { toObjectReference } from "../../models/objectOnWellbore"; import { truncateAbortHandler } from "../../services/apiClient"; import LogObjectService from "../../services/logObjectService"; import { getContextMenuPosition } from "../ContextMenus/ContextMenu"; import MnemonicsContextMenu from "../ContextMenus/MnemonicsContextMenu"; +import ProgressSpinner from "../ProgressSpinner"; import EditInterval from "./EditInterval"; import { LogCurveInfoRow } from "./LogCurveInfoListView"; -import { - ContentTableColumn, - ContentTableRow, - ExportableContentTableColumn, - Order, - VirtualizedContentTable, - calculateProgress, - getColumnType, - getComparatorByColumn, - getIndexRanges, - getProgressRange -} from "./table"; +import { ContentTable, ContentTableColumn, ContentTableRow, ContentType, ExportableContentTableColumn, Order } from "./table"; interface CurveValueRow extends LogDataRow, ContentTableRow {} @@ -38,7 +28,6 @@ export const CurveValuesView = (): React.ReactElement => { const [columns, setColumns] = useState[]>([]); const [tableData, setTableData] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [progress, setProgress] = useState(0); const [selectedRows, setSelectedRows] = useState([]); const selectedLog = selectedObject as LogObject; const { exportData, exportOptions } = useExport(); @@ -55,10 +44,6 @@ export const CurveValuesView = (): React.ReactElement => { return deleteLogCurveValuesJob; }; - const rowSelectionCallback = useCallback((rows: ContentTableRow[], sortOrder: Order, sortedColumn: ContentTableColumn) => { - setSelectedRows(orderBy([...rows.map((row) => row as CurveValueRow)], getComparatorByColumn(sortedColumn), [sortOrder, sortOrder])); - }, []); - const exportSelectedIndexRange = useCallback(() => { const exportColumns = columns.map((column) => `${column.columnOf.mnemonic}[${column.columnOf.unit}]`).join(exportOptions.separator); const data = orderBy(tableData, getComparatorByColumn(columns[0]), [Order.Ascending, Order.Ascending]) //Sorted because order is important when importing data @@ -106,40 +91,32 @@ export const CurveValuesView = (): React.ReactElement => { async function getLogData() { const mnemonics = selectedLogCurveInfo.map((lci) => lci.mnemonic); - let startIndex = String(selectedLogCurveInfo[0].minIndex); + const startIndex = String(selectedLogCurveInfo[0].minIndex); const endIndex = String(selectedLogCurveInfo[0].maxIndex); - const { minIndex, maxIndex } = getProgressRange(startIndex, endIndex, selectedLog.indexType); let completeData: CurveValueRow[] = []; - let fetchData = true; - while (fetchData) { - const logData: LogData = await LogObjectService.getLogData( - selectedWell.uid, - selectedWellbore.uid, - selectedLog.uid, - mnemonics, - completeData.length === 0, - startIndex, - endIndex, - controller.signal - ); - if (logData && logData.data) { - setProgress(calculateProgress(logData.endIndex, minIndex, maxIndex, selectedLog.indexType)); - updateColumns(logData.curveSpecifications); - - const logDataRows = logData.data.map((data, index) => { - const row: CurveValueRow = { - id: completeData.length + index, - ...data - }; - return row; - }); - completeData = [...completeData, ...logDataRows]; - setTableData(completeData); - startIndex = logData.endIndex; - } else { - fetchData = false; - } + const logData: LogData = await LogObjectService.getLogData( + selectedWell.uid, + selectedWellbore.uid, + selectedLog.uid, + mnemonics, + completeData.length === 0, + startIndex, + endIndex, + controller.signal + ); + if (logData && logData.data) { + updateColumns(logData.curveSpecifications); + + const logDataRows = logData.data.map((data, index) => { + const row: CurveValueRow = { + id: completeData.length + index, + ...data + }; + return row; + }); + completeData = [...completeData, ...logDataRows]; + setTableData(completeData); } } @@ -155,6 +132,7 @@ export const CurveValuesView = (): React.ReactElement => { }, [selectedLogCurveInfo, selectedLog]); const panelElements = [ + , , @@ -164,32 +142,80 @@ export const CurveValuesView = (): React.ReactElement => { ]; return ( - - {isLoading && } + <> + {isLoading && } {!isLoading && !tableData.length && No data} {Boolean(columns.length) && Boolean(tableData.length) && ( <> - - setSelectedRows(rows as CurveValueRow[])} onContextMenu={onContextMenu} data={tableData} checkableRows={true} panelElements={panelElements} + stickyLeftColumns={2} /> )} - + ); }; - -const Container = styled.div` - height: calc(100% - 65px); - width: calc(100% - 14px); -`; - const Message = styled.div` margin: 10px; padding: 10px; `; + +const getIndexRanges = (checkedContentItems: ContentTableRow[], selectedLog: LogObject): IndexRange[] => { + const sortedItems = checkedContentItems.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const indexCurve = selectedLog.indexCurve; + + return sortedItems.reduce((accumulator: IndexRange[], currentElement: any, currentIndex) => { + const currentId = currentElement["id"]; + const indexValue = String(currentElement[indexCurve]); + + if (accumulator.length === 0) { + accumulator.push({ startIndex: indexValue, endIndex: indexValue }); + } else { + const inSameRange = currentId - sortedItems[currentIndex - 1].id === 1; + if (inSameRange) { + accumulator[accumulator.length - 1].endIndex = indexValue; + } else { + accumulator.push({ startIndex: indexValue, endIndex: indexValue }); + } + } + return accumulator; + }, []); +}; + +const getComparatorByColumn = (column: ContentTableColumn): [(row: any) => any, string] => { + let comparator; + switch (column.type) { + case ContentType.Number: + comparator = (row: any): number => Number(row[column.property]); + break; + case ContentType.Measure: + comparator = (row: any): number => Number(indexToNumber(row[column.property])); + break; + default: + comparator = (row: any): string => row[column.property]; + break; + } + return [comparator, column.property]; +}; + +const getColumnType = (curveSpecification: CurveSpecification) => { + const isTimeMnemonic = (mnemonic: string) => ["time", "datetime", "date time"].indexOf(mnemonic.toLowerCase()) >= 0; + if (isTimeMnemonic(curveSpecification.mnemonic)) { + return ContentType.DateTime; + } + switch (curveSpecification.unit.toLowerCase()) { + case "time": + case "datetime": + return ContentType.DateTime; + case "unitless": + return ContentType.String; + default: + return ContentType.Number; + } +}; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx index a920cd993..b6cc19177 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/EditInterval.tsx @@ -87,6 +87,7 @@ const StyledTextField = styled(TextField)` div { background-color: transparent; } + min-width: 210px; `; const StyledButton = styled(Button)` @@ -110,6 +111,8 @@ const StyledButton = styled(Button)` display:flex; height: 2rem; width: 2rem; + min-height: 2rem; + min-width: 2rem; padding: 0; border-radius: 50%; align-items: center; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx index e62655855..d0ecf49da 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/JobsView.tsx @@ -47,7 +47,7 @@ export const JobsView = (): React.ReactElement => { useEffect(() => { const eventHandler = (notification: Notification) => { - const shouldFetch = notification.serverUrl.toString() === navigationState.selectedServer?.url; + const shouldFetch = notification.serverUrl.toString().toLowerCase() === navigationState.selectedServer?.url?.toLowerCase(); if (shouldFetch) { setShouldRefresh(true); } @@ -161,7 +161,7 @@ const serverUrlToName = (servers: Server[], url: string): string => { if (!url) { return "-"; } - const server = servers.find((server) => server.url == url); + const server = servers.find((server) => server.url.toLowerCase() == url.toLowerCase()); return server ? server.name : url; }; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/ContentTable.tests.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/ContentTable.tests.tsx index 3b2ad6d1a..7c2fa3f75 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/ContentTable.tests.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/ContentTable.tests.tsx @@ -32,7 +32,7 @@ describe("", () => { it("Should render a plain table", () => { const { container } = renderWithContexts(); expect(container.querySelectorAll("table")).toHaveLength(1); - expect(container.querySelectorAll("th")).toHaveLength(columns.length); + expect(container.querySelectorAll("th")).toHaveLength(columns.length + 2); data.forEach((element) => { expect(container.querySelector("tbody")).toHaveTextContent(element.name); expect(container.querySelector("tbody")).toHaveTextContent(element.field); @@ -42,15 +42,15 @@ describe("", () => { it("Should have sortable columns", () => { const { container } = renderWithContexts(); let firstRow = container.querySelector("tbody").querySelector("tr"); - expect(firstRow.querySelector("td")).toHaveTextContent(data[0].name); + expect(firstRow.querySelectorAll("td")[1]).toHaveTextContent(data[0].name); fireEvent.click(screen.queryAllByRole("button")[0]); firstRow = container.querySelector("tbody").querySelector("tr"); - expect(firstRow.querySelector("td")).toHaveTextContent(data[0].name); + expect(firstRow.querySelectorAll("td")[1]).toHaveTextContent(data[0].name); fireEvent.click(screen.queryAllByRole("button")[0]); firstRow = container.querySelector("tbody").querySelector("tr"); - expect(firstRow.querySelector("td")).toHaveTextContent(data[1].name); + expect(firstRow.querySelectorAll("td")[1]).toHaveTextContent(data[1].name); }); it("Should be possible to select single rows", () => { @@ -62,7 +62,7 @@ describe("", () => { }; const { container } = renderWithContexts(); const rowToSelect = 1; - const cellToClick = container.querySelector("tbody").querySelectorAll("tr")[rowToSelect].children[0]; + const cellToClick = container.querySelector("tbody").querySelectorAll("tr")[rowToSelect].children[1]; fireEvent.click(cellToClick); expect(selections).toBe(1); expect(selectedRow).toBe(data[rowToSelect]); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/VirtualizedContentTable.tests.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/VirtualizedContentTable.tests.tsx deleted file mode 100644 index 8f4f05292..000000000 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/VirtualizedContentTable.tests.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import LogObject from "../../../models/logObject"; -import { getIndexRanges } from "../table"; - -const logObject: LogObject = { - uid: "", - name: "", - wellUid: "", - wellName: "", - wellboreUid: "", - wellboreName: "", - indexType: "", - startIndex: "", - endIndex: "", - indexCurve: "DEP" -}; - -const checkedRows: any[] = [ - { id: 7, DEP: 70, MNEM: 700 }, - { id: 2, DEP: 20, MNEM: 200 }, - { id: 3, DEP: 30, MNEM: 300 }, - { id: 1, DEP: 10, MNEM: 100 }, - { id: 6, DEP: 60, MNEM: 600 }, - { id: 8, DEP: 80, MNEM: 800 } -]; - -describe("VirtualizedContentTable - getIndexRanges", () => { - it("Return two indexRange....", () => { - const range = getIndexRanges(checkedRows, logObject); - expect(range).toHaveLength(2); - expect(range[0].startIndex).toBe("10"); - expect(range[0].endIndex).toBe("30"); - expect(range[1].startIndex).toBe("60"); - expect(range[1].endIndex).toBe("80"); - }); -}); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/tableParts.tests.ts b/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/tableParts.tests.ts deleted file mode 100644 index 2fa3e4406..000000000 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/__tests__/tableParts.tests.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ContentTableRow, getRowsInRange, updateCheckedRows } from "../table/tableParts"; - -describe("Table parts - getRowsInRange", () => { - it("Selecting a single row", () => { - const rows: ContentTableRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - const indexRange = [2, 2]; - const range = getRowsInRange(rows, indexRange); - expect(range).toHaveLength(1); - expect(range[0].id).toBe(3); - }); - - it("Selecting an interval with lower start than end", () => { - const rows: ContentTableRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - const indexRange = [0, 2]; - const range = getRowsInRange(rows, indexRange); - expect(range).toHaveLength(3); - expect(range[0].id).toBe(1); - expect(range[1].id).toBe(2); - expect(range[2].id).toBe(3); - }); - - it("Selecting an interval with higher start index than end index", () => { - const rows: ContentTableRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - const indexRange = [2, 0]; - const range = getRowsInRange(rows, indexRange); - expect(range).toHaveLength(2); - expect(range).toContainEqual({ id: 1 }); - expect(range).toContainEqual({ id: 2 }); - }); -}); - -describe("Table parts - updateCheckedRows", () => { - it("Unchecking range", () => { - const checkedRows: ContentTableRow[] = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }]; - const rangeToToggle: ContentTableRow[] = [{ id: 2 }, { id: 3 }, { id: 4 }]; - const updatedRows = updateCheckedRows(checkedRows, rangeToToggle, false); - expect(updatedRows).toHaveLength(4); - expect(updatedRows).toContainEqual({ id: 1 }); - expect(updatedRows).toContainEqual({ id: 5 }); - expect(updatedRows).toContainEqual({ id: 6 }); - expect(updatedRows).toContainEqual({ id: 7 }); - }); - - it("Checking range", () => { - const checkedRows: ContentTableRow[] = [{ id: 5 }]; - const rangeToToggle: ContentTableRow[] = [{ id: 2 }, { id: 3 }, { id: 4 }]; - const updatedRows = updateCheckedRows(checkedRows, rangeToToggle, true); - expect(updatedRows).toHaveLength(4); - expect(updatedRows).toContainEqual({ id: 2 }); - expect(updatedRows).toContainEqual({ id: 3 }); - expect(updatedRows).toContainEqual({ id: 4 }); - expect(updatedRows).toContainEqual({ id: 5 }); - }); -}); diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx index 6df161010..460abb3bd 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnDef.tsx @@ -14,7 +14,7 @@ declare module "@tanstack/react-table" { } } -export const useColumnDef = (viewId: string, columns: ContentTableColumn[], insetColumns: ContentTableColumn[], checkableRows: boolean) => { +export const useColumnDef = (viewId: string, columns: ContentTableColumn[], insetColumns: ContentTableColumn[], checkableRows: boolean, stickyLeftColumns: number) => { const isCompactMode = useTheme().props.MuiCheckbox?.size === "small"; return useMemo(() => { @@ -26,7 +26,7 @@ export const useColumnDef = (viewId: string, columns: ContentTableColumn[], inse header: column.label, size: savedWidths ? savedWidths[column.label] : calculateColumnWidth(column.label, isCompactMode, column.type), meta: { type: column.type }, - sortingFn: column.type == ContentType.Measure ? measureSortingFn : "text", + sortingFn: getSortingFn(column.type), ...addComponentCell(column.type), ...addActiveCurveFiltering(column.label) }; @@ -43,6 +43,10 @@ export const useColumnDef = (viewId: string, columns: ContentTableColumn[], inse } columnDef = [...(checkableRows ? [getCheckableRowsColumnDef(isCompactMode)] : []), ...(insetColumns ? [getExpanderColumnDef(isCompactMode)] : []), ...columnDef]; + const firstToggleableIndex = Math.max((checkableRows ? 1 : 0) + (insetColumns ? 1 : 0), stickyLeftColumns); + for (let i = 0; i < firstToggleableIndex; i++) { + columnDef[i].enableHiding = false; + } return columnDef; }, [columns]); }; @@ -123,3 +127,12 @@ const getCheckableRowsColumnDef = (isCompactMode: boolean): ColumnDef ) }; }; + +const getSortingFn = (contentType: ContentType) => { + if (contentType == ContentType.Measure) { + return measureSortingFn; + } else if (contentType == ContentType.Number) { + return "alphanumeric"; + } + return "text"; +}; diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx index 441dc6a86..c4c87b9fa 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ColumnOptionsMenu.tsx @@ -17,9 +17,10 @@ export const ColumnOptionsMenu = (props: { expandableRows: boolean; viewId: string; columns: ContentTableColumn[]; + stickyLeftColumns: number; }): React.ReactElement => { - const { table, checkableRows, expandableRows, viewId, columns } = props; - const firstToggleableIndex = (checkableRows ? 1 : 0) + (expandableRows ? 1 : 0); + const { table, checkableRows, expandableRows, viewId, columns, stickyLeftColumns } = props; + const firstToggleableIndex = Math.max((checkableRows ? 1 : 0) + (expandableRows ? 1 : 0), stickyLeftColumns); const { operationState: { colors } } = useContext(OperationContext); @@ -110,7 +111,8 @@ export const ColumnOptionsMenu = (props: { {table.getAllLeafColumns().map((column, index) => { return ( column.id != selectId && - column.id != expanderId && ( + column.id != expanderId && + index >= stickyLeftColumns && ( onMoveUp(column.id)} disabled={index == firstToggleableIndex}> diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx index 5f16e2256..e3e915ea5 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/table/ContentTable.tsx @@ -1,5 +1,6 @@ import { TableBody, TableHead, useTheme } from "@material-ui/core"; import { + ColumnSizingState, Header, Row, RowData, @@ -12,9 +13,9 @@ import { getSortedRowModel, useReactTable } from "@tanstack/react-table"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"; import * as React from "react"; -import { Fragment, useContext, useState } from "react"; +import { Fragment, useContext, useEffect, useState } from "react"; import OperationContext from "../../../contexts/operationContext"; import { indexToNumber } from "../../../models/logObject"; import { Colors } from "../../../styles/Colors"; @@ -24,6 +25,7 @@ import Panel from "./Panel"; import { initializeColumnVisibility, useStoreVisibilityEffect, useStoreWidthsEffect } from "./contentTableStorage"; import { StyledResizer, StyledTable, StyledTd, StyledTh, StyledTr, TableContainer } from "./contentTableStyles"; import { + calculateHorizontalSpace, calculateRowHeight, componentSortingFn, constantTableOptions, @@ -56,9 +58,10 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE panelElements, showPanel = true, showRefresh = false, - stickyLeftColumns = false, + stickyLeftColumns = 0, + viewId, downloadToCsvFileName = null, - viewId + onRowSelectionChange } = contentTableProps; const { operationState: { colors } @@ -66,17 +69,19 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE const [previousIndex, setPreviousIndex] = useState(null); const [rowSelection, setRowSelection] = useState({}); const [columnVisibility, setColumnVisibility] = useState(initializeColumnVisibility(viewId)); + const [columnSizing, setColumnSizing] = useState({}); const isCompactMode = useTheme().props.MuiCheckbox?.size === "small"; const cellHeight = isCompactMode ? 30 : 53; const headCellHeight = isCompactMode ? 35 : 55; - const columnDef = useColumnDef(viewId, columns, insetColumns, checkableRows); + const columnDef = useColumnDef(viewId, columns, insetColumns, checkableRows, stickyLeftColumns); const table = useReactTable({ data: data ?? [], columns: columnDef, state: { rowSelection, - columnVisibility + columnVisibility, + columnSizing }, sortingFns: { [measureSortingFn]: (rowA: Row, rowB: Row, columnId: string) => { @@ -96,10 +101,11 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE getFilteredRowModel: getFilteredRowModel(), getRowCanExpand: insetColumns != null ? (row) => !!row.original.inset?.length : undefined, onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, onRowSelectionChange: (updaterOrValue) => { const newRowSelection = updaterOrValue instanceof Function ? updaterOrValue(rowSelection) : updaterOrValue; setRowSelection(newRowSelection); - // call onRowSelectionChange here with original rows filtered on newRowSelection once VirtualizedContentTable is replaced + onRowSelectionChange?.(data.filter((_, index) => newRowSelection[index])); }, meta: { previousIndex, @@ -124,6 +130,24 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE estimateSize: () => cellHeight }); + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: table.getVisibleLeafColumns().length, + getScrollElement: () => tableContainerRef.current, + estimateSize: (index: number) => table.getLeafHeaders()[index].getSize(), + overscan: 5, + rangeExtractor: (range) => { + return [...Array.from(Array(stickyLeftColumns).keys()), ...defaultRangeExtractor(range).filter((value) => value >= stickyLeftColumns)]; + } + }); + + useEffect(() => { + columnVirtualizer.measure(); + }, [columnSizing]); + + const columnItems = columnVirtualizer.getVirtualItems(); + const [spaceLeft, spaceRight] = calculateHorizontalSpace(columnItems, columnVirtualizer.getTotalSize(), stickyLeftColumns); + const onHeaderClick = (e: React.MouseEvent, header: Header) => { if (header.column.getCanSort()) { header.column.getToggleSortingHandler()(e); @@ -165,6 +189,7 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE expandableRows={insetColumns != null} showRefresh={showRefresh} downloadToCsvFileName={downloadToCsvFileName} + stickyLeftColumns={stickyLeftColumns} /> ) : null}
@@ -176,19 +201,27 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE zIndex: insetColumns != null ? 2 : 1 }} > - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - + + + {columnItems.map((column) => { + const header = table.getLeafHeaders()[column.index]; + return ( +
onHeaderClick(e, header)}> {header.column.getIsSorted() && sortingIcons[header.column.getIsSorted() as SortDirection]} {flexRender(header.column.columnDef.header, header.getContext())}
{header.id != selectId && header.id != expanderId && }
- ))} - - ))} + ); + })} + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { @@ -197,7 +230,11 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE onRowContextMenu(e, row)} + onContextMenu={async (e) => { + // await selection to ensure that the context menu detects that a row has been selected + await row.toggleSelected(true); + onRowContextMenu(e, row); + }} style={{ height: `${calculateRowHeight(row, headCellHeight, cellHeight)}px`, transform: `translateY(${virtualRow.start}px)` @@ -206,21 +243,24 @@ export const ContentTable = (contentTableProps: ContentTableProps): React.ReactE ref={rowVirtualizer.measureElement} colors={colors} > - {row.getVisibleCells().map((cell) => { + + {columnItems.map((column) => { + const cell = row.getVisibleCells()[column.index]; const clickable = isClickable(onSelect, cell.column.id, checkableRows); return ( onSelectRow(event, row, table) : undefined} clickable={clickable ? 1 : 0} - sticky={stickyLeftColumns ? 1 : 0} + sticky={column.index < stickyLeftColumns ? 1 : 0} colors={colors} > {flexRender(cell.column.columnDef.cell, cell.getContext())} ); })} + {row.getIsExpanded() && row.original.inset?.length != 0 && ( ; + table: Table; viewId?: string; columns?: ContentTableColumn[]; expandableRows?: boolean; + stickyLeftColumns?: number; downloadToCsvFileName?: string; } const Panel = (props: PanelProps) => { - const { checkableRows, panelElements, numberOfCheckedItems, numberOfItems, showRefresh, table, viewId, columns, downloadToCsvFileName, expandableRows = false } = props; + const { + checkableRows, + panelElements, + numberOfCheckedItems, + numberOfItems, + showRefresh, + table, + viewId, + columns, + expandableRows = false, + downloadToCsvFileName = null, + stickyLeftColumns + } = props; const { navigationState, dispatchNavigation } = useContext(NavigationContext); const { operationState: { colors } @@ -63,7 +76,7 @@ const Panel = (props: PanelProps) => { return (
- {table && } + {selectedItemsText} {showRefresh && (