diff --git a/workspaces/dcm/.changeset/multi-resource-catalog-items.md b/workspaces/dcm/.changeset/multi-resource-catalog-items.md new file mode 100644 index 00000000000..e24d9ecedf0 --- /dev/null +++ b/workspaces/dcm/.changeset/multi-resource-catalog-items.md @@ -0,0 +1,21 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dcm-common': minor +'@red-hat-developer-hub/backstage-plugin-dcm': minor +--- + +Add multi-resource support for Catalog Items and Catalog Item Instances. + +**API type changes (`dcm-common`)** + +- `CatalogItemSpec` now holds a `resources?: CatalogResource[]` array instead of a single `service_type` + `fields`. +- New `CatalogResource` interface: `{ name, service_type, requires_resources?, fields? }`. +- `UserValue` gains a required `resource` field that identifies which resource the value targets. +- `CatalogItemInstanceSpec` gains `resource_ids?: string[]` (replaces the top-level `resource_id`). + +**UI changes (`dcm`)** + +- Catalog Item create/edit now uses a vertical-tabbed wizard dialog (`CatalogItemWizardDialog`) with tabs: Overview, API, Resources, and one tab per resource for field configurations. +- Catalog Item Instance create now uses a vertical-tabbed wizard dialog (`InstanceWizardDialog`) with an Overview tab and one tab per resource that has editable fields. +- Shared components extracted: `VerticalTabDialog`, `SchemaButton`, `ResourceFieldsPanel`, `UserValueFields`. +- Shared utility `validateJsonObject` de-duplicates JSON-object validation across `SchemaButton` and `catalogItemFormTypes`. +- Table columns updated: "Service type" replaced by "Resources" (chips per service_type); field count sums across all resources. diff --git a/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md b/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md new file mode 100644 index 00000000000..e206f329fb2 --- /dev/null +++ b/workspaces/dcm/.changeset/server-side-pagination-all-tabs.md @@ -0,0 +1,28 @@ +--- +'@red-hat-developer-hub/backstage-plugin-dcm': minor +'@red-hat-developer-hub/backstage-plugin-dcm-common': minor +--- + +Add server-side cursor pagination to all tabs and harden pagination handling across APIs. + +**All six tabs now use server-side cursor pagination** + +Previously only Service Types, Catalog Items, and Catalog Item Instances fetched data page-by-page from the backend. Providers, Policies, and Resources loaded everything in a single call and silently lost records beyond the first page. + +- **Providers** and **Policies** — converted to `usePaginatedCrudTab` (same pattern as Catalog Items). Next / Previous buttons appear below the table; search filters the current page client-side without an extra round-trip. +- **Resources** — converted to `usePaginatedFetch` (same as Service Types). Retains the same read-only layout. + +**`dcm-common`: new pagination utilities and updated API interfaces** + +- `buildPaginationQuery` extracted from `CatalogClient` into `dcm-common/src/utils/buildPaginationQuery.ts` and exported publicly so all clients can share the same URL-builder. +- `ProvidersApi` / `ProvidersClient` — `listProviders` now accepts an optional `PaginationParams` argument. +- `PolicyManagerApi` / `PolicyManagerClient` — `listPolicies` now accepts an optional `PaginationParams` argument. +- `ServiceTypeList`, `CatalogItemList`, `CatalogItemInstanceList` — `next_page_token` is now optional (`?`) to match the real backend behaviour where the field is absent (not just empty) when there is only one page of results. + +**Dropdown options loaded once on mount** + +Service-type dropdown loads (used in the Providers and Catalog Items create/edit forms) are now fetched once on component mount via a dedicated `useEffect`, not on every page navigation. The request uses `max_page_size: 100` to avoid silently truncating valid options. + +**Test coverage** + +Added `ProvidersTabContent.test.tsx` and `ResourcesTabContent.test.tsx` with full cursor navigation test suites (initial load, error/retry, Next/Previous button states and token passing). Updated `PoliciesTabContent.test.tsx` with equivalent cursor navigation tests and refreshed mock return types. diff --git a/workspaces/dcm/plugins/dcm-common/report.api.md b/workspaces/dcm/plugins/dcm-common/report.api.md index 15f4709dd78..1335c49da0e 100644 --- a/workspaces/dcm/plugins/dcm-common/report.api.md +++ b/workspaces/dcm/plugins/dcm-common/report.api.md @@ -7,6 +7,9 @@ import { BasicPermission } from '@backstage/plugin-permission-common'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import type { FetchApi } from '@backstage/core-plugin-api'; +// @public +export function buildPaginationQuery(params: PaginationParams): string; + // @public export interface CatalogApi { // (undocumented) @@ -30,11 +33,13 @@ export interface CatalogApi { // (undocumented) getServiceType(serviceTypeId: string): Promise; // (undocumented) - listCatalogItemInstances(): Promise; + listCatalogItemInstances( + params?: PaginationParams, + ): Promise; // (undocumented) - listCatalogItems(): Promise; + listCatalogItems(params?: PaginationParams): Promise; // (undocumented) - listServiceTypes(): Promise; + listServiceTypes(params?: PaginationParams): Promise; rehydrateCatalogItemInstance( catalogItemInstanceId: string, ): Promise; @@ -68,11 +73,13 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // (undocumented) getServiceType(serviceTypeId: string): Promise; // (undocumented) - listCatalogItemInstances(): Promise; + listCatalogItemInstances( + params?: PaginationParams, + ): Promise; // (undocumented) - listCatalogItems(): Promise; + listCatalogItems(params?: PaginationParams): Promise; // (undocumented) - listServiceTypes(): Promise; + listServiceTypes(params?: PaginationParams): Promise; // (undocumented) rehydrateCatalogItemInstance( catalogItemInstanceId: string, @@ -114,7 +121,6 @@ export interface CatalogItemInstance { display_name: string; // (undocumented) path?: string; - resource_id?: string; // (undocumented) spec: CatalogItemInstanceSpec; // (undocumented) @@ -126,7 +132,7 @@ export interface CatalogItemInstance { // @public export interface CatalogItemInstanceList { // (undocumented) - next_page_token: string; + next_page_token?: string; // (undocumented) results: CatalogItemInstance[]; } @@ -135,6 +141,7 @@ export interface CatalogItemInstanceList { export interface CatalogItemInstanceSpec { // (undocumented) catalog_item_id: string; + resource_ids?: string[]; // (undocumented) user_values: UserValue[]; } @@ -142,17 +149,22 @@ export interface CatalogItemInstanceSpec { // @public export interface CatalogItemList { // (undocumented) - next_page_token: string; + next_page_token?: string; // (undocumented) results: CatalogItem[]; } // @public export interface CatalogItemSpec { - // (undocumented) + resources?: CatalogResource[]; +} + +// @public +export interface CatalogResource { fields?: FieldConfiguration[]; - // (undocumented) - service_type?: string; + name: string; + requires_resources?: string[]; + service_type: string; } // @public @@ -278,6 +290,12 @@ export interface ListServiceTypeInstancesParams { show_deleted?: boolean; } +// @public +export interface PaginationParams { + max_page_size?: number; + page_token?: string; +} + // @public export function parseDcmEntityStatus(raw: string): DcmEntityStatus | undefined; @@ -319,7 +337,7 @@ export interface PolicyManagerApi { // (undocumented) getPolicy(policyId: string): Promise; // (undocumented) - listPolicies(): Promise; + listPolicies(params?: PaginationParams): Promise; // (undocumented) updatePolicy(policyId: string, patch: Partial): Promise; } @@ -336,7 +354,7 @@ export class PolicyManagerClient // (undocumented) getPolicy(policyId: string): Promise; // (undocumented) - listPolicies(): Promise; + listPolicies(params?: PaginationParams): Promise; // (undocumented) protected readonly serviceName = 'Policy Manager'; // (undocumented) @@ -403,7 +421,7 @@ export interface ProvidersApi { // (undocumented) getProvider(providerId: string): Promise; // (undocumented) - listProviders(): Promise; + listProviders(params?: PaginationParams): Promise; } // @public @@ -417,7 +435,7 @@ export class ProvidersClient extends DcmBaseClient implements ProvidersApi { // (undocumented) getProvider(providerId: string): Promise; // (undocumented) - listProviders(): Promise; + listProviders(params?: PaginationParams): Promise; // (undocumented) protected readonly serviceName = 'Providers'; } @@ -507,7 +525,7 @@ export interface ServiceTypeInstanceSpec { // @public export interface ServiceTypeList { // (undocumented) - next_page_token: string; + next_page_token?: string; // (undocumented) results: ServiceType[]; } @@ -516,6 +534,7 @@ export interface ServiceTypeList { export interface UserValue { // (undocumented) path: string; + resource: string; // (undocumented) value: unknown; } diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts index a12fc771166..4cc3bb8fc2e 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogApi.ts @@ -22,6 +22,7 @@ import type { ServiceType, ServiceTypeList, } from '../types/catalog'; +import type { PaginationParams } from '../types/common'; /** * Interface for the DCM Catalog API client. @@ -30,12 +31,12 @@ import type { */ export interface CatalogApi { // Service Types - listServiceTypes(): Promise; + listServiceTypes(params?: PaginationParams): Promise; getServiceType(serviceTypeId: string): Promise; createServiceType(serviceType: ServiceType): Promise; // Catalog Items - listCatalogItems(): Promise; + listCatalogItems(params?: PaginationParams): Promise; getCatalogItem(catalogItemId: string): Promise; createCatalogItem(catalogItem: CatalogItem): Promise; updateCatalogItem( @@ -45,7 +46,9 @@ export interface CatalogApi { deleteCatalogItem(catalogItemId: string): Promise; // Catalog Item Instances - listCatalogItemInstances(): Promise; + listCatalogItemInstances( + params?: PaginationParams, + ): Promise; getCatalogItemInstance( catalogItemInstanceId: string, ): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts index 50679fdc72b..6e7d2c41cf2 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.test.ts @@ -27,8 +27,8 @@ const MOCK_INSTANCE: CatalogItemInstance = { spec: { catalog_item_id: 'ci-1', user_values: [], + resource_ids: ['res-new'], }, - resource_id: 'res-new', }; function makeClient(fetchFn: jest.Mock) { diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts index 2bc2e3500b2..727b0edc01c 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/CatalogClient.ts @@ -22,6 +22,8 @@ import type { ServiceType, ServiceTypeList, } from '../types/catalog'; +import type { PaginationParams } from '../types/common'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; import type { CatalogApi } from './CatalogApi'; import { DcmBaseClient } from './DcmBaseClient'; @@ -39,8 +41,12 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // ── Service Types ────────────────────────────────────────────────────────── - async listServiceTypes(): Promise { - return this.fetch('service-types'); + async listServiceTypes( + params: PaginationParams = {}, + ): Promise { + return this.fetch( + `service-types${buildPaginationQuery(params)}`, + ); } async getServiceType(serviceTypeId: string): Promise { @@ -56,8 +62,12 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // ── Catalog Items ────────────────────────────────────────────────────────── - async listCatalogItems(): Promise { - return this.fetch('catalog-items'); + async listCatalogItems( + params: PaginationParams = {}, + ): Promise { + return this.fetch( + `catalog-items${buildPaginationQuery(params)}`, + ); } async getCatalogItem(catalogItemId: string): Promise { @@ -90,8 +100,12 @@ export class CatalogClient extends DcmBaseClient implements CatalogApi { // ── Catalog Item Instances ───────────────────────────────────────────────── - async listCatalogItemInstances(): Promise { - return this.fetch('catalog-item-instances'); + async listCatalogItemInstances( + params: PaginationParams = {}, + ): Promise { + return this.fetch( + `catalog-item-instances${buildPaginationQuery(params)}`, + ); } async getCatalogItemInstance( diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts index 584ad728252..a0b60482ed3 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Policy, PolicyList } from '../types/policy-manager'; /** @@ -22,7 +23,7 @@ import type { Policy, PolicyList } from '../types/policy-manager'; * @public */ export interface PolicyManagerApi { - listPolicies(): Promise; + listPolicies(params?: PaginationParams): Promise; getPolicy(policyId: string): Promise; createPolicy(policy: Policy): Promise; updatePolicy(policyId: string, patch: Partial): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts index ab1fa58c6f3..4cf1d360327 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/PolicyManagerClient.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Policy, PolicyList } from '../types/policy-manager'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; import type { PolicyManagerApi } from './PolicyManagerApi'; import { DcmBaseClient } from './DcmBaseClient'; @@ -33,8 +35,8 @@ export class PolicyManagerClient { protected readonly serviceName = 'Policy Manager'; - async listPolicies(): Promise { - return this.fetch('policies'); + async listPolicies(params: PaginationParams = {}): Promise { + return this.fetch(`policies${buildPaginationQuery(params)}`); } async getPolicy(policyId: string): Promise { diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts index 46d772087b4..21098fd466d 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Provider, ProviderList } from '../types/providers'; /** @@ -22,7 +23,7 @@ import type { Provider, ProviderList } from '../types/providers'; * @public */ export interface ProvidersApi { - listProviders(): Promise; + listProviders(params?: PaginationParams): Promise; getProvider(providerId: string): Promise; createProvider(provider: Provider): Promise; applyProvider(providerId: string, provider: Provider): Promise; diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts index 9c2f315b673..4431698d835 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.test.ts @@ -59,6 +59,19 @@ describe('ProvidersClient', () => { ); }); + it('listProviders appends max_page_size and page_token query params', async () => { + const fetchFn = jest + .fn() + .mockResolvedValue(okJson({ providers: [MOCK_PROVIDER] })); + const client = makeClient(fetchFn); + + await client.listProviders({ max_page_size: 10, page_token: 'tok-1' }); + + const [url] = fetchFn.mock.calls[0]; + expect(url).toContain('max_page_size=10'); + expect(url).toContain('page_token=tok-1'); + }); + it('getProvider calls GET /providers/{id}', async () => { const fetchFn = jest.fn().mockResolvedValue(okJson(MOCK_PROVIDER)); const client = makeClient(fetchFn); diff --git a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts index 8b26331ae76..db626323920 100644 --- a/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts +++ b/workspaces/dcm/plugins/dcm-common/src/clients/ProvidersClient.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import type { PaginationParams } from '../types/common'; import type { Provider, ProviderList } from '../types/providers'; +import { buildPaginationQuery } from '../utils/buildPaginationQuery'; import type { ProvidersApi } from './ProvidersApi'; import { DcmBaseClient } from './DcmBaseClient'; @@ -30,8 +32,8 @@ import { DcmBaseClient } from './DcmBaseClient'; export class ProvidersClient extends DcmBaseClient implements ProvidersApi { protected readonly serviceName = 'Providers'; - async listProviders(): Promise { - return this.fetch('providers'); + async listProviders(params: PaginationParams = {}): Promise { + return this.fetch(`providers${buildPaginationQuery(params)}`); } async getProvider(providerId: string): Promise { diff --git a/workspaces/dcm/plugins/dcm-common/src/index.ts b/workspaces/dcm/plugins/dcm-common/src/index.ts index 4d6618c21d6..8ab9b9cb802 100644 --- a/workspaces/dcm/plugins/dcm-common/src/index.ts +++ b/workspaces/dcm/plugins/dcm-common/src/index.ts @@ -38,3 +38,4 @@ export * from './types'; export * from './clients'; export { DcmClientError } from './errors/DcmClientError'; export { extractApiError } from './utils/extractApiError'; +export { buildPaginationQuery } from './utils/buildPaginationQuery'; diff --git a/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts b/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts index 44a8dc2ee22..b4316b3922f 100644 --- a/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts +++ b/workspaces/dcm/plugins/dcm-common/src/types/catalog.ts @@ -50,11 +50,26 @@ export interface CatalogItem { /** Spec section of a {@link CatalogItem}. */ export interface CatalogItemSpec { - service_type?: string; + /** One or more named resources — each declares a service type and field configs. */ + resources?: CatalogResource[]; +} + +/** + * A named resource within a {@link CatalogItemSpec}. + * `name` and `service_type` are immutable after creation. + */ +export interface CatalogResource { + /** Unique identifier within the catalog item (e.g. "app", "ordersDb"). */ + name: string; + /** The service type for this resource (e.g. "vm", "three-tier-app-demo"). */ + service_type: string; + /** Names of other resources that must be ready before this one is provisioned. */ + requires_resources?: string[]; + /** Field configurations for this resource. */ fields?: FieldConfiguration[]; } -/** A single field within a {@link CatalogItemSpec}. */ +/** A single field within a {@link CatalogResource}. */ export interface FieldConfiguration { path: string; display_name?: string; @@ -77,8 +92,6 @@ export interface CatalogItemInstance { api_version: string; display_name: string; spec: CatalogItemInstanceSpec; - /** External resource identifier (readOnly). */ - resource_id?: string; path?: string; create_time?: string; update_time?: string; @@ -88,10 +101,14 @@ export interface CatalogItemInstance { export interface CatalogItemInstanceSpec { catalog_item_id: string; user_values: UserValue[]; + /** External resource identifiers assigned by the Placement Manager (readOnly). */ + resource_ids?: string[]; } /** A user-supplied value for a field in a {@link CatalogItemInstanceSpec}. */ export interface UserValue { + /** The resource name within the catalog item this value targets. */ + resource: string; path: string; value: unknown; } @@ -99,17 +116,17 @@ export interface UserValue { /** Paginated list of {@link ServiceType} resources. */ export interface ServiceTypeList { results: ServiceType[]; - next_page_token: string; + next_page_token?: string; } /** Paginated list of {@link CatalogItem} resources. */ export interface CatalogItemList { results: CatalogItem[]; - next_page_token: string; + next_page_token?: string; } /** Paginated list of {@link CatalogItemInstance} resources. */ export interface CatalogItemInstanceList { results: CatalogItemInstance[]; - next_page_token: string; + next_page_token?: string; } diff --git a/workspaces/dcm/plugins/dcm-common/src/types/common.ts b/workspaces/dcm/plugins/dcm-common/src/types/common.ts index d665715cfd4..1570d3b2ae6 100644 --- a/workspaces/dcm/plugins/dcm-common/src/types/common.ts +++ b/workspaces/dcm/plugins/dcm-common/src/types/common.ts @@ -50,3 +50,16 @@ export interface DcmHealth { status: string; path?: string; } + +/** + * Query parameters shared by all DCM list endpoints that support + * cursor-based pagination (AEP-158). + * + * @public + */ +export interface PaginationParams { + /** Maximum number of results to return in one page (1–100, default 100). */ + max_page_size?: number; + /** Opaque page token returned by a previous list response. */ + page_token?: string; +} diff --git a/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts new file mode 100644 index 00000000000..47fd3726b35 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { buildPaginationQuery } from './buildPaginationQuery'; + +describe('buildPaginationQuery', () => { + it('returns empty string when no params are provided', () => { + expect(buildPaginationQuery({})).toBe(''); + }); + + it('returns only max_page_size when only size is provided', () => { + expect(buildPaginationQuery({ max_page_size: 10 })).toBe( + '?max_page_size=10', + ); + }); + + it('returns only page_token when only token is provided', () => { + expect(buildPaginationQuery({ page_token: 'tok-abc' })).toBe( + '?page_token=tok-abc', + ); + }); + + it('returns both params when both are provided', () => { + const result = buildPaginationQuery({ + max_page_size: 25, + page_token: 'tok-xyz', + }); + expect(result).toContain('max_page_size=25'); + expect(result).toContain('page_token=tok-xyz'); + expect(result).toMatch(/^\?/); + }); + + it('omits page_token when it is an empty string', () => { + expect(buildPaginationQuery({ page_token: '' })).toBe(''); + }); + + it('omits max_page_size when it is undefined', () => { + expect( + buildPaginationQuery({ max_page_size: undefined, page_token: 'tok' }), + ).toBe('?page_token=tok'); + }); +}); diff --git a/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts new file mode 100644 index 00000000000..d1489bf0899 --- /dev/null +++ b/workspaces/dcm/plugins/dcm-common/src/utils/buildPaginationQuery.ts @@ -0,0 +1,32 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PaginationParams } from '../types/common'; + +/** + * Builds a URL query string from pagination params. + * Returns an empty string when no params are set. + * + * @public + */ +export function buildPaginationQuery(params: PaginationParams): string { + const q = new URLSearchParams(); + if (params.max_page_size !== undefined) + q.set('max_page_size', String(params.max_page_size)); + if (params.page_token) q.set('page_token', params.page_token); + const qs = q.toString(); + return qs ? `?${qs}` : ''; +} diff --git a/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx b/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx new file mode 100644 index 00000000000..73abc0cd222 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/components/CursorPaginationControls.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Table, type TableColumn } from '@backstage/core-components'; +import { Box, IconButton, MenuItem, Select, Tooltip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { useTranslation } from '../hooks/useTranslation'; + +const useStyles = makeStyles(theme => ({ + root: { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + gap: theme.spacing(0.5), + padding: theme.spacing(1, 2), + borderTop: `1px solid ${theme.palette.divider}`, + }, +})); + +export type CursorPaginationControlsProps = Readonly<{ + hasNext: boolean; + hasPrev: boolean; + onNext: () => void; + onPrev: () => void; + /** Disables both buttons while a page fetch is in progress. */ + loading?: boolean; + /** Currently selected page size. Required when `onPageSizeChange` is provided. */ + pageSize?: number; + /** Called with the newly selected page size. When omitted, the size selector is hidden. */ + onPageSizeChange?: (size: number) => void; + /** Options shown in the page-size dropdown. Defaults to [5, 15, 25]. */ + pageSizeOptions?: number[]; +}>; + +/** + * Previous / Next navigation row for cursor-based (server-side) pagination. + * Rendered below a table when the Backstage ``'s built-in pager is + * disabled (`paging: false`). + * + * When `onPageSizeChange` and `pageSize` are provided a rows-per-page selector + * is rendered to the left of the navigation buttons. + */ +export function CursorPaginationControls({ + hasNext, + hasPrev, + onNext, + onPrev, + loading = false, + pageSize, + onPageSizeChange, + pageSizeOptions = [5, 10, 25], +}: CursorPaginationControlsProps) { + const classes = useStyles(); + const { t } = useTranslation(); + + return ( + + {onPageSizeChange !== undefined && pageSize !== undefined && ( + + )} + + + + + + + + + + + + + + + + ); +} + +/** + * A `
` in cursor-pagination mode (built-in pager disabled) with + * {@link CursorPaginationControls} rendered directly below it. + * + * Use this wherever cursor-based server pagination is enabled to avoid + * duplicating the table-options + controls wiring. + */ +export function CursorPaginatedTable({ + data, + columns, + pagination, +}: Readonly<{ + data: T[]; + columns: TableColumn[]; + pagination: CursorPaginationControlsProps; +}>) { + return ( + <> + + data={data} + columns={columns} + options={{ + paging: false, + search: false, + sorting: true, + padding: 'default', + toolbar: false, + emptyRowsWhenPaging: false, + }} + /> + + + ); +} diff --git a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx index de6ee8b95a0..1e2f849dd4f 100644 --- a/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx +++ b/workspaces/dcm/plugins/dcm/src/components/DcmCrudTabLayout.tsx @@ -33,6 +33,7 @@ import MuiAlert from '@material-ui/lab/Alert'; import type { BoxProps } from '@material-ui/core/Box'; import { DcmDataCenterTabEmptyState } from './DcmDataCenterTabEmptyState'; import { DcmSearchCardAction } from './dcmTabListHelpers'; +import { CursorPaginatedTable } from './CursorPaginationControls'; import { useDcmStyles } from './dcmStyles'; import { useTranslation } from '../hooks/useTranslation'; @@ -60,11 +61,27 @@ export type DcmCrudTabLayoutProps = Readonly<{ search: string; onSearchChange: Dispatch>; - // ── Pagination ─────────────────────────────────────────────────────────── - page: number; - pageSize: number; - onPageChange: (page: number, pageSize: number) => void; - onRowsPerPageChange: (pageSize: number) => void; + // ── Client-side pagination (mutually exclusive with cursorPagination) ──── + page?: number; + pageSize?: number; + onPageChange?: (page: number, pageSize: number) => void; + onRowsPerPageChange?: (pageSize: number) => void; + + /** + * When provided, server-side cursor-based pagination is used instead of the + * Backstage Table's built-in pager. The table is rendered with `paging: + * false` and {@link CursorPaginationControls} is shown below it. + */ + cursorPagination?: { + hasNext: boolean; + hasPrev: boolean; + onNext: () => void; + onPrev: () => void; + loading?: boolean; + pageSize?: number; + onPageSizeChange?: (size: number) => void; + pageSizeOptions?: number[]; + }; // ── Empty state ────────────────────────────────────────────────────────── emptyTitle: string; @@ -131,10 +148,11 @@ export function DcmCrudTabLayout({ onDismissActionError, search, onSearchChange, - page, - pageSize, + page = 1, + pageSize = 5, onPageChange, onRowsPerPageChange, + cursorPagination, emptyTitle, emptyDescription, primaryActionLabel, @@ -169,7 +187,11 @@ export function DcmCrudTabLayout({ ); } - if (items.length === 0) { + // Show global empty-state only when we are certain the dataset is truly + // empty (i.e. not just an empty cursor page on page 2+). If hasPrev is true + // the user deleted the last row on a non-first page — fall through to the + // table view so cursor controls remain accessible. + if (items.length === 0 && !cursorPagination?.hasPrev) { return ( <> {actionError && ( @@ -237,28 +259,38 @@ export function DcmCrudTabLayout({ /> )} - - data={paginated} - columns={columns} - options={{ - paging: true, - pageSize, - pageSizeOptions: [5, 10, 25], - search: false, - sorting: true, - padding: 'default', - toolbar: false, - /** Avoid blank rows padding the table to `pageSize` when fewer rows exist. */ - emptyRowsWhenPaging: false, - }} - totalCount={filtered.length} - page={page} - onPageChange={onPageChange} - onRowsPerPageChange={onRowsPerPageChange} - localization={{ - pagination: { labelRowsPerPage: t('common.rows') }, - }} - /> + {cursorPagination ? ( + + data={filtered} + columns={columns} + pagination={cursorPagination} + /> + ) : ( + + data={paginated} + columns={columns} + options={{ + paging: true, + pageSize, + pageSizeOptions: [5, 10, 25], + search: false, + sorting: true, + padding: 'default', + toolbar: false, + /** Avoid blank rows padding the table to `pageSize` when fewer rows exist. */ + emptyRowsWhenPaging: false, + }} + totalCount={filtered.length} + page={Math.max(0, page - 1)} + onPageChange={ + onPageChange ? (p, ps) => onPageChange(p + 1, ps) : undefined + } + onRowsPerPageChange={onRowsPerPageChange} + localization={{ + pagination: { labelRowsPerPage: t('common.rows') }, + }} + /> + )} diff --git a/workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx b/workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx new file mode 100644 index 00000000000..c1374306a45 --- /dev/null +++ b/workspaces/dcm/plugins/dcm/src/components/SchemaButton.tsx @@ -0,0 +1,279 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import CodeIcon from '@material-ui/icons/Code'; +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; +import json from 'react-syntax-highlighter/dist/esm/languages/hljs/json'; +import docco from 'react-syntax-highlighter/dist/esm/styles/hljs/docco'; +import { useTranslation } from '../hooks/useTranslation'; +import { validateJsonObject } from '../utils/validateJsonObject'; + +SyntaxHighlighter.registerLanguage('json', json); + +const useStyles = makeStyles(theme => ({ + schemaLabel: { + marginBottom: theme.spacing(0.5), + }, + dialogContent: { + paddingTop: theme.spacing(1), + }, + editorWrapper: { + position: 'relative' as const, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + overflow: 'hidden', + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 1px ${theme.palette.primary.main}`, + }, + }, + editorWrapperError: { + borderColor: theme.palette.error.main, + '&:focus-within': { + borderColor: theme.palette.error.main, + boxShadow: `0 0 0 1px ${theme.palette.error.main}`, + }, + }, + editorTextarea: { + position: 'absolute' as const, + top: 0, + left: 0, + width: '100%', + height: '100%', + margin: 0, + padding: '12px', + border: 'none', + outline: 'none', + resize: 'none' as const, + background: 'transparent', + color: 'transparent', + caretColor: theme.palette.text.primary, + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontSize: 13, + lineHeight: '1.45', + whiteSpace: 'pre' as const, + overflowWrap: 'normal' as const, + overflow: 'auto', + zIndex: 1, + WebkitTextFillColor: 'transparent', + }, + editorHighlight: { + margin: 0, + padding: '12px !important', + minHeight: 280, + fontFamily: + '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace !important', + fontSize: '13px !important', + lineHeight: '1.45 !important', + whiteSpace: 'pre' as const, + overflowWrap: 'normal' as const, + overflow: 'auto', + background: `${theme.palette.background.paper} !important`, + }, + editorHelperText: { + marginTop: theme.spacing(0.5), + display: 'block', + }, +})); + +function validateSchemaJsonRaw(raw: string): 'object' | 'syntax' | '' { + const result = validateJsonObject(raw); + if (result === '' || typeof result === 'object') return ''; + return result; +} + +function prettyPrintIfValid(raw: string): string { + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +} + +export type SchemaButtonProps = Readonly<{ + value: string; + onChange: (v: string) => void; + /** Error on the stored value (e.g. from import or duplicate detection). */ + fieldError?: string; +}>; + +/** + * Inline JSON schema editor — shows a "Add JSON" / "Edit JSON" button that + * opens a syntax-highlighted textarea dialog. + */ +export function SchemaButton({ + value, + onChange, + fieldError, +}: SchemaButtonProps) { + const classes = useStyles(); + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(''); + const textareaRef = useRef(null); + const highlightRef = useRef(null); + + const jsonErrorCode = useMemo(() => validateSchemaJsonRaw(draft), [draft]); + let jsonError = ''; + if (jsonErrorCode === 'object') { + jsonError = t('catalogItems.form.schemaMustBeObject'); + } else if (jsonErrorCode === 'syntax') { + jsonError = t('catalogItems.form.schemaInvalidJson'); + } + const applyDisabled = draft.trim() !== '' && Boolean(jsonErrorCode); + const hasError = Boolean(draft.trim() && jsonErrorCode); + + const handleOpen = () => { + setDraft(value ? prettyPrintIfValid(value) : ''); + setOpen(true); + }; + + const handleApply = useCallback(() => { + if (applyDisabled) return; + onChange(draft.trim()); + setOpen(false); + }, [applyDisabled, draft, onChange]); + + const handleClose = () => setOpen(false); + + const syncScroll = () => { + if (textareaRef.current && highlightRef.current) { + highlightRef.current.scrollTop = textareaRef.current.scrollTop; + highlightRef.current.scrollLeft = textareaRef.current.scrollLeft; + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') return; + const afterEnter = `${draft}\n`; + const formatted = prettyPrintIfValid(afterEnter); + if (formatted !== afterEnter) { + e.preventDefault(); + setDraft(formatted); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const pasted = e.clipboardData.getData('text'); + const { selectionStart: start, selectionEnd: end } = e.currentTarget; + const afterPaste = + draft.slice(0, start ?? 0) + pasted + draft.slice(end ?? 0); + const formatted = prettyPrintIfValid(afterPaste); + if (formatted !== afterPaste) { + e.preventDefault(); + setDraft(formatted); + } + }; + + return ( + <> + + + {t('catalogItems.form.schemaLabel')} + + + + + {fieldError && ( + + {fieldError} + + )} + + + + {t('catalogItems.form.schemaDialogTitle')} + + +
+ + {draft || ' '} + +
+