diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml
index a7ffb565fb..6e4fd0b435 100644
--- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml
+++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml
@@ -1,18 +1,9 @@
@page
@model Unity.AI.Web.Pages.AIReporting.IndexModel
-@section styles {
- @if (Model.CanViewAiReporting)
- {
-
- }
-}
-
@section scripts {
@if (Model.CanViewAiReporting)
{
-
-
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs
index cbf5075072..7f07112a4b 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs
@@ -152,7 +152,7 @@ public async Task> GetPagedPaymentRequestsWithIncludesAsync
if (includeTags)
{
paymentsQueryable = paymentsQueryable
- .Include(pr => pr.PaymentTags)
+ .Include(pr => pr.PaymentTags!)
.ThenInclude(pt => pt.Tag);
}
@@ -161,9 +161,7 @@ public async Task> GetPagedPaymentRequestsWithIncludesAsync
paymentsQueryable = paymentsQueryable.Include(pr => pr.ExpenseApprovals);
}
-#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
var paymentWithIncludes = await paymentsQueryable.ToListAsync();
-#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
return paymentWithIncludes;
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationMapperlyProfile.cs
index 2eb6dcf743..10ff71d7e1 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationMapperlyProfile.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationMapperlyProfile.cs
@@ -24,11 +24,15 @@ public partial class PaymentRequestToPaymentRequestDtoMapper : MapperBase 0;
- $('.grp-savedStates').text(savedStatesExist ? 'Saved States' : 'Save View');
+ $('.grp-savedStates').text('Save View');
}
dataTable.on('stateRestore-change', updateSavedStatesButtonText);
diff --git a/applications/Unity.GrantManager/sonar-project.properties b/applications/Unity.GrantManager/sonar-project.properties
index f5d66f8714..4cb6670b8c 100644
--- a/applications/Unity.GrantManager/sonar-project.properties
+++ b/applications/Unity.GrantManager/sonar-project.properties
@@ -16,7 +16,7 @@ sonar.tests=test
sonar.qualitygate.wait=true
# SonarQube Exclusions (generic build artifacts and generated code only)
-sonar.exclusions=src/Unity.GrantManager.EntityFrameworkCore/Migrations/**,src/Unity.GrantManager.EntityFrameworkCore/Scripts/**,**/bin/**,**/obj/**,**/wwwroot/lib/**,**/*.Designer.cs,**/node_modules/**
+sonar.exclusions=src/Unity.GrantManager.EntityFrameworkCore/Migrations/**,src/Unity.GrantManager.EntityFrameworkCore/Scripts/**,**/bin/**,**/obj/**,**/wwwroot/libs/**,**/*.Designer.cs,**/node_modules/**
# Rule exclusion: javascript:S7721 "Functions should be moved to the highest possible scope"
# Disabled project-wide for JS. ABP renders pages by composing many independent widgets,
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Dockerfile b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Dockerfile
index c6104449bc..18adafbc44 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Dockerfile
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/Dockerfile
@@ -3,16 +3,10 @@ WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
-COPY ["NuGet.Config", "."]
-COPY ["src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj", "src/Unity.GrantManager.DbMigrator/"]
-COPY ["src/Unity.GrantManager.Application.Contracts/Unity.GrantManager.Application.Contracts.csproj", "src/Unity.GrantManager.Application.Contracts/"]
-COPY ["src/Unity.GrantManager.Domain.Shared/Unity.GrantManager.Domain.Shared.csproj", "src/Unity.GrantManager.Domain.Shared/"]
-COPY ["src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj", "src/Unity.GrantManager.EntityFrameworkCore/"]
-COPY ["src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj", "src/Unity.GrantManager.Domain/"]
-RUN dotnet restore "src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj"
COPY . .
+RUN dotnet restore "src/Unity.GrantManager.DbMigrator/Unity.GrantManager.DbMigrator.csproj"
WORKDIR "/src/src/Unity.GrantManager.DbMigrator"
-RUN dotnet build "Unity.GrantManager.DbMigrator.csproj" -c Release -o /app/build
+RUN dotnet build "Unity.GrantManager.DbMigrator.csproj" -c Release -o /app/build --no-restore
FROM build AS publish
RUN dotnet publish "Unity.GrantManager.DbMigrator.csproj" -c Release -o /app/publish /p:UseAppHost=false
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/README.md
index 21a27a7214..9aa057d9fd 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/README.md
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/README.md
@@ -41,6 +41,10 @@ Because the file is optional, its absence is not an error — omit it entirely i
## Running the Migrator
+The DbMigrator enables EF Core command logging by default so the migration plan,
+SQL command execution, and final migration state are visible in container logs.
+Sensitive data logging is not supported by the DbMigrator.
+
Once you've configured your connection strings via `appsettings.secrets.json` (or environment variables), you can run the migrator:
```bash
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs
index f300cb738c..d80b2df208 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Data/GrantManagerDbMigrationService.cs
@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -31,7 +30,8 @@ public GrantManagerDbMigrationService(
IEnumerable dbSchemaMigrators,
ITenantRepository tenantRepository,
ICurrentTenant currentTenant,
- TenantConnectionStringEncryptionMigrator connectionStringEncryptionMigrator)
+ TenantConnectionStringEncryptionMigrator connectionStringEncryptionMigrator,
+ ILogger logger)
{
_dataSeeder = dataSeeder;
_dbSchemaMigrators = dbSchemaMigrators;
@@ -39,7 +39,7 @@ public GrantManagerDbMigrationService(
_currentTenant = currentTenant;
_connectionStringEncryptionMigrator = connectionStringEncryptionMigrator;
- Logger = NullLogger.Instance;
+ Logger = logger;
}
public async Task MigrateAsync()
@@ -59,6 +59,7 @@ public async Task MigrateAsync()
await _connectionStringEncryptionMigrator.MigrateAsync();
var tenants = await _tenantRepository.GetListAsync(includeDetails: true);
+ Logger.LogInformation("Found {TenantCount} tenants to process.", tenants.Count);
var migratedDatabaseSchemas = new HashSet();
@@ -76,6 +77,11 @@ public async Task MigrateAndSeedTenantAsync(HashSet migratedDatabaseSche
{
if (tenant == null) { return; }
+ Logger.LogInformation(
+ "Processing tenant {TenantName} with {ConnectionStringCount} configured connection strings.",
+ tenant.Name,
+ tenant.ConnectionStrings.Count);
+
using (_currentTenant.Change(tenant.Id))
{
if (tenant.ConnectionStrings.Count > 0)
@@ -94,6 +100,11 @@ public async Task MigrateAndSeedTenantAsync(HashSet migratedDatabaseSche
await SeedDataAsync(tenant);
}
+
+ if (tenant.ConnectionStrings.Count == 0)
+ {
+ Logger.LogWarning("Skipping schema migration for tenant {TenantName}: no connection string is configured.", tenant.Name);
+ }
}
private async Task MigrateDatabaseSchemaAsync(Tenant? tenant = null)
@@ -121,6 +132,7 @@ private async Task SeedDataAsync(Tenant? tenant = null)
catch (Exception ex)
{
Logger.LogError(ex, "An error occurred while seeding {Database} database data.", tenant == null ? "host" : tenant.Name + TenantSuffix);
+ throw;
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs
index 7ec5d02662..132701f78d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs
@@ -12,6 +12,7 @@
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity;
using Volo.Abp.MultiTenancy;
+using Volo.Abp.Uow;
namespace Unity.GrantManager;
@@ -19,7 +20,8 @@ public class GrantManagerDataSeederContributor(
IApplicationStatusRepository applicationStatusRepository,
IPersonRepository personRepository,
IIdentityUserRepository userRepository,
- ICurrentTenant currentTenant) : IDataSeedContributor, ITransientDependency
+ ICurrentTenant currentTenant,
+ IUnitOfWorkManager unitOfWorkManager) : IDataSeedContributor, ITransientDependency
{
public static class GrantApplicationStates
{
@@ -79,7 +81,9 @@ private async Task SeedApplicationStatusAsync()
{
try
{
+ using var unitOfWork = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
await applicationStatusRepository.InsertAsync(status, autoSave: true);
+ await unitOfWork.CompleteAsync();
}
catch (Exception ex) when (IsDuplicateStatusCodeException(ex))
{
@@ -100,15 +104,24 @@ private async Task SeedAiScoringPersonAsync(System.Guid? tenantId)
var existing = await personRepository.FirstOrDefaultAsync(p => p.Id == AIScoringConstants.AiPersonId);
if (existing == null)
{
- await personRepository.InsertAsync(new Person
+ try
+ {
+ using var unitOfWork = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
+ await personRepository.InsertAsync(new Person
+ {
+ Id = AIScoringConstants.AiPersonId,
+ OidcSub = AIScoringConstants.AiOidcSub,
+ OidcDisplayName = AIScoringConstants.AiDisplayName,
+ FullName = AIScoringConstants.AiDisplayName,
+ Badge = AIScoringConstants.AiBadge,
+ TenantId = tenantId
+ }, autoSave: true);
+ await unitOfWork.CompleteAsync();
+ }
+ catch (Exception ex) when (ex.ToString().Contains("PK_Persons"))
{
- Id = AIScoringConstants.AiPersonId,
- OidcSub = AIScoringConstants.AiOidcSub,
- OidcDisplayName = AIScoringConstants.AiDisplayName,
- FullName = AIScoringConstants.AiDisplayName,
- Badge = AIScoringConstants.AiBadge,
- TenantId = tenantId
- });
+ // Another concurrent seeder instance inserted the person first; safe to ignore.
+ }
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs
index 2e1ca2113f..0b68fd752b 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/EntityFrameworkCoreGrantManagerDbSchemaMigrator.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
@@ -7,6 +8,7 @@
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Npgsql;
using Unity.GrantManager.Data;
using Volo.Abp.DependencyInjection;
@@ -15,7 +17,10 @@
namespace Unity.GrantManager.EntityFrameworkCore;
-public class EntityFrameworkCoreGrantManagerDbSchemaMigrator
+public class EntityFrameworkCoreGrantManagerDbSchemaMigrator(
+ IServiceProvider serviceProvider,
+ IStringEncryptionService encryptionService,
+ ILogger logger)
: IGrantManagerDbSchemaMigrator, ITransientDependency
{
/* Migration history was squashed to a single "Initial" migration per context
@@ -37,16 +42,9 @@ public class EntityFrameworkCoreGrantManagerDbSchemaMigrator
private const string TenantInitialMigrationId = "20260721203242_Initial";
private const string EfCoreProductVersion = "10.0.3";
- private readonly IServiceProvider _serviceProvider;
- private readonly IStringEncryptionService _encryptionService;
-
- public EntityFrameworkCoreGrantManagerDbSchemaMigrator(
- IServiceProvider serviceProvider,
- IStringEncryptionService encryptionService)
- {
- _serviceProvider = serviceProvider;
- _encryptionService = encryptionService;
- }
+ private readonly IServiceProvider _serviceProvider = serviceProvider;
+ private readonly IStringEncryptionService _encryptionService = encryptionService;
+ private readonly ILogger _logger = logger;
public async Task MigrateAsync(Tenant? tenant)
{
@@ -87,8 +85,6 @@ public async Task MigrateAsync(Tenant? tenant)
// Create the PostgreSQL role (idempotent via DO block)
await CreateRoleIfNotExistsAsync(adminConnectionString, roleName, rolePassword);
- // Create the database if it does not exist; use the admin connection so
- // MigrateAsync connects cleanly and avoids logging a ConnectionError.
var tenantDb = _serviceProvider
.GetRequiredService()
.Database;
@@ -111,7 +107,7 @@ await tenantDb.ExecuteSqlRawAsync(
await ReconcileMigrationHistoryAsync(tenantDb, TenantInitialMigrationId);
// Run migrations as admin against the tenant database
- await tenantDb.MigrateAsync();
+ await MigrateAndLogAsync(tenantDb, $"tenant:{tenant.Name}");
// Grant table and sequence privileges after migrations have created all objects
await GrantTablePrivilegesAsync(adminTenantConnectionString, roleName);
@@ -143,21 +139,62 @@ the correct one. */
.GetRequiredService()
.Database;
- // The database itself may not exist yet on a brand new Postgres instance.
- // MigrateAsync() would normally create it as its first step, but
- // ReconcileMigrationHistoryAsync needs to connect before that, so ensure
- // it exists here first (mirrors the tenant path further up).
if (!await hostDb.CanConnectAsync())
{
- await hostDb.GetService().CreateAsync();
+ var configuration = _serviceProvider.GetRequiredService();
+ var hostConnectionString = configuration.GetConnectionString(GrantManagerConsts.DefaultConnectionStringName)
+ ?? throw new InvalidOperationException($"Connection string '{GrantManagerConsts.DefaultConnectionStringName}' is not configured.");
+ var hostCsb = new NpgsqlConnectionStringBuilder(hostConnectionString);
+ var hostDatabaseName = hostCsb.Database
+ ?? throw new InvalidOperationException("Host connection string is missing the Database value.");
+ EnsureSafeIdentifier(hostDatabaseName, "host database name");
+
+ hostCsb.Database = "postgres";
+ if (!CanCreateDatabaseLocally())
+ {
+ throw new InvalidOperationException(
+ $"Host database '{hostDatabaseName}' does not exist and automatic database creation is disabled outside local execution.");
+ }
+
+ await CreateDatabaseIfNotExistsAsync(hostCsb.ToString(), hostDatabaseName);
}
+ await hostDb.ExecuteSqlRawAsync(
+ hostDb.GetService().GetCreateIfNotExistsScript());
+
await ReconcileMigrationHistoryAsync(hostDb, HostInitialMigrationId);
- await hostDb.MigrateAsync();
+ await MigrateAndLogAsync(hostDb, "host");
}
}
+ private static bool CanCreateDatabaseLocally()
+ {
+ return string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST"))
+ && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENSHIFT_BUILD_NAME"));
+ }
+
+ private async Task MigrateAndLogAsync(DatabaseFacade database, string contextName)
+ {
+ var pendingMigrations = (await database.GetPendingMigrationsAsync()).ToArray();
+ var appliedMigrations = (await database.GetAppliedMigrationsAsync()).ToArray();
+
+ _logger.LogInformation(
+ "Migration plan for {Context}: applied={AppliedMigrations}, pending={PendingMigrations}",
+ contextName,
+ appliedMigrations.Length == 0 ? "none" : string.Join(",", appliedMigrations),
+ pendingMigrations.Length == 0 ? "none" : string.Join(",", pendingMigrations));
+
+ await database.MigrateAsync();
+
+ var finalMigrations = (await database.GetAppliedMigrationsAsync()).ToArray();
+ _logger.LogInformation(
+ "Migration completed for {Context}: result={Result}, final={FinalMigrations}",
+ contextName,
+ pendingMigrations.Length == 0 ? "NoPendingMigrations" : "AppliedMigrations",
+ finalMigrations.Length == 0 ? "none" : string.Join(",", finalMigrations));
+ }
+
private static async Task ReconcileMigrationHistoryAsync(DatabaseFacade database, string initialMigrationId)
{
// initialMigrationId/EfCoreProductVersion are hardcoded constants above, never
@@ -178,6 +215,28 @@ IF EXISTS (SELECT 1 FROM public."__EFMigrationsHistory" WHERE "MigrationId" <> '
#pragma warning restore EF1002
}
+ private static async Task CreateDatabaseIfNotExistsAsync(string adminConnectionString, string databaseName)
+ {
+ await using var connection = new NpgsqlConnection(adminConnectionString);
+ await connection.OpenAsync();
+
+ await using (var existsCommand = new NpgsqlCommand(
+ "SELECT 1 FROM pg_database WHERE datname = @databaseName",
+ connection))
+ {
+ existsCommand.Parameters.AddWithValue("databaseName", databaseName);
+
+ if (await existsCommand.ExecuteScalarAsync() != null)
+ {
+ return;
+ }
+ }
+
+ await using var createCommand = connection.CreateCommand();
+ createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
+ await createCommand.ExecuteNonQueryAsync();
+ }
+
// Decrypt the stored value — plain-text rows (pre-encryption) fall back to their original value.
// A successful decrypt that produces non-connection-string output (wrong passphrase returning garbage)
// is also rejected by the Contains('=') check so we surface a meaningful error rather than a
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs
index 9a68968726..2a48fe23ae 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerEntityFrameworkCoreModule.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using System;
using Volo.Abp.AuditLogging.EntityFrameworkCore;
using Volo.Abp.BackgroundJobs.EntityFrameworkCore;
@@ -56,7 +57,11 @@ public override void ConfigureServices(ServiceConfigurationContext context)
{
/* The main point to change your DBMS.
* See also GrantManagerMigrationsDbContextFactory for EF Core tooling. */
- options.UseNpgsql();
+ options.Configure(dbContextConfiguration =>
+ {
+ dbContextConfiguration.UseNpgsql();
+ dbContextConfiguration.DbContextOptions.LogTo(Console.WriteLine, LogLevel.Information);
+ });
});
}
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml
index 51a67ef65e..c7c3b6f584 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml
@@ -87,7 +87,7 @@ else
- Loading...
+ Loading...
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js
index 46c9c51dd3..79bf2852df 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js
@@ -653,14 +653,9 @@ $(function () {
// Initialize button styling
$('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view');
- // Update button text based on whether any named saved views exist.
- // Driven by the StateRestore extension's own 'stateRestore-change' event (the same
- // event it uses internally to update its default label) rather than a localStorage
- // key guess or a one-shot draw handler - those raced against the extension's own
- // loading of previously-saved states from storage on page refresh.
+ // Update button text based
function updateSavedStatesButtonText() {
- const savedStatesExist = dataTable.stateRestore.states().length > 0;
- $('.grp-savedStates').text(savedStatesExist ? 'Saved States' : 'Save View');
+ $('.grp-savedStates').text('Save View');
}
dataTable.on('stateRestore-change', updateSavedStatesButtonText);
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js
index 7e4992918b..e655747a58 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js
@@ -8,7 +8,6 @@
let worksheetMapColumn = document.querySelector('#worksheet-map-available-fields-column');
let excludedIntakeMappings = new Set(['ConfirmationId', 'SubmissionId', 'SubmissionDate']);
let dataTable;
- if (globalThis.toastr) { toastr.options.positionClass = 'toast-top-center'; }
let allowableTypes = new Set(['textarea',
'orgbook',
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.cshtml
index be2f3c00af..5cfb432069 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.cshtml
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.cshtml
@@ -3,17 +3,14 @@
@using Unity.GrantManager.Web.Pages.Dashboard
@section styles {
+
+
-
-
}
@section scripts
{
+
-
-
-
-
}
@@ -32,32 +29,27 @@
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css
index a6efc06f1b..fd85e840da 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.css
@@ -24,10 +24,6 @@
justify-content: center;
}
-.application-dashboard-dropdown select {
- display: none !important;
-}
-
.application-dashboard-date {
min-width: 250px;
margin-top: 10px;
@@ -35,16 +31,136 @@
font-weight: bold;
}
-.bs-actionsbox {
- margin-top: 4px;
-}
-
-.bs-actionsbox .btn-group {
+/* Select All / Deselect All controls injected into the Select2 dropdown,
+ replacing bootstrap-select's actionsBox. */
+.dashboard-actionsbox {
display: flex;
justify-content: space-between;
- gap: 8px;
+ gap: var(--bc-layout-margin-small);
+ padding: var(--bc-layout-margin-xsmall);
}
-.bs-actionsbox .btn-group button {
+.dashboard-actionsbox button {
+ flex: 1 1 auto;
width: auto;
+}
+
+/* The filter dropdowns always show a fixed label, so style them as the
+ toolbar buttons they replace (see .custom-table-btn in unity-styles.css)
+ rather than as multi-select inputs. */
+.application-dashboard-dropdown .select2-container--bootstrap-5 .select2-selection {
+ position: relative;
+ /* Fixed rather than content-driven so all six stay level: "SUB-STATUS" is
+ long enough to wrap and would otherwise make its box taller. Not
+ --bc-input-field-height (34px), which nothing in this toolbar uses. */
+ height: 38px;
+ min-height: 38px;
+ display: flex;
+ align-items: center;
+ padding: 0 1.75rem 0 0.75rem;
+ /* Matches the literal in .custom-table-btn; the theme has no radius token. */
+ border-radius: 5px;
+ background-color: var(--bc-colors-white-primary-500);
+}
+
+.application-dashboard-dropdown .select2-container--bootstrap-5 .select2-selection:hover {
+ border-color: var(--bc-colors-blue-primary);
+}
+
+/* The label is written straight into the
, so strip its list styling and
+ keep it on one line: "SUB-STATUS" wrapping made that box taller than the
+ other five and broke the row alignment. */
+.application-dashboard-dropdown .select2-container--bootstrap-5 .select2-selection__rendered {
+ display: block;
+ /* Grow to fill the button: overflow:hidden below zeroes a flex item's
+ automatic minimum size, so without this the label collapses and
+ ellipsises even though there is room for it. */
+ flex: 1 1 auto;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ color: var(--bc-colors-blue-primary);
+ /* These filters replaced buttons styled with .btn/.dt-buttons, so use the
+ theme's button size rather than the base body size. layout.css feeds the
+ same variable into --bs-btn-font-size. */
+ font-size: var(--bc-font-size-btn);
+ font-weight: 700;
+ text-transform: uppercase;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Select2 only draws an arrow for single selects, so supply one. */
+.application-dashboard-dropdown .select2-container--bootstrap-5 .select2-selection::after {
+ content: "";
+ position: absolute;
+ right: 0.75rem;
+ top: 50%;
+ transform: translateY(-25%);
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 5px solid var(--bc-colors-blue-primary);
+ pointer-events: none;
+}
+
+/* For multiple selects Select2 renders the search field inline in the control,
+ as a sibling of the label, and the two compete for width. Rather than move
+ the field (which detaches it from Select2's filtering), swap which one is
+ visible: label while closed, search while open. */
+.application-dashboard-dropdown .select2-container:not(.select2-container--open) .select2-search--inline {
+ display: none;
+}
+
+.application-dashboard-dropdown .select2-container--open .select2-selection__rendered {
+ display: none;
+}
+
+/* Select2 sizes the inline search field to its content, which clips the
+ placeholder to a character or two. The label is hidden while open, so give
+ the field the whole control. */
+.application-dashboard-dropdown .select2-container--open .select2-search--inline,
+.application-dashboard-dropdown .select2-container--open .select2-search--inline .select2-search__field {
+ width: 100% !important;
+ margin: 0;
+}
+
+/* Everything below is scoped to dashboard-filter-dropdown (set via
+ dropdownCssClass) because Select2 renders panels at the end of ,
+ outside .application-dashboard-dropdown. */
+
+/* Selected entries get a light tint and a checkmark rather than the theme's
+ solid fill, which turned the whole list blue given these filters default to
+ everything selected.
+ These selectors deliberately mirror the shape of the ones in
+ select2-bootstrap-5-theme.css and add .dashboard-filter-dropdown on top:
+ the theme's are five classes deep, so anything shorter loses to them. */
+.select2-container--bootstrap-5 .select2-dropdown.dashboard-filter-dropdown .select2-results__options .select2-results__option.select2-results__option--selected {
+ /* Same tint the theme uses for pressed state and DataTables selected rows
+ (--dt-row-selected is the same colour), so selection reads consistently. */
+ background-color: var(--bc-colors-grey-pressed);
+ color: var(--bc-type-color-primary);
+}
+
+.select2-container--bootstrap-5 .select2-dropdown.dashboard-filter-dropdown .select2-results__options .select2-results__option.select2-results__option--selected::after {
+ content: "\2713";
+ float: right;
+ color: var(--bc-type-color-primary);
+}
+
+/* Keyboard position: subtle, so it does not look like a hover left behind.
+ Declared before :hover so that hovering the keyboard-highlighted option
+ still shows the hover colour, since both carry the same specificity. */
+.select2-container--bootstrap-5 .select2-dropdown.dashboard-filter-dropdown .select2-results__options .select2-results__option.select2-results__option--highlighted {
+ background-color: var(--bc-colors-grey-hover);
+ color: var(--bc-type-color-primary);
+}
+
+/* Mouse feedback comes from :hover, not Select2's --highlighted class, which
+ marks the keyboard position and lingers once the pointer leaves the list.
+ --bc-colors-blue-hover is the theme's hover tint and is light enough to keep
+ the text and checkmark readable in their normal colour. */
+.select2-container--bootstrap-5 .select2-dropdown.dashboard-filter-dropdown .select2-results__options .select2-results__option:hover {
+ background-color: var(--bc-colors-blue-hover);
+ color: var(--bc-type-color-primary);
}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js
index 270eafe0f8..adb540d9d1 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Dashboard/Index.js
@@ -407,18 +407,266 @@ $('#dashboardIntakeId').change(function () {
reloadDashboard();
});
-function highlightSelected(dropdownId,title) {
+// These dropdowns always show a fixed label (e.g. "STATUS") rather than the
+// current selection, so the filter bar keeps a constant width.
+function setDropdownLabel(dropdownId, title) {
+ $('#' + dropdownId)
+ .next('.select2-container')
+ .find('.select2-selection__rendered')
+ .attr('title', title)
+ .text(title);
+}
+
+function highlightSelected(dropdownId, title) {
$('#' + dropdownId + ' option:selected').addClass('dt-button-active');
$('#' + dropdownId + ' option:not(:selected)').removeClass('dt-button-active');
- $('#' + dropdownId).selectpicker('refresh');
- $('#' + dropdownId).closest('.bootstrap-select').find('.btn .filter-option-inner-inner').html(title);
- $('#' + dropdownId).closest('.bootstrap-select').find('.btn').removeClass('bs-placeholder');
+ setDropdownLabel(dropdownId, title);
+}
+
+// Select2 has no equivalent of bootstrap-select's actionsBox, so build one.
+function addSelectAllControls($select) {
+ const $dropdown = $('.select2-container--open .select2-dropdown');
+ if ($dropdown.find('.dashboard-actionsbox').length) {
+ return;
+ }
+
+ const $box = $('');
+ const $selectAll = $('');
+ const $deselectAll = $('');
+
+ const setAll = (selected) => (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ $select.find('option').prop('selected', selected);
+ // Fires the inline onchange (reloadDashboard) plus our own change handlers.
+ $select.trigger('change');
+ syncResultRows($select);
+ };
+
+ $selectAll.on('click', setAll(true));
+ $deselectAll.on('click', setAll(false));
+
+ $box.append($selectAll, $deselectAll);
+ $dropdown.prepend($box);
+
+ // Select2 leaves --highlighted on the last option the mouse passed over,
+ // because it doubles as the keyboard position. Left alone it looks like a
+ // hover that never cleared, so drop it once the pointer leaves the list.
+ // Select2 reapplies it on the next mousemove or arrow key.
+ $dropdown.find('.select2-results')
+ .off('mouseleave.dashboardFilters')
+ .on('mouseleave.dashboardFilters', function () {
+ $(this).find('.select2-results__option--highlighted')
+ .removeClass('select2-results__option--highlighted');
+ });
+}
+
+// Result rows are matched to their
- Loading...
+ Loading...
@@ -129,7 +129,7 @@
- Loading...
+ Loading...
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js
index 072af00edd..9cfeae270f 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js
@@ -398,7 +398,7 @@ $(function () {
const bootstrapCSS = doc.createElement('link');
bootstrapCSS.rel = 'stylesheet';
- bootstrapCSS.href = '/libs/bootstrap-4/dist/css/bootstrap.min.css';
+ bootstrapCSS.href = '/libs/bootstrap/css/bootstrap.min.css';
const formioCSS = doc.createElement('link');
formioCSS.rel = 'stylesheet';
@@ -416,6 +416,19 @@ $(function () {
button {
display: none !important;
}
+ /* Print only. Bootstrap 5 sizes headings with calc(... + vw), so
+ printed output would otherwise vary with the width of the window
+ it was opened from. Pin them to the fixed sizes Bootstrap 4 used
+ so the printed result is the same whatever the screen, while the
+ on-screen preview keeps Bootstrap 5's responsive sizing. */
+ @media print {
+ h1, .h1 { font-size: 2.5rem !important; }
+ h2, .h2 { font-size: 2rem !important; }
+ h3, .h3 { font-size: 1.75rem !important; }
+ h4, .h4 { font-size: 1.5rem !important; }
+ h5, .h5 { font-size: 1.25rem !important; }
+ h6, .h6 { font-size: 1rem !important; }
+ }
`;
head.appendChild(jqueryScript);
@@ -462,7 +475,7 @@ $(function () {
// Create and append stylesheets
const stylesheets = [
- { href: '/libs/bootstrap-4/dist/css/bootstrap.min.css' },
+ { href: '/libs/bootstrap/css/bootstrap.min.css' },
{ href: '/Pages/GrantApplications/ScoresheetPrint.css' }
];
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js
index 70524e66f9..1a56145547 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js
@@ -523,14 +523,9 @@ $(function () {
// Initialize button styling
$('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view');
- // Update button text based on whether any named saved views exist.
- // Driven by the StateRestore extension's own 'stateRestore-change' event (the same
- // event it uses internally to update its default label) rather than a localStorage
- // key guess or a one-shot draw handler - those raced against the extension's own
- // loading of previously-saved states from storage on page refresh.
+ // Update button text based to Save Views
function updateSavedStatesButtonText() {
- const savedStatesExist = dataTable.stateRestore.states().length > 0;
- $('.grp-savedStates').text(savedStatesExist ? 'Saved States' : 'Save View');
+ $('.grp-savedStates').text('Save View');
}
dataTable.on('stateRestore-change', updateSavedStatesButtonText);
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ScoresheetPrint.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ScoresheetPrint.css
index b2cd89f790..4a032d7928 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ScoresheetPrint.css
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ScoresheetPrint.css
@@ -1,3 +1,15 @@
+/* Print only. Bootstrap 5 sizes headings with calc(... + vw), so printed output
+ would otherwise vary with the width of the window it was opened from. Pin them
+ to the fixed sizes Bootstrap 4 used, leaving the on-screen preview alone. */
+@media print {
+ h1, .h1 { font-size: 2.5rem; }
+ h2, .h2 { font-size: 2rem; }
+ h3, .h3 { font-size: 1.75rem; }
+ h4, .h4 { font-size: 1.5rem; }
+ h5, .h5 { font-size: 1.25rem; }
+ h6, .h6 { font-size: 1rem; }
+}
+
.unity-user-initials {
display: none !important;
}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml
index fa4b64e81d..7843f10950 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml
@@ -134,7 +134,7 @@