diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js index 8bc5385e7..0b00bdc5e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js @@ -229,7 +229,8 @@ function initializeDataTable(options) { onStateLoadParams, onStateLoaded, fixedHeaders = false, - lengthMenu = [25, 50, 75, 100, -1] + lengthMenu = [25, 50, 75, 100, -1], + deferRender = false } = options; // Process columns and visibility @@ -255,7 +256,7 @@ function initializeDataTable(options) { scrollX: true, scrollCollapse: true, autoWidth: true, - deferRender: false, + deferRender: deferRender, deferLoading: serverSideEnabled ? 0 : null, ajax: abp.libs.datatables.createAjax( dataEndpoint, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs new file mode 100644 index 000000000..04aa7a77b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/DbWarmupOptions.cs @@ -0,0 +1,40 @@ +namespace Unity.GrantManager.EntityFrameworkCore; + +/// +/// Configuration options for . +/// Bind from appsettings.json under the "DbWarmup" section. +/// +/// Example: +/// +/// "DbWarmup": { +/// "IsPhase2Enabled": true, +/// "MaxTenants": 5, +/// "Phase2TimeoutSeconds": 30 +/// } +/// +/// +public class DbWarmupOptions +{ + public const string SectionName = "DbWarmup"; + + /// + /// When false, Phase 2 (per-tenant DB round-trips) is skipped entirely. + /// Phase 1 (EF Core model compilation) always runs regardless of this setting. + /// Default: true. + /// + public bool IsPhase2Enabled { get; set; } = true; + + /// + /// Maximum number of tenants to warm in Phase 2. + /// 0 means no limit. Default: 0. + /// Useful in constrained environments or when tenant count is very large. + /// + public int MaxTenants { get; set; } = 0; + + /// + /// Total seconds allowed for Phase 2 across all tenants before it is abandoned. + /// 0 means no timeout. Default: 0. + /// Remaining tenants are skipped gracefully when the timeout elapses. + /// + public int Phase2TimeoutSeconds { get; set; } = 0; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs new file mode 100644 index 000000000..723f7638e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbWarmupService.cs @@ -0,0 +1,225 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Unity.GrantManager.Applications; + +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.EntityFrameworkCore; + +/// +/// Background service that pre-warms the EF Core query pipeline after application startup. +/// +/// On first use, EF Core performs three expensive one-time operations: +/// 1. Model snapshot compilation — GrantTenantDbContext.OnModelCreating (30+ entity types) +/// 2. LINQ→SQL expression tree translation — especially costly for multi-JOIN includes +/// 3. Npgsql connection pool establishment + PostgreSQL query plan caching +/// +/// These costs are normally deferred to the first HTTP request, causing 6-8 second cold-start +/// latency for the GrantApplications DataTable. This service fires the most expensive query +/// shape (GetApplicationListRecordsAsync with typical date filters) shortly after startup so the +/// cache is warm before any user makes a request. +/// +/// Warmup is split into two independent phases: +/// Phase 1 (model compilation) — always succeeds; no DB connection required. +/// Phase 2 (per-tenant DB round-trip) — iterates tenants from the host database and warms +/// Npgsql's connection pool and PostgreSQL's query plan cache for each. +/// +/// Phase 2 behaviour is configurable via (appsettings "DbWarmup" section): +/// IsPhase2Enabled — set false to skip Phase 2 entirely (default: true). +/// MaxTenants — cap the number of tenants warmed; 0 = unlimited (default: 0). +/// Phase2TimeoutSeconds — abandon Phase 2 after N seconds; 0 = no timeout (default: 0). +/// +/// +public class GrantManagerDbWarmupService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly DbWarmupOptions _options; + + public GrantManagerDbWarmupService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IHostApplicationLifetime hostApplicationLifetime, + IOptions options) + { + _scopeFactory = scopeFactory; + _logger = logger; + _hostApplicationLifetime = hostApplicationLifetime; + _options = options.Value; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait until the host has fully started so ABP module initialization and startup hooks + // are complete before issuing any warmup queries. + if (!_hostApplicationLifetime.ApplicationStarted.IsCancellationRequested) + { + var applicationStartedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var applicationStartedRegistration = _hostApplicationLifetime.ApplicationStarted.Register( + static state => ((TaskCompletionSource)state!).TrySetResult(), + applicationStartedTcs); + using var cancellationRegistration = stoppingToken.Register( + static state => ((TaskCompletionSource)state!).TrySetCanceled(), + applicationStartedTcs); + + await applicationStartedTcs.Task; + } + + if (stoppingToken.IsCancellationRequested) return; + + _logger.LogInformation("[DbWarmup] Starting EF Core query pipeline warmup."); + + // Step 1: Model + // Accessing dbContext.Model forces EF Core to run OnModelCreating synchronously. + // This is a pure in-process operation; no DB connection is opened. + using (var phase1Scope = _scopeFactory.CreateScope()) + { + var unitOfWorkManager = phase1Scope.ServiceProvider.GetRequiredService(); + try + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var dbContextProvider = phase1Scope.ServiceProvider + .GetRequiredService>(); + var dbContext = await dbContextProvider.GetDbContextAsync(); + + // Accessing Model triggers OnModelCreating if not yet compiled. + // The result is cached for the lifetime of the application. + _ = dbContext.Model; + + await uow.CompleteAsync(); + _logger.LogInformation("[DbWarmup] Phase 1 complete — EF Core model compiled."); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DbWarmup] Phase 1 (model compilation) failed — this is unexpected."); + } + } + + // Step 2: Per-tenant DB connection + PostgreSQL query plan warmup + // Enumerates all tenants (ITenantRepository -> GrantManagerDbContext -> accessible without an active tenant scope). + // Foreach tenant, opens a new DI scope, activates the tenant via ICurrentTenant.Change, and issues a Take(1) query so that: + // - Opens and pools a connection to that tenant's database + // - PostgreSQL parses and caches the parameterised execution plan for the query shape + // - EFCore's compiled query cache is populated for this tenant + // Each tenant is isolated in its own scope to prevent UoW state from leaking between tenants. + // Uses GetApplicationListRecordsAsync — the same optimized projected query the DataTable endpoint calls. + if (!_options.IsPhase2Enabled) + { + _logger.LogInformation("[DbWarmup] Phase 2 disabled via configuration — skipping per-tenant warmup."); + return; + } + + IReadOnlyList tenants; + + using (var tenantListScope = _scopeFactory.CreateScope()) + { + var tenantUowManager = tenantListScope.ServiceProvider.GetRequiredService(); + try + { + using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); + var tenantRepository = tenantListScope.ServiceProvider.GetRequiredService(); + tenants = await tenantRepository.GetListAsync(); + await uow.CompleteAsync(); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogWarning(ex, "[DbWarmup] Phase 2 — could not retrieve tenant list from host database. Skipping per-tenant warmup."); + return; + } + } + + if (tenants.Count == 0) + { + _logger.LogDebug("[DbWarmup] Phase 2 — no tenants found in host database. Skipping per-tenant DB warmup."); + return; + } + + // Apply MaxTenants cap + var tenantsToWarm = _options.MaxTenants > 0 + ? tenants.Take(_options.MaxTenants).ToList() + : (IReadOnlyList)tenants; + + if (_options.MaxTenants > 0 && tenants.Count > _options.MaxTenants) + { + _logger.LogInformation( + "[DbWarmup] Phase 2 — capped at {MaxTenants} of {TotalTenants} tenant(s) (MaxTenants setting).", + _options.MaxTenants, tenants.Count); + } + + _logger.LogInformation("[DbWarmup] Phase 2 — warming {TenantCount} tenant(s).", tenantsToWarm.Count); + + // Apply Phase2TimeoutSeconds — link a deadline token with stoppingToken + using var phase2Cts = _options.Phase2TimeoutSeconds > 0 + ? CancellationTokenSource.CreateLinkedTokenSource(stoppingToken) + : null; + if (phase2Cts != null) + { + phase2Cts.CancelAfter(TimeSpan.FromSeconds(_options.Phase2TimeoutSeconds)); + _logger.LogDebug("[DbWarmup] Phase 2 — timeout set to {Seconds}s.", _options.Phase2TimeoutSeconds); + } + var phase2Token = phase2Cts?.Token ?? stoppingToken; + + var warmed = 0; + foreach (var tenant in tenantsToWarm) + { + if (phase2Token.IsCancellationRequested) + { + // Distinguish between a Phase 2 timeout and a host shutdown + if (!stoppingToken.IsCancellationRequested) + _logger.LogInformation( + "[DbWarmup] Phase 2 — timeout reached after {Warmed}/{Total} tenant(s).", + warmed, tenantsToWarm.Count); + return; + } + + using var tenantScope = _scopeFactory.CreateScope(); + var currentTenant = tenantScope.ServiceProvider.GetRequiredService(); + var tenantUowManager = tenantScope.ServiceProvider.GetRequiredService(); + + using (currentTenant.Change(tenant.Id)) + { + try + { + using var uow = tenantUowManager.Begin(requiresNew: true, isTransactional: false); + var repository = tenantScope.ServiceProvider.GetRequiredService(); + + await repository.GetApplicationListRecordsAsync( + skipCount: 0, + maxResultCount: 1, + sorting: null, + submittedFromDate: DateTime.UtcNow.AddMonths(-6), + submittedToDate: DateTime.UtcNow); + + await uow.CompleteAsync(); + warmed++; + _logger.LogDebug("[DbWarmup] Tenant '{TenantName}' ({TenantId}) warmed.", tenant.Name, tenant.Id); + } + catch (OperationCanceledException) when (phase2Token.IsCancellationRequested) { return; } + catch (Exception ex) + { + _logger.LogDebug(ex, + "[DbWarmup] Tenant '{TenantName}' ({TenantId}) — DB round-trip skipped. " + + "Tenant database may not be accessible in this environment.", + tenant.Name, tenant.Id); + } + } + } + + _logger.LogInformation("[DbWarmup] Phase 2 complete — {Warmed}/{Total} tenant(s) warmed.", warmed, tenantsToWarm.Count); + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index e6f9d5eb1..081fa8dd7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -135,6 +135,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) var hostingEnvironment = context.Services.GetHostingEnvironment(); var configuration = context.Services.GetConfiguration(); + // Pre-warm the EF Core query pipeline after startup (web host only, not DbMigrator) + context.Services.Configure(configuration.GetSection(DbWarmupOptions.SectionName)); + context.Services.AddHostedService(); + ConfgureFormsApiAuhentication(context); ConfigureAuthentication(context, configuration); ConfigurePolicies(context); 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 0c752256c..7c10cc14d 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 @@ -9,6 +9,8 @@ $(function () { const l = abp.localization.getResource('GrantManager'); const defaultQuickDateRange = 'last6months'; const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const canViewApplicants = abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList'); + const dtTextRenderer = $.fn.dataTable.render.text(); let dt = $('#GrantApplicationsTable'); let dataTable; @@ -89,6 +91,7 @@ $(function () { } }) dt.colReorder.order(orderedIndexes); + dt.columns.adjust(); if (typeof dt.filterRow === 'function') { const filterRowApi = dt.filterRow(); @@ -168,13 +171,15 @@ $(function () { }; let formatItems = function (items) { - const newData = items.map((item, index) => { - return { - ...item, - rowCount: index - }; + // Previously used + // const newData = items.map((item, index) => { return { ...item, rowCount: index }; }); + // return newData; + // While in clientside mode, we're always retrieving the full dataset. + // Can be reverted for server-side + items.forEach((item, index) => { + item.rowCount = index; }); - return newData; + return items; } init(); @@ -350,12 +355,10 @@ $(function () { UIElements.quickDateRange.val('custom'); localStorage.setItem('GrantApplications_QuickRange', 'custom'); - const dtInstance = $('#GrantApplicationsTable').DataTable(); - localStorage.setItem("GrantApplications_FromDate", grantTableFilters.submittedFromDate); localStorage.setItem("GrantApplications_ToDate", grantTableFilters.submittedToDate); - dtInstance.ajax.reload(null, true); + dataTable.ajax.reload(null, true); } function handleQuickDateRangeChange() { const selectedRange = $(this).val(); @@ -375,8 +378,7 @@ $(function () { setDateRangeLocalStorage(selectedRange, range); // Reload the table with new filters - const dtInstance = $('#GrantApplicationsTable').DataTable(); - dtInstance.ajax.reload(null, true); + dataTable.ajax.reload(null, true); } function initializeDataTableAndEvents() { @@ -424,6 +426,7 @@ $(function () { }, responseCallback, actionButtons, + deferRender: true, serverSideEnabled: false, pagingEnabled: true, reorderEnabled: true, @@ -511,8 +514,7 @@ $(function () { } $('#search').on('input', function () { - let table = $('#GrantApplicationsTable').DataTable(); - table.search($(this).val()).draw(); + dataTable.search($(this).val()).draw(); }); //For savedStates @@ -622,7 +624,7 @@ $(function () { getNonRegisteredOrganizationNameColumn(columnIndex++), getUnityApplicationIdColumn(columnIndex++), getLinkRelationshipType(columnIndex++), - ].map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })) + ].map((column) => ({ ...column, targets: [column.index] })) .sort((a, b) => a.index - b.index); return sortedColumns; } @@ -637,13 +639,13 @@ $(function () { render: function(data, type, row) { let applicantName = (typeof data !== 'string' || data.trim() === '') ? 'Applicant Name' : data; - if (type === 'sort' || type === 'filter') { + if (type !== 'display') { return applicantName; } - const safeApplicantName = $.fn.dataTable.render.text().display(applicantName); + const safeApplicantName = dtTextRenderer.display(applicantName); - if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { + if (canViewApplicants) { const applicantId = row?.applicant?.id; const isGuid = applicantId && guidPattern.test(applicantId); @@ -666,6 +668,7 @@ $(function () { name: 'referenceNo', className: 'data-table-header text-nowrap', render: function (data, type, row) { + if (type !== 'display') return data ?? ''; return `${data}`; }, index: columnIndex @@ -760,10 +763,13 @@ $(function () { displayText = getNames(data); } + if (type !== 'display') return displayText.trim(); + + const tooltipText = data?.length ? getNames(data) : ''; return ` ' + displayText + '' + + + tooltipText + '">' + displayText + '' + ``; }, index: columnIndex @@ -884,10 +890,8 @@ $(function () { name: 'projectStartDate', data: 'projectStartDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -899,10 +903,8 @@ $(function () { name: 'projectEndDate', data: 'projectEndDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -1107,11 +1109,10 @@ $(function () { className: '', refreshData: true, render: function (data) { - - let tagNames = data - .filter(x => x?.tag?.name) - .map(x => x.tag.name); - return tagNames.join(', ') ?? ''; + return data + .filter(x => x?.tag?.name) + .map(x => x.tag.name) + .join(', '); }, index: columnIndex } @@ -1167,10 +1168,8 @@ $(function () { name: 'dueDate', data: 'dueDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -1196,10 +1195,8 @@ $(function () { name: 'finalDecisionDate', data: 'finalDecisionDate', className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, index: columnIndex } @@ -1290,7 +1287,10 @@ $(function () { data: 'applicationLinks', className: 'data-table-header', refreshData: true, - render: function (data) { + render: function (data, type) { + if (type !== 'display' && type !== 'fullName') { + return (data || []).filter(x => x?.linkType).map(x => x.linkType).join(', '); + } const linkNames = Array.from(new Set((data || []) .filter(x => x?.linkType) .map(x => { @@ -1504,9 +1504,6 @@ $(function () { data: 'notes', className: 'data-table-header multi-line', width: "20rem", - createdCell: function (td) { - $(td).css('min-width', '20rem'); - }, render: function (data) { return data ?? ''; }, @@ -1600,50 +1597,47 @@ $(function () { return data.duty ? (" [" + data.duty + "]") : ''; } + const _companyTypeMap = new Map([ + ['BC', 'BC Company'], + ['CP', 'Cooperative'], + ['GP', 'General Partnership'], + ['S', 'Society'], + ['SP', 'Sole Proprietorship'], + ['A', 'Extraprovincial Company'], + ['B', 'Extraprovincial'], + ['BEN', 'Benefit Company'], + ['C', 'Continuation In'], + ['CC', 'BC Community Contribution Company'], + ['CS', 'Continued In Society'], + ['CUL', 'Continuation In as a BC ULC'], + ['EPR', 'Extraprovincial Registration'], + ['FI', 'Financial Institution'], + ['FOR', 'Foreign Registration'], + ['LIB', 'Public Library Association'], + ['LIC', 'Licensed (Extra-Pro)'], + ['LL', 'Limited Liability Partnership'], + ['LLC', 'Limited Liability Company'], + ['LP', 'Limited Partnership'], + ['MF', 'Miscellaneous Firm'], + ['PA', 'Private Act'], + ['PAR', 'Parish'], + ['QA', 'CO 1860'], + ['QB', 'CO 1862'], + ['QC', 'CO 1878'], + ['QD', 'CO 1890'], + ['QE', 'CO 1897'], + ['REG', 'Registraton (Extra-pro)'], + ['ULC', 'BC Unlimited Liability Company'], + ['XCP', 'Extraprovincial Cooperative'], + ['XL', 'Extrapro Limited Liability Partnership'], + ['XP', 'Extraprovincial Limited Partnership'], + ['XS', 'Extraprovincial Society'] + ]); + function getFullType(code) { - const companyTypes = [ - { code: "BC", name: "BC Company" }, - { code: "CP", name: "Cooperative" }, - { code: "GP", name: "General Partnership" }, - { code: "S", name: "Society" }, - { code: "SP", name: "Sole Proprietorship" }, - { code: "A", name: "Extraprovincial Company" }, - { code: "B", name: "Extraprovincial" }, - { code: "BEN", name: "Benefit Company" }, - { code: "C", name: "Continuation In" }, - { code: "CC", name: "BC Community Contribution Company" }, - { code: "CS", name: "Continued In Society" }, - { code: "CUL", name: "Continuation In as a BC ULC" }, - { code: "EPR", name: "Extraprovincial Registration" }, - { code: "FI", name: "Financial Institution" }, - { code: "FOR", name: "Foreign Registration" }, - { code: "LIB", name: "Public Library Association" }, - { code: "LIC", name: "Licensed (Extra-Pro)" }, - { code: "LL", name: "Limited Liability Partnership" }, - { code: "LLC", name: "Limited Liability Company" }, - { code: "LP", name: "Limited Partnership" }, - { code: "MF", name: "Miscellaneous Firm" }, - { code: "PA", name: "Private Act" }, - { code: "PAR", name: "Parish" }, - { code: "QA", name: "CO 1860" }, - { code: "QB", name: "CO 1862" }, - { code: "QC", name: "CO 1878" }, - { code: "QD", name: "CO 1890" }, - { code: "QE", name: "CO 1897" }, - { code: "REG", name: "Registraton (Extra-pro)" }, - { code: "ULC", name: "BC Unlimited Liability Company" }, - { code: "XCP", name: "Extraprovincial Cooperative" }, - { code: "XL", name: "Extrapro Limited Liability Partnership" }, - { code: "XP", name: "Extraprovincial Limited Partnership" }, - { code: "XS", name: "Extraprovincial Society" } - ]; - const match = companyTypes.find(entry => entry.code === code); - return match ? match.name : "Unknown"; - } - - - window.addEventListener('resize', () => { - }); + return _companyTypeMap.get(code) ?? 'Unknown'; + } + PubSub.subscribe( 'refresh_application_list', @@ -1655,22 +1649,17 @@ $(function () { ); function getNames(data) { - let name = ''; - data.forEach((d, index) => { - name = name + (' ' + d.fullName + getDutyText(d)); - if (index != (data.length - 1)) { - name = name + ','; - } - }); - - return name; + return data.map(d => d.fullName + getDutyText(d)).join(', '); } + const _titleCaseCache = new Map(); function titleCase(str) { - str = str.toLowerCase().split(' '); - for (let i = 0; i < str.length; i++) { - str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); - } - return str.join(' '); + //This funciton is currently called by 6 columns, all of which are status types or predetermined values + //Caching the results in this case is to improve large data table loads while we're in client side. + //Columns: likelihoodOfFunding, assessmentResult, riskRanking, acquisition, fyeMonth, dueDiligenceStatus + if (_titleCaseCache.has(str)) return _titleCaseCache.get(str); + const result = str.toLowerCase().split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + _titleCaseCache.set(str, result); + return result; } function convertToYesNo(str) {