From 99405811b051cf9d4fc967a8523f0620747f5d66 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 20 May 2026 21:15:06 -0500 Subject: [PATCH 1/2] Polish next UI saved views and sample data --- src/Exceptionless.Core/Bootstrapper.cs | 2 +- .../GenerateSampleEventsWorkItemHandler.cs | 11 +- src/Exceptionless.Core/Models/SavedView.cs | 8 +- .../WorkItems/GenerateSampleEventsWorkItem.cs | 2 +- .../Pipeline/001_CheckEventDateAction.cs | 2 +- .../Pipeline/EventPipeline.cs | 14 +- .../Plugins/EventProcessor/EventContext.cs | 1 + .../Configuration/Indexes/SavedViewIndex.cs | 1 - .../Utility/RandomEventGenerator.cs | 31 +- .../Utility/SampleDataService.cs | 4 +- src/Exceptionless.Web/ClientApp/src/app.css | 28 +- .../components/extended-data-item.svelte | 4 +- .../events/components/log-level.svelte | 10 +- .../events/components/table/options.svelte.ts | 79 ++++- .../components/table/stack-status-cell.svelte | 8 +- .../impersonate-organization-dialog.svelte | 2 +- .../lib/features/saved-views/api.svelte.ts | 10 +- .../components/save-view-dialog.svelte | 25 +- .../components/saved-view-picker.svelte | 146 ++++++++- .../saved-views/use-saved-views.svelte.ts | 108 +++---- .../saved-views/use-saved-views.test.ts | 19 +- .../data-table/data-table-body.svelte | 47 ++- .../data-table/data-table-view-options.svelte | 23 +- .../faceted-filter-actions.svelte | 6 +- .../faceted-filter-builder.svelte | 23 +- .../notification/notification.svelte | 2 +- .../shared/components/ui/badge/badge.svelte | 2 + .../shared/components/ui/button/button.svelte | 15 +- .../ui/command/command-dialog.svelte | 2 +- .../ui/command/command-shortcut.svelte | 33 +- .../dropdown-menu-checkbox-item.svelte | 4 +- .../dropdown-menu/dropdown-menu-item.svelte | 2 +- .../shared/keyboard-shortcuts.test.ts | 17 +- .../lib/features/shared/keyboard-shortcuts.ts | 24 +- .../src/lib/features/shared/table.svelte.ts | 43 ++- .../stacks/components/table/options.svelte.ts | 2 +- .../components/table/stack-status-cell.svelte | 26 +- .../ClientApp/src/lib/generated/api.ts | 7 +- .../ClientApp/src/lib/generated/schemas.ts | 8 +- .../keyboard-shortcuts-dialog.svelte | 105 ++++++ .../sidebar-organization-switcher.svelte | 71 +++-- .../(components)/layouts/sidebar-user.svelte | 99 +++--- .../(app)/(components)/layouts/sidebar.svelte | 121 +++++-- .../(components)/navigation-command.svelte | 66 +++- .../ClientApp/src/routes/(app)/+layout.svelte | 126 ++++++-- .../ClientApp/src/routes/(app)/+page.svelte | 13 +- .../src/routes/(app)/issues/+page.svelte | 9 +- .../[projectId]/integrations/+page.svelte | 5 +- .../src/routes/(app)/routes.svelte.ts | 15 +- .../src/routes/(app)/stream/+page.svelte | 7 +- .../src/routes/(auth)/login/+page.svelte | 261 ++++++++------- .../src/routes/(auth)/routes.svelte.ts | 2 +- .../ClientApp/src/routes/routes.svelte.ts | 4 +- .../Controllers/AdminController.cs | 2 +- .../Controllers/SavedViewController.cs | 68 ++-- .../Models/SavedView/NewSavedView.cs | 47 ++- .../Models/SavedView/UpdateSavedView.cs | 11 +- .../Models/SavedView/ViewSavedView.cs | 2 +- .../Controllers/Data/openapi.json | 34 +- .../Controllers/SavedViewControllerTests.cs | 301 +++--------------- .../Mapping/SavedViewMapperTests.cs | 11 +- .../Pipeline/CheckEventDateActionTests.cs | 103 ++++++ .../Utility/RandomEventGeneratorTests.cs | 57 ++++ tests/http/saved-views.http | 7 +- 64 files changed, 1499 insertions(+), 849 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/keyboard-shortcuts-dialog.svelte create mode 100644 tests/Exceptionless.Tests/Pipeline/CheckEventDateActionTests.cs create mode 100644 tests/Exceptionless.Tests/Utility/RandomEventGeneratorTests.cs diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index 5bd772047f..9d745131ac 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -254,7 +254,7 @@ private static async Task CreateSampleDataAsync(IServiceProvider container) var dataHelper = container.GetRequiredService(); await dataHelper.CreateDataAsync(); - await dataHelper.EnqueueSampleEventsAsync(eventCount: 100, daysBack: 7); + await dataHelper.EnqueueSampleEventsAsync(); } public static void AddHostedJobs(IServiceCollection services, ILoggerFactory loggerFactory) diff --git a/src/Exceptionless.Core/Jobs/WorkItemHandlers/GenerateSampleEventsWorkItemHandler.cs b/src/Exceptionless.Core/Jobs/WorkItemHandlers/GenerateSampleEventsWorkItemHandler.cs index a02cc441e0..f88d91c33f 100644 --- a/src/Exceptionless.Core/Jobs/WorkItemHandlers/GenerateSampleEventsWorkItemHandler.cs +++ b/src/Exceptionless.Core/Jobs/WorkItemHandlers/GenerateSampleEventsWorkItemHandler.cs @@ -48,14 +48,13 @@ public override async Task HandleItemAsync(WorkItemContext context) var workItem = context.GetData()!; int eventCount = Math.Clamp(workItem.EventCount, 1, 10000); int daysBack = Math.Clamp(workItem.DaysBack, 1, 365); - int acceptedDaysBack = Math.Min(daysBack, 3); - Log.LogInformation("Generating {EventCount} sample events over {DaysBack} days", eventCount, acceptedDaysBack); - await context.ReportProgressAsync(0, $"Generating {eventCount} sample events over {acceptedDaysBack} days"); + Log.LogInformation("Generating {EventCount} sample events over {DaysBack} days", eventCount, daysBack); + await context.ReportProgressAsync(0, $"Generating {eventCount} sample events over {daysBack} days"); var generator = new RandomEventGenerator(_timeProvider); var utcNow = _timeProvider.GetUtcNow().UtcDateTime; - var minDate = utcNow.AddDays(-acceptedDaysBack); + var minDate = utcNow.AddDays(-daysBack); if (IsProjectScoped(workItem)) { @@ -98,7 +97,7 @@ public override async Task HandleItemAsync(WorkItemContext context) if (context.CancellationToken.IsCancellationRequested) break; - await _eventPipeline.RunAsync(batch, organization, project); + await _eventPipeline.RunAsync(batch, organization, project, allowExtendedEventDateRange: true); totalProcessed += batch.Length; int percentage = (int)Math.Min(99, totalProcessed * 100.0 / eventCount); @@ -141,7 +140,7 @@ private async Task GenerateProjectSampleEventsAsync(WorkItemContext context, Ran if (context.CancellationToken.IsCancellationRequested) break; - await _eventPipeline.RunAsync(batch, organization, project); + await _eventPipeline.RunAsync(batch, organization, project, allowExtendedEventDateRange: true); totalProcessed += batch.Length; int percentage = (int)Math.Min(99, totalProcessed * 100.0 / eventCount); diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs index a7f1cb7646..9ac86d7c6f 100644 --- a/src/Exceptionless.Core/Models/SavedView.cs +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -10,6 +10,8 @@ namespace Exceptionless.Core.Models; /// public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates { + public const int MaxFilterDefinitionsLength = 100_000; + // Identity [ObjectId] public string Id { get; set; } = null!; @@ -38,14 +40,14 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates public string? Filter { get; set; } /// JSON array of structured filter objects for UI chip hydration. - [MaxLength(10000)] + [MaxLength(MaxFilterDefinitionsLength)] public string? FilterDefinitions { get; set; } /// Column visibility state per dashboard table, keyed by column id. public Dictionary? Columns { get; set; } - /// Whether this view loads automatically when navigating to the page. - public bool IsDefault { get; set; } + /// Column display order per dashboard table, excluding utility columns. + public List? ColumnOrder { get; set; } /// Display name shown in the sidebar and picker. [Required] diff --git a/src/Exceptionless.Core/Models/WorkItems/GenerateSampleEventsWorkItem.cs b/src/Exceptionless.Core/Models/WorkItems/GenerateSampleEventsWorkItem.cs index a25dbd94de..4bf9a8db67 100644 --- a/src/Exceptionless.Core/Models/WorkItems/GenerateSampleEventsWorkItem.cs +++ b/src/Exceptionless.Core/Models/WorkItems/GenerateSampleEventsWorkItem.cs @@ -4,6 +4,6 @@ public record GenerateSampleEventsWorkItem { public string? OrganizationId { get; init; } public string? ProjectId { get; init; } - public int EventCount { get; init; } = 100; + public int EventCount { get; init; } = 250; public int DaysBack { get; init; } = 7; } diff --git a/src/Exceptionless.Core/Pipeline/001_CheckEventDateAction.cs b/src/Exceptionless.Core/Pipeline/001_CheckEventDateAction.cs index 42f24ea9e3..ee64e96d53 100644 --- a/src/Exceptionless.Core/Pipeline/001_CheckEventDateAction.cs +++ b/src/Exceptionless.Core/Pipeline/001_CheckEventDateAction.cs @@ -24,7 +24,7 @@ public override Task ProcessAsync(EventContext ctx) // Discard events that are being submitted outside of the plan retention limit. double eventAgeInDays = _timeProvider.GetUtcNow().UtcDateTime.Subtract(ctx.Event.Date.UtcDateTime).TotalDays; - if (eventAgeInDays > 3 || ctx.Organization.RetentionDays > 0 && eventAgeInDays > ctx.Organization.RetentionDays) + if ((!ctx.AllowExtendedEventDateRange && eventAgeInDays > 3) || (ctx.Organization.RetentionDays > 0 && eventAgeInDays > ctx.Organization.RetentionDays)) { _logger.LogInformation("Discarding event that occurred more than three days ago or outside of organization retention limit"); diff --git a/src/Exceptionless.Core/Pipeline/EventPipeline.cs b/src/Exceptionless.Core/Pipeline/EventPipeline.cs index 8d17da81b9..528558d92f 100644 --- a/src/Exceptionless.Core/Pipeline/EventPipeline.cs +++ b/src/Exceptionless.Core/Pipeline/EventPipeline.cs @@ -11,14 +11,20 @@ public class EventPipeline : PipelineBase { public EventPipeline(IServiceProvider serviceProvider, AppOptions options, ILoggerFactory loggerFactory) : base(serviceProvider, options, loggerFactory) { } - public Task RunAsync(PersistentEvent ev, Organization organization, Project project, EventPostInfo? epi = null) + public Task RunAsync(PersistentEvent ev, Organization organization, Project project, EventPostInfo? epi = null, bool allowExtendedEventDateRange = false) { - return RunAsync(new EventContext(ev, organization, project, epi)); + return RunAsync(new EventContext(ev, organization, project, epi) + { + AllowExtendedEventDateRange = allowExtendedEventDateRange + }); } - public Task> RunAsync(IEnumerable events, Organization organization, Project project, EventPostInfo? epi = null) + public Task> RunAsync(IEnumerable events, Organization organization, Project project, EventPostInfo? epi = null, bool allowExtendedEventDateRange = false) { - return RunAsync(events.Select(ev => new EventContext(ev, organization, project, epi)).ToList()); + return RunAsync(events.Select(ev => new EventContext(ev, organization, project, epi) + { + AllowExtendedEventDateRange = allowExtendedEventDateRange + }).ToList()); } public override async Task> RunAsync(ICollection contexts) diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs b/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs index ddf5ca8f9e..5b348b016c 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs @@ -28,6 +28,7 @@ public EventContext(PersistentEvent ev, Organization organization, Project proje public bool IsNew { get; set; } public bool IsRegression { get; set; } public bool IncludePrivateInformation { get; set; } + public bool AllowExtendedEventDateRange { get; set; } public string? SignatureHash { get; set; } public IDictionary StackSignatureData { get; private set; } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs index 4449144466..32d676f5ac 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs @@ -27,7 +27,6 @@ public SavedViewIndex(ExceptionlessElasticConfiguration configuration) : base(co .Keyword(f => f.Name(e => e.UpdatedByUserId)) .Text(f => f.Name(e => e.Name).Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField()) .Keyword(f => f.Name(e => e.ViewType)) - .Boolean(f => f.Name(e => e.IsDefault)) .Number(f => f.Name(e => e.Version).Type(NumberType.Integer))); } diff --git a/src/Exceptionless.Core/Utility/RandomEventGenerator.cs b/src/Exceptionless.Core/Utility/RandomEventGenerator.cs index d552a360bc..1fe80991b8 100644 --- a/src/Exceptionless.Core/Utility/RandomEventGenerator.cs +++ b/src/Exceptionless.Core/Utility/RandomEventGenerator.cs @@ -26,6 +26,7 @@ public List Generate(string organizationId, string projectId, i // Reserve ~20% of events for sessions int sessionCount = Math.Clamp(count / 5, 0, count); int regularCount = count - sessionCount; + int errorLogCount = Math.Clamp(regularCount / 10, regularCount > 0 ? 1 : 0, regularCount); for (int i = 0; i < regularCount; i++) { @@ -36,7 +37,7 @@ public List Generate(string organizationId, string projectId, i Date = RandomData.GetDateTime(min, max) }; - PopulateEvent(ev); + PopulateEvent(ev, i < errorLogCount ? "Error" : null); events.Add(ev); } @@ -88,12 +89,12 @@ private List GenerateSession(string organizationId, string proj return events; } - private void PopulateEvent(Event ev) + private void PopulateEvent(Event ev, string? logLevel = null) { ev.Data ??= new DataDictionary(); ev.Tags ??= []; - ev.Type = EventTypes.Random()!; + ev.Type = String.IsNullOrEmpty(logLevel) ? EventTypes.Random()! : Event.KnownTypes.Log; switch (ev.Type) { case Event.KnownTypes.FeatureUsage: @@ -104,8 +105,8 @@ private void PopulateEvent(Event ev) break; case Event.KnownTypes.Log: ev.Source = LogSources.Random(); - ev.Message = LogMessages.Random(); - string? level = LogLevels.Random(); + string? level = logLevel ?? LogLevels.Random(); + ev.Message = GetLogMessage(level); if (!String.IsNullOrEmpty(level)) ev.Data[Event.KnownDataKeys.Level] = level; break; @@ -147,8 +148,8 @@ private void PopulateEvent(Event ev) if (ev.Type == Event.KnownTypes.Error) { // Pre-generate a limited set of errors so stacking occurs - _randomErrors ??= [.. Enumerable.Range(1, 15).Select(_ => GenerateError())]; - _randomSimpleErrors ??= [.. Enumerable.Range(1, 10).Select(_ => GenerateSimpleError())]; + _randomErrors ??= [.. Enumerable.Range(1, 5).Select(_ => GenerateError())]; + _randomSimpleErrors ??= [.. Enumerable.Range(1, 3).Select(_ => GenerateSimpleError())]; if (RandomData.GetBool()) ev.Data[Event.KnownDataKeys.Error] = _randomErrors.Random(); @@ -157,6 +158,13 @@ private void PopulateEvent(Event ev) } } + private static string? GetLogMessage(string? level) + { + return String.Equals(level, "Error", StringComparison.OrdinalIgnoreCase) + ? ErrorLogMessages.Random() + : LogMessages.Random(); + } + private List? _randomErrors; private List? _randomSimpleErrors; @@ -380,6 +388,15 @@ private static DataDictionary GenerateErrorData() "Health check passed" ]; + private static readonly List ErrorLogMessages = + [ + "Failed to process event batch after retry limit was reached", + "Unhandled exception while processing background job", + "Unable to publish notification email", + "Database command failed while saving project usage", + "Elasticsearch request failed while searching events" + ]; + private static readonly List LogLevels = [ "Trace", "Debug", "Info", "Info", "Warn", "Error" diff --git a/src/Exceptionless.Core/Utility/SampleDataService.cs b/src/Exceptionless.Core/Utility/SampleDataService.cs index 897a2e92cc..ce6e1a6faa 100644 --- a/src/Exceptionless.Core/Utility/SampleDataService.cs +++ b/src/Exceptionless.Core/Utility/SampleDataService.cs @@ -277,7 +277,7 @@ await _tokenRepository.AddAsync(new Token _logger.LogDebug("Created Internal Organization {OrganizationName} and Project {ProjectName}", organization.Name, project.Name); } - public async Task EnqueueSampleEventsAsync(int eventCount = 100, int daysBack = 7) + public async Task EnqueueSampleEventsAsync(int eventCount = 250, int daysBack = 7) { await _workItemQueue.EnqueueAsync(new GenerateSampleEventsWorkItem { @@ -287,7 +287,7 @@ await _workItemQueue.EnqueueAsync(new GenerateSampleEventsWorkItem _logger.LogInformation("Enqueued sample event generation: {EventCount} events over {DaysBack} days", eventCount, daysBack); } - public async Task EnqueueSampleEventsAsync(string organizationId, string projectId, int eventCount = 100, int daysBack = 7) + public async Task EnqueueSampleEventsAsync(string organizationId, string projectId, int eventCount = 250, int daysBack = 7) { string workItemId = await _workItemQueue.EnqueueAsync(new GenerateSampleEventsWorkItem { diff --git a/src/Exceptionless.Web/ClientApp/src/app.css b/src/Exceptionless.Web/ClientApp/src/app.css index 04ba110d8c..49c5cf7d8f 100644 --- a/src/Exceptionless.Web/ClientApp/src/app.css +++ b/src/Exceptionless.Web/ClientApp/src/app.css @@ -44,12 +44,12 @@ --sidebar-border: var(--border); --sidebar-ring: var(--ring); - --chart-1: #7bb662; /* Total (green, light) */ - --chart-2: #56b4e9; /* Blocked (blue, light) */ - --chart-3: #d47a00; /* Discarded – hsl(32 100% 42%) */ - --chart-4: #ffd64d; /* Too Big – hsl(46 100% 65%) */ - --chart-5: #d9d9d9; /* Total in Organization (magenta, light) */ - --chart-6: #c62828; /* material-red-700: deep red for light mode */ + --chart-1: #72c928; /* Total / events: classic Exceptionless green */ + --chart-2: #43a047; /* Secondary series: deeper green */ + --chart-3: #f0ad4e; /* Discarded / regressed: warm amber */ + --chart-4: #f7d34a; /* Ignored / too big: golden yellow */ + --chart-5: #8a8f98; /* Organization total / snoozed: neutral gray */ + --chart-6: #d9534f; /* Limit / destructive: classic red */ } .dark { @@ -91,12 +91,12 @@ --sidebar-border: var(--border); --sidebar-ring: var(--ring); - --chart-1: #a4d56f; /* Total (green, dark) */ - --chart-2: #8fdbff; /* Blocked (blue, dark) */ - --chart-3: #ff9e3d; /* Discarded – hsl(30 100% 62%) */ - --chart-4: #ffea70; /* Too Big – hsl(48 100% 70%) */ - --chart-5: #5a5a5a; /* Total in Organization (magenta, dark) */ - --chart-6: #ff5c5c; /* hsl(0 100% 66%): bright red for dark mode */ + --chart-1: #91dc45; /* Total / events: classic Exceptionless green */ + --chart-2: #5fc263; /* Secondary series: deeper green */ + --chart-3: #ffbd63; /* Discarded / regressed: warm amber */ + --chart-4: #ffe16a; /* Ignored / too big: golden yellow */ + --chart-5: #a4abb5; /* Organization total / snoozed: neutral gray */ + --chart-6: #ff746f; /* Limit / destructive: classic red */ } @theme inline { @@ -207,6 +207,10 @@ } /* HACK: Need to figure out why this is needed for range selection. */ +[data-slot='chart'] .lc-area-path { + fill-opacity: 0.16; +} + .lc-brush-range { @apply bg-primary/10 border-primary border-2; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte index dd820cb20c..15bb0f5e13 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/extended-data-item.svelte @@ -127,12 +127,12 @@ Actions {#if canToggle} - + Toggle View {/if} - + Copy to Clipboard diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte index d6678e589a..07c0802aa3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/log-level.svelte @@ -10,16 +10,20 @@ let { level }: Props = $props(); - function getLogLevelVariant(level: LogLevel | null): 'default' | 'destructive' | 'outline' | 'secondary' { + function getLogLevelVariant(level: LogLevel | null): 'default' | 'destructive' | 'info' | 'outline' | 'secondary' | 'yellow' { if (level === 'trace' || level === 'debug') { return 'secondary'; } if (level === 'info') { - return 'default'; + return 'info'; } - if (level === 'warn' || level === 'error') { + if (level === 'warn') { + return 'yellow'; + } + + if (level === 'error') { return 'destructive'; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts index c7cc5011f8..c535100776 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/options.svelte.ts @@ -4,16 +4,25 @@ import NumberFormatter from '$comp/formatters/number.svelte'; import TimeAgo from '$comp/formatters/time-ago.svelte'; import { Checkbox } from '$comp/ui/checkbox'; import { nameof } from '$lib/utils'; -import { type ColumnDef, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; +import { type ColumnDef, type ColumnVisibilityState, renderComponent, type StockFeatures } from '@tanstack/svelte-table'; import type { GetEventsMode } from '../../api.svelte'; import type { EventSummaryModel, StackSummaryModel, SummaryModel, SummaryTemplateKeys } from '../summary/index'; +import LogLevel from '../log-level.svelte'; import Summary from '../summary/summary.svelte'; import EventsUserIdentitySummaryCell from './events-user-identity-summary-cell.svelte'; import StackStatusCell from './stack-status-cell.svelte'; import StackUsersSummaryCell from './stack-users-summary-cell.svelte'; +export const defaultEventColumnVisibility: ColumnVisibilityState = { + level: false, + message: false, + name: false, + source: false, + type: false +}; + export function getColumns>( mode: GetEventsMode = 'summary' ): ColumnDef[] { @@ -43,8 +52,11 @@ export function getColumns renderComponent(Summary, { showStatus: false, summary: prop.row.original }), - enableHiding: false, - header: 'Summary' + header: 'Summary', + id: 'summary', + meta: { + class: 'w-full' + } } ]; @@ -53,7 +65,6 @@ export function getColumns renderComponent(EventsUserIdentitySummaryCell, { summary: prop.row.original }), - enableSorting: false, header: 'User', id: 'user', meta: { @@ -68,6 +79,53 @@ export function getColumns getSummaryDataValue(row, 'Message'), + cell: (prop) => formatTextColumn(prop.getValue()), + enableSorting: false, + header: 'Message', + id: 'message', + meta: { + class: 'w-full' + } + }, + { + accessorFn: (row) => getSummaryDataValue(row, 'Type'), + cell: (prop) => formatTextColumn(prop.getValue()), + header: 'Type', + id: 'type', + meta: { + class: 'w-36' + } + }, + { + accessorFn: (row) => getSource(row), + cell: (prop) => formatTextColumn(prop.getValue()), + header: 'Source', + id: 'source', + meta: { + class: 'w-40' + } + }, + { + accessorFn: (row) => getSummaryDataValue(row, 'Name'), + cell: (prop) => formatTextColumn(prop.getValue()), + enableSorting: false, + header: 'Name', + id: 'name', + meta: { + class: 'w-40' + } + }, + { + accessorFn: (row) => getSummaryDataValue(row, 'Level'), + cell: (prop) => renderComponent(LogLevel, { level: prop.getValue() }), + header: 'Level', + id: 'level', + meta: { + class: 'w-[4.5rem] min-w-[4.5rem] max-w-[4.5rem] px-1 text-center' + } } ); } else { @@ -126,3 +184,16 @@ export function getColumns 0 ? value : '—'; +} + +function getSource>(summary: TSummaryModel): string | undefined { + return getSummaryDataValue(summary, 'SourceShortName') ?? getSummaryDataValue(summary, 'Source'); +} + +function getSummaryDataValue>(summary: TSummaryModel, key: string): string | undefined { + const value = (summary.data as Record)[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-status-cell.svelte index 789d409c56..70b8f4ba08 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-status-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/table/stack-status-cell.svelte @@ -1,17 +1,17 @@ {#if value} -
- -
+ {label} {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte index 447c634614..550913be3d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte @@ -251,7 +251,7 @@ {#if hasFilters} - + {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts index 29dff9b6d7..8af9e9ea8b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -144,15 +144,7 @@ export function syncSavedViewCaches(queryClient: QueryClient, savedView: SavedVi } export function upsertSavedViewCache(cachedViews: SavedView[] | undefined, savedView: SavedView): SavedView[] { - const views = savedView.is_default - ? (cachedViews ?? []).map((view) => { - if (view.id === savedView.id || view.view_type !== savedView.view_type || !view.is_default) { - return view; - } - - return { ...view, is_default: false }; - }) - : (cachedViews ?? []); + const views = cachedViews ?? []; const savedViewIndex = views.findIndex((view) => view.id === savedView.id); if (savedViewIndex === -1) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 28f34f51cd..da69eb4645 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -12,7 +12,7 @@ duplicateView?: SavedView; onClose: () => void; onLoadView: (id: string) => void; - onSave: (name: string, isPrivate: boolean, isDefault: boolean) => Promise; + onSave: (name: string, isPrivate: boolean) => Promise; open: boolean; saving: boolean; } @@ -21,13 +21,11 @@ let saveName = $state(''); let isPrivate = $state(false); - let isDefault = $state(false); $effect(() => { if (open) { saveName = ''; isPrivate = false; - isDefault = false; } }); @@ -36,7 +34,7 @@ return; } - await onSave(saveName.trim(), isPrivate, isDefault); + await onSave(saveName.trim(), isPrivate); } @@ -77,25 +75,8 @@ Only visible to you - { - if (checked) { - isDefault = false; - } - }} - /> + - {#if !isPrivate} -
-
- - Auto-loads for everyone on page visit -
- -
- {/if} @@ -249,7 +337,7 @@
openDeleteDialog(activeView)}> @@ -258,15 +346,49 @@ {/if} - {#if hideableColumns.length > 0} + {#if reorderableColumns.length > 0} Columns - {#each hideableColumns as column (column.id)} - column.toggleVisibility()}> - {column.columnDef.header} - - {/each} +
+ {#each reorderableColumns as column (column.id)} +
handleColumnDragOver(event, column.id)} + ondragstart={(event) => handleColumnDragStart(event, column.id)} + role="listitem" + > + {#if column.getCanHide()} + { + event.preventDefault(); + toggleColumn(column); + }} + onSelect={(event) => event.preventDefault()} + > + {getColumnLabel(column)} + + {:else} + + + {getColumnLabel(column)} + + {/if} +
+ {/each} +
{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index f220eb6025..1259ef49db 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -1,5 +1,5 @@ import type { IFilter } from '$comp/faceted-filter'; -import type { ColumnVisibilityState } from '@tanstack/svelte-table'; +import type { ColumnOrderState, ColumnVisibilityState } from '@tanstack/svelte-table'; import { deserializeFilters } from '$features/events/components/filters/helpers.svelte'; import { organization } from '$features/organizations/context.svelte'; @@ -17,10 +17,13 @@ export interface SavedViewQueryParams { } export interface UseSavedViewsOptions { + defaultColumnVisibility?: ColumnVisibilityState; filterCacheKey: (filter: null | string) => string; + getColumnOrder?: () => ColumnOrderState; getColumnVisibility?: () => ColumnVisibilityState; getFilterDefinitions?: () => string; queryParams: SavedViewQueryParams; + setColumnOrder?: (order: ColumnOrderState) => void; setColumnVisibility?: (visibility: ColumnVisibilityState) => void; updateFilterCache: (key: string, filters: IFilter[]) => void; view: string; @@ -77,11 +80,19 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur const activeSavedView = $derived(savedViewsListQuery.data?.find((v) => v.id === options.queryParams.saved)); + function applyColumnState(view: Pick | undefined): void { + if (options.setColumnVisibility) { + options.setColumnVisibility(view?.columns ?? {}); + } + + if (options.setColumnOrder) { + options.setColumnOrder(view?.column_order ?? []); + } + } + // Hydrate filters/columns when a saved view loads, or clear params if the view is no longer found. // lastLoadedViewId prevents re-hydration on background refetches (which would stomp user edits). - let lastLoadedViewId = ''; - let hasAutoRestored = false; - let lastAutoRestoredOrganizationId = ''; + let lastLoadedViewId: string | undefined; $effect(() => { const savedId = options.queryParams.saved; const isLoading = savedViewsListQuery.isLoading; @@ -90,6 +101,10 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur if (!savedId || isLoading || !views) { if (!savedId) { + if (lastLoadedViewId !== '') { + applyColumnState(undefined); + } + lastLoadedViewId = ''; } @@ -110,7 +125,6 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.queryParams.filter = null; setSortQueryParam(options.queryParams, null); setTimeQueryParam(options.queryParams, null); - hasAutoRestored = false; return; } @@ -133,52 +147,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.queryParams.filter = view.filter ?? null; setSortQueryParam(options.queryParams, view.sort ?? null); setTimeQueryParam(options.queryParams, view.time ?? null); - - if (view.columns && options.setColumnVisibility) { - options.setColumnVisibility(view.columns); - } - }); - - // Auto-load default saved view when navigating to page without explicit params - $effect(() => { - const organizationId = organization.current; - const views = savedViewsListQuery.data; - if (!organizationId) { - return; - } - - if (organizationId !== lastAutoRestoredOrganizationId) { - hasAutoRestored = false; - lastAutoRestoredOrganizationId = organizationId; - } - - if (hasAutoRestored) { - return; - } - - // Don't lock out the auto-restore while the feature flag is still resolving - // or the views list hasn't loaded yet. Without this guard the effect fires - // while the query is disabled (isLoading=false but data=undefined) and marks - // hasAutoRestored=true before any view data is available. - if (!isEnabled || savedViewsListQuery.isLoading) { - return; - } - - hasAutoRestored = true; - - const search = window.location.search; - const hasExplicitParams = - /[?&]saved(?:[=&]|$)/.test(search) || /[?&]filter(?:[=&]|$)/.test(search) || /[?&]sort(?:[=&]|$)/.test(search) || /[?&]time(?:[=&]|$)/.test(search); - if (hasExplicitParams) { - return; - } - - untrack(() => { - const defaultView = views?.find((v) => v.is_default); - if (defaultView) { - options.queryParams.saved = defaultView.id; - } - }); + applyColumnState(view); }); // Detect if current filters or columns differ from the active saved view @@ -204,7 +173,11 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return true; } - if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns)) { + if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns, options.defaultColumnVisibility)) { + return true; + } + + if (options.getColumnOrder && !columnOrderEqual(options.getColumnOrder(), view.column_order)) { return true; } @@ -233,9 +206,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.queryParams.filter = view.filter ?? null; setSortQueryParam(options.queryParams, view.sort ?? null); setTimeQueryParam(options.queryParams, view.time ?? null); - if (view.columns && options.setColumnVisibility) { - options.setColumnVisibility(view.columns); - } + applyColumnState(view); } function handleClearSavedView() { @@ -243,9 +214,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur options.queryParams.filter = null; setSortQueryParam(options.queryParams, null); setTimeQueryParam(options.queryParams, null); - if (options.setColumnVisibility) { - options.setColumnVisibility({}); - } + applyColumnState(undefined); } return { @@ -270,9 +239,26 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur }; } -function columnsEqual(a: ColumnVisibilityState | undefined, b: null | Record | undefined): boolean { - const aEntries = Object.entries(a ?? {}).sort(([k1], [k2]) => k1.localeCompare(k2)); - const bEntries = Object.entries(b ?? {}).sort(([k1], [k2]) => k1.localeCompare(k2)); +function columnOrderEqual(a: ColumnOrderState | undefined, b: null | string[] | undefined): boolean { + const normalize = (value: null | string[] | undefined) => (value ?? []).filter((columnId) => columnId !== 'select'); + const aOrder = normalize(a); + const bOrder = normalize(b); + + if (aOrder.length !== bOrder.length) { + return false; + } + + return aOrder.every((columnId, index) => columnId === bOrder[index]); +} + +function columnsEqual( + a: ColumnVisibilityState | undefined, + b: null | Record | undefined, + defaultColumnVisibility: ColumnVisibilityState = {} +): boolean { + const normalize = (value: ColumnVisibilityState | null | undefined) => ({ ...defaultColumnVisibility, ...(value ?? {}) }); + const aEntries = Object.entries(normalize(a)).sort(([k1], [k2]) => k1.localeCompare(k2)); + const bEntries = Object.entries(normalize(b)).sort(([k1], [k2]) => k1.localeCompare(k2)); if (aEntries.length !== bEntries.length) { return false; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts index 429efee384..74c3a81d56 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SavedView } from './models'; -import { invalidateSavedViewQueries, queryKeys, SAVED_VIEW_REFRESH_DELAY_MS, syncSavedViewCaches, upsertSavedViewCache } from './api.svelte'; +import { invalidateSavedViewQueries, queryKeys, SAVED_VIEW_REFRESH_DELAY_MS, syncSavedViewCaches } from './api.svelte'; import { type SavedViewQueryParams, setSortQueryParam, setTimeQueryParam, supportsSortQueryParam, supportsTimeQueryParam } from './use-saved-views.svelte'; const TEST_ORG_ID = '507f1f77bcf86cd799439011'; @@ -16,13 +16,13 @@ afterEach(() => { function buildSavedView({ id, name, ...overrides }: Partial & Pick): SavedView { return { + column_order: null, columns: {}, created_by_user_id: TEST_USER_ID, created_utc: new Date().toISOString(), filter: null, filter_definitions: null, id, - is_default: false, name, organization_id: TEST_ORG_ID, sort: null, @@ -345,21 +345,6 @@ describe('useSavedViews', () => { expect(queryClient.getQueryData(queryKeys.view(TEST_ORG_ID, 'issues'))).toEqual([updatedView, otherView]); expect(queryClient.getQueryData(queryKeys.organization(TEST_ORG_ID))).toEqual([updatedView, otherView]); }); - - it('keeps only one default per saved-view type in the cached list', () => { - // Arrange - const currentDefault = buildSavedView({ id: 'view-1', is_default: true, name: 'Current Default' }); - const otherIssuesView = buildSavedView({ id: 'view-2', name: 'Other Issues View' }); - const streamDefault = buildSavedView({ id: 'view-3', is_default: true, name: 'Stream Default', view_type: 'stream' }); - const newDefault = buildSavedView({ id: 'view-4', is_default: true, name: 'New Default' }); - - // Act - const updatedViews = upsertSavedViewCache([currentDefault, otherIssuesView, streamDefault], newDefault); - - // Assert - expect(updatedViews.filter((view) => view.view_type === 'issues' && view.is_default)).toEqual([newDefault]); - expect(updatedViews.filter((view) => view.view_type === 'stream' && view.is_default)).toEqual([streamDefault]); - }); }); describe('rename cache update pattern', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte index b3d7d1838e..3d45cf2c9a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-body.svelte @@ -20,29 +20,55 @@ let { children, rowClick, rowHref, table }: Props = $props(); + const selectColumnClass = 'w-8 min-w-8 max-w-8'; + function getHeaderColumnClass(header: Header) { - const metaClass = (header.column.columnDef.meta as { class?: string })?.class || ''; + if (header.column.id === 'select') { + return selectColumnClass; + } + + const metaClass = getMetaClass(header.column.columnDef.meta); if (!metaClass) { return ''; } - if (metaClass.includes('text-right')) { - return [metaClass, 'justify-end'].join(' '); + const className = getVisibleDataColumnCount() === 1 ? removeWidthClasses(metaClass) : metaClass; + if (className.includes('text-right')) { + return [className, 'justify-end'].join(' '); } - return metaClass; + if (className.includes('text-center')) { + return [className, 'justify-center'].join(' '); + } + + return className; } function getCellClass(cell: Cell) { if (cell.column.id === 'select') { - return; + return selectColumnClass; } - const metaClass = (cell.column.columnDef.meta as { class?: string })?.class ?? ''; - const classes = rowClick ? ['cursor-pointer', 'truncate', 'max-w-sm', metaClass] : ['truncate', 'max-w-sm', metaClass]; + const isOnlyDataColumn = getVisibleDataColumnCount() === 1; + const metaClass = isOnlyDataColumn ? removeWidthClasses(getMetaClass(cell.column.columnDef.meta)) : getMetaClass(cell.column.columnDef.meta); + const classes = rowClick + ? ['cursor-pointer', 'truncate', !isOnlyDataColumn && 'max-w-sm', metaClass] + : ['truncate', !isOnlyDataColumn && 'max-w-sm', metaClass]; return classes.filter(Boolean).join(' '); } + function getMetaClass(meta: unknown): string { + return (meta as { class?: string })?.class ?? ''; + } + + function getVisibleDataColumnCount(): number { + return table.getVisibleLeafColumns().filter((column) => column.id !== 'select').length; + } + + function isWidthClass(className: string): boolean { + return /^(?:max-w|min-w|w)-/.test(className); + } + function onCellClick(event: MouseEvent, cell: Cell): void { if (cell.column.id === 'select') { return; @@ -73,6 +99,13 @@ // Call the row click handler, passing the event so consumer can override if needed rowClick(cell.row.original, event); } + + function removeWidthClasses(className: string): string { + return className + .split(' ') + .filter((part) => !isWidthClass(part)) + .join(' '); + }
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-view-options.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-view-options.svelte index 3aa0a09e99..9847193440 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-view-options.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-view-options.svelte @@ -16,6 +16,19 @@ } let { size = 'icon', table }: Props = $props(); + + const hideableColumns = $derived(table.getAllLeafColumns().filter((column) => column.getCanHide())); + const visibleHideableColumnCount = $derived(hideableColumns.filter((column) => column.getIsVisible()).length); + + function canToggleColumn(column: (typeof hideableColumns)[number]): boolean { + return !column.getIsVisible() || visibleHideableColumnCount > 1; + } + + function toggleColumn(column: (typeof hideableColumns)[number]): void { + if (canToggleColumn(column)) { + column.toggleVisibility(); + } + } @@ -30,12 +43,10 @@ Toggle columns - {#each table.getAllLeafColumns() as column (column.id)} - {#if column.getCanHide()} - column.toggleVisibility()}> - {column.columnDef.header} - - {/if} + {#each hideableColumns as column (column.id)} + toggleColumn(column)}> + {column.columnDef.header} + {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte index a14f11af01..ca328a662d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-actions.svelte @@ -16,10 +16,10 @@
{#if showClear} - + {/if} {#if toggleHidden} - + {/if} - +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte index 35f6048209..365e3ce714 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.svelte @@ -2,6 +2,7 @@ import type { KeywordFilter } from '$features/events/components/filters'; import type { Snippet } from 'svelte'; + import { Badge } from '$comp/ui/badge'; import { Button } from '$comp/ui/button'; import * as Command from '$comp/ui/command'; import * as Popover from '$comp/ui/popover'; @@ -54,6 +55,7 @@ }); const hiddenFilterCount = $derived(filters.filter((filter) => filter.hidden).length); + const hiddenFilterLabel = $derived(`${hiddenFilterCount} Hidden ${hiddenFilterCount === 1 ? 'Filter' : 'Filters'}`); const hasFilters = $derived(filters.length > 0); const visibleFacets = $derived(facets.filter((facet) => !facet.filter.hidden || showHiddenFilters)); @@ -184,13 +186,22 @@ {#snippet child({ props })} {/if} {#if filters.some((f) => f.type !== 'date')} - + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte index dc15aa08d7..a029fd7ea9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/notification/notification.svelte @@ -15,7 +15,7 @@ default: 'border-l-border bg-background text-foreground', destructive: 'border-l-red-500 bg-red-50 text-red-900 dark:bg-red-900/30 dark:text-red-200', impersonation: 'border-l-violet-500 bg-violet-50 text-violet-900 dark:bg-violet-900/30 dark:text-violet-200', - information: 'border-l-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-900/30 dark:text-blue-200', + information: 'border-l-primary bg-[#dff0d8] text-[#3c763d] dark:bg-[#dff0d8] dark:text-[#3c763d]', success: 'border-l-green-500 bg-green-50 text-green-900 dark:bg-green-900/30 dark:text-green-200', warning: 'border-l-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-900/30 dark:text-yellow-200' } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte index b994026ac9..c590c840fa 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte @@ -13,6 +13,8 @@ link: "text-primary underline-offset-4 hover:underline", // Custom semantic color variants — adapted to new [a]:hover: selector format red: "bg-red-100 text-red-700 [a]:hover:bg-red-200 border-transparent focus-visible:ring-red-400 dark:bg-red-900/30 dark:text-red-300 dark:[a]:hover:bg-red-900/50", + info: "bg-primary/10 text-foreground border-transparent [a]:hover:bg-primary/15 focus-visible:ring-primary/30 dark:bg-primary/15 dark:[a]:hover:bg-primary/20", + yellow: "bg-yellow-100 text-yellow-700 [a]:hover:bg-yellow-200 border-transparent focus-visible:ring-yellow-400 dark:bg-yellow-900/30 dark:text-yellow-300 dark:[a]:hover:bg-yellow-900/50", amber: "bg-amber-100 text-amber-700 [a]:hover:bg-amber-200 border-transparent focus-visible:ring-amber-400 dark:bg-amber-900/30 dark:text-amber-300 dark:[a]:hover:bg-amber-900/50", orange: "bg-orange-100 text-orange-700 [a]:hover:bg-orange-200 border-transparent focus-visible:ring-orange-400 dark:bg-orange-900/30 dark:text-orange-300 dark:[a]:hover:bg-orange-900/50", }, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/button/button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/button/button.svelte index ca773653fb..554a86f44d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/button/button.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/button/button.svelte @@ -3,14 +3,17 @@ import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements"; import { type VariantProps, tv } from "tailwind-variants"; + const primaryActionVariant = + "bg-[#4f9630] text-white hover:bg-[#427f28] focus-visible:border-[#427f28] focus-visible:ring-[#4f9630]/20 dark:bg-[#4f9630] dark:hover:bg-[#427f28]"; + export const buttonVariants = tv({ base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", variants: { variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + default: primaryActionVariant, outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - success: "bg-green-600 text-white hover:bg-green-700 focus-visible:border-green-700 focus-visible:ring-green-600/20 dark:bg-green-600 dark:hover:bg-green-700", + success: primaryActionVariant, ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", link: "text-primary underline-offset-4 hover:underline", @@ -21,10 +24,10 @@ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xl: "h-10 gap-2 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", - icon: "size-8", - "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", + icon: "size-8 [&_svg]:stroke-[2.5]", + "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3 [&_svg]:stroke-[2.5]", + "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg [&_svg]:stroke-[2.5]", + "icon-lg": "size-9 [&_svg]:stroke-[2.5]", }, }, defaultVariants: { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-dialog.svelte index 669ac82a57..1eafcb9b94 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/command/command-dialog.svelte @@ -36,7 +36,7 @@ - import { cn, type WithElementRef } from "$lib/utils.js"; - import type { HTMLAttributes } from "svelte/elements"; + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLAttributes } from "svelte/elements"; - let { - ref = $bindable(null), - class: className, - children, - ...restProps - }: WithElementRef> = $props(); + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef> = $props(); - - {@render children?.()} - + {@render children?.()} + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index cf87038dff..781a2865e9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -23,14 +23,14 @@ bind:indeterminate data-slot="dropdown-menu-checkbox-item" class={cn( - "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm whitespace-nowrap data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm whitespace-nowrap data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...restProps} > {#snippet children({ checked, indeterminate })} {#if indeterminate} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-item.svelte index 0c6e993acc..6448ac34e0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -20,7 +20,7 @@ data-inset={inset} data-variant={variant} class={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm whitespace-nowrap data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "focus:bg-accent focus:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm whitespace-nowrap data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...restProps} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts index af123c7f31..30f23a7e22 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { formatKeyboardShortcutForPlatform } from './keyboard-shortcuts'; +import { appKeyboardShortcuts, formatKeyboardShortcutForPlatform, isKeyboardShortcut } from './keyboard-shortcuts'; describe('formatKeyboardShortcutForPlatform', () => { it('should format modifier shortcuts for Apple platforms', () => { @@ -14,4 +14,19 @@ describe('formatKeyboardShortcutForPlatform', () => { expect(formatKeyboardShortcutForPlatform(['Shift', 'Mod', 'Q'], false)).toBe('⇧⌃Q'); expect(formatKeyboardShortcutForPlatform(['Alt'], false)).toBe('⎇'); }); + + it('should format single key shortcuts', () => { + expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.switchOrganization.keys, false)).toBe('O'); + expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.userMenu.keys, false)).toBe('U'); + expect(formatKeyboardShortcutForPlatform(appKeyboardShortcuts.keyboardShortcuts.keys, false)).toBe('?'); + }); + + it('should match shortcut keys case-insensitively', () => { + expect(isKeyboardShortcut({ key: 'o' } as KeyboardEvent, appKeyboardShortcuts.switchOrganization)).toBe(true); + expect(isKeyboardShortcut({ key: 'O' } as KeyboardEvent, appKeyboardShortcuts.switchOrganization)).toBe(true); + expect(isKeyboardShortcut({ key: 'p' } as KeyboardEvent, appKeyboardShortcuts.switchOrganization)).toBe(false); + expect(isKeyboardShortcut({ key: 'u' } as KeyboardEvent, appKeyboardShortcuts.userMenu)).toBe(true); + expect(isKeyboardShortcut({ key: 'U' } as KeyboardEvent, appKeyboardShortcuts.userMenu)).toBe(true); + expect(isKeyboardShortcut({ key: '?' } as KeyboardEvent, appKeyboardShortcuts.keyboardShortcuts)).toBe(true); + }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts index 66f3dc8205..41b855618a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/keyboard-shortcuts.ts @@ -1,17 +1,35 @@ const applePlatformPattern = /Mac|iPhone|iPad|iPod/i; -type ShortcutKey = 'Alt' | 'Mod' | 'Shift' | string; +export type KeyboardShortcut = { + key: string; + keys: readonly ShortcutKey[]; +}; -export function formatKeyboardShortcut(keys: ShortcutKey[]): string { +export type ShortcutKey = 'Alt' | 'Mod' | 'Shift' | string; + +export const appKeyboardShortcuts = { + allEvents: { key: 'e', keys: ['E'] }, + commandPalette: { key: '/', keys: ['/'] }, + issues: { key: 'i', keys: ['I'] }, + keyboardShortcuts: { key: '?', keys: ['?'] }, + switchOrganization: { key: 'o', keys: ['O'] }, + userMenu: { key: 'u', keys: ['U'] } +} as const satisfies Record; + +export function formatKeyboardShortcut(keys: readonly ShortcutKey[]): string { return formatKeyboardShortcutForPlatform(keys, isApplePlatform()); } -export function formatKeyboardShortcutForPlatform(keys: ShortcutKey[], isApplePlatformValue: boolean): string { +export function formatKeyboardShortcutForPlatform(keys: readonly ShortcutKey[], isApplePlatformValue: boolean): string { const formattedKeys = keys.map((key) => formatShortcutKey(key, isApplePlatformValue)); return formattedKeys.join(''); } +export function isKeyboardShortcut(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean { + return event.key.toLowerCase() === shortcut.key; +} + function formatShortcutKey(key: ShortcutKey, isApplePlatformValue: boolean): string { switch (key) { case 'Alt': diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts index abbf4293bf..2a78b84152 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/table.svelte.ts @@ -2,6 +2,7 @@ import type { FetchClientResponse } from '@exceptionless/fetchclient'; import { type ColumnDef, + type ColumnOrderState, type ColumnSort, type ColumnVisibilityState, createCoreRowModel, @@ -25,6 +26,7 @@ export interface TableConfiguration[]; configureOptions?: (options: TableOptions) => TableOptions; + defaultColumnOrder?: ColumnOrderState; defaultColumnVisibility?: ColumnVisibilityState; paginationStrategy: TPaginationStrategy; queryData?: TData[]; @@ -70,10 +72,18 @@ export function getSharedTableOptions{} ); + const columnVisibility = () => ({ ...configuration.defaultColumnVisibility, ...persistedColumnVisibility() }); + + const orderKey = configuration.columnPersistenceKey ? `${configuration.columnPersistenceKey}-column-order` : 'events-column-order'; + const [persistedColumnOrder, setPersistedColumnOrder] = createPersistedTableState(orderKey, configuration.defaultColumnOrder ?? []); + const columnOrder = () => sanitizeColumnOrder(persistedColumnOrder(), columns()); + const setColumnOrder = (updaterOrValue: Updater) => { + setPersistedColumnOrder(updaterOrValue instanceof Function ? updaterOrValue(resolveColumnOrder(columnOrder(), columns())) : updaterOrValue); + }; // Initialize pagination state from parameters const initialPageIndex = @@ -243,6 +253,7 @@ export function getSharedTableOptions(initialValue: T): [() => T, (updater: Updater) = ]; } +function getColumnIds(columns: ColumnDef[]): string[] { + return columns.flatMap((column) => { + const columnDefinition = column as { accessorKey?: number | string; columns?: ColumnDef[]; id?: string }; + if (columnDefinition.columns) { + return getColumnIds(columnDefinition.columns); + } + + if (columnDefinition.id) { + return [columnDefinition.id]; + } + + return typeof columnDefinition.accessorKey === 'string' ? [columnDefinition.accessorKey] : []; + }); +} + function hasSortQueryParameter(parameters: TablePagingParameters): parameters is TableCursorPagingParameters | TableOffsetPagingParameters { return Object.prototype.hasOwnProperty.call(parameters, 'sort'); } @@ -362,6 +391,18 @@ function parseSortString(sort: string | undefined): ColumnSort[] { .filter((value) => value.id.length > 0); } +function resolveColumnOrder(columnOrder: ColumnOrderState, columns: ColumnDef[]): ColumnOrderState { + const defaultColumnOrder = getColumnIds(columns); + const explicitColumnOrder = columnOrder.filter((columnId, index) => defaultColumnOrder.includes(columnId) && columnOrder.indexOf(columnId) === index); + const nextColumnOrder = [...explicitColumnOrder, ...defaultColumnOrder.filter((columnId) => !explicitColumnOrder.includes(columnId))]; + + return defaultColumnOrder.includes('select') ? ['select', ...nextColumnOrder.filter((columnId) => columnId !== 'select')] : nextColumnOrder; +} + +function sanitizeColumnOrder(columnOrder: ColumnOrderState, columns: ColumnDef[]): ColumnOrderState { + return columnOrder.length === 0 ? columnOrder : resolveColumnOrder(columnOrder, columns); +} + function serializeSortState(sorting: ColumnSort[]): string | undefined { return sorting.length > 0 ? sorting.map((sort) => `${sort.desc ? '-' : ''}${sort.id}`).join(',') : undefined; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts index 4ec8e663a0..6f7e021ba2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/options.svelte.ts @@ -61,7 +61,7 @@ export function getColumns(onTagClick?: (tag: string) => void): ColumnDef('status'), - cell: (prop) => renderComponent(StackStatusCell, { value: prop.getValue() }), + cell: (prop) => renderComponent(StackStatusCell, { value: prop.getValue() }), header: 'Status', id: 'status', meta: { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte index 86a162b57e..b3f6171bc8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/table/stack-status-cell.svelte @@ -1,29 +1,15 @@ -{statusLabels[value] ?? value} +{label} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index fa51579a5b..d607b802a3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -140,8 +140,7 @@ export interface NewSavedView { view_type: string; filter_definitions?: null | string; columns?: null | Record; - /** If true, this view will be the default for its view type. Defaults to false. */ - is_default?: null | boolean; + column_order?: null | string[]; /** If true, the view will only be visible to the current user. Defaults to false. */ is_private?: null | boolean; } @@ -368,12 +367,12 @@ export interface UpdateProject { /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateSavedView { name?: null | string; - is_default?: null | boolean; filter?: null | string; time?: null | string; sort?: null | string; filter_definitions?: null | string; columns?: null | Record; + column_order?: null | string[]; } /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ @@ -565,7 +564,7 @@ export interface ViewSavedView { filter?: null | string; filter_definitions?: null | string; columns?: null | Record; - is_default: boolean; + column_order?: null | string[]; name: string; time?: null | string; sort?: null | string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 0f7bd2f559..8667139ebb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -197,11 +197,11 @@ export const NewSavedViewSchema = object({ view_type: string().min(1, "View type is required"), filter_definitions: string() .min(1, "Filter definitions is required") - .max(10000, "Filter definitions must be at most 10000 characters") + .max(100000, "Filter definitions must be at most 100000 characters") .nullable() .optional(), columns: record(string(), boolean()).nullable().optional(), - is_default: boolean().nullable().optional(), + column_order: array(string()).nullable().optional(), is_private: boolean().nullable().optional(), }); export type NewSavedViewFormData = Infer; @@ -415,7 +415,6 @@ export type UpdateProjectFormData = Infer; export const UpdateSavedViewSchema = object({ name: string().min(1, "Name is required").nullable().optional(), - is_default: boolean().nullable().optional(), filter: string().min(1, "Filter is required").nullable().optional(), time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), @@ -424,6 +423,7 @@ export const UpdateSavedViewSchema = object({ .nullable() .optional(), columns: record(string(), boolean()).nullable().optional(), + column_order: array(string()).nullable().optional(), }); export type UpdateSavedViewFormData = Infer; @@ -612,7 +612,7 @@ export const ViewSavedViewSchema = object({ .nullable() .optional(), columns: record(string(), boolean()).nullable().optional(), - is_default: boolean(), + column_order: array(string()).nullable().optional(), name: string().min(1, "Name is required"), time: string().min(1, "Time is required").nullable().optional(), sort: string().min(1, "Sort is required").nullable().optional(), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/keyboard-shortcuts-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/keyboard-shortcuts-dialog.svelte new file mode 100644 index 0000000000..6934c978b5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/keyboard-shortcuts-dialog.svelte @@ -0,0 +1,105 @@ + + + + + + Keyboard Shortcuts + + Quick actions available from the app shell. + + + +
+ {#each shortcutSections as section (section.title)} +
+

+ {section.title} +

+
+ {#each section.rows as row (row.action)} +
+ {row.action} +
+ {#each row.shortcuts as shortcut, index (shortcut.join('+'))} + {#if index > 0} + or + {/if} + + {shortcutLabel(shortcut)} + + {/each} +
+
+ {/each} +
+
+ {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index e834a76f2a..5162743c7b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -5,7 +5,6 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import DelayedRender from '$comp/delayed-render.svelte'; - import { A } from '$comp/typography'; import * as Avatar from '$comp/ui/avatar/index'; import * as DropdownMenu from '$comp/ui/dropdown-menu/index'; import * as Sidebar from '$comp/ui/sidebar/index'; @@ -20,21 +19,46 @@ import Plus from '@lucide/svelte/icons/plus'; import Settings from '@lucide/svelte/icons/settings'; import UserRoundSearch from '@lucide/svelte/icons/user-round-search'; + import { tick } from 'svelte'; type Props = HTMLAttributes & { currentOrganizationId: string | undefined; impersonatedOrganization: undefined | ViewOrganization; isLoading: boolean; + open?: boolean; organizations: undefined | ViewOrganization[]; }; - let { class: className, currentOrganizationId = $bindable(), impersonatedOrganization, isLoading, organizations = [] }: Props = $props(); + let { + class: className, + currentOrganizationId = $bindable(), + impersonatedOrganization, + isLoading, + open = $bindable(false), + organizations = [] + }: Props = $props(); const sidebar = useSidebar(); const activeOrganization = $derived(impersonatedOrganization ?? organizations.find((organization) => organization.id === currentOrganizationId)); const isImpersonating = $derived(!!impersonatedOrganization); + let menuContentElement = $state(null); let openImpersonateDialog = $state(false); + $effect(() => { + if (open) { + void focusActiveOrganizationItem(); + } + }); + + async function focusActiveOrganizationItem(): Promise { + await tick(); + await new Promise((resolve) => window.setTimeout(resolve)); + ( + menuContentElement?.querySelector('[data-current-organization="true"]') ?? + menuContentElement?.querySelector('[data-slot="dropdown-menu-item"]') + )?.focus(); + } + function onOrganizationSelected(organization: ViewOrganization): void { if (sidebar.isMobile) { sidebar.toggle(); @@ -56,12 +80,16 @@ await goto(resolve('/(app)')); currentOrganizationId = organizations[0]?.id; } + + async function navigateTo(href: string): Promise { + await goto(href); + } {#if organizations.length > 0 || isImpersonating} - + {#snippet child({ props })} onOrganizationSelected(organization)} - data-active={organization.id === currentOrganizationId && !isImpersonating} - class="data-[active=true]:bg-accent data-[active=true]:text-accent-foreground gap-2 p-2" + data-current-organization={organization.id === currentOrganizationId && !isImpersonating ? 'true' : undefined} + class="gap-2 p-2" > {getInitials(organization.name)} @@ -120,31 +149,25 @@
- No organizations available + No Organizations Available
{/if} {#if activeOrganization?.id} - - -
-
- Manage Organization -
-
- {/if} - - + void navigateTo(resolve('/(app)/organization/[organizationId]/manage', { organizationId: activeOrganization.id }))} + >
-
- Add organization -
+ Manage Organization +
+ {/if} + void navigateTo(resolve('/(app)/organization/add'))}> +
+
+ Add Organization
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index 03264e27eb..eee878c070 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -3,9 +3,9 @@ import type { Gravatar } from '$features/users/gravatar.svelte'; import type { ViewCurrentUser } from '$features/users/models'; + import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import GitHubIcon from '$comp/icons/GitHubIcon.svelte'; - import { A } from '$comp/typography'; import * as Avatar from '$comp/ui/avatar/index'; import { Badge } from '$comp/ui/badge'; import * as DropdownMenu from '$comp/ui/dropdown-menu/index'; @@ -30,12 +30,24 @@ intercomUnreadCount: number; isChatEnabled: boolean; isLoading: boolean; + open?: boolean; openChat: () => void; + openKeyboardShortcuts: () => Promise | void; organizations?: ViewOrganization[]; user: undefined | ViewCurrentUser; } - let { gravatar, intercomUnreadCount = 0, isChatEnabled, isLoading, openChat, organizations = [], user }: Props = $props(); + let { + gravatar, + intercomUnreadCount = 0, + isChatEnabled, + isLoading, + open = $bindable(false), + openChat, + openKeyboardShortcuts, + organizations = [], + user + }: Props = $props(); const sidebar = useSidebar(); const currentOrganizationId = $derived(organizations.find((organizationItem) => organizationItem.id === organization.current)?.id); @@ -53,6 +65,21 @@ onMenuClick(); openChat(); } + + function onKeyboardShortcutsClick() { + onMenuClick(); + void openKeyboardShortcuts(); + } + + function navigateTo(href: string): void { + onMenuClick(); + void goto(href); + } + + function openExternalLink(href: string): void { + onMenuClick(); + window.open(href, '_blank', 'noopener,noreferrer'); + } {#if isLoading} @@ -78,7 +105,7 @@ >