diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2cce7e49ed91e..014bfd2fc7b44 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -413,6 +413,8 @@ src/platform/packages/shared/content-management/content_editor @elastic/appex-sh src/platform/packages/shared/content-management/content_insights/content_insights_public @elastic/appex-sharedux src/platform/packages/shared/content-management/content_insights/content_insights_server @elastic/appex-sharedux src/platform/packages/shared/content-management/content_list/kbn-content-list-mock-data @elastic/appex-sharedux +src/platform/packages/shared/content-management/content_list/kbn-content-list-provider @elastic/appex-sharedux +src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client @elastic/appex-sharedux src/platform/packages/shared/content-management/favorites/favorites_common @elastic/appex-sharedux src/platform/packages/shared/content-management/favorites/favorites_public @elastic/appex-sharedux src/platform/packages/shared/content-management/favorites/favorites_server @elastic/appex-sharedux diff --git a/package.json b/package.json index ec3f764052d31..e1743cdbd3502 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,8 @@ "@kbn/connector-specs": "link:src/platform/packages/shared/kbn-connector-specs", "@kbn/console-plugin": "link:src/platform/plugins/shared/console", "@kbn/content-connectors-plugin": "link:x-pack/platform/plugins/shared/content_connectors", + "@kbn/content-list-provider": "link:src/platform/packages/shared/content-management/content_list/kbn-content-list-provider", + "@kbn/content-list-provider-client": "link:src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client", "@kbn/content-management-access-control-public": "link:src/platform/packages/shared/content-management/access_control/access_control_public", "@kbn/content-management-access-control-server": "link:src/platform/packages/shared/content-management/access_control/access_control_server", "@kbn/content-management-content-editor": "link:src/platform/packages/shared/content-management/content_editor", diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/README.md b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/README.md new file mode 100644 index 0000000000000..eb128a4b272b0 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/README.md @@ -0,0 +1,148 @@ +# @kbn/content-list-provider-client + +Client-side Content List Provider — designed for easy migration from `TableListView`. + +## Overview + +This package provides a client-side adapter for content listing that wraps existing `TableListView`-style `findItems` functions. It enables consumers to migrate from `TableListView` to the new `ContentListProvider` architecture with minimal code changes. + +## When to Use + +Use this provider when: + +- **Migrating from `TableListView`** with minimal code changes. +- Working with **smaller datasets** (< 10,000 items). +- Your existing `findItems` implementation already handles data fetching. + +## How It Works + +This adapter is designed for easy migration from `TableListView`. It: + +- **Passes only `searchQuery`** to your existing `findItems` function (matching `TableListView` behavior). +- **Applies client-side sorting and pagination** on the returned results — your `findItems` fetches all matching items, and the adapter sorts and paginates in memory. +- **Does not forward** `sort`, `page`, or `filters` parameters to your `findItems` implementation. +- **Caches by `searchQuery`** — React Query caches results based on the search query. Changing sort or page reuses cached data. + +## Usage + +### Basic Migration from TableListView + +The key migration step is passing your existing `findItems` function: + +```tsx +import { ContentListClientProvider } from '@kbn/content-list-provider-client'; + +// Your existing findItems function from TableListView - no changes needed! +const findItems = useCallback( + async (searchTerm) => { + return dashboardClient.search({ + search: searchTerm, + }).then(({ total, dashboards }) => ({ + total, + hits: dashboards.map(transformToDashboardUserContent), + })); + }, + [] +); + +// Before: TableListView + + +// After: ContentListClientProvider + + + +``` + +### Using the Adapter Function Directly + +If you need more control, you can use the adapter function directly: + +```tsx +import { createFindItemsFn } from '@kbn/content-list-provider-client'; +import { ContentListProvider } from '@kbn/content-list-provider'; + +// Wrap your existing findItems. +const findItems = createFindItemsFn(myExistingFindItems); + +// Use with the base provider. + + + +``` + +## Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `id` | `string` | Yes* | Unique identifier. `queryKeyScope` derived as `${id}-listing` if not provided. | +| `queryKeyScope` | `string` | Yes* | Explicit React Query cache key scope. | +| `labels` | `ContentListLabels` | Yes | User-facing entity labels (should be i18n-translated). | +| `findItems` | `TableListViewFindItemsFn` | Yes | Your existing `TableListView` findItems function. | +| `features` | `ContentListFeatures` | No | Feature configuration. | +| `item` | `ContentListItemConfig` | No | Per-item configuration for links. | +| `isReadOnly` | `boolean` | No | Disable mutation actions. | + +*At least one of `id` or `queryKeyScope` is required. + +## findItems Function Signature + +Your existing `findItems` function should match this signature: + +```typescript +interface SavedObjectReference { + type: string; + id: string; + name?: string; +} + +type TableListViewFindItemsFn = ( + searchQuery: string, + refs?: { + references?: SavedObjectReference[]; + referencesToExclude?: SavedObjectReference[]; + } +) => Promise<{ total: number; hits: UserContentCommonSchema[] }>; +``` + +This is the same signature expected by `TableListView.findItems`. + +> **Note:** The `refs` parameter (for tag filtering) is not yet supported in the new `ContentListProvider` architecture. Only `searchQuery` is forwarded in this initial version. Tag filtering support is planned for a future release. + +## Exports + +```typescript +// Provider component. +export { ContentListClientProvider } from './provider'; +export type { ContentListClientProviderProps } from './provider'; + +// Adapter for direct usage. +export { createFindItemsFn } from './strategy'; + +// Types. +export type { + TableListViewFindItemsFn, + TableListViewFindItemsResult, + SavedObjectReference, +} from './types'; +``` + +## Related Packages + +- [`@kbn/content-list-provider`](../kbn-content-list-provider) — Core provider and hooks. diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/index.ts new file mode 100644 index 0000000000000..c2f9d59a5b9c7 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// Provider. +export { ContentListClientProvider } from './src/provider'; +export type { ContentListClientProviderProps } from './src/provider'; + +// Strategy. +export { createFindItemsFn } from './src/strategy'; + +// Types. +export type { + TableListViewFindItemsFn, + TableListViewFindItemsResult, + SavedObjectReference, +} from './src/types'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/jest.config.js b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/jest.config.js new file mode 100644 index 0000000000000..4e0d5940819be --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/jest.config.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: [ + '/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client', + ], +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/kibana.jsonc b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/kibana.jsonc new file mode 100644 index 0000000000000..1f70e6eafaa48 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/content-list-provider-client", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/moon.yml b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/moon.yml new file mode 100644 index 0000000000000..8944585ef2a7b --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/moon.yml @@ -0,0 +1,47 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/content-list-provider-client' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/content-list-provider-client' +type: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchain: + default: node +language: typescript +project: + name: '@kbn/content-list-provider-client' + description: Moon project for @kbn/content-list-provider-client + channel: '' + owner: '@elastic/appex-sharedux' + metadata: + sourceRoot: src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client +dependsOn: + - '@kbn/content-list-provider' + - '@kbn/content-management-table-list-view-common' +tags: + - shared-browser + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' +tasks: + jest: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' + jestCI: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/package.json b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/package.json new file mode 100644 index 0000000000000..332c1602c2afd --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/content-list-provider-client", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "sideEffects": false +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/provider.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/provider.test.tsx new file mode 100644 index 0000000000000..c3c286e2ff7de --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/provider.test.tsx @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; +import { useContentListConfig } from '@kbn/content-list-provider'; +import { ContentListClientProvider } from './provider'; +import type { ContentListClientProviderProps } from './provider'; +import type { TableListViewFindItemsFn } from './types'; + +describe('ContentListClientProvider', () => { + const createMockItem = (id: string): UserContentCommonSchema => ({ + id, + type: 'dashboard', + updatedAt: '2024-01-15T10:30:00.000Z', + references: [], + attributes: { + title: `Dashboard ${id}`, + description: `Description for ${id}`, + }, + }); + + const createMockFindItems = ( + items: UserContentCommonSchema[] = [] + ): jest.Mock> => { + return jest.fn().mockResolvedValue({ hits: items, total: items.length }); + }; + + const createWrapper = (props?: Partial) => { + const defaultFindItems = createMockFindItems([createMockItem('1')]); + const defaultProps: ContentListClientProviderProps = { + id: 'test-client-list', + labels: { entity: 'dashboard', entityPlural: 'dashboards' }, + findItems: defaultFindItems, + children: null, + }; + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('context provision', () => { + it('provides context to children', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + }); + + it('provides labels from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ + labels: { entity: 'visualization', entityPlural: 'visualizations' }, + }), + }); + + expect(result.current.labels).toEqual({ + entity: 'visualization', + entityPlural: 'visualizations', + }); + }); + + it('provides id from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ id: 'my-dashboard-list' }), + }); + + expect(result.current.id).toBe('my-dashboard-list'); + }); + + it('provides isReadOnly from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ isReadOnly: true }), + }); + + expect(result.current.isReadOnly).toBe(true); + }); + }); + + describe('dataSource creation', () => { + it('creates dataSource with findItems', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.dataSource).toBeDefined(); + expect(result.current.dataSource.findItems).toBeDefined(); + expect(typeof result.current.dataSource.findItems).toBe('function'); + }); + + it('does not include onFetchSuccess by default', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + // onFetchSuccess is optional and not set by the client provider. + expect(result.current.dataSource.onFetchSuccess).toBeUndefined(); + }); + }); + + describe('features pass-through', () => { + it('provides empty features by default', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.features).toEqual({}); + }); + + it('provides features from props', () => { + const features = { + sorting: { initialSort: { field: 'updatedAt', direction: 'desc' as const } }, + }; + + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ features }), + }); + + expect(result.current.features).toEqual(features); + }); + }); + + describe('supports flags', () => { + it('enables sorting by default', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.supports.sorting).toBe(true); + }); + + it('respects sorting: false in features', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ features: { sorting: false } }), + }); + + expect(result.current.supports.sorting).toBe(false); + }); + }); + + describe('memoization', () => { + it('maintains stable dataSource reference across renders', () => { + const { result, rerender } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + const firstDataSource = result.current.dataSource; + rerender(); + const secondDataSource = result.current.dataSource; + + expect(firstDataSource).toBe(secondDataSource); + }); + + it('maintains stable findItems reference across renders', () => { + const { result, rerender } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + const firstFindItems = result.current.dataSource.findItems; + rerender(); + const secondFindItems = result.current.dataSource.findItems; + + expect(firstFindItems).toBe(secondFindItems); + }); + }); + + describe('queryKeyScope derivation', () => { + it('derives queryKeyScope from id', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ id: 'dashboard-list' }), + }); + + expect(result.current.queryKeyScope).toBe('dashboard-list-listing'); + }); + + it('uses explicit queryKeyScope when provided', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ id: 'my-list', queryKeyScope: 'custom-scope' }), + }); + + expect(result.current.queryKeyScope).toBe('custom-scope'); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/provider.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/provider.tsx new file mode 100644 index 0000000000000..b2ea78e117ae4 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/provider.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo, type ReactNode } from 'react'; +import type { + ContentListCoreConfig, + ContentListFeatures, + DataSourceConfig, +} from '@kbn/content-list-provider'; +import { ContentListProvider } from '@kbn/content-list-provider'; +import type { TableListViewFindItemsFn } from './types'; +import { createFindItemsFn } from './strategy'; + +/** + * Props for the Client provider. + * + * This provider wraps an existing `TableListView`-style `findItems` function and handles + * client-side sorting, filtering, and pagination. + */ +export type ContentListClientProviderProps = ContentListCoreConfig & { + children?: ReactNode; + /** + * The consumer's existing `findItems` function (same signature as `TableListView`). + */ + findItems: TableListViewFindItemsFn; + /** + * Feature configuration for enabling/customizing capabilities. + */ + features?: ContentListFeatures; +}; + +/** + * Client-side content list provider. + * + * Wraps an existing `TableListView`-style `findItems` function and provides + * client-side sorting, filtering, and pagination. The strategy handles transformation + * of `UserContentCommonSchema` items to `ContentListItem` format. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export const ContentListClientProvider = ({ + children, + findItems: tableListViewFindItems, + ...rest +}: ContentListClientProviderProps): JSX.Element => { + // Create the adapted findItems function (includes transformation). + const findItems = useMemo( + () => createFindItemsFn(tableListViewFindItems), + [tableListViewFindItems] + ); + + // Build the dataSource config. No transform needed - strategy handles it. + const dataSource: DataSourceConfig = useMemo(() => ({ findItems }), [findItems]); + + return ( + + {children} + + ); +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/strategy.test.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/strategy.test.ts new file mode 100644 index 0000000000000..d7ef0865a60ea --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/strategy.test.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; +import type { FindItemsParams } from '@kbn/content-list-provider'; +import { createFindItemsFn } from './strategy'; +import type { TableListViewFindItemsFn } from './types'; + +/** + * Creates a complete `FindItemsParams` object for testing. + */ +const createParams = (overrides?: Partial): FindItemsParams => ({ + searchQuery: '', + filters: {}, + sort: { field: 'title', direction: 'asc' }, + page: { index: 0, size: 20 }, + ...overrides, +}); + +describe('createFindItemsFn', () => { + const createMockItem = (id: string): UserContentCommonSchema => ({ + id, + type: 'dashboard', + updatedAt: '2024-01-15T10:30:00.000Z', + references: [], + attributes: { + title: `Dashboard ${id}`, + description: `Description for ${id}`, + }, + }); + + const createMockFindItems = ( + items: UserContentCommonSchema[] = [] + ): jest.Mock> => { + return jest.fn().mockResolvedValue({ hits: items, total: items.length }); + }; + + describe('findItems', () => { + it('calls the consumer findItems with searchQuery and signal', async () => { + const mockItems = [createMockItem('1')]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + await findItems(createParams({ searchQuery: 'test query' })); + + // Forwards searchQuery, undefined for refs, and undefined for signal. + expect(mockFindItems).toHaveBeenCalledWith('test query', undefined, undefined); + }); + + it('forwards abort signal to consumer findItems', async () => { + const mockItems = [createMockItem('1')]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + const controller = new AbortController(); + + await findItems(createParams({ signal: controller.signal })); + + expect(mockFindItems).toHaveBeenCalledWith('', undefined, controller.signal); + }); + + it('returns transformed items and total from consumer findItems', async () => { + const mockItems = [createMockItem('1'), createMockItem('2')]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems(createParams()); + + // Items should be transformed to ContentListItem format. + expect(result.items).toEqual([ + { + id: '1', + title: 'Dashboard 1', + description: 'Description for 1', + type: 'dashboard', + updatedAt: new Date('2024-01-15T10:30:00.000Z'), + }, + { + id: '2', + title: 'Dashboard 2', + description: 'Description for 2', + type: 'dashboard', + updatedAt: new Date('2024-01-15T10:30:00.000Z'), + }, + ]); + expect(result.total).toBe(2); + }); + + it('handles empty results', async () => { + const mockFindItems = createMockFindItems([]); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems(createParams()); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + }); + + it('propagates errors from consumer findItems', async () => { + const mockFindItems = jest.fn().mockRejectedValue(new Error('Network error')); + const findItems = createFindItemsFn(mockFindItems); + + await expect(findItems(createParams())).rejects.toThrow('Network error'); + }); + }); + + describe('client-side sorting', () => { + const createItemWithTitle = (id: string, title: string): UserContentCommonSchema => ({ + id, + type: 'dashboard', + updatedAt: '2024-01-15T10:30:00.000Z', + references: [], + attributes: { title, description: '' }, + }); + + it('sorts items by field in ascending order', async () => { + const mockItems = [ + createItemWithTitle('3', 'Charlie'), + createItemWithTitle('1', 'Alpha'), + createItemWithTitle('2', 'Bravo'), + ]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems(createParams({ sort: { field: 'title', direction: 'asc' } })); + + expect(result.items.map((i) => i.id)).toEqual(['1', '2', '3']); + }); + + it('sorts items by field in descending order', async () => { + const mockItems = [ + createItemWithTitle('1', 'Alpha'), + createItemWithTitle('3', 'Charlie'), + createItemWithTitle('2', 'Bravo'), + ]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems(createParams({ sort: { field: 'id', direction: 'desc' } })); + + expect(result.items.map((i) => i.id)).toEqual(['3', '2', '1']); + }); + + it('handles null values when sorting - nulls always last', async () => { + const itemWithNull: UserContentCommonSchema = { + id: '2', + type: 'dashboard', + updatedAt: undefined as unknown as string, + references: [], + attributes: { title: 'No date', description: '' }, + }; + const mockItems = [ + createItemWithTitle('1', 'Has date'), + itemWithNull, + createItemWithTitle('3', 'Also has date'), + ]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + // Ascending order - nulls should be last. + const resultAsc = await findItems( + createParams({ sort: { field: 'updatedAt', direction: 'asc' } }) + ); + expect(resultAsc.items[resultAsc.items.length - 1].id).toBe('2'); + + // Descending order - nulls should still be last (matches TableListView behavior). + const resultDesc = await findItems( + createParams({ sort: { field: 'updatedAt', direction: 'desc' } }) + ); + expect(resultDesc.items[resultDesc.items.length - 1].id).toBe('2'); + }); + }); + + describe('client-side pagination', () => { + it('returns paginated subset of items', async () => { + const mockItems = Array.from({ length: 10 }, (_, i) => createMockItem(String(i + 1))); + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + // Use sort: undefined to test pagination without sorting interference. + const result = await findItems( + createParams({ sort: undefined, page: { index: 0, size: 3 } }) + ); + + expect(result.items).toHaveLength(3); + expect(result.items.map((i) => i.id)).toEqual(['1', '2', '3']); + expect(result.total).toBe(10); + }); + + it('returns correct page for non-zero index', async () => { + const mockItems = Array.from({ length: 10 }, (_, i) => createMockItem(String(i + 1))); + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + // Use sort: undefined to test pagination without sorting interference. + const result = await findItems( + createParams({ sort: undefined, page: { index: 1, size: 3 } }) + ); + + expect(result.items).toHaveLength(3); + expect(result.items.map((i) => i.id)).toEqual(['4', '5', '6']); + }); + + it('returns remaining items for last partial page', async () => { + const mockItems = Array.from({ length: 5 }, (_, i) => createMockItem(String(i + 1))); + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems(createParams({ page: { index: 1, size: 3 } })); + + expect(result.items).toHaveLength(2); + expect(result.items.map((i) => i.id)).toEqual(['4', '5']); + }); + + it('returns empty array for out-of-bounds page', async () => { + const mockItems = [createMockItem('1')]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems(createParams({ page: { index: 10, size: 20 } })); + + expect(result.items).toHaveLength(0); + expect(result.total).toBe(1); + }); + + it('applies sorting before pagination', async () => { + const mockItems = [ + createMockItem('3'), + createMockItem('1'), + createMockItem('4'), + createMockItem('2'), + ]; + const mockFindItems = createMockFindItems(mockItems); + const findItems = createFindItemsFn(mockFindItems); + + const result = await findItems( + createParams({ sort: { field: 'id', direction: 'asc' }, page: { index: 0, size: 2 } }) + ); + + // Items should be sorted first, then paginated. + expect(result.items.map((i) => i.id)).toEqual(['1', '2']); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/strategy.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/strategy.ts new file mode 100644 index 0000000000000..12bb49aaa8259 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/strategy.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; +import type { + FindItemsFn, + FindItemsParams, + FindItemsResult, + ContentListItem, +} from '@kbn/content-list-provider'; +import type { TableListViewFindItemsFn } from './types'; + +/** + * Safely retrieves a sortable value from an object by key. + * + * Returns `null` if the key doesn't exist or the value isn't a sortable type. + */ +const getSortableProperty = (obj: object | undefined, key: string): string | number | null => { + if (!obj || !(key in obj)) { + return null; + } + const value = (obj as Record)[key]; + if (typeof value === 'string' || typeof value === 'number') { + return value; + } + return null; +}; + +/** + * Gets the value of a field from a `UserContentCommonSchema` item for sorting. + * + * Handles the nested `attributes` structure and provides a fallback chain: + * 1. Known fields (`title`, `description`, `updatedAt`, `createdAt`). + * 2. Top-level item fields (`id`, `type`, etc.). + * 3. Custom attributes (`status`, `priority`, etc.). + * + * Returns `null` for missing values so the sorting logic can push them to the end. + */ +const getUserContentFieldValue = ( + item: UserContentCommonSchema, + field: string +): string | number | null => { + // Handle known fields. + switch (field) { + case 'title': + return item.attributes?.title ?? ''; + case 'description': + return item.attributes?.description ?? null; + case 'updatedAt': + return item.updatedAt ?? null; + case 'createdAt': + return item.createdAt ?? null; + default: + break; + } + + // Check top-level item fields first (id, type, etc.). + const topLevelValue = getSortableProperty(item, field); + if (topLevelValue !== null) { + return topLevelValue; + } + + // Check custom attributes (status, priority, etc.). + return getSortableProperty(item.attributes, field); +}; + +/** + * Sorts items by a specified field. + * + * @param items - The items to sort. + * @param field - The field name to sort by. + * @param direction - Sort direction ('asc' or 'desc'). + * @returns A new sorted array (does not mutate the original). + */ +const sortItems = ( + items: UserContentCommonSchema[], + field: string, + direction: 'asc' | 'desc' +): UserContentCommonSchema[] => { + return [...items].sort((a, b) => { + const aValue = getUserContentFieldValue(a, field); + const bValue = getUserContentFieldValue(b, field); + + // Push null values to the end regardless of sort direction. + if (aValue === null && bValue === null) { + return 0; + } + if (aValue === null) { + return 1; + } + if (bValue === null) { + return -1; + } + + // Use localeCompare for proper string sorting. + if (typeof aValue === 'string' && typeof bValue === 'string') { + const comparison = aValue.localeCompare(bValue); + return direction === 'asc' ? comparison : -comparison; + } + + // Numeric comparison. + if (aValue < bValue) { + return direction === 'asc' ? -1 : 1; + } + if (aValue > bValue) { + return direction === 'asc' ? 1 : -1; + } + return 0; + }); +}; + +/** + * Transforms a `UserContentCommonSchema` item to `ContentListItem`. + * + * This is applied only to paginated results for performance. + * Missing `attributes` or `title` are normalized to an empty string so the UI can still render. + */ +const transformItem = (item: UserContentCommonSchema): ContentListItem => ({ + id: item.id, + title: item.attributes?.title ?? '', + description: item.attributes?.description, + type: item.type, + updatedAt: item.updatedAt ? new Date(item.updatedAt) : undefined, +}); + +/** + * Creates a `FindItemsFn` that wraps a `TableListView`-style `findItems` function. + * + * This adapter: + * - Translates between the old and new API signatures. + * - Applies client-side sorting and pagination (matching original `TableListView` behavior). + * - Transforms results to `ContentListItem` format (only for the returned page). + * - Forwards the `AbortSignal` for request cancellation (consumers may optionally respect it). + * + * **Limitations:** + * - Only `searchQuery` is passed to the underlying `findItems`. The `params.filters` object + * and `refs` (references/referencesToExclude) from the second parameter are not forwarded. + * + * @param tableListViewFindItems - The consumer's existing `findItems` function. + * @returns A `FindItemsFn` compatible with `ContentListProvider`. + */ +export const createFindItemsFn = ( + tableListViewFindItems: TableListViewFindItemsFn +): FindItemsFn => { + return async (params: FindItemsParams): Promise => { + const { searchQuery, sort, page, signal } = params; + + // Fetch all items from the consumer's findItems, forwarding the abort signal. + const result = await tableListViewFindItems(searchQuery, undefined, signal); + let items = result.hits; + + // Apply client-side sorting only if sort is specified. + // When sorting is disabled, items are returned in their natural order (server default). + if (sort?.field) { + items = sortItems(items, sort.field, sort.direction ?? 'asc'); + } + + // Apply client-side pagination. + const start = page.index * page.size; + const end = start + page.size; + const pageItems = items.slice(start, end); + + // Transform only the paginated items to ContentListItem format. + return { + items: pageItems.map(transformItem), + total: result.total, + }; + }; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/types.ts new file mode 100644 index 0000000000000..b73032ff3edb0 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/src/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common'; + +/** + * Reference type matching `SavedObjectsFindOptionsReference` from Kibana core. + */ +export interface SavedObjectReference { + type: string; + id: string; + name?: string; +} + +/** + * Result type from the `TableListView` `findItems` function. + */ +export interface TableListViewFindItemsResult { + /** Total count of matching items. */ + total: number; + /** Items matching the search query. */ + hits: UserContentCommonSchema[]; +} + +/** + * The existing `TableListView` `findItems` signature that consumers already have. + * + * This matches the signature expected by `TableListViewTableProps.findItems`, with an + * optional `signal` parameter for request cancellation. + */ +export type TableListViewFindItemsFn = ( + searchQuery: string, + refs?: { + references?: SavedObjectReference[]; + referencesToExclude?: SavedObjectReference[]; + }, + /** Optional abort signal for request cancellation. */ + signal?: AbortSignal +) => Promise; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/tsconfig.json b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/tsconfig.json new file mode 100644 index 0000000000000..fd80ab218a669 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/content-list-provider", + "@kbn/content-management-table-list-view-common" + ] +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/README.md b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/README.md new file mode 100644 index 0000000000000..7455aa90cf119 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/README.md @@ -0,0 +1,177 @@ +# @kbn/content-list-provider + +React context for managing content list state, enabling child components to focus on rendering while the provider handles data fetching, caching, and state management. + +## Overview + +This package provides a React context that encapsulates data fetching and state management for content items. Child components (tables, grids, toolbars) consume this context via hooks to render items and respond to user interactions without managing the underlying state themselves. + +The package exports: + +- **`ContentListProvider`** — Base provider that accepts a datasource configuration. Works in any environment (Kibana apps, Jest tests, Storybook, etc.). + +## Quick Start + +```tsx +import { ContentListProvider, useContentListItems, type FindItemsFn } from '@kbn/content-list-provider'; + +// 1. Define your findItems function. +const findItems: FindItemsFn = async ({ searchQuery, filters, sort, page, signal }) => { + const response = await myApi.search({ + query: searchQuery, + sortField: sort.field, + sortOrder: sort.direction, + page: page.index, + pageSize: page.size, + signal, + }); + + return { + items: response.items, + total: response.total, + }; +}; + +// 2. Wrap your app with the provider. +const DashboardListPage = () => ( + + + +); + +// 3. Consume state via hooks. +const DashboardList = () => { + const { items, isLoading } = useContentListItems(); + + if (isLoading) return ; + + return ; +}; +``` + +## Configuration + +### Identity + +At least one of `id` or `queryKeyScope` must be provided: + +| Prop | Type | Description | +|------|------|-------------| +| `id` | `string` | Unique identifier for the content list instance. If `queryKeyScope` is not provided, it's derived as `${id}-listing`. | +| `queryKeyScope` | `string` | Explicit scope for React Query cache keys. Use when you need cache isolation separate from the semantic `id`. | + +```tsx +// Using id only: queryKeyScope derived as "dashboard-listing". + + +// Using queryKeyScope only. + + +// Using both: queryKeyScope for caching, id for analytics/test selectors. + +``` + +### Labels + +User-facing labels for the content type. These should be i18n-translated strings. + +```tsx +labels={{ + entity: i18n.translate('myPlugin.listing.entity', { defaultMessage: 'dashboard' }), + entityPlural: i18n.translate('myPlugin.listing.entityPlural', { defaultMessage: 'dashboards' }), +}} +``` + +### Data Source + +The `dataSource` prop configures how items are fetched: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `findItems` | `FindItemsFn` | Yes | Async function that fetches items (returns `{ items: ContentListItem[], total: number }`). | +| `onFetchSuccess` | `(result) => void` | No | Callback after successful fetch. | + +#### findItems Parameters + +```typescript +interface FindItemsParams { + searchQuery: string; + filters: ActiveFilters; + sort?: { field: string; direction: 'asc' | 'desc' }; + page: { index: number; size: number }; + signal?: AbortSignal; +} +``` + +### Item Configuration + +Configure per-item behavior via the `item` prop: + +```tsx +item={{ + getHref: (item) => `/app/dashboard/${item.id}`, +}} +``` + +### Other Props + +| Prop | Type | Description | +|------|------|-------------| +| `isReadOnly` | `boolean` | When `true`, disables selection and editing actions. | +| `features` | `ContentListFeatures` | Feature configuration for sorting and other capabilities. | + +## Architecture + +The package uses a two-layer provider pattern: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ContentListProvider │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Configuration Context (static) │ │ +│ │ - Labels, data source, feature configs │ │ +│ │ - Accessed via useContentListConfig() │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ State Context (dynamic) │ │ │ +│ │ │ - Items, loading, sort │ │ │ +│ │ │ - Managed by reducer + React Query │ │ │ +│ │ │ - Accessed via feature hooks │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Configuration Context + +Holds static values that define content list behavior. Access via `useContentListConfig()`. + +### State Context + +Manages runtime data using a reducer pattern with React Query for data fetching. Access via feature-specific hooks. + +## API + +### Hooks + +#### Configuration + +| Hook | Returns | Purpose | +|------|---------|---------| +| `useContentListConfig()` | Full config + `supports` flags | Access configuration and check feature support. | + +#### State + +| Hook | Returns | Purpose | +|------|---------|---------| +| `useContentListItems()` | `{ items, totalItems, isLoading, error, refetch }` | Access loaded items and loading state. | +| `useContentListSort()` | `{ field, direction, setSort, isSupported }` | Read/update sort configuration. | + diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts new file mode 100644 index 0000000000000..31ef050219c53 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Content List Provider + * + * A modular, feature-based architecture for building content listing UIs. + */ + +// Provider. +export { ContentListProvider, useContentListConfig } from './src/context'; +export type { + ContentListProviderProps, + ContentListIdentity, + ContentListLabels, + ContentListCoreConfig, + ContentListConfig, +} from './src/context'; + +// Hooks. +export { useContentListItems } from './src/state'; +export { useContentListSort } from './src/features'; + +// Types. +export type { ContentListItem, ContentListItemConfig } from './src/item'; +export type { + ContentListFeatures, + ContentListSupports, + SortingConfig, + UseContentListSortReturn, +} from './src/features'; +export type { + FindItemsFn, + FindItemsParams, + FindItemsResult, + DataSourceConfig, +} from './src/datasource'; + +// Utilities. +export { contentListKeys } from './src/query'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/jest.config.js b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/jest.config.js new file mode 100644 index 0000000000000..af92ac364ea70 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/jest.config.js @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: [ + '/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider', + ], +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/kibana.jsonc b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/kibana.jsonc new file mode 100644 index 0000000000000..9ccffa98c2745 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-browser", + "id": "@kbn/content-list-provider", + "owner": [ + "@elastic/appex-sharedux" + ], + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/moon.yml b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/moon.yml new file mode 100644 index 0000000000000..72934a1a72c9f --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/moon.yml @@ -0,0 +1,47 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/content-list-provider' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/content-list-provider' +type: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchain: + default: node +language: typescript +project: + name: '@kbn/content-list-provider' + description: Moon project for @kbn/content-list-provider + channel: '' + owner: '@elastic/appex-sharedux' + metadata: + sourceRoot: src/platform/packages/shared/content-management/content_list/kbn-content-list-provider +dependsOn: + - '@kbn/react-query' + - '@kbn/content-list-mock-data' +tags: + - shared-browser + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' +tasks: + jest: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' + jestCI: + args: + - '--config' + - $projectRoot/jest.config.js + inputs: + - '@group(src)' diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/package.json b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/package.json new file mode 100644 index 0000000000000..d1221dd7ad1e6 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/content-list-provider", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "sideEffects": false +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/index.ts new file mode 100644 index 0000000000000..7b8b22b61f7d3 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { + ContentListProvider, + ContentListContext, + useContentListConfig, + type ContentListProviderProps, + type ContentListProviderContextValue, +} from './provider'; + +export type { + ContentListIdentity, + ContentListLabels, + ContentListCoreConfig, + ContentListConfig, + ContentListServices, +} from './types'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.test.tsx new file mode 100644 index 0000000000000..29b9d732edcfd --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { ContentListProvider, useContentListConfig } from './provider'; +import type { ContentListProviderProps } from './provider'; +import type { FindItemsResult, FindItemsParams } from '../datasource'; +import type { ContentListItem } from '../item'; + +describe('ContentListProvider', () => { + const mockFindItems = jest.fn( + async (_params: FindItemsParams): Promise => ({ + items: [], + total: 0, + }) + ); + + const createWrapper = (props?: Partial) => { + const defaultProps: ContentListProviderProps = { + id: 'test-list', + labels: { entity: 'item', entityPlural: 'items' }, + dataSource: { findItems: mockFindItems }, + children: null, + ...props, + }; + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('context provision', () => { + it('provides context to children', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeDefined(); + }); + + it('provides labels from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ + labels: { entity: 'dashboard', entityPlural: 'dashboards' }, + }), + }); + + expect(result.current.labels).toEqual({ + entity: 'dashboard', + entityPlural: 'dashboards', + }); + }); + + it('provides id from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ id: 'my-custom-id' }), + }); + + expect(result.current.id).toBe('my-custom-id'); + }); + + it('provides isReadOnly from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ isReadOnly: true }), + }); + + expect(result.current.isReadOnly).toBe(true); + }); + + it('provides dataSource from props', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.dataSource).toBeDefined(); + expect(result.current.dataSource.findItems).toBeDefined(); + }); + }); + + describe('queryKeyScope derivation', () => { + it('derives queryKeyScope from id when not provided', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ id: 'my-list' }), + }); + + expect(result.current.queryKeyScope).toBe('my-list-listing'); + }); + + it('uses explicit queryKeyScope when provided', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ id: 'my-list', queryKeyScope: 'custom-scope' }), + }); + + expect(result.current.queryKeyScope).toBe('custom-scope'); + }); + + it('uses queryKeyScope without id when only queryKeyScope is provided', () => { + // Create a wrapper without id but with queryKeyScope. + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useContentListConfig(), { + wrapper: Wrapper, + }); + + expect(result.current.queryKeyScope).toBe('standalone-scope'); + }); + }); + + describe('supports flags', () => { + it('enables sorting by default', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.supports.sorting).toBe(true); + }); + + it('respects sorting: false in features', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ features: { sorting: false } }), + }); + + expect(result.current.supports.sorting).toBe(false); + }); + + it('enables sorting when sorting config is provided', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ + features: { sorting: { initialSort: { field: 'title', direction: 'asc' } } }, + }), + }); + + expect(result.current.supports.sorting).toBe(true); + }); + }); + + describe('features pass-through', () => { + it('provides empty features by default', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.features).toEqual({}); + }); + + it('provides features from props', () => { + const features = { + sorting: { initialSort: { field: 'updatedAt', direction: 'desc' as const } }, + }; + + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ features }), + }); + + expect(result.current.features).toEqual(features); + }); + }); + + describe('useContentListConfig', () => { + it('throws when used outside provider', () => { + // Suppress console.error for expected error. + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useContentListConfig()); + }).toThrow( + 'ContentListContext is missing. Ensure your component is wrapped with ContentListProvider.' + ); + + consoleSpy.mockRestore(); + }); + + it('returns consistent structure across renders', () => { + const { result, rerender } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + const firstValue = result.current; + rerender(); + const secondValue = result.current; + + expect(Object.keys(firstValue)).toEqual(Object.keys(secondValue)); + }); + }); + + describe('item config', () => { + it('provides item config when specified', () => { + const itemConfig = { + getHref: (item: ContentListItem) => `/view/${item.id}`, + }; + + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper({ item: itemConfig }), + }); + + expect(result.current.item).toEqual(itemConfig); + }); + + it('provides undefined item config when not specified', () => { + const { result } = renderHook(() => useContentListConfig(), { + wrapper: createWrapper(), + }); + + expect(result.current.item).toBeUndefined(); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx new file mode 100644 index 0000000000000..14125ab834a0b --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/provider.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo, createContext, useContext, type ReactNode } from 'react'; +import type { ContentListCoreConfig, ContentListConfig, ContentListServices } from './types'; +import type { ContentListFeatures, ContentListSupports } from '../features'; +import type { DataSourceConfig } from '../datasource'; +import { ContentListStateProvider } from '../state'; +import { QueryClientProvider, contentListQueryClient } from '../query'; + +/** + * Internal context value type. + */ +export type ContentListProviderContextValue = Omit< + ContentListCoreConfig, + 'id' | 'queryKeyScope' +> & { + /** Optional identifier (may be undefined if only `queryKeyScope` was provided). */ + id?: string; + /** Resolved query key scope (always present after provider initialization). */ + queryKeyScope: string; + /** Data source configuration. */ + dataSource: DataSourceConfig; + /** Feature configuration. */ + features: ContentListFeatures; + /** Resolved feature support flags. */ + supports: ContentListSupports; +}; + +/** + * Context for the content list configuration. + * + * @internal Use `useContentListConfig` hook to access this context. + */ +export const ContentListContext = createContext(null); + +/** + * Props for the `ContentListProvider` component. + */ +export type ContentListProviderProps = ContentListConfig & { + /** Child components that will have access to the content list context. */ + children: ReactNode; + /** Optional services for the provider. */ + services?: ContentListServices; + /** Feature configuration for enabling/customizing capabilities. */ + features?: ContentListFeatures; +}; + +/** + * Main provider component for content list functionality, including data fetching + * (via React Query) and sorting. + * + * Props like `dataSource` and `features` should be stable references to avoid + * unnecessary re-renders. Configuration from `features.sorting` is read once at + * mount; use a `key` prop to remount if you need to change initial sort dynamically. + */ +export const ContentListProvider = ({ + children, + dataSource, + labels, + item, + isReadOnly, + id, + queryKeyScope: queryKeyScopeProp, + features = {}, +}: ContentListProviderProps): JSX.Element => { + // Derive queryKeyScope: explicit prop takes priority, otherwise derive from id. + // At least one of id or queryKeyScope is guaranteed by ContentListIdentity type. + const queryKeyScope = queryKeyScopeProp ?? `${id}-listing`; + + // Resolve feature support flags. + const supports: ContentListSupports = useMemo( + () => ({ + sorting: features.sorting !== false, + }), + [features.sorting] + ); + + // Create context value. + const value: ContentListProviderContextValue = useMemo( + () => ({ + labels, + item, + isReadOnly, + id, + queryKeyScope, + dataSource, + features, + supports, + }), + [labels, item, isReadOnly, id, queryKeyScope, dataSource, features, supports] + ); + + return ( + + + {children} + + + ); +}; + +/** + * Hook to access the content list configuration context. + * + * This is a low-level hook that provides access to configuration and support flags. + * For most use cases, prefer the feature-specific hooks like `useContentListItems`, + * `useContentListSort`, etc. + * + * @throws Error if used outside `ContentListProvider`. + * @returns The content list context including configuration and support flags. + */ +export const useContentListConfig = (): ContentListProviderContextValue => { + const context = useContext(ContentListContext); + if (!context) { + throw new Error( + 'ContentListContext is missing. Ensure your component is wrapped with ContentListProvider.' + ); + } + return context; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/types.ts new file mode 100644 index 0000000000000..953f9d6c2592a --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/context/types.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DataSourceConfig } from '../datasource'; +import type { ContentListItemConfig } from '../item'; + +/** + * Identity configuration for content lists. + * + * At least one of `id` or `queryKeyScope` must be provided: + * + * - **`id` only**: `queryKeyScope` is derived as `${id}-listing`. Use when you want a simple + * identifier that also serves as the cache key base. + * + * - **`queryKeyScope` only**: Use when you only need cache isolation without a semantic identifier. + * + * - **Both**: `queryKeyScope` is used for caching/persistence, `id` is available for other + * purposes (e.g., `data-test-subj`, analytics). + */ +export type ContentListIdentity = + | { id: string; queryKeyScope?: string } + | { id?: string; queryKeyScope: string }; + +/** + * User-facing labels for the content type. + * + * These should be i18n-translated strings for display in the UI. + * + * @example + * ```ts + * labels: { + * entity: i18n.translate('myPlugin.listing.entityName', { defaultMessage: 'dashboard' }), + * entityPlural: i18n.translate('myPlugin.listing.entityNamePlural', { defaultMessage: 'dashboards' }), + * } + * ``` + */ +export interface ContentListLabels { + /** Singular form of the entity name (e.g., "dashboard"). Should be i18n-translated. */ + entity: string; + /** Plural form of the entity name (e.g., "dashboards"). Should be i18n-translated. */ + entityPlural: string; +} + +/** + * Base configuration fields shared by all content list variants. + * @internal + */ +interface ContentListCoreConfigBase { + /** User-facing labels for the content type. Should be i18n-translated strings. */ + labels: ContentListLabels; + /** Optional, per-item configuration. */ + item?: ContentListItemConfig; + /** When `true`, disables selection and editing actions. */ + isReadOnly?: boolean; +} + +/** + * Core configuration - entity metadata and base settings. + */ +export type ContentListCoreConfig = ContentListCoreConfigBase & ContentListIdentity; + +/** + * Complete configuration for a content list. + * + * @template T The raw item type from the datasource (defaults to `UserContentCommonSchema`). + */ +export type ContentListConfig = ContentListCoreConfig & { + dataSource: DataSourceConfig; +}; + +/** + * Services provided to the content list provider to enable additional capabilities. + * + * @internal This interface is a placeholder for future service integrations. + * @remarks + * Planned services include: + * - Tagging service for tag-based filtering. + * - Favorites service for user favorites. + * - Permissions service for access control. + * + * This interface is not yet used and may change without notice. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ContentListServices { + // Future services will be added here. +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/datasource/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/datasource/index.ts new file mode 100644 index 0000000000000..47ca508c4b7ce --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/datasource/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { + ActiveFilters, + FindItemsParams, + FindItemsResult, + FindItemsFn, + DataSourceConfig, +} from './types'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/datasource/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/datasource/types.ts new file mode 100644 index 0000000000000..1f8255f05fa75 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/datasource/types.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ContentListItem } from '../item'; + +/** + * Active filters applied to the content list. + */ +export interface ActiveFilters { + /** Search text extracted from the search bar, without filter syntax. */ + search?: string; +} + +/** + * Parameters for the `findItems` function. + */ +export interface FindItemsParams { + /** Search query text with filter syntax already extracted. */ + searchQuery: string; + + /** Active filters. */ + filters: ActiveFilters; + + /** + * Sort configuration. + * + * When sorting is disabled via `features.sorting: false`, this will be `undefined`. + * Implementations should return items in their natural order (e.g., server default). + */ + sort?: { + /** Field name to sort by. */ + field: string; + /** Sort direction. */ + direction: 'asc' | 'desc'; + }; + + /** Pagination configuration. */ + page: { + /** Zero-based page index. */ + index: number; + /** Number of items per page. */ + size: number; + }; + + /** AbortSignal for request cancellation. */ + signal?: AbortSignal; +} + +/** + * Result from the `findItems` function. + */ +export interface FindItemsResult { + /** Items for the current page. */ + items: ContentListItem[]; + + /** Total matching items for pagination. */ + total: number; +} + +/** + * Function signature for fetching items from a data source. + */ +export type FindItemsFn = (params: FindItemsParams) => Promise; + +/** + * Data source configuration properties. + */ +export interface DataSourceConfig { + /** Fetches items from the data source. */ + findItems: FindItemsFn; + + /** Called after successful fetch. */ + onFetchSuccess?: (result: FindItemsResult) => void; +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts new file mode 100644 index 0000000000000..6d1f979e77d4d --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +// Feature types. +export type { ContentListFeatures, ContentListSupports } from './types'; +export { isSortingConfig } from './types'; + +// Sorting feature. +export type { SortField, SortOption, SortingConfig, UseContentListSortReturn } from './sorting'; +export { useContentListSort } from './sorting'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/index.ts new file mode 100644 index 0000000000000..45d8bab9f383a --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { SortField, SortOption, SortingConfig } from './types'; +export { useContentListSort } from './use_content_list_sort'; +export type { UseContentListSortReturn } from './use_content_list_sort'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/types.ts new file mode 100644 index 0000000000000..f4139ff606679 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/types.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Simplified sort field definition. + * + * The Sort component auto-generates asc/desc options from this definition. + * + * @example + * ```ts + * // Basic usage - labels auto-generated as "Name A-Z" / "Name Z-A". + * { field: 'title', name: 'Name' } + * + * // Date fields auto-generate "Last updated (newest first)" / "(oldest first)". + * { field: 'updatedAt', name: 'Last updated' } + * + * // Custom labels for non-standard fields. + * { field: 'status', name: 'Status', ascLabel: 'Draft → Active', descLabel: 'Active → Draft' } + * ``` + */ +export interface SortField { + /** Field to sort by (must match data field name). */ + field: string; + /** Display name for the field (used to generate default labels). */ + name: string; + /** Custom label for ascending sort (overrides auto-generated label). */ + ascLabel?: string; + /** Custom label for descending sort (overrides auto-generated label). */ + descLabel?: string; +} + +/** + * Sort option definition with explicit label, field, and direction. + */ +export interface SortOption { + /** Display label for the sort option. */ + label: string; + /** Field to sort by. */ + field: string; + /** Sort direction. */ + direction: 'asc' | 'desc'; +} + +/** + * Sorting configuration. + * + * Use `fields` for the simpler API where asc/desc options are auto-generated. + * Use `options` for full control over each dropdown option. + */ +export interface SortingConfig { + /** Simplified sortable fields - auto-generates asc/desc options for each field. */ + fields?: SortField[]; + + /** + * Explicit sort options with full control over labels. + * Ignored if `fields` is provided. + */ + options?: SortOption[]; + + /** Initial sort state. */ + initialSort?: { + field: string; + direction: 'asc' | 'desc'; + }; +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/use_content_list_sort.test.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/use_content_list_sort.test.tsx new file mode 100644 index 0000000000000..2e641b2ee3352 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/use_content_list_sort.test.tsx @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { ContentListProvider } from '../../context'; +import type { FindItemsResult, FindItemsParams } from '../../datasource'; +import { useContentListSort } from './use_content_list_sort'; + +describe('useContentListSort', () => { + const mockFindItems = jest.fn( + async (_params: FindItemsParams): Promise => ({ + items: [], + total: 0, + }) + ); + + const createWrapper = (options?: { + initialSort?: { field: string; direction: 'asc' | 'desc' }; + sortingDisabled?: boolean; + }) => { + const { initialSort, sortingDisabled } = options ?? {}; + const features = sortingDisabled + ? { sorting: false as const } + : initialSort + ? { sorting: { initialSort } } + : undefined; + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initial state', () => { + it('returns default sort (title ascending) when no initial sort specified', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper(), + }); + + expect(result.current.field).toBe('title'); + expect(result.current.direction).toBe('asc'); + }); + + it('returns initial sort from features config', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper({ initialSort: { field: 'updatedAt', direction: 'desc' } }), + }); + + expect(result.current.field).toBe('updatedAt'); + expect(result.current.direction).toBe('desc'); + }); + + it('returns isSupported true by default', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper(), + }); + + expect(result.current.isSupported).toBe(true); + }); + + it('returns isSupported false when sorting is disabled', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper({ sortingDisabled: true }), + }); + + expect(result.current.isSupported).toBe(false); + }); + }); + + describe('setSort', () => { + it('updates field and direction', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSort('name', 'desc'); + }); + + expect(result.current.field).toBe('name'); + expect(result.current.direction).toBe('desc'); + }); + + it('updates only field when direction stays the same', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper({ initialSort: { field: 'title', direction: 'asc' } }), + }); + + act(() => { + result.current.setSort('updatedAt', 'asc'); + }); + + expect(result.current.field).toBe('updatedAt'); + expect(result.current.direction).toBe('asc'); + }); + + it('updates only direction when field stays the same', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper({ initialSort: { field: 'title', direction: 'asc' } }), + }); + + act(() => { + result.current.setSort('title', 'desc'); + }); + + expect(result.current.field).toBe('title'); + expect(result.current.direction).toBe('desc'); + }); + + it('is a no-op when sorting is disabled', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper({ sortingDisabled: true }), + }); + + const initialField = result.current.field; + const initialDirection = result.current.direction; + + act(() => { + result.current.setSort('updatedAt', 'desc'); + }); + + // Sort should not change when disabled. + expect(result.current.field).toBe(initialField); + expect(result.current.direction).toBe(initialDirection); + }); + + it('can be called multiple times', () => { + const { result } = renderHook(() => useContentListSort(), { + wrapper: createWrapper(), + }); + + act(() => { + result.current.setSort('name', 'desc'); + }); + + act(() => { + result.current.setSort('updatedAt', 'asc'); + }); + + act(() => { + result.current.setSort('createdAt', 'desc'); + }); + + expect(result.current.field).toBe('createdAt'); + expect(result.current.direction).toBe('desc'); + }); + }); + + describe('function stability', () => { + it('provides stable setSort reference across renders', () => { + const { result, rerender } = renderHook(() => useContentListSort(), { + wrapper: createWrapper(), + }); + + const firstSetSort = result.current.setSort; + rerender(); + const secondSetSort = result.current.setSort; + + expect(firstSetSort).toBe(secondSetSort); + }); + }); + + describe('error handling', () => { + it('throws when used outside provider', () => { + // Suppress console.error for expected error. + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useContentListSort()); + }).toThrow( + 'ContentListContext is missing. Ensure your component is wrapped with ContentListProvider.' + ); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/use_content_list_sort.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/use_content_list_sort.ts new file mode 100644 index 0000000000000..849d71c14caaf --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/sorting/use_content_list_sort.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useCallback } from 'react'; +import { useContentListState } from '../../state/use_content_list_state'; +import { useContentListConfig } from '../../context'; +import { CONTENT_LIST_ACTIONS } from '../../state/types'; + +/** + * Return type for the `useContentListSort` hook. + */ +export interface UseContentListSortReturn { + /** Current sort field name. */ + field: string; + /** Current sort direction. */ + direction: 'asc' | 'desc'; + /** Updates the sort configuration. No-op if sorting is disabled. */ + setSort: (field: string, direction: 'asc' | 'desc') => void; + /** Whether sorting is supported (enabled via features). */ + isSupported: boolean; +} + +/** + * Hook to access and control sorting functionality. + * + * Use this hook when you need to read or update the sort configuration. + * When sorting is disabled via `features.sorting: false`, `setSort` becomes a no-op + * and `isSupported` returns `false`. + * + * @throws Error if used outside `ContentListProvider`. + * @returns Object containing field, direction, setSort function, and isSupported flag. + * + * @example + * ```tsx + * function SortControls() { + * const { field, direction, setSort, isSupported } = useContentListSort(); + * + * if (!isSupported) return null; + * + * return ( + * { + * const [newField, newDirection] = e.target.value.split('-'); + * setSort(newField, newDirection as 'asc' | 'desc'); + * }} + * options={[ + * { value: 'title-asc', text: 'Title A-Z' }, + * { value: 'title-desc', text: 'Title Z-A' }, + * { value: 'updatedAt-desc', text: 'Recently updated' }, + * ]} + * /> + * ); + * } + * ``` + */ +export const useContentListSort = (): UseContentListSortReturn => { + const { supports } = useContentListConfig(); + const { state, dispatch } = useContentListState(); + + const setSort = useCallback( + (field: string, direction: 'asc' | 'desc') => { + if (!supports.sorting) { + return; + } + dispatch({ type: CONTENT_LIST_ACTIONS.SET_SORT, payload: { field, direction } }); + }, + [dispatch, supports.sorting] + ); + + return { + field: state.sort.field, + direction: state.sort.direction, + setSort, + isSupported: supports.sorting, + }; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts new file mode 100644 index 0000000000000..74926a61d18b5 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/features/types.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { SortingConfig } from './sorting'; + +/** + * Feature configuration for enabling/customizing content list capabilities. + */ +export interface ContentListFeatures { + /** Sorting configuration. */ + sorting?: SortingConfig | boolean; +} + +/** + * Type guard to check if sorting config is a `SortingConfig` object (not boolean). + */ +export const isSortingConfig = ( + sorting: ContentListFeatures['sorting'] +): sorting is SortingConfig => { + return typeof sorting === 'object' && sorting !== null; +}; + +/** + * Resolved feature support flags. + * + * These flags represent the **effective** availability of features after evaluating: + * - Explicit configuration (e.g., `features.sorting: true` or `features.sorting: { ... }`) + * - Implicit enablement (e.g., providing required services enabling a feature) + * - Explicit disablement (e.g., `features.sorting: false` overrides defaults) + * - Implicit disablement (e.g., missing services) + * + * Use these flags to conditionally render UI elements or enable functionality based on + * what's actually available, rather than checking raw configuration values. + * + * @example + * ```tsx + * const { supports } = useContentListConfig(); + * + * return ( + *
+ * {supports.sorting && } + *
+ * ); + * ``` + */ +export interface ContentListSupports { + /** Whether sorting is supported. */ + sorting: boolean; +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/item/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/item/index.ts new file mode 100644 index 0000000000000..22522d92f37bc --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/item/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export type { ContentListItem, ContentListItemConfig } from './types'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/item/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/item/types.ts new file mode 100644 index 0000000000000..35395f5af6c17 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/item/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Standardized item structure for rendering components (tables, grids, etc.). + * + * This is the common interface that all rendering components work with, + * regardless of the underlying datasource type. The `findItems` function + * in `DataSourceConfig` must return items in this format. + * + * @template T Additional properties to include on the item type. + */ +export type ContentListItem> = T & { + /** Unique identifier for the item. */ + id: string; + /** Display title for the item. */ + title: string; + /** Optional description text. */ + description?: string; + /** Item type identifier (e.g., "dashboard", "visualization"). */ + type?: string; + /** Last update timestamp. */ + updatedAt?: Date; +}; + +/** + * Per-item configuration for link behavior and actions. + */ +export interface ContentListItemConfig { + /** + * Function to generate the href for an item link. + * When provided, item titles become clickable links. + */ + getHref?: (item: ContentListItem) => string; +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/index.ts new file mode 100644 index 0000000000000..8ad3f4316923c --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { contentListKeys } from './keys'; +export { contentListQueryClient, QueryClientProvider } from './query_client'; +export { useContentListItemsQuery } from './use_content_list_items_query'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/keys.test.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/keys.test.ts new file mode 100644 index 0000000000000..924c9e3d66115 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/keys.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { contentListKeys } from './keys'; +import type { FindItemsParams } from '../datasource'; + +/** + * Creates a complete FindItemsParams object for testing. + */ +const createParams = ( + overrides?: Partial> +): Omit => ({ + searchQuery: '', + filters: {}, + sort: { field: 'title', direction: 'asc' }, + page: { index: 0, size: 20 }, + ...overrides, +}); + +describe('contentListKeys', () => { + describe('all', () => { + it('returns key with queryKeyScope', () => { + const key = contentListKeys.all('dashboard-listing'); + + expect(key).toEqual(['content-list', 'dashboard-listing']); + }); + + it('returns readonly array', () => { + const key = contentListKeys.all('my-scope'); + + // TypeScript ensures this is readonly, but we can verify the shape. + expect(Array.isArray(key)).toBe(true); + }); + + it('creates unique keys for different scopes', () => { + const key1 = contentListKeys.all('scope-a'); + const key2 = contentListKeys.all('scope-b'); + + expect(key1).not.toEqual(key2); + }); + }); + + describe('items', () => { + it('includes base key and items identifier with params', () => { + const params = createParams({ searchQuery: 'test' }); + const key = contentListKeys.items('dashboard-listing', params); + + expect(key).toEqual(['content-list', 'dashboard-listing', 'items', params]); + }); + + it('handles complex params object', () => { + const params = createParams({ + searchQuery: 'my search', + sort: { field: 'title', direction: 'asc' }, + page: { index: 1, size: 20 }, + }); + const key = contentListKeys.items('my-scope', params); + + expect(key).toEqual(['content-list', 'my-scope', 'items', params]); + }); + + it('creates unique keys for different params', () => { + const params1 = createParams({ searchQuery: 'test1' }); + const params2 = createParams({ searchQuery: 'test2' }); + + const key1 = contentListKeys.items('dashboard-listing', params1); + const key2 = contentListKeys.items('dashboard-listing', params2); + + expect(key1).not.toEqual(key2); + }); + + it('creates unique keys for different scopes', () => { + const params = createParams(); + + const key1 = contentListKeys.items('scope-a', params); + const key2 = contentListKeys.items('scope-b', params); + + expect(key1).not.toEqual(key2); + }); + }); + + describe('key hierarchy', () => { + it('items key starts with all key components', () => { + const allKey = contentListKeys.all('my-scope'); + const itemsKey = contentListKeys.items('my-scope', createParams()); + + // Items key should start with the same components as all key. + expect(itemsKey.slice(0, allKey.length)).toEqual([...allKey]); + }); + + it('allows invalidating all queries for a scope using all key', () => { + // This tests the expected behavior: all('dashboard-listing') should be a prefix + // of items('dashboard-listing', ...), enabling React Query invalidation patterns. + const allForScope = contentListKeys.all('dashboard-listing'); + const itemsForScope = contentListKeys.items( + 'dashboard-listing', + createParams({ searchQuery: 'x' }) + ); + + // The items key starts with ['content-list', 'dashboard-listing', ...]. + expect(itemsForScope[0]).toBe(allForScope[0]); + expect(itemsForScope[1]).toBe(allForScope[1]); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/keys.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/keys.ts new file mode 100644 index 0000000000000..f439172215f0b --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/keys.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { FindItemsParams } from '../datasource'; + +/** + * Query keys for content list items. + * + * Uses `queryKeyScope` (a stable identifier) to prevent cache collisions. + * Intentionally does not use user-facing labels (which may be i18n-translated) + * to ensure cache stability across locales. + * + * @example + * ```ts + * // Invalidate all content list queries for a specific scope. + * queryClient.invalidateQueries(contentListKeys.all('dashboard-listing')); + * ``` + */ +export const contentListKeys = { + /** + * Base query key for all content list queries. + * + * @param queryKeyScope - Stable scope identifier for cache isolation. + */ + all: (queryKeyScope: string) => ['content-list', queryKeyScope] as const, + + /** + * Query key for items queries with specific parameters. + * + * @param queryKeyScope - Stable scope identifier for cache isolation. + * @param params - Query parameters (search, filters, sort, page). + */ + items: (queryKeyScope: string, params: Omit) => + [...contentListKeys.all(queryKeyScope), 'items', params] as const, +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/query_client.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/query_client.tsx new file mode 100644 index 0000000000000..e2eee5c71ea09 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/query_client.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { QueryClient, QueryClientProvider } from '@kbn/react-query'; + +/** + * Shared React Query client for content list queries. + * + * Uses conservative defaults: + * - No automatic retries (errors surface immediately). + * - Standard stale time and cache time. + */ +export const contentListQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +export { QueryClientProvider }; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/use_content_list_items_query.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/use_content_list_items_query.ts new file mode 100644 index 0000000000000..36cb022adb3de --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/query/use_content_list_items_query.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useMemo } from 'react'; +import { useQuery } from '@kbn/react-query'; +import type { ContentListClientState, ContentListQueryData } from '../state/types'; +import { useContentListConfig } from '../context'; +import { contentListKeys } from './keys'; + +/** + * Default page configuration. + */ +const DEFAULT_PAGE = { index: 0, size: 20 }; + +/** + * React Query hook for fetching content list items. + * + * This hook: + * - Fetches items using the configured `findItems` function. + * - Returns query data directly (items, loading, error) without dispatching. + * + * Note: Items are expected to already be in `ContentListItem` format. + * Transformation should happen in the `findItems` implementation. + * + * @param clientState - Client-controlled state (filters, sort). + * @returns Query data and refetch function. + */ +export const useContentListItemsQuery = ( + clientState: ContentListClientState +): ContentListQueryData & { refetch: () => void } => { + const { dataSource, queryKeyScope, supports } = useContentListConfig(); + + // Build query parameters from client state. + // Only include sort if sorting is supported; otherwise, let the data source use its natural order. + const queryParams = useMemo( + () => ({ + searchQuery: clientState.filters.search ?? '', + filters: clientState.filters, + sort: supports.sorting ? clientState.sort : undefined, + page: DEFAULT_PAGE, + }), + [clientState.filters, clientState.sort, supports.sorting] + ); + + // React Query for data fetching. + const query = useQuery({ + queryKey: contentListKeys.items(queryKeyScope, queryParams), + queryFn: async ({ signal }) => { + const result = await dataSource.findItems({ ...queryParams, signal }); + + // Invoke success callback if provided. + // Note: Errors from `onFetchSuccess` are caught and logged to prevent them from + // breaking the query. In production, errors are logged but not surfaced to the UI. + // If you need to handle callback failures, consider adding error handling within + // your `onFetchSuccess` implementation. + if (dataSource.onFetchSuccess) { + try { + dataSource.onFetchSuccess(result); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('[ContentListProvider] onFetchSuccess callback error:', error); + } + } + + return result; + }, + }); + + // Derive error (normalize to Error type). + const error = useMemo(() => { + if (!query.error) { + return undefined; + } + return query.error instanceof Error ? query.error : new Error(String(query.error)); + }, [query.error]); + + return { + items: query.data?.items ?? [], + totalItems: query.data?.total ?? 0, + isLoading: query.isLoading || query.isFetching, + error, + refetch: query.refetch, + }; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/services/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/services/index.ts new file mode 100644 index 0000000000000..06f2aae6ce1fa --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/services/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Services module for content list provider. + */ + +// Placeholder - services will be exported here. +export {}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/services/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/services/types.ts new file mode 100644 index 0000000000000..688fde9aba92d --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/services/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Services skeleton for content list provider. + * + * This file establishes the pattern for service adapters that will + * integrate Kibana services with the content list provider. + */ + +// Placeholder export to establish the module. +export {}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts new file mode 100644 index 0000000000000..8dc5e9cf85f5a --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { ContentListStateProvider } from './state_provider'; +export { useContentListState, ContentListStateContext } from './use_content_list_state'; +export { useContentListItems } from './use_content_list_items'; +export { reducer } from './state_reducer'; +export type { ContentListState, ContentListAction, ContentListStateContextValue } from './types'; +export { CONTENT_LIST_ACTIONS, DEFAULT_FILTERS } from './types'; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx new file mode 100644 index 0000000000000..1d7c2055aaeba --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_provider.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo, useReducer, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import type { ContentListClientState, ContentListStateContextValue } from './types'; +import { DEFAULT_FILTERS } from './types'; +import { ContentListStateContext } from './use_content_list_state'; +import { useContentListConfig } from '../context'; +import { isSortingConfig } from '../features'; +import { reducer } from './state_reducer'; +import { useContentListItemsQuery } from '../query'; + +/** + * Props for `ContentListStateProvider`. + */ +export interface ContentListStateProviderProps { + /** Child components that will have access to the state context. */ + children: ReactNode; +} + +/** + * Internal provider component that manages the runtime state of the content list. + * + * This provider: + * - Manages client-controlled state (filters, sort) via reducer. + * - Uses React Query for data fetching with caching and deduplication. + * - Combines client state with query data for a unified state interface. + * + * Note: Initial state is derived from `features.sorting` at mount and not updated + * if configuration changes. See {@link ContentListProvider} for details. + * + * @internal This is automatically included when using `ContentListProvider`. + */ +export const ContentListStateProvider = ({ children }: ContentListStateProviderProps) => { + const { features } = useContentListConfig(); + const { sorting } = features; + + // Determine initial sort from sorting config (default: title ascending). + const initialSort = useMemo(() => { + if (isSortingConfig(sorting) && sorting.initialSort) { + return sorting.initialSort; + } + return { field: 'title', direction: 'asc' as const }; + }, [sorting]); + + // Initial client state (filters, sort). + const initialClientState: ContentListClientState = useMemo( + () => ({ + filters: { ...DEFAULT_FILTERS }, + sort: initialSort, + }), + [initialSort] + ); + + const [clientState, dispatch] = useReducer(reducer, initialClientState); + + // Use React Query for data fetching - returns query data directly. + const { + items, + totalItems, + isLoading, + error, + refetch: queryRefetch, + } = useContentListItemsQuery(clientState); + + // Expose refetch for manual refresh. + const refetch = useCallback(() => queryRefetch(), [queryRefetch]); + + // Combine client state with query data for unified state interface. + const contextValue: ContentListStateContextValue = useMemo( + () => ({ + state: { + ...clientState, + items, + totalItems, + isLoading, + error, + }, + dispatch, + refetch, + }), + [clientState, items, totalItems, isLoading, error, dispatch, refetch] + ); + + return ( + + {children} + + ); +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts new file mode 100644 index 0000000000000..0344745d2f001 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { reducer } from './state_reducer'; +import { CONTENT_LIST_ACTIONS, DEFAULT_FILTERS } from './types'; +import type { ContentListClientState, ContentListAction } from './types'; + +describe('state_reducer', () => { + /** + * Creates initial client state for testing. + * + * Note: The reducer only manages client-controlled state (filters, sort). + * Query data (items, isLoading, error) is managed by React Query directly. + */ + const createInitialState = ( + overrides?: Partial + ): ContentListClientState => ({ + filters: DEFAULT_FILTERS, + sort: { field: 'updatedAt', direction: 'desc' }, + ...overrides, + }); + + describe('SET_SORT', () => { + it('sets sort field and direction', () => { + const initialState = createInitialState(); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SORT, + payload: { field: 'title', direction: 'asc' }, + }; + + const newState = reducer(initialState, action); + + expect(newState.sort).toEqual({ field: 'title', direction: 'asc' }); + }); + + it('updates existing sort', () => { + const initialState = createInitialState({ + sort: { field: 'title', direction: 'asc' }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SORT, + payload: { field: 'updatedAt', direction: 'desc' }, + }; + + const newState = reducer(initialState, action); + + expect(newState.sort).toEqual({ field: 'updatedAt', direction: 'desc' }); + }); + + it('preserves filters when setting sort', () => { + const initialState = createInitialState({ + filters: { search: 'test query' }, + }); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SORT, + payload: { field: 'title', direction: 'asc' }, + }; + + const newState = reducer(initialState, action); + + expect(newState.filters).toEqual({ search: 'test query' }); + }); + }); + + describe('unknown action', () => { + it('returns current state for unknown action types', () => { + const initialState = createInitialState(); + const action = { type: 'UNKNOWN_ACTION', payload: {} } as unknown as ContentListAction; + + const newState = reducer(initialState, action); + + expect(newState).toBe(initialState); + }); + }); + + describe('immutability', () => { + it('returns a new state object for SET_SORT', () => { + const initialState = createInitialState(); + const action: ContentListAction = { + type: CONTENT_LIST_ACTIONS.SET_SORT, + payload: { field: 'title', direction: 'asc' }, + }; + + const newState = reducer(initialState, action); + + expect(newState).not.toBe(initialState); + }); + + it('does not mutate the original state', () => { + const initialState = createInitialState(); + const originalSort = initialState.sort; + const originalFilters = initialState.filters; + + reducer(initialState, { + type: CONTENT_LIST_ACTIONS.SET_SORT, + payload: { field: 'title', direction: 'asc' }, + }); + + expect(initialState.sort).toBe(originalSort); + expect(initialState.filters).toBe(originalFilters); + }); + }); +}); diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts new file mode 100644 index 0000000000000..46e912b8f58fc --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/state_reducer.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ContentListClientState, ContentListAction } from './types'; +import { CONTENT_LIST_ACTIONS } from './types'; + +/** + * State reducer for client-controlled state. + * + * Handles only user-driven state mutations (filters, sort). + * Query data (items, loading, error) is managed by React Query directly. + * + * @param state - Current client state. + * @param action - Action to apply. + * @returns New client state. + */ +export const reducer = ( + state: ContentListClientState, + action: ContentListAction +): ContentListClientState => { + switch (action.type) { + case CONTENT_LIST_ACTIONS.SET_SORT: + return { + ...state, + sort: { + field: action.payload.field, + direction: action.payload.direction, + }, + }; + + default: + return state; + } +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts new file mode 100644 index 0000000000000..dabe084e03bd0 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/types.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Dispatch } from 'react'; +import type { ContentListItem } from '../item'; +import type { ActiveFilters } from '../datasource'; + +/** + * Action type constants for state reducer. + * + * @internal + */ +export const CONTENT_LIST_ACTIONS = { + SET_SORT: 'SET_SORT', +} as const; + +/** + * Default filter state. + */ +export const DEFAULT_FILTERS: ActiveFilters = { + search: undefined, +}; + +/** + * Client-controlled state managed by the reducer. + * + * This includes user-driven state like filters and sort configuration. + * Query data (items, loading, error) comes directly from React Query. + */ +export interface ContentListClientState { + /** Filter state - currently applied filters. */ + filters: ActiveFilters; + /** Sort state. */ + sort: { + /** Field name to sort by. */ + field: string; + /** Sort direction. */ + direction: 'asc' | 'desc'; + }; +} + +/** + * Query data returned from React Query. + * + * This is read-only state derived from the data fetching layer. + */ +export interface ContentListQueryData { + /** Currently loaded items (transformed for rendering). */ + items: ContentListItem[]; + /** Total number of items matching the current query (for pagination). */ + totalItems: number; + /** Whether data is currently being fetched. */ + isLoading: boolean; + /** Error from the most recent fetch attempt. */ + error?: Error; +} + +/** + * Combined state structure for the content list. + * + * Merges client-controlled state with query data. + */ +export type ContentListState = ContentListClientState & ContentListQueryData; + +/** + * Union type of all possible state actions. + * + * @internal Used by the state reducer and dispatch function. + */ +export interface ContentListAction { + type: typeof CONTENT_LIST_ACTIONS.SET_SORT; + payload: { field: string; direction: 'asc' | 'desc' }; +} + +/** + * Context value provided by `ContentListStateProvider`. + */ +export interface ContentListStateContextValue { + /** Current state of the content list (client state + query data). */ + state: ContentListState; + /** Dispatch function for client state updates (filters, sort). */ + dispatch: Dispatch; + /** Function to manually refetch items from the data source. */ + refetch: () => void; +} diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/use_content_list_items.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/use_content_list_items.ts new file mode 100644 index 0000000000000..fbb4c6f0be794 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/use_content_list_items.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useContentListState } from './use_content_list_state'; + +/** + * Hook to access the current list of items and loading state. + * + * @throws Error if used outside `ContentListProvider`. + * @returns Object containing items, totalItems, isLoading, error, and refetch. + * + * @example + * ```tsx + * function MyList() { + * const { items, isLoading, error, refetch } = useContentListItems(); + * + * if (isLoading) return ; + * if (error) return {error.message}; + * + * return ( + *
    + * {items.map((item) => ( + *
  • {item.title}
  • + * ))} + *
