diff --git a/jsapp/js/UniversalTable/UniversalTableCore.module.scss b/jsapp/js/UniversalTable/UniversalTableCore.module.scss index d22ba710e1..c5ad8ba0fa 100644 --- a/jsapp/js/UniversalTable/UniversalTableCore.module.scss +++ b/jsapp/js/UniversalTable/UniversalTableCore.module.scss @@ -36,6 +36,8 @@ $z-index-spinner: 8; overflow-x: auto; position: relative; border-radius: $universal-table-border-radius-inner; + // Hard-coded minimum height that will not cut off dropdowns in header if there are little to no entries in the table + min-height: 200px; } // If the footer is displayed, don't round bottom corners diff --git a/jsapp/js/UniversalTable/index.tsx b/jsapp/js/UniversalTable/index.tsx index f526ad3c63..b5221d9bd6 100644 --- a/jsapp/js/UniversalTable/index.tsx +++ b/jsapp/js/UniversalTable/index.tsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react' import type { UseQueryResult } from '@tanstack/react-query' +import type { ErrorObject } from 'schema-utils/declarations/validate' +import type { ErrorDetail } from '#/api/models/errorDetail' import UniversalTableCore from './UniversalTableCore' import type { UniversalTableColumn } from './UniversalTableCore' @@ -44,7 +46,7 @@ export namespace PaginatedListResponse { } } -interface UniversalTableProps { +interface UniversalTableProps { // Below are props from `UniversalTable` that should come from the parent // component (these are kind of "configuration" props). The other // `UniversalTable` props are being handled here internally. diff --git a/jsapp/js/account/usage/assetUsage.api.ts b/jsapp/js/account/usage/assetUsage.api.ts index b72607ebc9..129dd92358 100644 --- a/jsapp/js/account/usage/assetUsage.api.ts +++ b/jsapp/js/account/usage/assetUsage.api.ts @@ -1,18 +1,11 @@ import { fetchGet } from '#/api' +import type { PaginatedResponse } from '#/dataInterface' import { PROJECT_FIELDS } from '#/projects/projectViews/constants' import type { ProjectFieldName } from '#/projects/projectViews/constants' import type { ProjectsTableOrder } from '#/projects/projectsTable/projectsTable' -export interface AssetUsage { - count: string - next: string | null - previous: string | null - results: AssetWithUsage[] -} - export interface AssetWithUsage { asset: string - uid: string asset__name: string nlp_usage_current_period: { total_nlp_asr_seconds: number @@ -31,14 +24,16 @@ export interface AssetWithUsage { const ORG_ASSET_USAGE_URL = '/api/v2/organizations/:organization_id/asset_usage/' export async function getOrgAssetUsage( - pageNumber: number | string, + limit: number, + offset: number, organizationId: string, order?: ProjectsTableOrder, ) { const apiUrl = ORG_ASSET_USAGE_URL.replace(':organization_id', organizationId) const params = new URLSearchParams({ - page: pageNumber.toString(), + limit: limit.toString(), + offset: offset.toString(), }) if (order?.fieldName && order.direction && (order.direction === 'ascending' || order.direction === 'descending')) { @@ -47,8 +42,11 @@ export async function getOrgAssetUsage( params.set('ordering', orderingPrefix + fieldDefinition.apiOrderingName) } - return fetchGet(`${apiUrl}?${params}`, { - includeHeaders: true, - errorMessageDisplay: t('There was an error fetching asset usage data.'), - }) + return { + status: 200 as const, + data: await fetchGet>(`${apiUrl}?${params}`, { + includeHeaders: true, + errorMessageDisplay: t('There was an error fetching asset usage data.'), + }), + } } diff --git a/jsapp/js/account/usage/usageProjectBreakdown.module.scss b/jsapp/js/account/usage/usageProjectBreakdown.module.scss index a07c7ea20a..7d404aee58 100644 --- a/jsapp/js/account/usage/usageProjectBreakdown.module.scss +++ b/jsapp/js/account/usage/usageProjectBreakdown.module.scss @@ -30,88 +30,7 @@ padding: 2%; } -.root table { - width: 100%; - border-collapse: collapse; - margin-block-end: 60px; -} - -.root table th { - padding: 0 8px; -} - -.projects { - padding-right: 15%; - padding-left: 8px; -} - -.root th { - font-weight: 700; - font-size: 13px; - color: colors.$kobo-gray-700; - text-align: initial; - padding-block: 1.5%; -} - -.root tr { - font-weight: 400; - font-size: 16px; - color: colors.$kobo-gray-800; - border-bottom: 1px solid colors.$kobo-gray-300; -} - -.root td { - padding: 18px 8px; -} - .link { color: colors.$kobo-dark-blue; font-weight: 600; } - -.currentMonth { - font-weight: 600; -} - -.pagination { - font-weight: 400; - font-size: 14px; - color: colors.$kobo-gray-800; - position: fixed; - bottom: 20px; - background-color: white; - border: 1px solid colors.$kobo-gray-400; - width: 150px; - border-radius: 4px; - padding: 5px 0; - display: flex; - align-items: center; - height: 36px; -} - -.root button { - background: transparent; - border: none; - padding: 0 8px; - color: colors.$kobo-gray-400; - cursor: pointer; - - &.active { - color: colors.$kobo-gray-800; - } -} - -.range { - padding: 6px; - border-right: 1px solid colors.$kobo-gray-400; - border-left: 1px solid colors.$kobo-gray-400; - flex: 1; - text-align: center; -} - -.emptyMessage { - padding: 40px; - font-size: 1.2rem; - justify-content: center; - text-align: center; -} diff --git a/jsapp/js/account/usage/usageProjectBreakdown.tsx b/jsapp/js/account/usage/usageProjectBreakdown.tsx index 119b06706f..07423f2050 100644 --- a/jsapp/js/account/usage/usageProjectBreakdown.tsx +++ b/jsapp/js/account/usage/usageProjectBreakdown.tsx @@ -1,101 +1,68 @@ -import React, { useEffect, useState } from 'react' +import { useState } from 'react' +import { keepPreviousData } from '@tanstack/react-query' import prettyBytes from 'pretty-bytes' import { Link } from 'react-router-dom' -import type { AssetUsage, AssetWithUsage } from '#/account/usage/assetUsage.api' -import { getOrgAssetUsage } from '#/account/usage/assetUsage.api' +import UniversalTable, { DEFAULT_PAGE_SIZE, type UniversalTableColumn } from '#/UniversalTable' +import type { CustomAssetUsage } from '#/api/models/customAssetUsage' +import type { ErrorObject } from '#/api/models/errorObject' +import { + getOrganizationsAssetUsageListQueryKey, + useOrganizationsAssetUsageList, +} from '#/api/react-query/user-team-organization-usage' import { useOrganizationAssumed } from '#/api/useOrganizationAssumed' import AssetStatusBadge from '#/components/common/assetStatusBadge' import Button from '#/components/common/button' import Icon from '#/components/common/icon' -import LoadingSpinner from '#/components/common/loadingSpinner' -import { USAGE_ASSETS_PER_PAGE } from '#/constants' import type { ProjectFieldDefinition } from '#/projects/projectViews/constants' import type { ProjectsTableOrder } from '#/projects/projectsTable/projectsTable' import SortableProjectColumnHeader from '#/projects/projectsTable/sortableProjectColumnHeader' import { ROUTES } from '#/router/routerConstants' -import { convertSecondsToMinutes } from '#/utils' +import { convertSecondsToMinutes, notify } from '#/utils' import styles from './usageProjectBreakdown.module.scss' import { useBillingPeriod } from './useBillingPeriod' -type ButtonType = 'back' | 'forward' - const ProjectBreakdown = () => { - const [currentPage, setCurrentPage] = useState(1) - const [projectData, setProjectData] = useState({ - count: '0', - next: null, - previous: null, - results: [], - }) - const [order, setOrder] = useState({}) const [showIntervalBanner, setShowIntervalBanner] = useState(true) - const [loading, setLoading] = useState(true) const [organization] = useOrganizationAssumed() const { billingPeriod } = useBillingPeriod() + const [order, setOrder] = useState({}) + const [pagination, setPagination] = useState({ + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }) - useEffect(() => { - async function fetchData(orgId: string) { - const data = await getOrgAssetUsage(currentPage, orgId, order) - const updatedResults = data.results.map((projectResult) => { - const assetParts = projectResult.asset.split('/') - const uid = assetParts[assetParts.length - 2] - return { - ...projectResult, - uid: uid, - } - }) - - setProjectData({ - ...data, - results: updatedResults, - }) - setLoading(false) - } - - fetchData(organization.id) - }, [currentPage, order, organization.id]) - - if (loading) { - return - } - - function dismissIntervalBanner() { - setShowIntervalBanner(false) - } - - const calculateRange = (): string => { - const totalProjects = Number.parseInt(projectData.count) - let startRange = (currentPage - 1) * USAGE_ASSETS_PER_PAGE + 1 - if (Number.parseInt(projectData.count) === 0) { - startRange = 0 - } - const endRange = Math.min(currentPage * USAGE_ASSETS_PER_PAGE, totalProjects) - return `${startRange}-${endRange} of ${totalProjects}` - } - - const handleClick = async (event: React.MouseEvent, buttonType: ButtonType): Promise => { - event.preventDefault() + function getQueryParams() { + // HACK FIX: a bit of a roundabout way to incorporate what the backend expects without diving too deep into changing + // existing types + const orderPrefix = order.direction === 'descending' ? '-' : '' + const fieldName = order.fieldName === 'status' ? '_deployment_status' : order.fieldName - try { - if (buttonType === 'back' && projectData.previous) { - setCurrentPage((prevPage) => Math.max(prevPage - 1, 1)) - } else if (buttonType === 'forward' && projectData.next) { - setCurrentPage((prevPage) => - Math.min(prevPage + 1, Math.ceil(Number.parseInt(projectData.count) / USAGE_ASSETS_PER_PAGE)), - ) - } - } catch (error) { - console.error('Error fetching data:', error) + return { + ...pagination, + ordering: orderPrefix + fieldName, } } - const isActiveBack = currentPage > 1 - const isActiveForward = currentPage < Math.ceil(Number.parseInt(projectData.count) / USAGE_ASSETS_PER_PAGE) + const queryResult = useOrganizationsAssetUsageList(organization.id, getQueryParams(), { + query: { + queryKey: getOrganizationsAssetUsageListQueryKey(organization.id, getQueryParams()), + placeholderData: keepPreviousData, + // We might want to improve this in future, for now let's not retry + retry: false, + // The `refetchOnWindowFocus` option is `true` by default, I'm setting it + // here so we don't forget about it. + refetchOnWindowFocus: true, + throwOnError: () => { + notify(t('There was an error getting the list.'), 'error') // TODO: update message in backend (DEV-1218). + return false + }, + }, + }) const usageName: ProjectFieldDefinition = { name: 'name', - label: t('##count## Projects').replace('##count##', projectData.count), + label: getUsageNameLabel(), apiFilteringName: 'name', apiOrderingName: 'name', availableConditions: [], @@ -112,33 +79,89 @@ const ProjectBreakdown = () => { setOrder(newOrder) } - const renderProjectRow = (project: AssetWithUsage) => { - const periodSubmissions = project.submission_count_current_period.toLocaleString() + function dismissIntervalBanner() { + setShowIntervalBanner(false) + } - const periodASRSeconds = convertSecondsToMinutes( - project.nlp_usage_current_period.total_nlp_asr_seconds, - ).toLocaleString() + function getUsageNameLabel() { + if (queryResult.data) { + return t('##count## Projects').replace('##count##', '') // FIXME: `count` doens't exist, seems related to the error type mismatch stuff queryResult.data.data.count.toString()) + } else { + return t('Projects') + } + } - const periodMTCharacters = project.nlp_usage_current_period.total_nlp_mt_characters.toLocaleString() + const columns: Array> = [ + { + key: 'asset_name', + label: ( + + ), + size: 100, + cellFormatter: (data: CustomAssetUsage) => { + const assetParts = data.asset.split('/') + const uid = assetParts[assetParts.length - 2] - return ( - - - - {project.asset__name} + return ( + + {data.asset__name} - - {project.submission_count_all_time.toLocaleString()} - {periodSubmissions} - {prettyBytes(project.storage_bytes)} - {periodASRSeconds} - {periodMTCharacters} - - - - - ) - } + ) + }, + }, + { + key: 'submissions_all', + label: t('Submissions (Total)'), + size: 100, + cellFormatter: (data: CustomAssetUsage) => data.submission_count_all_time, + }, + { + key: 'submissions_current', + label: t('Submissions'), + size: 100, + cellFormatter: (data: CustomAssetUsage) => data.submission_count_current_period, + }, + { + key: 'storage', + label: t('Storage'), + size: 100, + cellFormatter: (data: CustomAssetUsage) => prettyBytes(data.storage_bytes), + }, + { + key: 'transcript_minutes', + label: t('Transcript minutes'), + size: 100, + cellFormatter: (data: CustomAssetUsage) => + convertSecondsToMinutes(data.nlp_usage_current_period.total_nlp_asr_seconds).toLocaleString(), + }, + { + key: 'translation_characters', + label: t('Translation characters'), + size: 100, + cellFormatter: (data: CustomAssetUsage) => + convertSecondsToMinutes(data.nlp_usage_current_period.total_nlp_mt_characters).toLocaleString(), + }, + { + key: 'staus', + label: ( + + ), + size: 100, + cellFormatter: (data: CustomAssetUsage) => , + }, + ] return (
@@ -155,57 +178,12 @@ const ProjectBreakdown = () => {
)} - - - - - - - - - - - - - {Number.parseInt(projectData.count) === 0 ? ( - - - - - - ) : ( - {projectData.results.map((project) => renderProjectRow(project))} - )} -
- - {t('Submissions (Total)')}{t('Submissions')}{t('Data storage')}{t('Transcript minutes')}{t('Translation characters')} - -
-
{t('There are no projects to display.')}
-
- + + pagination={pagination} + setPagination={setPagination} + queryResult={queryResult} + columns={columns} + /> ) }