diff --git a/src/back-end/lib/db/opportunity/code-with-us.ts b/src/back-end/lib/db/opportunity/code-with-us.ts index 9050ff1ff..d7a711e9a 100644 --- a/src/back-end/lib/db/opportunity/code-with-us.ts +++ b/src/back-end/lib/db/opportunity/code-with-us.ts @@ -309,27 +309,24 @@ export function generateCWUOpportunityQuery( "cwuOpportunities as opp" ) // Join on latest CWU status - .join("cwuOpportunityStatuses as stat", function () { - this.on("opp.id", "=", "stat.opportunity").andOn( - "stat.createdAt", - "=", - connection.raw( - '(select max("createdAt") from "cwuOpportunityStatuses" as stat2 where \ - stat2.opportunity = opp.id and stat2.status is not null)' - ) - ); - }) + .join( + connection.raw( + `(SELECT DISTINCT ON (opportunity) * FROM "cwuOpportunityStatuses" + WHERE status IS NOT NULL + ORDER BY opportunity, "createdAt" DESC) as stat` + ), + "opp.id", + "stat.opportunity" + ) // Join on latest CWU version - .join("cwuOpportunityVersions as version", function () { - this.on("opp.id", "=", "version.opportunity").andOn( - "version.createdAt", - "=", - connection.raw( - '(select max("createdAt") from "cwuOpportunityVersions" as version2 where \ - version2.opportunity = opp.id)' - ) - ); - }) + .join( + connection.raw( + `(SELECT DISTINCT ON (opportunity) * FROM "cwuOpportunityVersions" + ORDER BY opportunity, "createdAt" DESC) as version` + ), + "opp.id", + "version.opportunity" + ) .select( "opp.id", "opp.createdAt", diff --git a/src/back-end/lib/db/opportunity/sprint-with-us.ts b/src/back-end/lib/db/opportunity/sprint-with-us.ts index 15c34c93d..5069c5966 100644 --- a/src/back-end/lib/db/opportunity/sprint-with-us.ts +++ b/src/back-end/lib/db/opportunity/sprint-with-us.ts @@ -462,27 +462,24 @@ export function generateSWUOpportunityQuery( "swuOpportunities as opportunities" ) // Join on latest SWU status - .join("swuOpportunityStatuses as statuses", function () { - this.on("opportunities.id", "=", "statuses.opportunity").andOn( - "statuses.createdAt", - "=", - connection.raw( - '(select max("createdAt") from "swuOpportunityStatuses" as statuses2 where \ - statuses2.opportunity = opportunities.id and statuses2.status is not null)' - ) - ); - }) + .join( + connection.raw( + `(SELECT DISTINCT ON (opportunity) * FROM "swuOpportunityStatuses" + WHERE status IS NOT NULL + ORDER BY opportunity, "createdAt" DESC) as statuses` + ), + "opportunities.id", + "statuses.opportunity" + ) // Join on latest SWU version - .join("swuOpportunityVersions as versions", function () { - this.on("opportunities.id", "=", "versions.opportunity").andOn( - "versions.createdAt", - "=", - connection.raw( - '(select max("createdAt") from "swuOpportunityVersions" as versions2 where \ - versions2.opportunity = opportunities.id)' - ) - ); - }) + .join( + connection.raw( + `(SELECT DISTINCT ON (opportunity) * FROM "swuOpportunityVersions" + ORDER BY opportunity, "createdAt" DESC) as versions` + ), + "opportunities.id", + "versions.opportunity" + ) .select( "opportunities.id", "opportunities.createdAt", diff --git a/src/back-end/lib/db/opportunity/team-with-us.ts b/src/back-end/lib/db/opportunity/team-with-us.ts index 508a83670..0f28a468d 100644 --- a/src/back-end/lib/db/opportunity/team-with-us.ts +++ b/src/back-end/lib/db/opportunity/team-with-us.ts @@ -514,27 +514,24 @@ export function generateTWUOpportunityQuery( "twuOpportunities as opportunities" ) // Join on latest TWU status - .join("twuOpportunityStatuses as statuses", function () { - this.on("opportunities.id", "=", "statuses.opportunity").andOn( - "statuses.createdAt", - "=", - connection.raw( - '(select max("createdAt") from "twuOpportunityStatuses" as statuses2 where \ - statuses2.opportunity = opportunities.id and statuses2.status is not null)' - ) - ); - }) + .join( + connection.raw( + `(SELECT DISTINCT ON (opportunity) * FROM "twuOpportunityStatuses" + WHERE status IS NOT NULL + ORDER BY opportunity, "createdAt" DESC) as statuses` + ), + "opportunities.id", + "statuses.opportunity" + ) // Join on latest TWU version - .join("twuOpportunityVersions as versions", function () { - this.on("opportunities.id", "=", "versions.opportunity").andOn( - "versions.createdAt", - "=", - connection.raw( - '(select max("createdAt") from "twuOpportunityVersions" as versions2 where \ - versions2.opportunity = opportunities.id)' - ) - ); - }) + .join( + connection.raw( + `(SELECT DISTINCT ON (opportunity) * FROM "twuOpportunityVersions" + ORDER BY opportunity, "createdAt" DESC) as versions` + ), + "opportunities.id", + "versions.opportunity" + ) .select( "opportunities.id", "opportunities.createdAt", diff --git a/src/front-end/sass/components/_virtualized-table.scss b/src/front-end/sass/components/_virtualized-table.scss new file mode 100644 index 000000000..35de718f9 --- /dev/null +++ b/src/front-end/sass/components/_virtualized-table.scss @@ -0,0 +1,89 @@ +/* Scrollable container for the table body */ +.virtualized-body-container { + position: relative; /* Needed for absolute positioning inside and sticky context */ + border: 1px solid #dee2e6; + border-radius: 4px; /* Full radius since no separate header */ +} + +/* Body table specific styles */ +.virtualized-body-table { + margin-bottom: 0; + width: 100%; + table-layout: fixed; + background-color: white; + + /* Sticky header */ + thead { + position: sticky; + top: 0; /* Sticks to top of scrollable container */ + z-index: 10; /* Layers above body rows */ + background-color: #f8f9fa; /* Header background */ + + th { + background: inherit; /* Inherit from thead or set explicitly */ + border-bottom: 2px solid #dee2e6; + vertical-align: middle; + padding: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + } + } + + /* Virtualized tbody styles */ + .virtualized-tbody { + display: block; + height: 550px; + overflow-y: auto; + overflow-x: hidden; + position: relative; + + /* Virtual spacer row */ + .virtual-spacer { + display: block; + height: 0; + } + + /* Container td for virtualized content */ + .virtualized-container-td { + padding: 0; + } + + /* Container for all virtualized rows */ + .virtualized-rows-container { + display: table; + width: 100%; + table-layout: fixed; + position: absolute; + left: 0; + } + + /* Individual virtualized row */ + .virtualized-row { + display: table-row; + + &:hover { + background-color: rgba(0, 0, 0, 0.075); + } + } + + /* Individual virtualized cell */ + .virtualized-cell { + display: table-cell; + vertical-align: middle; + padding: 0.75rem; + border-top: 1px solid #dee2e6; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + } +} + +.virtualized-table-layout { + table { + display: block; + } +} diff --git a/src/front-end/sass/index.scss b/src/front-end/sass/index.scss index ca9d6e234..eaf66adfd 100644 --- a/src/front-end/sass/index.scss +++ b/src/front-end/sass/index.scss @@ -1,6 +1,7 @@ // Imports @import "./font"; +@import "./components/virtualized-table"; // Custom Bootstrap variable overrides. diff --git a/src/front-end/typescript/lib/components/virtualized-table/index.tsx b/src/front-end/typescript/lib/components/virtualized-table/index.tsx new file mode 100644 index 000000000..d04c0910d --- /dev/null +++ b/src/front-end/typescript/lib/components/virtualized-table/index.tsx @@ -0,0 +1,333 @@ +import React, { CSSProperties } from "react"; +import { component as component_ } from "front-end/lib/framework"; +import * as Table from "front-end/lib/components/table"; +import { Table as ReactstrapTable } from "reactstrap"; +import { ADT, adt } from "shared/lib/types"; + +// Constants +const DEFAULT_BUFFER_SIZE = 5; +const INITIAL_VISIBLE_ITEMS = 20; +const BUFFER_MULTIPLIER = 2; + +// Simple visible range interface +interface VisibleRange { + start: number; + end: number; +} + +export interface State { + idNamespace: string; + THView: component_.base.View; + TDView: component_.base.View; + activeTooltipThIndex: number | null; + // Virtualization-specific state + visibleRange: VisibleRange; + scrollTop: number; + containerHeight: number; + totalItems: number; + rowHeight: number; + bufferSize: number; + scrollContainerId: string; +} + +export interface Params { + idNamespace: string; + THView?: component_.base.View; + TDView?: component_.base.View; + totalItems: number; + rowHeight: number; + bufferSize?: number; +} + +export type Msg = + | ADT<"toggleTooltipTh", number> + | ADT<"toggleTooltipTd", [number, number]> + | ADT<"handleScroll", { scrollTop: number; containerHeight: number }> + | ADT<"updateVisibleRange", { start: number; end: number }> + | ADT<"updateTotalItems", number> + | ADT<"scrollToTop"> + | ADT<"scrollCompleted">; + +export const init: component_.base.Init = ({ + idNamespace, + THView = Table.DefaultTHView, + TDView = Table.DefaultTDView, + totalItems, + rowHeight, + bufferSize = DEFAULT_BUFFER_SIZE +}) => { + const initialEnd = Math.min(INITIAL_VISIBLE_ITEMS, totalItems); + return [ + { + idNamespace, + THView, + TDView, + activeTooltipThIndex: null, + activeTooltipTdIndex: null, + visibleRange: { start: 0, end: initialEnd }, + scrollTop: 0, + containerHeight: 0, + totalItems, + rowHeight, + bufferSize, + scrollContainerId: `virtualized-table-${idNamespace}-scroll-container` + }, + [component_.cmd.delayedDispatch(10, adt("scrollToTop"))] + ]; +}; + +export const update: component_.base.Update = ({ state, msg }) => { + switch (msg.tag) { + case "toggleTooltipTh": { + const currentThIndex = state.activeTooltipThIndex; + if (currentThIndex === null) { + return [state.set("activeTooltipThIndex", msg.value), []]; + } else { + return [state.set("activeTooltipThIndex", null), []]; + } + } + case "toggleTooltipTd": { + return [state, []]; + } + case "handleScroll": { + const { scrollTop, containerHeight } = msg.value; + const rowHeight = state.rowHeight; + const buffer = state.bufferSize; + const start = Math.floor(scrollTop / rowHeight); + const visibleStart = Math.max(0, start - buffer); + + if (containerHeight <= 0) { + return [state, []]; + } + + const visibleCount = Math.ceil(containerHeight / rowHeight); + const visibleEnd = Math.min( + state.totalItems || 0, + visibleStart + visibleCount + buffer * BUFFER_MULTIPLIER + ); + + return [ + state + .set("scrollTop", scrollTop) + .set("containerHeight", containerHeight) + .set("visibleRange", { start: visibleStart, end: visibleEnd }), + [] + ]; + } + case "updateVisibleRange": { + return [ + state.set("visibleRange", { + start: msg.value.start, + end: msg.value.end + }), + [] + ]; + } + case "updateTotalItems": { + const newTotalItems = msg.value; + const currentRange = state.visibleRange; + // Update visible range if current range is invalid with new total + const updatedRange = { + start: currentRange.start, + end: Math.min(currentRange.end, newTotalItems) + }; + return [ + state + .set("totalItems", newTotalItems) + .set("visibleRange", updatedRange), + [] + ]; + } + case "scrollToTop": { + // Get the scrollable container element and scroll within it instead of the document + const tableElement = document.getElementById(state.scrollContainerId); + const scrollContainer = tableElement?.querySelector( + ".virtualized-tbody" + ) as HTMLElement; + if (scrollContainer) { + return [ + state, + [ + component_.cmd.scrollContainerTo( + 0, + 0, + adt("scrollCompleted"), + scrollContainer + ) + ] + ]; + } else { + return [state, []]; + } + } + case "scrollCompleted": + return [state, []]; + default: + return [state, []]; + } +}; + +export interface Props extends component_.base.ComponentViewProps { + headCells: Table.HeadCells; + bodyRows: Table.BodyRows; + className?: string; + style?: CSSProperties; + borderless?: boolean; + hover?: boolean; +} + +// Custom TBody component for virtualized table +interface VirtualizedTBodyProps { + rows: Table.BodyRows; + rowHeight: number; + borderless?: boolean; + totalHeight: number; + paddingTop: number; + columnStyles?: CSSProperties[]; +} + +const VirtualizedTBody: component_.base.View = ({ + rows, + rowHeight, + borderless, + totalHeight, + paddingTop, + columnStyles = [] +}) => { + return ( + + {/* Virtual spacer as first row */} + + + {/* Container for visible rows positioned absolutely */} + + +
+ {/* Visible rows */} + {rows.map((row, rowIndex) => ( +
+ {row.map((cell, cellIndex) => ( +
+
{cell.children}
+
+ ))} +
+ ))} +
+ + + + ); +}; + +export const view: component_.base.View = ({ + state, + dispatch, + className = "", + style, + headCells, + bodyRows, + borderless, + hover = true +}) => { + const visibleRange = state.visibleRange; + + const headProps = { + THView: state.THView, + cells: headCells.map((spec, index) => { + return { + ...spec, + index, + dispatch, + id: `virtualized-table-${state.idNamespace}-th-${index}`, + tooltipIsOpen: index === state.activeTooltipThIndex + }; + }) + }; + + const visibleRows = bodyRows.slice(visibleRange.start, visibleRange.end); + + const virtualizationStyles = { + totalHeight: state.totalItems * state.rowHeight, + paddingTop: visibleRange.start * state.rowHeight + }; + + const handleScroll = (event: React.UIEvent) => { + const target = event.target as HTMLDivElement; + dispatch( + adt("handleScroll", { + scrollTop: target.scrollTop, + containerHeight: target.clientHeight + }) + ); + }; + + const bodyProps = { + rows: visibleRows.map((row, rowIndex) => { + const actualRowIndex = visibleRange.start + rowIndex; + return row.map((cell, cellIndex) => { + return { + ...cell, + dispatch, + index: [actualRowIndex, cellIndex], + id: `virtualized-table-${state.idNamespace}-td-${actualRowIndex}-${cellIndex}` + }; + }); + }), + rowHeight: state.rowHeight, + borderless, + totalHeight: virtualizationStyles.totalHeight, + paddingTop: virtualizationStyles.paddingTop, + columnStyles: headCells.map((cell) => cell.style || {}) + }; + + return ( +
+ + + + {headCells.map((cell, index) => ( + + ))} + + + +
+ ); +}; + +export const component: component_.base.Component = { + init, + update, + view +}; + +// Re-export the Check component for convenience +export const Check = Table.Check; diff --git a/src/front-end/typescript/lib/framework/component/cmd.ts b/src/front-end/typescript/lib/framework/component/cmd.ts index 30f76df82..586869c16 100644 --- a/src/front-end/typescript/lib/framework/component/cmd.ts +++ b/src/front-end/typescript/lib/framework/component/cmd.ts @@ -128,6 +128,18 @@ export function scrollTo(x: number, y: number, msg: Msg): Cmd { }); } +export function scrollContainerTo( + x: number, + y: number, + msg: Msg, + element: HTMLElement +): Cmd { + return adt("async", async () => { + element.scrollTo(x, y); + return msg; + }); +} + export function pushUrlState(url: string, msg: Msg): Cmd { return adt("async", async () => { router.pushState(url, 0); diff --git a/src/front-end/typescript/lib/pages/user/list.tsx b/src/front-end/typescript/lib/pages/user/list.tsx index 23e419f0c..a3fb0a5e6 100644 --- a/src/front-end/typescript/lib/pages/user/list.tsx +++ b/src/front-end/typescript/lib/pages/user/list.tsx @@ -1,9 +1,11 @@ -import { EMPTY_STRING } from "front-end/config"; +import { SEARCH_DEBOUNCE_DURATION } from "front-end/config"; import { makePageMetadata } from "front-end/lib"; import { isUserType } from "front-end/lib/access-control"; import router from "front-end/lib/app/router"; import { Route, SharedState } from "front-end/lib/app/types"; import * as Table from "front-end/lib/components/table"; +import * as ShortText from "front-end/lib/components/form-field/short-text"; +import * as FormField from "front-end/lib/components/form-field"; import { immutable, Immutable, @@ -14,25 +16,51 @@ import { userStatusToColor, userStatusToTitleCase } from "front-end/lib/pages/user/lib"; -import { userTypeToTitleCase } from "shared/lib/resources/user"; -import Badge from "front-end/lib/views/badge"; -import Link, { routeDest, externalDest } from "front-end/lib/views/link"; +import { + userTypeToTitleCase, + UserType, + isAdmin, + User +} from "shared/lib/resources/user"; +import Link, { externalDest, routeDest } from "front-end/lib/views/link"; +import * as VirtualizedTable from "front-end/lib/components/virtualized-table"; import React from "react"; -import { Button, Col, Row } from "reactstrap"; +import { Button, Col, Row, Spinner } from "reactstrap"; import * as Checkbox from "front-end/lib/components/form-field/checkbox"; -import * as FormField from "front-end/lib/components/form-field"; import { compareStrings } from "shared/lib"; -import { isAdmin, User, UserType } from "shared/lib/resources/user"; import { adt, ADT } from "shared/lib/types"; +import Badge from "front-end/lib/views/badge"; +import { EMPTY_STRING } from "shared/config"; + +// Constants +const TABLE_ROW_HEIGHT = 50; +const LOADING_CONTAINER_HEIGHT = "60vh"; + +// Column width constants +const COLUMN_WIDTHS = { + STATUS: "10%", + ACCOUNT_TYPE: "15%", + NAME: "30%", + ADMIN: "10%" +}; + +const MIN_COLUMN_WIDTHS = { + STATUS: "80px", + ACCOUNT_TYPE: "180px", + NAME: "200px", + ADMIN: "52px" +}; interface TableUser extends User { statusTitleCase: string; typeTitleCase: string; } - export interface State { - table: Immutable; users: TableUser[]; + visibleUsers: TableUser[]; + loading: boolean; + searchFilter: Immutable; + virtualizedTable: Immutable; showExportModal: boolean; userTypeCheckboxes: { [UserType.Government]: Immutable; @@ -48,7 +76,11 @@ export interface State { type InnerMsg = | ADT<"onInitResponse", TableUser[]> - | ADT<"table", Table.Msg> + | ADT<"searchFilter", ShortText.Msg> + | ADT<"search"> + | ADT<"searchCompleted", { filteredUsers: TableUser[] }> + | ADT<"noop"> + | ADT<"virtualizedTable", VirtualizedTable.Msg> | ADT<"showExportModal"> | ADT<"hideExportModal"> | ADT<"userTypeCheckboxGovernment", Checkbox.Msg> @@ -62,9 +94,51 @@ export type Msg = component_.page.Msg; export type RouteParams = null; +function makeQueryRegExp(query: string): RegExp | null { + if (!query) { + return null; + } + return new RegExp(query.split(/\s+/).join(".*"), "i"); +} + +function filterUsers(users: TableUser[], query: string): TableUser[] { + const regExp = makeQueryRegExp(query); + if (!regExp) { + return users; + } + return users.filter((user) => { + return user.name && regExp.exec(user.name); + }); +} + +// Search command factory that encapsulates search logic +const makeSearchCommand = ( + state: Immutable +): component_.cmd.Cmd => { + const query = FormField.getValue(state.searchFilter); + const filteredUsers = filterUsers(state.users, query); + return component_.cmd.dispatch(adt("searchCompleted", { filteredUsers })); +}; + +const dispatchSearch = component_.cmd.makeDebouncedDispatch( + adt("noop") as InnerMsg, + adt("search") as InnerMsg, + SEARCH_DEBOUNCE_DURATION +); + function baseInit(): component_.base.InitReturnValue { - const [tableState, tableCmds] = Table.init({ - idNamespace: "user-list-table" + const [searchFilterState, searchFilterCmds] = ShortText.init({ + errors: [], + child: { + type: "text", + value: "", + id: "user-list-search" + } + }); + const [virtualizedTableState, virtualizedTableCmds] = VirtualizedTable.init({ + idNamespace: "user-list-virtualized", + totalItems: 0, + rowHeight: TABLE_ROW_HEIGHT }); // Initialize user type checkboxes @@ -120,8 +194,11 @@ function baseInit(): component_.base.InitReturnValue { return [ { + visibleUsers: [], + loading: true, + searchFilter: immutable(searchFilterState), + virtualizedTable: immutable(virtualizedTableState), users: [], - table: immutable(tableState), showExportModal: false, userTypeCheckboxes: { [UserType.Government]: immutable(govCheckboxState), @@ -136,7 +213,14 @@ function baseInit(): component_.base.InitReturnValue { }, [ component_.cmd.dispatch(component_.page.readyMsg()), - ...component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg), + ...component_.cmd.mapMany( + searchFilterCmds, + (msg) => adt("searchFilter", msg) as Msg + ), + ...component_.cmd.mapMany( + virtualizedTableCmds, + (msg) => adt("virtualizedTable", msg) as Msg + ), ...component_.cmd.mapMany( govCheckboxCmds, (msg) => adt("userTypeCheckboxGovernment", msg) as Msg @@ -234,15 +318,78 @@ const update: component_.page.Update = ({ msg }) => { switch (msg.tag) { - case "onInitResponse": - return [state.set("users", msg.value), []]; - case "table": + case "onInitResponse": { + const newState = state.set("users", msg.value).set("loading", false); + const stateWithVisibleUsers = newState.set("visibleUsers", msg.value); + const [virtualizedTableState, virtualizedTableCmds] = + VirtualizedTable.init({ + idNamespace: "user-list-virtualized", + totalItems: msg.value.length, + rowHeight: TABLE_ROW_HEIGHT + }); + return [ + stateWithVisibleUsers.set( + "virtualizedTable", + immutable(virtualizedTableState) + ), + [ + ...component_.cmd.mapMany( + virtualizedTableCmds, + (msg) => adt("virtualizedTable", msg) as Msg + ) + ] + ]; + } + case "searchFilter": + return component_.base.updateChild({ + state, + childStatePath: ["searchFilter"], + childUpdate: ShortText.update, + childMsg: msg.value, + mapChildMsg: (value) => ({ tag: "searchFilter", value }), + updateAfter: (state) => + [state, [dispatchSearch()]] as component_.page.UpdateReturnValue< + State, + InnerMsg, + Route + > + }); + case "search": { + const [virtualizedTableState, virtualizedTableCmds] = + VirtualizedTable.init({ + idNamespace: "user-list-virtualized", + totalItems: state.visibleUsers.length, + rowHeight: TABLE_ROW_HEIGHT + }); + return [ + state.set("virtualizedTable", immutable(virtualizedTableState)), + [ + makeSearchCommand(state), + ...component_.cmd.mapMany( + virtualizedTableCmds, + (msg) => adt("virtualizedTable", msg) as Msg + ) + ] + ]; + } + case "searchCompleted": { + return [ + state + .set("visibleUsers", msg.value.filteredUsers) + .setIn( + ["virtualizedTable", "totalItems"], + msg.value.filteredUsers.length + ), + [] + ]; + } + case "virtualizedTable": return component_.base.updateChild({ state, - childStatePath: ["table"], - childUpdate: Table.update, + childStatePath: ["virtualizedTable"], + childUpdate: VirtualizedTable.update, childMsg: msg.value, - mapChildMsg: (value) => ({ tag: "table", value }) + mapChildMsg: (value) => ({ tag: "virtualizedTable", value }) }); case "showExportModal": return [state.set("showExportModal", true), []]; @@ -307,57 +454,60 @@ function tableHeadCells(): Table.HeadCells { { children: "Status", className: "text-nowrap", - style: { width: "0px" } + style: { width: COLUMN_WIDTHS.STATUS, minWidth: MIN_COLUMN_WIDTHS.STATUS } }, { children: "Account Type", className: "text-nowrap", - style: { width: "0px" } + style: { + width: COLUMN_WIDTHS.ACCOUNT_TYPE, + minWidth: MIN_COLUMN_WIDTHS.ACCOUNT_TYPE + } }, { children: "Name", className: "text-nowrap", style: { - width: "100%", - minWidth: "200px" + width: COLUMN_WIDTHS.NAME, + minWidth: MIN_COLUMN_WIDTHS.NAME } }, { children: "Admin?", className: "text-center text-nowrap", - style: { width: "0px" } + style: { width: COLUMN_WIDTHS.ADMIN, minWidth: MIN_COLUMN_WIDTHS.STATUS } } ]; } function tableBodyRows(state: Immutable): Table.BodyRows { - return state.users.map((user) => { - return [ - { - children: ( - - ) - }, - { - children: user.typeTitleCase, - className: "text-nowrap" - }, - { - children: ( - - {user.name || EMPTY_STRING} - - ) - }, - { - children: , - className: "text-center" - } - ]; - }); + return state.visibleUsers.map((user: TableUser) => [ + { + children: ( + + ), + className: "align-middle" + }, + { + children: user.typeTitleCase, + className: "align-middle text-nowrap" + }, + { + children: ( + + {user.name || EMPTY_STRING} + + ), + className: "align-middle" + }, + { + children: , + className: "align-middle text-center" + } + ]); } const getModal: component_.page.GetModal = (state) => { @@ -502,10 +652,17 @@ const view: component_.page.View = ({ state, dispatch }) => { - const dispatchTable = component_.base.mapDispatch( + const dispatchSearchFilter = component_.base.mapDispatch( dispatch, - (value) => ({ tag: "table", value }) + (value) => ({ tag: "searchFilter", value }) ); + const dispatchVirtualizedTable = component_.base.mapDispatch< + Msg, + VirtualizedTable.Msg + >(dispatch, (value) => ({ tag: "virtualizedTable", value })); + const ShortTextView = ShortText.view; + const VirtualizedTableView = VirtualizedTable.view; + return ( @@ -517,12 +674,34 @@ const view: component_.page.View = ({ Export Contact List - +
+ {state.loading ? ( +
+ +
+ ) : ( + <> +
+ +
+ + + )} +
); diff --git a/src/migrations/tasks/20250414232549_optimize_page_loading.ts b/src/migrations/tasks/20250414232549_optimize_page_loading.ts new file mode 100644 index 000000000..58ed51bd9 --- /dev/null +++ b/src/migrations/tasks/20250414232549_optimize_page_loading.ts @@ -0,0 +1,180 @@ +import { makeDomainLogger } from "back-end/lib/logger"; +import { console as consoleAdapter } from "back-end/lib/logger/adapters"; +import { Knex } from "knex"; + +const logger = makeDomainLogger(consoleAdapter, "migrations"); + +export async function up(connection: Knex): Promise { + // Add indexes for opportunity statuses tables + await connection.schema.alterTable("swuOpportunityStatuses", (table) => { + table.index(["opportunity", "createdAt"]); + table.index(["status"]); + }); + logger.info("Added indexes to swuOpportunityStatuses table"); + + await connection.schema.alterTable("cwuOpportunityStatuses", (table) => { + table.index(["opportunity", "createdAt"]); + table.index(["status"]); + }); + logger.info("Added indexes to cwuOpportunityStatuses table"); + + await connection.schema.alterTable("twuOpportunityStatuses", (table) => { + table.index(["opportunity", "createdAt"]); + table.index(["status"]); + }); + logger.info("Added indexes to twuOpportunityStatuses table"); + + // Add indexes for opportunity versions tables + await connection.schema.alterTable("swuOpportunityVersions", (table) => { + table.index(["opportunity", "createdAt"]); + }); + logger.info("Added indexes to swuOpportunityVersions table"); + + await connection.schema.alterTable("cwuOpportunityVersions", (table) => { + table.index(["opportunity", "createdAt"]); + }); + logger.info("Added indexes to cwuOpportunityVersions table"); + + await connection.schema.alterTable("twuOpportunityVersions", (table) => { + table.index(["opportunity", "createdAt"]); + }); + logger.info("Added indexes to twuOpportunityVersions table"); + + // Add indexes for proposal statuses tables + await connection.schema.alterTable("swuProposalStatuses", (table) => { + table.index(["proposal", "createdAt"]); + table.index(["status"]); + }); + logger.info("Added indexes to swuProposalStatuses table"); + + await connection.schema.alterTable("cwuProposalStatuses", (table) => { + table.index(["proposal", "createdAt"]); + table.index(["status"]); + }); + logger.info("Added indexes to cwuProposalStatuses table"); + + await connection.schema.alterTable("twuProposalStatuses", (table) => { + table.index(["proposal", "createdAt"]); + table.index(["status"]); + }); + logger.info("Added indexes to twuProposalStatuses table"); + + // Add indexes for affiliations + await connection.schema.alterTable("affiliations", (table) => { + table.index(["user", "organization"]); + table.index(["membershipStatus"]); + }); + logger.info("Added indexes to affiliations table"); + + // Add indexes for organization service areas + await connection.schema.alterTable("twuOrganizationServiceAreas", (table) => { + table.index(["organization", "serviceArea"]); + }); + logger.info("Added indexes to twuOrganizationServiceAreas table"); + + // Add indexes for opportunity attachments + await connection.schema.alterTable("swuOpportunityAttachments", (table) => { + table.index(["opportunityVersion"]); + }); + logger.info("Added indexes to swuOpportunityAttachments table"); + + await connection.schema.alterTable("cwuOpportunityAttachments", (table) => { + table.index(["opportunityVersion"]); + }); + logger.info("Added indexes to cwuOpportunityAttachments table"); + + await connection.schema.alterTable("twuOpportunityAttachments", (table) => { + table.index(["opportunityVersion"]); + }); + logger.info("Added indexes to twuOpportunityAttachments table"); + + // Add indexes for opportunity addenda + await connection.schema.alterTable("swuOpportunityAddenda", (table) => { + table.index(["opportunity"]); + }); + logger.info("Added indexes to swuOpportunityAddenda table"); + + await connection.schema.alterTable("cwuOpportunityAddenda", (table) => { + table.index(["opportunity"]); + }); + logger.info("Added indexes to cwuOpportunityAddenda table"); + + await connection.schema.alterTable("twuOpportunityAddenda", (table) => { + table.index(["opportunity"]); + }); + logger.info("Added indexes to twuOpportunityAddenda table"); +} + +export async function down(connection: Knex): Promise { + // Remove indexes from opportunity statuses tables + await connection.schema.alterTable("swuOpportunityStatuses", (table) => { + table.dropIndex(["opportunity", "createdAt"]); + table.dropIndex(["status"]); + }); + await connection.schema.alterTable("cwuOpportunityStatuses", (table) => { + table.dropIndex(["opportunity", "createdAt"]); + table.dropIndex(["status"]); + }); + await connection.schema.alterTable("twuOpportunityStatuses", (table) => { + table.dropIndex(["opportunity", "createdAt"]); + table.dropIndex(["status"]); + }); + + // Remove indexes from opportunity versions tables + await connection.schema.alterTable("swuOpportunityVersions", (table) => { + table.dropIndex(["opportunity", "createdAt"]); + }); + await connection.schema.alterTable("cwuOpportunityVersions", (table) => { + table.dropIndex(["opportunity", "createdAt"]); + }); + await connection.schema.alterTable("twuOpportunityVersions", (table) => { + table.dropIndex(["opportunity", "createdAt"]); + }); + + // Remove indexes from proposal statuses tables + await connection.schema.alterTable("swuProposalStatuses", (table) => { + table.dropIndex(["proposal", "createdAt"]); + table.dropIndex(["status"]); + }); + await connection.schema.alterTable("cwuProposalStatuses", (table) => { + table.dropIndex(["proposal", "createdAt"]); + table.dropIndex(["status"]); + }); + await connection.schema.alterTable("twuProposalStatuses", (table) => { + table.dropIndex(["proposal", "createdAt"]); + table.dropIndex(["status"]); + }); + + // Remove indexes from affiliations + await connection.schema.alterTable("affiliations", (table) => { + table.dropIndex(["user", "organization"]); + table.dropIndex(["membershipStatus"]); + }); + + // Remove indexes from organization service areas + await connection.schema.alterTable("twuOrganizationServiceAreas", (table) => { + table.dropIndex(["organization", "serviceArea"]); + }); + + // Remove indexes from opportunity attachments + await connection.schema.alterTable("swuOpportunityAttachments", (table) => { + table.dropIndex(["opportunityVersion"]); + }); + await connection.schema.alterTable("cwuOpportunityAttachments", (table) => { + table.dropIndex(["opportunityVersion"]); + }); + await connection.schema.alterTable("twuOpportunityAttachments", (table) => { + table.dropIndex(["opportunityVersion"]); + }); + + // Remove indexes from opportunity addenda + await connection.schema.alterTable("swuOpportunityAddenda", (table) => { + table.dropIndex(["opportunity"]); + }); + await connection.schema.alterTable("cwuOpportunityAddenda", (table) => { + table.dropIndex(["opportunity"]); + }); + await connection.schema.alterTable("twuOpportunityAddenda", (table) => { + table.dropIndex(["opportunity"]); + }); +}