+ * ); + * } + * ``` + */ +export const useContentListItems = () => { + const { state, refetch } = useContentListState(); + + return { + items: state.items, + totalItems: state.totalItems, + isLoading: state.isLoading, + error: state.error, + refetch, + }; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/use_content_list_state.ts b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/use_content_list_state.ts new file mode 100644 index 0000000000000..73ca9d5fd276c --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/state/use_content_list_state.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createContext, useContext } from 'react'; +import type { ContentListStateContextValue } from './types'; + +/** + * Context for the content list state. + * + * @internal Use `useContentListState` hook to access this context. + */ +export const ContentListStateContext = createContext(null); + +/** + * Hook to access the content list state and dispatch function. + * + * This is a low-level hook. For most use cases, prefer the feature-specific hooks + * like `useContentListSort`, `useContentListItems`, etc. + * + * @throws Error if used outside `ContentListProvider`. + * @returns The state context value including state, dispatch, and refetch. + */ +export const useContentListState = (): ContentListStateContextValue => { + const context = useContext(ContentListStateContext); + if (!context) { + throw new Error( + 'ContentListStateContext is missing. Ensure your component is wrapped with ContentListProvider.' + ); + } + return context; +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/stories/content_list_provider.stories.tsx b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/stories/content_list_provider.stories.tsx new file mode 100644 index 0000000000000..94e82363a914a --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/src/stories/content_list_provider.stories.tsx @@ -0,0 +1,268 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { useMemo } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + EuiPanel, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiBadge, + EuiButton, + EuiButtonGroup, + EuiBasicTable, + EuiIcon, + EuiCallOut, +} from '@elastic/eui'; +import { MOCK_DASHBOARDS, createMockFindItems } from '@kbn/content-list-mock-data/storybook'; +import { ContentListProvider, useContentListConfig } from '../context'; +import { useContentListItems } from '../state'; +import { useContentListSort } from '../features/sorting'; +import type { FindItemsParams, FindItemsResult } from '../datasource'; + +interface StoryArgs { + entityName: string; + entityNamePlural: string; + enableSorting: boolean; + initialSortField: 'title' | 'updatedAt'; + initialSortDirection: 'asc' | 'desc'; + numberOfItems: number; + showConfig: boolean; +} + +/** + * Demo component that displays the config context. + */ +const ConfigDisplay = () => { + const config = useContentListConfig(); + + return ( + + + Config Context: +
+          {JSON.stringify(
+            {
+              id: config.id,
+              labels: config.labels,
+              queryKeyScope: config.queryKeyScope,
+              features: config.features,
+              supports: config.supports,
+            },
+            null,
+            2
+          )}
+        
+
+
+ ); +}; + +/** + * Demo component that displays items from the provider. + */ +const ItemsList = () => { + const { items, totalItems, refetch } = useContentListItems(); + + return ( +
+ + + {totalItems} items + + + refetch()}> + Refresh + + + + + + + {title} }, + { field: 'description', name: 'Description' }, + { + field: 'updatedAt', + name: 'Updated', + render: (date: Date | undefined) => (date ? date.toLocaleDateString() : '—'), + }, + ]} + /> +
+ ); +}; + +/** + * Demo component that displays sort controls. + */ +const SortControls = () => { + const { field, direction, setSort } = useContentListSort(); + const { supports } = useContentListConfig(); + + if (!supports.sorting) { + return ( + + Sorting is disabled + + ); + } + + const sortOptions = [ + { id: 'title-asc', label: 'Title A-Z' }, + { id: 'title-desc', label: 'Title Z-A' }, + { id: 'updatedAt-desc', label: 'Recently updated' }, + { id: 'updatedAt-asc', label: 'Oldest first' }, + ]; + + return ( + + + + + + Sort by: + + + { + const [newField, newDirection] = id.split('-'); + setSort(newField, newDirection as 'asc' | 'desc'); + }} + buttonSize="compressed" + /> + + + ); +}; + +const meta: Meta = { + title: 'Content Management/Content List', + parameters: { layout: 'padded' }, + argTypes: { + entityName: { control: 'text', description: 'Singular entity name' }, + entityNamePlural: { control: 'text', description: 'Plural entity name' }, + enableSorting: { control: 'boolean', description: 'Enable sorting feature' }, + initialSortField: { + control: 'select', + options: ['title', 'updatedAt'], + description: 'Initial sort field', + }, + initialSortDirection: { + control: 'radio', + options: ['asc', 'desc'], + description: 'Initial sort direction', + }, + numberOfItems: { + control: { type: 'range', min: 0, max: 8, step: 1 }, + description: 'Number of items to display', + }, + showConfig: { control: 'boolean', description: 'Show config context panel' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * Story wrapper component that handles stable prop references. + */ +const ProviderStory = ({ args }: { args: StoryArgs }) => { + // Memoize labels to maintain stable reference. + const labels = useMemo( + () => ({ entity: args.entityName, entityPlural: args.entityNamePlural }), + [args.entityName, args.entityNamePlural] + ); + + // Memoize dataSource to maintain stable reference. + const dataSource = useMemo(() => { + const mockFindItems = createMockFindItems({ + items: MOCK_DASHBOARDS.slice(0, args.numberOfItems), + }); + + const findItems = async (params: FindItemsParams): Promise => { + const result = await mockFindItems({ + searchQuery: params.searchQuery, + filters: {}, + sort: params.sort ?? { field: 'title', direction: 'asc' }, + page: params.page, + }); + return { + items: result.items.map((item) => ({ + id: item.id, + title: item.attributes.title, + description: item.attributes.description, + type: item.type, + updatedAt: item.updatedAt ? new Date(item.updatedAt) : undefined, + })), + total: result.total, + }; + }; + + return { findItems }; + }, [args.numberOfItems]); + + // Memoize features to maintain stable reference. + const features = useMemo( + () => + args.enableSorting + ? { + sorting: { + initialSort: { field: args.initialSortField, direction: args.initialSortDirection }, + }, + } + : { sorting: false as const }, + [args.enableSorting, args.initialSortField, args.initialSortDirection] + ); + + // Key forces re-mount when configuration changes. + const key = `${args.enableSorting}-${args.initialSortField}-${args.initialSortDirection}-${args.numberOfItems}`; + + return ( + + {args.showConfig && ( + <> + + + + )} + + + + + ); +}; + +export const Provider: Story = { + args: { + entityName: 'dashboard', + entityNamePlural: 'dashboards', + enableSorting: true, + initialSortField: 'title', + initialSortDirection: 'asc', + numberOfItems: 8, + showConfig: true, + }, + render: (args) => , +}; diff --git a/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/tsconfig.json b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/tsconfig.json new file mode 100644 index 0000000000000..de3dcebf89772 --- /dev/null +++ b/src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/react-query", + "@kbn/content-list-mock-data" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 554c2e8f7a5e9..a9686c264e86f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -256,6 +256,10 @@ "@kbn/content-connectors-plugin/*": ["x-pack/platform/plugins/shared/content_connectors/*"], "@kbn/content-list-mock-data": ["src/platform/packages/shared/content-management/content_list/kbn-content-list-mock-data"], "@kbn/content-list-mock-data/*": ["src/platform/packages/shared/content-management/content_list/kbn-content-list-mock-data/*"], + "@kbn/content-list-provider": ["src/platform/packages/shared/content-management/content_list/kbn-content-list-provider"], + "@kbn/content-list-provider/*": ["src/platform/packages/shared/content-management/content_list/kbn-content-list-provider/*"], + "@kbn/content-list-provider-client": ["src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client"], + "@kbn/content-list-provider-client/*": ["src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client/*"], "@kbn/content-management-access-control-public": ["src/platform/packages/shared/content-management/access_control/access_control_public"], "@kbn/content-management-access-control-public/*": ["src/platform/packages/shared/content-management/access_control/access_control_public/*"], "@kbn/content-management-access-control-server": ["src/platform/packages/shared/content-management/access_control/access_control_server"], diff --git a/yarn.lock b/yarn.lock index a3d7d6d56f325..812cc1619dba0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5193,6 +5193,14 @@ version "0.0.0" uid "" +"@kbn/content-list-provider-client@link:src/platform/packages/shared/content-management/content_list/kbn-content-list-provider-client": + version "0.0.0" + uid "" + +"@kbn/content-list-provider@link:src/platform/packages/shared/content-management/content_list/kbn-content-list-provider": + version "0.0.0" + uid "" + "@kbn/content-management-access-control-public@link:src/platform/packages/shared/content-management/access_control/access_control_public": version "0.0.0" uid ""