From a94f71f218b910472a0ee695daccdb091fb8e458 Mon Sep 17 00:00:00 2001 From: "j.dev" Date: Tue, 24 Sep 2024 15:56:21 -0700 Subject: [PATCH] feat(3165): enhance search, filter, and sort on private products --- .../products/_operations/search.ts | 45 ----- .../products/download/route.test.ts | 12 +- .../private-cloud/products/download/route.ts | 17 +- .../products/search/route.test.ts | 8 +- .../private-cloud/products/search/route.ts | 29 +-- .../api/v1/private-cloud/products/route.ts | 11 +- .../[licencePlate]/requests/FilterPanel.tsx | 13 +- .../[licencePlate]/requests/page.tsx | 4 +- .../[licencePlate]/requests/state.ts | 17 +- .../products/all/FilterPanel.tsx | 170 +++++------------- app/app/private-cloud/products/all/page.tsx | 24 ++- app/app/private-cloud/products/all/state.ts | 20 +-- .../requests/all/FilterPanel.tsx | 12 +- app/app/private-cloud/requests/all/page.tsx | 4 +- app/app/private-cloud/requests/all/state.ts | 20 +-- .../generic/table/SearchFilterExport.tsx | 59 ++++-- app/components/generic/table/Table.tsx | 19 +- app/constants/index.ts | 18 +- app/queries/private-cloud-products.ts | 46 ++--- .../backend/private-cloud/products.ts | 26 +-- .../backend/private-cloud/requests.ts | 4 +- app/types/next-auth.d.ts | 6 + app/validation-schemas/private-cloud.ts | 10 +- 23 files changed, 252 insertions(+), 342 deletions(-) delete mode 100644 app/app/api/private-cloud/products/_operations/search.ts diff --git a/app/app/api/private-cloud/products/_operations/search.ts b/app/app/api/private-cloud/products/_operations/search.ts deleted file mode 100644 index 1ae152f6e..000000000 --- a/app/app/api/private-cloud/products/_operations/search.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ProjectStatus, Ministry, Cluster, Prisma } from '@prisma/client'; -import { Session } from 'next-auth'; -import { parsePaginationParams } from '@/helpers/pagination'; -import { searchPrivateCloudProducts } from '@/queries/private-cloud-products'; - -export default async function searchOp({ - session, - search, - page, - pageSize, - ministry, - cluster, - status, - sortKey, - sortOrder, - isTest, -}: { - session: Session; - search: string; - page: number; - pageSize: number; - ministry?: Ministry; - cluster?: Cluster; - status?: ProjectStatus; - sortKey?: string; - sortOrder?: Prisma.SortOrder; - isTest: boolean; -}) { - const { skip, take } = parsePaginationParams(page, pageSize, 10); - - const { docs, totalCount } = await searchPrivateCloudProducts({ - session: session as Session, - skip, - take, - ministry, - cluster, - status, - search, - sortKey, - sortOrder, - isTest, - }); - - return { docs, totalCount }; -} diff --git a/app/app/api/private-cloud/products/download/route.test.ts b/app/app/api/private-cloud/products/download/route.test.ts index 317d68dd6..96a888455 100644 --- a/app/app/api/private-cloud/products/download/route.test.ts +++ b/app/app/api/private-cloud/products/download/route.test.ts @@ -1,5 +1,5 @@ import { expect } from '@jest/globals'; -import { DecisionStatus, Ministry, Cluster } from '@prisma/client'; +import { DecisionStatus, Ministry, Cluster, ProjectStatus } from '@prisma/client'; import { parse } from 'csv-parse/sync'; import prisma from '@/core/prisma'; import { createSamplePrivateCloudProductData } from '@/helpers/mock-resources'; @@ -289,9 +289,9 @@ describe('Download Private Cloud Products - Validations', () => { await mockSessionByRole('admin'); const res1 = await downloadPrivateCloudProjects({ - ministry: Ministry.AEST, - cluster: Cluster.CLAB, - includeInactive: false, + ministries: [Ministry.AEST], + clusters: [Cluster.CLAB], + status: [ProjectStatus.ACTIVE], }); expect(res1.status).toBe(200); @@ -331,7 +331,7 @@ describe('Download Private Cloud Products - Validations', () => { await mockSessionByRole('admin'); const res1 = await downloadPrivateCloudProjects({ - cluster: 'INVALID' as Cluster, + clusters: ['INVALID' as Cluster], }); expect(res1.status).toBe(400); @@ -341,7 +341,7 @@ describe('Download Private Cloud Products - Validations', () => { await mockSessionByRole('admin'); const res1 = await downloadPrivateCloudProjects({ - ministry: 'INVALID' as Ministry, + ministries: ['INVALID' as Ministry], }); expect(res1.status).toBe(400); diff --git a/app/app/api/private-cloud/products/download/route.ts b/app/app/api/private-cloud/products/download/route.ts index 11fcdcf33..a44f3a325 100644 --- a/app/app/api/private-cloud/products/download/route.ts +++ b/app/app/api/private-cloud/products/download/route.ts @@ -4,30 +4,25 @@ import { NoContent, CsvResponse } from '@/core/responses'; import { ministryKeyToName, getTotalQuotaStr } from '@/helpers/product'; import { formatFullName } from '@/helpers/user'; import { createEvent } from '@/mutations/events'; +import { searchPrivateCloudProducts } from '@/queries/private-cloud-products'; import { PrivateProductCsvRecord } from '@/types/csv'; import { formatDateSimple } from '@/utils/date'; import { privateCloudProductSearchNoPaginationBodySchema } from '@/validation-schemas/private-cloud'; -import searchOp from '../_operations/search'; export const POST = createApiHandler({ roles: ['user'], validations: { body: privateCloudProductSearchNoPaginationBodySchema }, })(async ({ session, body }) => { - const { search = '', ministry, cluster, includeInactive = false, showTest = false, sortKey, sortOrder } = body; - const searchProps = { - search, page: 1, pageSize: 10000, - ministry, - cluster, - status: includeInactive ? undefined : ProjectStatus.ACTIVE, - sortKey: sortKey || undefined, - sortOrder, - isTest: showTest, + ...body, }; - const { docs, totalCount } = await searchOp({ ...searchProps, session }); + const { docs, totalCount } = await searchPrivateCloudProducts({ + session, + ...searchProps, + }); if (docs.length === 0) { return NoContent(); diff --git a/app/app/api/private-cloud/products/search/route.test.ts b/app/app/api/private-cloud/products/search/route.test.ts index c58fe1489..05a95eebb 100644 --- a/app/app/api/private-cloud/products/search/route.test.ts +++ b/app/app/api/private-cloud/products/search/route.test.ts @@ -1,5 +1,5 @@ import { expect } from '@jest/globals'; -import { DecisionStatus, Ministry, Cluster } from '@prisma/client'; +import { DecisionStatus, Ministry, Cluster, ProjectStatus } from '@prisma/client'; import prisma from '@/core/prisma'; import { createSamplePrivateCloudProductData } from '@/helpers/mock-resources'; import { mockNoRoleUsers, findMockUserByIdr, findOtherMockUsers } from '@/helpers/mock-users'; @@ -211,9 +211,9 @@ describe('Search Private Cloud Products - Validations', () => { await mockSessionByRole('admin'); const res1 = await searchPrivateCloudProjects({ - ministry: Ministry.AEST, - cluster: Cluster.CLAB, - includeInactive: false, + ministries: [Ministry.AEST], + clusters: [Cluster.CLAB], + status: [ProjectStatus.ACTIVE], }); expect(res1.status).toBe(200); diff --git a/app/app/api/private-cloud/products/search/route.ts b/app/app/api/private-cloud/products/search/route.ts index 428ce2a98..28a082901 100644 --- a/app/app/api/private-cloud/products/search/route.ts +++ b/app/app/api/private-cloud/products/search/route.ts @@ -1,38 +1,17 @@ -import { ProjectStatus } from '@prisma/client'; import _isString from 'lodash-es/isString'; import createApiHandler from '@/core/api-handler'; import { OkResponse } from '@/core/responses'; +import { searchPrivateCloudProducts } from '@/queries/private-cloud-products'; import { privateCloudProductSearchBodySchema } from '@/validation-schemas/private-cloud'; -import searchOp from '../_operations/search'; export const POST = createApiHandler({ roles: ['user'], validations: { body: privateCloudProductSearchBodySchema }, })(async ({ session, body }) => { - const { - search = '', - page = 1, - pageSize = 5, - ministry, - cluster, - includeInactive = false, - sortKey, - sortOrder, - showTest, - } = body; - - const data = await searchOp({ + const { docs, totalCount } = await searchPrivateCloudProducts({ session, - search, - page, - pageSize, - ministry, - cluster, - status: includeInactive ? undefined : ProjectStatus.ACTIVE, - sortKey: sortKey || undefined, - sortOrder, - isTest: showTest, + ...body, }); - return OkResponse(data); + return OkResponse({ docs, totalCount }); }); diff --git a/app/app/api/v1/private-cloud/products/route.ts b/app/app/api/v1/private-cloud/products/route.ts index 0ee0471e2..e393c68c9 100644 --- a/app/app/api/v1/private-cloud/products/route.ts +++ b/app/app/api/v1/private-cloud/products/route.ts @@ -1,5 +1,4 @@ import { ProjectStatus, Ministry, Cluster } from '@prisma/client'; -import { Session } from 'next-auth'; import { z } from 'zod'; import createApiHandler from '@/core/api-handler'; import { OkResponse, BadRequestResponse } from '@/core/responses'; @@ -33,13 +32,13 @@ export const GET = apiHandler(async ({ queryParams, session }) => { const { skip, take, page } = parsePaginationParams(_page ?? defaultPage, _pageSize ?? defaultPageSize, 10); const { docs, totalCount } = await searchPrivateCloudProducts({ - session: session as Session, + session, skip, take, - ministry, - cluster, - status, - isTest: false, + ministries: ministry ? [ministry] : [], + clusters: cluster ? [cluster] : [], + status: status ? [status] : [], + temporary: [], }); const data = docs.map((doc) => { diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/requests/FilterPanel.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/requests/FilterPanel.tsx index c194d0acc..a64da4599 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/requests/FilterPanel.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/requests/FilterPanel.tsx @@ -1,7 +1,8 @@ -import { Prisma } from '@prisma/client'; +import { Cluster, Ministry, Prisma } from '@prisma/client'; import { useEffect, useRef, useState } from 'react'; import { useSnapshot, subscribe } from 'valtio'; import FormToggle from '@/components/generic/checkbox/FormToggle'; +import FormMultiSelect from '@/components/generic/select/FormMultiSelect'; import FormSelect from '@/components/generic/select/FormSelect'; import { clusters, productSorts, ministryOptions } from '@/constants'; import { pageState } from './state'; @@ -33,11 +34,11 @@ export default function FilterPanel() { }; const handleClusterChange = (value: string) => { - pageState.cluster = value; + pageState.cluster = value as Cluster; }; const handleMinistryChange = (value: string) => { - pageState.ministry = value; + pageState.ministry = value as Ministry; }; const clearFilters = () => { @@ -51,10 +52,10 @@ export default function FilterPanel() { sortRef.current.value = ''; } - pageState.cluster = ''; - pageState.ministry = ''; + pageState.cluster = undefined; + pageState.ministry = undefined; pageState.sortKey = ''; - pageState.sortOrder = ''; + pageState.sortOrder = Prisma.SortOrder.asc; pageState.includeInactive = false; }; diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/requests/page.tsx b/app/app/private-cloud/products/(product)/[licencePlate]/requests/page.tsx index ac38957e9..a9e6c7317 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/requests/page.tsx +++ b/app/app/private-cloud/products/(product)/[licencePlate]/requests/page.tsx @@ -42,8 +42,8 @@ export default privateCloudProductRequests(({ pathParams, queryParams, session } return ( { pageState.page = page; diff --git a/app/app/private-cloud/products/(product)/[licencePlate]/requests/state.ts b/app/app/private-cloud/products/(product)/[licencePlate]/requests/state.ts index a8eb96d61..25fe5cb61 100644 --- a/app/app/private-cloud/products/(product)/[licencePlate]/requests/state.ts +++ b/app/app/private-cloud/products/(product)/[licencePlate]/requests/state.ts @@ -1,15 +1,16 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, ProjectStatus } from '@prisma/client'; import { proxy, useSnapshot } from 'valtio'; -import { PrivateCloudProductSearchCriteria } from '@/services/backend/private-cloud/products'; +import { productSorts } from '@/constants'; +import { PrivateCloudRequestSearchBody } from '@/validation-schemas/private-cloud'; -export const pageState = proxy>({ +export const pageState = proxy({ search: '', page: 1, pageSize: 10, - ministry: '', - cluster: '', + ministry: undefined, + cluster: undefined, includeInactive: true, - sortKey: '', - sortOrder: Prisma.SortOrder.desc, - showTest: false, + showTest: true, + sortKey: productSorts[0].sortKey, + sortOrder: productSorts[0].sortOrder, }); diff --git a/app/app/private-cloud/products/all/FilterPanel.tsx b/app/app/private-cloud/products/all/FilterPanel.tsx index 526f608cb..1838abffa 100644 --- a/app/app/private-cloud/products/all/FilterPanel.tsx +++ b/app/app/private-cloud/products/all/FilterPanel.tsx @@ -1,132 +1,58 @@ -import { Prisma } from '@prisma/client'; -import { useRef } from 'react'; +import { Ministry, Cluster, Prisma, ProjectStatus } from '@prisma/client'; import { useSnapshot } from 'valtio'; -import FormToggle from '@/components/generic/checkbox/FormToggle'; -import FormSelect from '@/components/generic/select/FormSelect'; -import { clusters, productSorts, ministryOptions } from '@/constants'; +import FormMultiSelect from '@/components/generic/select/FormMultiSelect'; +import { clusters, ministryOptions } from '@/constants'; import { pageState } from './state'; export default function FilterPanel() { const pageSnapshot = useSnapshot(pageState); - const clusterProviderRef = useRef(null); - const ministryRef = useRef(null); - const sortRef = useRef(null); - const toggleDeletedProductsText = 'Show Deleted Products'; - const toggleTestProductsText = 'Filter Temp Products'; - - const handleSortChange = (value: string) => { - const selectedOption = productSorts.find((privateSortName) => privateSortName.humanFriendlyName === value); - if (selectedOption) { - pageState.sortKey = selectedOption.sortKey; - pageState.sortOrder = selectedOption.sortOrder; - } else { - pageState.sortKey = ''; - pageState.sortOrder = Prisma.SortOrder.desc; - } - - pageState.page = 1; - }; - - const handleDeletedProductsToggleChange = () => { - pageState.includeInactive = !pageSnapshot.includeInactive; - pageState.page = 1; - }; - - const handleTestProductsToggleChange = () => { - pageState.showTest = !pageSnapshot.showTest; - pageState.page = 1; - }; - - const handleClusterChange = (value: string) => { - pageState.cluster = value; - pageState.page = 1; - }; - - const handleMinistryChange = (value: string) => { - pageState.ministry = value; - pageState.page = 1; - }; - - const clearFilters = () => { - if (clusterProviderRef.current) { - clusterProviderRef.current.value = ''; - } - if (ministryRef.current) { - ministryRef.current.value = ''; - } - if (sortRef.current) { - sortRef.current.value = ''; - } - - pageState.cluster = ''; - pageState.ministry = ''; - pageState.sortKey = ''; - pageState.sortOrder = ''; - pageState.includeInactive = false; - pageState.showTest = false; - }; return ( -
-
-
- ({ label: v.humanFriendlyName, value: v.humanFriendlyName }))} - defaultValue={ - productSorts.find((v) => v.sortKey === pageSnapshot.sortKey && v.sortOrder === pageSnapshot.sortOrder) - ?.humanFriendlyName - } - onChange={handleSortChange} - /> -
-
-
- ({ label: v, value: v }))]} - defaultValue={pageSnapshot.cluster} - onChange={handleClusterChange} - /> -
-
-
-
- -
-
- - -
- -
-
+
+ { + pageState.ministries = value as Ministry[]; + pageState.page = 1; + }} + classNames={{ wrapper: 'col-span-5' }} + /> + ({ label: v, value: v }))]} + onChange={(value) => { + pageState.clusters = value as Cluster[]; + pageState.page = 1; + }} + classNames={{ wrapper: 'col-span-3' }} + /> + { + pageState.status = value as ProjectStatus[]; + pageState.page = 1; + }} + classNames={{ wrapper: 'col-span-2' }} + /> + { + pageState.temporary = value as ('YES' | 'NO')[]; + pageState.page = 1; + }} + classNames={{ wrapper: 'col-span-2' }} + />
); } diff --git a/app/app/private-cloud/products/all/page.tsx b/app/app/private-cloud/products/all/page.tsx index 647be2eb4..9ba6f9e5c 100644 --- a/app/app/private-cloud/products/all/page.tsx +++ b/app/app/private-cloud/products/all/page.tsx @@ -1,9 +1,11 @@ 'use client'; +import { Prisma } from '@prisma/client'; import { useQuery } from '@tanstack/react-query'; import { proxy, useSnapshot } from 'valtio'; import Table from '@/components/generic/table/Table'; import TableBodyPrivateProducts from '@/components/table/TableBodyPrivateProducts'; +import { productSorts } from '@/constants'; import createClientPage from '@/core/client-page'; import { processPrivateCloudProductData } from '@/helpers/row-mapper'; import { searchPrivateCloudProducts, downloadPrivateCloudProducts } from '@/services/backend/private-cloud/products'; @@ -37,9 +39,13 @@ export default privateCloudProducts(({ pathParams, queryParams, session }) => { title="Products in Private Cloud OpenShift Platform" description="These are your products hosted on Private Cloud OpenShift platform" totalCount={totalCount} - page={snap.page} - pageSize={snap.pageSize} + page={snap.page ?? 1} + pageSize={snap.pageSize ?? 10} search={snap.search} + sortKey={ + (productSorts.find((v) => v.sortKey === snap.sortKey && v.sortOrder === snap.sortOrder) ?? productSorts[0]) + ?.humanFriendlyName + } onPagination={(page: number, pageSize: number) => { pageState.page = page; pageState.pageSize = pageSize; @@ -52,6 +58,20 @@ export default privateCloudProducts(({ pathParams, queryParams, session }) => { const result = await downloadPrivateCloudProducts(snap); return result; }} + onSort={(sortKey) => { + pageState.page = 1; + + const selectedOption = productSorts.find((privateSortName) => privateSortName.humanFriendlyName === sortKey); + + if (selectedOption) { + pageState.sortKey = selectedOption.sortKey; + pageState.sortOrder = selectedOption.sortOrder; + } else { + pageState.sortKey = ''; + pageState.sortOrder = Prisma.SortOrder.desc; + } + }} + sortOptions={productSorts.map((v) => ({ label: v.humanFriendlyName, value: v.humanFriendlyName }))} filters={} isLoading={isLoading} > diff --git a/app/app/private-cloud/products/all/state.ts b/app/app/private-cloud/products/all/state.ts index 71c48d309..bf1c5707b 100644 --- a/app/app/private-cloud/products/all/state.ts +++ b/app/app/private-cloud/products/all/state.ts @@ -1,16 +1,16 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, ProjectStatus } from '@prisma/client'; import { proxy, useSnapshot } from 'valtio'; -import { PrivateCloudProductSearchCriteria } from '@/services/backend/private-cloud/products'; +import { productSorts } from '@/constants'; +import { PrivateCloudProductSearchBody } from '@/validation-schemas/private-cloud'; -export const pageState = proxy({ +export const pageState = proxy({ search: '', page: 1, pageSize: 10, - licencePlate: '', - ministry: '', - cluster: '', - includeInactive: false, - sortKey: '', - sortOrder: Prisma.SortOrder.desc, - showTest: false, + ministries: [], + clusters: [], + status: [ProjectStatus.ACTIVE], + temporary: [], + sortKey: productSorts[0].sortKey, + sortOrder: productSorts[0].sortOrder, }); diff --git a/app/app/private-cloud/requests/all/FilterPanel.tsx b/app/app/private-cloud/requests/all/FilterPanel.tsx index 3dc766e73..ebc558d83 100644 --- a/app/app/private-cloud/requests/all/FilterPanel.tsx +++ b/app/app/private-cloud/requests/all/FilterPanel.tsx @@ -1,4 +1,4 @@ -import { Prisma } from '@prisma/client'; +import { Cluster, Ministry, Prisma } from '@prisma/client'; import { useRef } from 'react'; import { useSnapshot } from 'valtio'; import FormToggle from '@/components/generic/checkbox/FormToggle'; @@ -34,11 +34,11 @@ export default function FilterPanel() { }; const handleClusterChange = (value: string) => { - pageState.cluster = value; + pageState.cluster = value as Cluster; }; const handleMinistryChange = (value: string) => { - pageState.ministry = value; + pageState.ministry = value as Ministry; }; const clearFilters = () => { @@ -52,10 +52,10 @@ export default function FilterPanel() { sortRef.current.value = ''; } - pageState.cluster = ''; - pageState.ministry = ''; + pageState.cluster = undefined; + pageState.ministry = undefined; pageState.sortKey = ''; - pageState.sortOrder = ''; + pageState.sortOrder = Prisma.SortOrder.asc; pageState.includeInactive = false; }; diff --git a/app/app/private-cloud/requests/all/page.tsx b/app/app/private-cloud/requests/all/page.tsx index ef9d86402..ed88fba65 100644 --- a/app/app/private-cloud/requests/all/page.tsx +++ b/app/app/private-cloud/requests/all/page.tsx @@ -36,8 +36,8 @@ export default privateCloudRequests(({ pathParams, queryParams, session }) => { title="Products in Private Cloud OpenShift Platform" description="Products with pending requests currently under admin review." totalCount={totalCount} - page={snap.page} - pageSize={snap.pageSize} + page={snap.page ?? 1} + pageSize={snap.pageSize ?? 10} search={snap.search} onPagination={(page: number, pageSize: number) => { pageState.page = page; diff --git a/app/app/private-cloud/requests/all/state.ts b/app/app/private-cloud/requests/all/state.ts index 71c48d309..25fe5cb61 100644 --- a/app/app/private-cloud/requests/all/state.ts +++ b/app/app/private-cloud/requests/all/state.ts @@ -1,16 +1,16 @@ -import { Prisma } from '@prisma/client'; +import { Prisma, ProjectStatus } from '@prisma/client'; import { proxy, useSnapshot } from 'valtio'; -import { PrivateCloudProductSearchCriteria } from '@/services/backend/private-cloud/products'; +import { productSorts } from '@/constants'; +import { PrivateCloudRequestSearchBody } from '@/validation-schemas/private-cloud'; -export const pageState = proxy({ +export const pageState = proxy({ search: '', page: 1, pageSize: 10, - licencePlate: '', - ministry: '', - cluster: '', - includeInactive: false, - sortKey: '', - sortOrder: Prisma.SortOrder.desc, - showTest: false, + ministry: undefined, + cluster: undefined, + includeInactive: true, + showTest: true, + sortKey: productSorts[0].sortKey, + sortOrder: productSorts[0].sortOrder, }); diff --git a/app/components/generic/table/SearchFilterExport.tsx b/app/components/generic/table/SearchFilterExport.tsx index 04ffba8c2..e138bd61b 100644 --- a/app/components/generic/table/SearchFilterExport.tsx +++ b/app/components/generic/table/SearchFilterExport.tsx @@ -1,21 +1,33 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'; -import { UnstyledButton, Tooltip } from '@mantine/core'; +import { UnstyledButton, Tooltip, ComboboxData } from '@mantine/core'; import { IconFilter, IconSearch, IconCircleX } from '@tabler/icons-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState, useRef } from 'react'; import ExportButton from '@/components/buttons/ExportButton'; +import LightButton from '@/components/generic/button/LightButton'; +import FormSingleSelect, { FormSingleSelectProps } from '@/components/generic/select/FormSingleSelect'; import { useDebounce } from '@/utils/hooks'; -import LightButton from '../button/LightButton'; import { useTableState } from './Table'; type Props = { initialSearch?: string; - onSearch?: (search: string) => void; + onSort?: (sortKey: string) => void; + sortOptions?: ComboboxData; + sortKey?: string; + onSearch?: (searchKey: string) => void; onExport?: () => Promise; children?: React.ReactNode; }; -export default function SearchFilterExport({ initialSearch = '', onSearch, onExport, children }: Props) { +export default function SearchFilterExport({ + initialSearch = '', + onSort, + sortOptions = [], + sortKey = '', + onSearch, + onExport, + children, +}: Props) { const pathname = usePathname(); const { replace } = useRouter(); const searchParams = useSearchParams()!; @@ -44,11 +56,24 @@ export default function SearchFilterExport({ initialSearch = '', onSearch, onExp return (
-
-
-
+
+
+ {onSort && ( + { + if (!value) return; + onSort(value); + }} + /> + )} +
+
+
{onSearch && ( -
+
@@ -87,18 +112,18 @@ export default function SearchFilterExport({ initialSearch = '', onSearch, onExp )}
- +
+ )} + {children && ( + + + Filter + )} + {onExport && }
- {children && ( - - - Filter - - )} - {onExport && }
- {children} + {children}
); diff --git a/app/components/generic/table/Table.tsx b/app/components/generic/table/Table.tsx index ab23bc7fd..b172f21db 100644 --- a/app/components/generic/table/Table.tsx +++ b/app/components/generic/table/Table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button, Divider, Grid, LoadingOverlay, Box } from '@mantine/core'; +import { Button, Divider, Grid, LoadingOverlay, Box, ComboboxData } from '@mantine/core'; import { createContext, useContext, useRef, useEffect } from 'react'; import { proxy, useSnapshot } from 'valtio'; import Pagination from './Pagination'; @@ -13,6 +13,7 @@ const defaultValue = { pageSize: 0, totalCount: 0, search: '', + sortKey: '', onPagination: (page: number, pageSize: number) => {}, isLoading: false, }; @@ -29,6 +30,9 @@ export default function Table({ onPagination = () => {}, onSearch, onExport, + onSort, + sortOptions = [], + sortKey = '', filters, isLoading = false, children, @@ -42,6 +46,9 @@ export default function Table({ onPagination?: (page: number, pageSize: number) => void; onSearch?: (search: string) => void; onExport?: () => Promise; + onSort?: (sortKey: string) => void; + sortOptions?: ComboboxData; + sortKey?: string; filters?: React.ReactNode; isLoading?: boolean; children: React.ReactNode; @@ -53,6 +60,7 @@ export default function Table({ state.pageSize = pageSize; state.totalCount = totalCount; state.search = search; + state.sortKey = sortKey; state.onPagination = onPagination; state.isLoading = isLoading; }, [state, page, pageSize, totalCount, search, onPagination, isLoading]); @@ -62,7 +70,14 @@ export default function Table({
{(onSearch || onExport || filters) && ( - + {filters} )} diff --git a/app/constants/index.ts b/app/constants/index.ts index 5a6b93a7a..81a737eb4 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -1,4 +1,4 @@ -import { Cluster, Ministry, Provider } from '@prisma/client'; +import { Cluster, Ministry, Prisma, Provider } from '@prisma/client'; import { CpuQuotaEnum, MemoryQuotaEnum, StorageQuotaEnum } from '@/validation-schemas/private-cloud'; export const clusters = Object.values(Cluster).filter((cluster) => cluster !== 'GOLDDR'); @@ -171,42 +171,42 @@ export const productSorts = [ { sortKey: 'updatedAt', humanFriendlyName: 'Product last update (new to old)', - sortOrder: 'desc', + sortOrder: Prisma.SortOrder.desc, }, { sortKey: 'updatedAt', humanFriendlyName: 'Product last update (old to new)', - sortOrder: 'asc', + sortOrder: Prisma.SortOrder.asc, }, { sortKey: 'name', humanFriendlyName: 'Product name (A-Z)', - sortOrder: 'asc', + sortOrder: Prisma.SortOrder.asc, }, { sortKey: 'name', humanFriendlyName: 'Product name (Z-A)', - sortOrder: 'desc', + sortOrder: Prisma.SortOrder.desc, }, { sortKey: 'description', humanFriendlyName: 'Product description (A-Z)', - sortOrder: 'asc', + sortOrder: Prisma.SortOrder.asc, }, { sortKey: 'description', humanFriendlyName: 'Product description (Z-A)', - sortOrder: 'desc', + sortOrder: Prisma.SortOrder.desc, }, { sortKey: 'licencePlate', humanFriendlyName: 'Product Licence Plate (A-Z)', - sortOrder: 'asc', + sortOrder: Prisma.SortOrder.asc, }, { sortKey: 'licencePlate', humanFriendlyName: 'Product Licence Plate (Z-A)', - sortOrder: 'desc', + sortOrder: Prisma.SortOrder.desc, }, ]; diff --git a/app/queries/private-cloud-products.ts b/app/queries/private-cloud-products.ts index 8340447f9..e0f40c13a 100644 --- a/app/queries/private-cloud-products.ts +++ b/app/queries/private-cloud-products.ts @@ -1,7 +1,10 @@ import { Ministry, Cluster, ProjectStatus, Prisma } from '@prisma/client'; +import _isNumber from 'lodash-es/isNumber'; import { Session } from 'next-auth'; import prisma from '@/core/prisma'; +import { parsePaginationParams } from '@/helpers/pagination'; import { PrivateCloudProductDetail, PrivateCloudProductDetailDecorated } from '@/types/private-cloud'; +import { PrivateCloudProductSearchBody } from '@/validation-schemas/private-cloud'; import { getMatchingUserIds } from './users'; export const privateCloudProductSimpleInclude = { @@ -32,27 +35,26 @@ export async function searchPrivateCloudProducts({ session, skip, take, - ministry, - cluster, + page, + pageSize, + ministries, + clusters, status, - search, + temporary, + search = '', sortKey = defaultSortKey, sortOrder = Prisma.SortOrder.desc, extraFilter, - isTest, -}: { +}: PrivateCloudProductSearchBody & { session: Session; - skip: number; - take: number; - status?: ProjectStatus; - ministry?: Ministry; - cluster?: Cluster; - search?: string; - sortKey?: string; - sortOrder?: Prisma.SortOrder; + skip?: number; + take?: number; extraFilter?: Prisma.PrivateCloudProjectWhereInput; - isTest: boolean; }) { + if (!_isNumber(skip) && !_isNumber(take) && page && pageSize) { + ({ skip, take } = parsePaginationParams(page, pageSize, 10)); + } + const where: Prisma.PrivateCloudProjectWhereInput = extraFilter ?? {}; const orderBy = { [sortKey || defaultSortKey]: Prisma.SortOrder[sortOrder] }; @@ -77,20 +79,20 @@ export async function searchPrivateCloudProducts({ } } - if (ministry) { - where.ministry = ministry as Ministry; + if (ministries && ministries.length > 0) { + where.ministry = { in: ministries }; } - if (cluster) { - where.cluster = cluster as Cluster; + if (clusters && clusters.length > 0) { + where.cluster = { in: clusters }; } - if (status) { - where.status = status; + if (status && status.length > 0) { + where.status = { in: status }; } - if (isTest) { - where.isTest = isTest; + if (temporary && temporary.length === 1) { + where.isTest = temporary[0] === 'YES'; } const [docs, totalCount] = await Promise.all([ diff --git a/app/services/backend/private-cloud/products.ts b/app/services/backend/private-cloud/products.ts index b985e13f5..0a2176e32 100644 --- a/app/services/backend/private-cloud/products.ts +++ b/app/services/backend/private-cloud/products.ts @@ -7,6 +7,10 @@ import { PrivateCloudRequestDetail, } from '@/types/private-cloud'; import { downloadFile } from '@/utils/file-download'; +import { + PrivateCloudProductSearchBody, + PrivateCloudProductSearchNoPaginationBody, +} from '@/validation-schemas/private-cloud'; import { instance as parentInstance } from './instance'; export const instance = axios.create({ @@ -14,25 +18,7 @@ export const instance = axios.create({ baseURL: `${parentInstance.defaults.baseURL}/products`, }); -export interface PrivateCloudProductAllCriteria { - search: string; - page: number; - pageSize: number; - licencePlate: string; - ministry: string; - cluster: string; - includeInactive: boolean; - sortKey: string; - sortOrder: string; - showTest: boolean; -} - -export interface PrivateCloudProductSearchCriteria extends PrivateCloudProductAllCriteria { - page: number; - pageSize: number; -} - -export async function searchPrivateCloudProducts(data: PrivateCloudProductSearchCriteria) { +export async function searchPrivateCloudProducts(data: PrivateCloudProductSearchBody) { const result = await instance.post('/search', data).then((res) => { return res.data; }); @@ -40,7 +26,7 @@ export async function searchPrivateCloudProducts(data: PrivateCloudProductSearch return result as PrivateCloudProductSearch; } -export async function downloadPrivateCloudProducts(data: PrivateCloudProductAllCriteria) { +export async function downloadPrivateCloudProducts(data: PrivateCloudProductSearchNoPaginationBody) { const result = await instance.post('/download', data, { responseType: 'blob' }).then((res) => { if (res.status === 204) return false; diff --git a/app/services/backend/private-cloud/requests.ts b/app/services/backend/private-cloud/requests.ts index 4d958fe9c..48de78a53 100644 --- a/app/services/backend/private-cloud/requests.ts +++ b/app/services/backend/private-cloud/requests.ts @@ -4,8 +4,8 @@ import { PrivateCloudRequestDetailDecorated, PrivateCloudRequestSearch, } from '@/types/private-cloud'; +import { PrivateCloudRequestSearchBody } from '@/validation-schemas/private-cloud'; import { instance as parentInstance } from './instance'; -import { PrivateCloudProductSearchCriteria } from './products'; export const instance = axios.create({ ...parentInstance.defaults, @@ -32,7 +32,7 @@ export async function getPrivateCloudRequest(id: string) { return result as PrivateCloudRequestDetailDecorated; } -export async function searchPrivateCloudRequests(data: PrivateCloudProductSearchCriteria) { +export async function searchPrivateCloudRequests(data: PrivateCloudRequestSearchBody) { const result = await instance.post('/search', data).then((res) => { return res.data; }); diff --git a/app/types/next-auth.d.ts b/app/types/next-auth.d.ts index 6f422a583..2ca00812a 100644 --- a/app/types/next-auth.d.ts +++ b/app/types/next-auth.d.ts @@ -101,3 +101,9 @@ declare module 'next-auth/jwt' { roles?: string[]; } } + +// This issue requires further investigation. +// See https://github.com/pmndrs/valtio/issues/327#issuecomment-1035937848 +declare module 'valtio' { + function useSnapshot(p: T): T; +} diff --git a/app/validation-schemas/private-cloud.ts b/app/validation-schemas/private-cloud.ts index 747e8b604..b8926e319 100644 --- a/app/validation-schemas/private-cloud.ts +++ b/app/validation-schemas/private-cloud.ts @@ -1,4 +1,4 @@ -import { Cluster, Ministry, Prisma } from '@prisma/client'; +import { Cluster, Ministry, Prisma, ProjectStatus } from '@prisma/client'; import _isString from 'lodash-es/isString'; import { string, z } from 'zod'; import { phoneNumberRegex } from '@/constants/regex'; @@ -135,12 +135,12 @@ export const privateCloudRequestDecisionBodySchema = privateCloudEditRequestBody export const privateCloudProductSearchNoPaginationBodySchema = z.object({ search: z.string().optional(), - ministry: z.preprocess(processUpperEnumString, z.nativeEnum(Ministry).optional()), - cluster: z.preprocess(processUpperEnumString, z.nativeEnum(Cluster).optional()), - includeInactive: z.boolean().optional(), + ministries: z.array(z.nativeEnum(Ministry)).optional(), + clusters: z.array(z.nativeEnum(Cluster)).optional(), + status: z.array(z.nativeEnum(ProjectStatus)).optional(), + temporary: z.array(z.enum(['YES', 'NO'])).optional(), sortKey: z.string().optional(), sortOrder: z.preprocess(processEnumString, z.nativeEnum(Prisma.SortOrder).optional()), - showTest: z.boolean().default(false), }); export const privateCloudProductSearchBodySchema = privateCloudProductSearchNoPaginationBodySchema.merge(