diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..4e4d2ee --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,127 @@ +# This workflow performs basic checks: +# +# 1. run a preparation step to install and cache node modules +# 2. once prep succeeds, lint and test run in parallel +# +# The checks only run on non-draft Pull Requests. They don't run on the main +# branch prior to deploy. It's recommended to use branch protection to avoid +# pushes straight to 'main'. + +name: Checks + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + prep: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ env.NODE }} + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Cache node_modules + uses: actions/cache@v4 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} + + - name: Install + run: npm install + + lint: + needs: prep + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ env.NODE }} + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Cache node_modules + uses: actions/cache@v4 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} + + - name: Install + run: npm install + + - name: Lint + run: npm run lint + + test: + needs: prep + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ env.NODE }} + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Cache node_modules + uses: actions/cache@v4 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} + + - name: Install + run: npm install + + - name: Build plugins + run: npm run plugins:build + + - name: Test + run: npm run test + + build: + needs: prep + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ env.NODE }} + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - name: Cache node_modules + uses: actions/cache@v4 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} + + - name: Install + run: npm install + + - name: Test + run: npm run all:build \ No newline at end of file diff --git a/jest-setup.ts b/jest-setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/jest-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/jest.config.ts b/jest.config.ts index e4c0c7f..19835a2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -90,7 +90,7 @@ const config: Config = { // ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, + moduleNameMapper: { 'lodash-es': 'lodash' }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], @@ -137,7 +137,7 @@ const config: Config = { // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ['/jest-setup.ts'], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, diff --git a/package-lock.json b/package-lock.json index 60ceb9b..0984543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.10.2", "@types/rollup-plugin-peer-deps-external": "^2.2.5", + "@types/testing-library__jest-dom": "^5.14.9", "babel-jest": "^29.7.0", "eslint": "^9.13.0", "eslint-config-prettier": "^9.1.0", @@ -6416,6 +6417,16 @@ "@types/geojson": "*" } }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.9", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", + "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jest": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", diff --git a/package.json b/package.json index c4f8e99..5ae6dc6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "client:stage": "lerna run stage --scope='@stac-manager/client'", "all:build": "lerna run build", "all:clean": "lerna run clean", - "test": "jest" + "test": "jest", + "lint": "lerna run lint" }, "devDependencies": { "@eslint/js": "^9.13.0", @@ -23,6 +24,7 @@ "@types/jest": "^29.5.14", "@types/node": "^22.10.2", "@types/rollup-plugin-peer-deps-external": "^2.2.5", + "@types/testing-library__jest-dom": "^5.14.9", "babel-jest": "^29.7.0", "eslint": "^9.13.0", "eslint-config-prettier": "^9.1.0", diff --git a/packages/client/package.json b/packages/client/package.json index 9ca8e93..5a1e804 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -21,8 +21,7 @@ "build": "npm run clean && NODE_ENV=production node tasks/build.mjs", "stage": "npm run clean && NODE_ENV=staging node tasks/build.mjs", "clean": "rm -rf dist .parcel-cache", - "lint": "yarn lint:scripts", - "lint:scripts": "eslint app/", + "lint": "eslint src/", "ts-check": "yarn tsc --noEmit --skipLibCheck", "test": "jest" }, diff --git a/packages/client/src/pages/CollectionDetail/index.tsx b/packages/client/src/pages/CollectionDetail/index.tsx index 8a959f1..fbf7e1a 100644 --- a/packages/client/src/pages/CollectionDetail/index.tsx +++ b/packages/client/src/pages/CollectionDetail/index.tsx @@ -24,7 +24,8 @@ import { PopoverArrow, PopoverBody, PopoverContent, - ButtonGroup + ButtonGroup, + Button } from '@chakra-ui/react'; import { useCollection, useStacSearch } from '@developmentseed/stac-react'; import { diff --git a/packages/data-core/lib/components/__snapshots__/plugin-box.test.tsx.snap b/packages/data-core/lib/components/__snapshots__/plugin-box.test.tsx.snap new file mode 100644 index 0000000..cf1ccab --- /dev/null +++ b/packages/data-core/lib/components/__snapshots__/plugin-box.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PluginBox renders ErrorBox when editSchema is null 1`] = ` +
+ Plugin + + TestPlugin + + has no edit schema. +
+`; diff --git a/packages/data-core/lib/components/plugin-box.test.tsx b/packages/data-core/lib/components/plugin-box.test.tsx new file mode 100644 index 0000000..f179d22 --- /dev/null +++ b/packages/data-core/lib/components/plugin-box.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Formik } from 'formik'; +import { ChakraProvider } from '@chakra-ui/react'; + +import { PluginBox } from './plugin-box'; +import { Plugin } from '../plugin-utils/plugin'; + +const mockPlugin = { + name: 'TestPlugin', + editSchema: jest.fn() +}; + +// Custom renderer to add context or providers if needed +const renderWithProviders = ( + ui: React.ReactNode, + { renderOptions = {} } = {} +) => { + // You can wrap the component with any context providers here + return render(ui, { + wrapper: ({ children }) => ( + + {}}> + {children} + + + ), + ...renderOptions + }); +}; + +describe('PluginBox', () => { + it('renders ErrorBox when editSchema is null', () => { + mockPlugin.editSchema.mockReturnValue(null); + + const { getByTestId } = renderWithProviders( + {}}> + + {({ field }) =>
{field.label}
} +
+
+ ); + + expect(getByTestId('plugin-box-error')).toMatchSnapshot(); + }); + + it('renders nothing when editSchema is Plugin.HIDDEN', () => { + mockPlugin.editSchema.mockReturnValue(Plugin.HIDDEN); + + const { container } = render( + {}}> + + {({ field }) =>
{field.label}
} +
+
+ ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders children when editSchema is valid', () => { + const mockSchema = { type: 'string', label: 'testField' }; + mockPlugin.editSchema.mockReturnValue(mockSchema); + + const { getByText } = renderWithProviders( + {}}> + + {({ field }) =>
{field.label}
} +
+
+ ); + + expect(getByText(/testField/i)).toBeInTheDocument(); + }); +}); diff --git a/packages/data-core/lib/components/plugin-box.tsx b/packages/data-core/lib/components/plugin-box.tsx index 084e1fa..6b95a83 100644 --- a/packages/data-core/lib/components/plugin-box.tsx +++ b/packages/data-core/lib/components/plugin-box.tsx @@ -27,7 +27,7 @@ export function PluginBox(props: PluginBoxProps) { if (!editSchema) { return ( - + Plugin {plugin.name} has no edit schema. ); diff --git a/packages/data-core/lib/components/widget-renderer.test.tsx b/packages/data-core/lib/components/widget-renderer.test.tsx new file mode 100644 index 0000000..60d8aaf --- /dev/null +++ b/packages/data-core/lib/components/widget-renderer.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ChakraProvider } from '@chakra-ui/react'; + +import { WidgetRenderer } from './widget-renderer'; +import { usePluginConfig } from '../context/plugin-config'; +import { SchemaField } from '../schema/types'; + +jest.mock('../context/plugin-config', () => ({ + usePluginConfig: jest.fn() +})); + +const mockPluginConfig = { + 'ui:widget': { + text: ({ pointer }: { pointer: string }) => ( +
Text Widget: {pointer}
+ ), + number: ({ pointer }: { pointer: string }) => ( +
Number Widget: {pointer}
+ ), + radio: ({ pointer }: { pointer: string }) => ( +
Radio Widget: {pointer}
+ ), + broken: () => { + throw new Error('Widget failed'); + } + } +}; + +describe('WidgetRenderer', () => { + beforeEach(() => { + (usePluginConfig as jest.Mock).mockReturnValue(mockPluginConfig); + }); + + it('renders a text widget', () => { + const field: SchemaField = { type: 'string' }; + render(); + expect(screen.getByText('Text Widget: test.pointer')).toBeInTheDocument(); + }); + + it('renders a number widget', () => { + const field: SchemaField = { type: 'number' }; + render(); + expect(screen.getByText('Number Widget: test.pointer')).toBeInTheDocument(); + }); + + it('renders a radio widget for enum strings', () => { + const field: SchemaField = { + type: 'string', + enum: [ + ['option1', 'Option 1'], + ['option2', 'Option 2'] + ] + }; + render(); + expect(screen.getByText('Radio Widget: test.pointer')).toBeInTheDocument(); + }); + + it('renders an error box when widget is not found', () => { + const field: SchemaField = { type: 'string', 'ui:widget': 'custom' }; + render( + + + + ); + expect(screen.getByText('Widget "custom" not found')).toBeInTheDocument(); + }); + + it('renders error boundary when widget throws an error', () => { + // The test will pass but there will be some noise in the output: + // Error: Uncaught [Error: Widget failed] + // So we need to spyOn the console error: + jest.spyOn(console, 'error').mockImplementation(() => null); + + const field: SchemaField = { type: 'string', 'ui:widget': 'broken' }; + render( + + + + ); + expect( + screen.getByText('💔 Error rendering widget (broken)') + ).toBeInTheDocument(); + expect(screen.getByText('Widget failed')).toBeInTheDocument(); + + // Restore the original console.error to avoid affecting other tests. + jest.spyOn(console, 'error').mockRestore(); + }); +}); diff --git a/packages/data-core/lib/components/widget-renderer.tsx b/packages/data-core/lib/components/widget-renderer.tsx index b140596..3178451 100644 --- a/packages/data-core/lib/components/widget-renderer.tsx +++ b/packages/data-core/lib/components/widget-renderer.tsx @@ -1,9 +1,9 @@ import React from 'react'; +import { Box, Text } from '@chakra-ui/react'; import { usePluginConfig } from '../context/plugin-config'; import { ErrorBox } from './error-box'; import { SchemaField } from '../schema/types'; -import { Box, Text } from '@chakra-ui/react'; interface WidgetProps { pointer: string; @@ -26,7 +26,7 @@ export function WidgetRenderer(props: WidgetProps) { const Widget = config['ui:widget'][widget]; return ( - + {Widget ? ( ) : ( @@ -75,7 +75,7 @@ export function WidgetRenderer(props: WidgetProps) { interface WidgetErrorBoundaryProps { children: React.ReactNode; field: SchemaField; - widget: string; + widgetName: string; pointer: string; } @@ -101,7 +101,7 @@ class WidgetErrorBoundary extends React.Component< return ( - 💔 Error rendering widget ({this.props.widget}) + 💔 Error rendering widget ({this.props.widgetName}) {this.state.error.message || 'Something is wrong with this widget'} diff --git a/packages/data-core/lib/config/config.test.ts b/packages/data-core/lib/config/config.test.ts new file mode 100644 index 0000000..195b01f --- /dev/null +++ b/packages/data-core/lib/config/config.test.ts @@ -0,0 +1,51 @@ +import { extendPluginConfig } from './index'; +import { PluginConfig } from './index'; + +describe('extendPluginConfig', () => { + it('should merge multiple configurations', () => { + const config1: Partial = { + collectionPlugins: [{ name: 'plugin1' } as any], + itemPlugins: [{ name: 'itemPlugin1' } as any], + 'ui:widget': { widget1: () => null } + }; + + const config2: Partial = { + collectionPlugins: [{ name: 'plugin2' } as any], + 'ui:widget': { widget2: () => null } + }; + + const result = extendPluginConfig(config1, config2); + + expect(result).toEqual({ + collectionPlugins: [{ name: 'plugin1' }, { name: 'plugin2' }], + itemPlugins: [{ name: 'itemPlugin1' }], + 'ui:widget': { + widget1: expect.any(Function), + widget2: expect.any(Function) + } + }); + }); + + it('should handle empty configurations', () => { + const result = extendPluginConfig(); + expect(result).toEqual({ + collectionPlugins: [], + itemPlugins: [], + 'ui:widget': {} + }); + }); + + it('should override properties with later configurations', () => { + const config1: Partial = { + 'ui:widget': { widget1: () => 'old' } + }; + + const config2: Partial = { + 'ui:widget': { widget1: () => 'new' } + }; + + const result = extendPluginConfig(config1, config2); + + expect(result['ui:widget'].widget1({} as any)).toBe('new'); + }); +}); diff --git a/packages/data-core/lib/context/plugin-config.tsx b/packages/data-core/lib/context/plugin-config.tsx index 7ac8958..2b34a4f 100644 --- a/packages/data-core/lib/context/plugin-config.tsx +++ b/packages/data-core/lib/context/plugin-config.tsx @@ -34,7 +34,7 @@ export const usePluginConfig = () => { if (!context) { throw new Error( - 'usePluginConfig must be used within a PluginConfigContextProvider' + 'usePluginConfig must be used within a PluginConfigProvider' ); } diff --git a/packages/data-core/lib/plugin-utils/resolve.test.ts b/packages/data-core/lib/plugin-utils/resolve.test.ts new file mode 100644 index 0000000..4df8523 --- /dev/null +++ b/packages/data-core/lib/plugin-utils/resolve.test.ts @@ -0,0 +1,76 @@ +import { resolvePlugins, applyHooks } from './resolve'; +import { Plugin } from './plugin'; + +class MockPlugin extends Plugin { + name = 'MockPlugin'; + init = jest.fn(); + editSchema = jest.fn(); +} + +describe('resolvePlugins', () => { + it('should resolve an array of Plugin instances', () => { + const plugin = new MockPlugin(); + const result = resolvePlugins([plugin], {}); + expect(result).toContainEqual(plugin); + }); + + it('should resolve functions returning Plugin instances', () => { + const plugin = new MockPlugin(); + const result = resolvePlugins([() => plugin], {}); + expect(result).toContainEqual(plugin); + }); + + it('should filter out invalid items', () => { + const plugin = new MockPlugin(); + const result = resolvePlugins([plugin, null, undefined], {}); + expect(result).toContainEqual(plugin); + expect(result.length).toBe(1); + }); +}); + +describe('applyHooks', () => { + it('should apply onAfterInit hooks', async () => { + const targetPlugin = new MockPlugin(); + const sourcePlugin = new MockPlugin(); + sourcePlugin[Plugin.HOOKS] = [ + { + name: targetPlugin.name, + onAfterInit: jest.fn() + } + ]; + + // applyHooks creates a copy, so we need the plugins back. + const [newTargetPl, newSourcePl] = applyHooks([targetPlugin, sourcePlugin]); + await newTargetPl.init({}); + expect(newSourcePl[Plugin.HOOKS][0].onAfterInit).toHaveBeenCalled(); + }); + + it('should compose onAfterEditSchema hooks', () => { + const targetPlugin = new MockPlugin(); + const sourcePlugin = new MockPlugin(); + sourcePlugin[Plugin.HOOKS] = [ + { + name: targetPlugin.name, + onAfterEditSchema: jest.fn((_, __, origEditSchema) => origEditSchema) + } + ]; + + // applyHooks creates a copy, so we need the plugins back. + const [newTargetPl, newSourcePl] = applyHooks([targetPlugin, sourcePlugin]); + newTargetPl.editSchema({}); + expect(newSourcePl[Plugin.HOOKS][0].onAfterEditSchema).toHaveBeenCalled(); + }); + + it('should ignore hooks targeting non-existent plugins', () => { + const sourcePlugin = new MockPlugin(); + sourcePlugin[Plugin.HOOKS] = [ + { + name: 'NonExistentPlugin', + onAfterInit: jest.fn() + } + ]; + + const plugins = applyHooks([sourcePlugin]); + expect(plugins).toContainEqual(sourcePlugin); + }); +}); diff --git a/packages/data-core/lib/plugin-utils/resolve.ts b/packages/data-core/lib/plugin-utils/resolve.ts index 075b9ca..2785ba7 100644 --- a/packages/data-core/lib/plugin-utils/resolve.ts +++ b/packages/data-core/lib/plugin-utils/resolve.ts @@ -69,7 +69,7 @@ export const applyHooks = (plugins: Plugin[]) => { for (const plSource of pluginsCopy) { for (const hook of plSource[Plugin.HOOKS]) { // Target where to apply the hook - const plTarget = plugins.find((p) => p.name === hook.name); + const plTarget = pluginsCopy.find((p) => p.name === hook.name); if (!plTarget) { continue; } diff --git a/packages/data-core/lib/schema/schema.test.ts b/packages/data-core/lib/schema/schema.test.ts new file mode 100644 index 0000000..1116817 --- /dev/null +++ b/packages/data-core/lib/schema/schema.test.ts @@ -0,0 +1,90 @@ +import { schemaToFormDataStructure } from './index'; +import { SchemaField } from './types'; + +describe('schemaToFormDataStructure', () => { + it('should handle root/object type fields', () => { + const schema: SchemaField = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'string' } + } + }; + const result = schemaToFormDataStructure(schema); + expect(result).toEqual({ name: '', age: '' }); + }); + + it('should handle array type fields with minItems', () => { + const schema: SchemaField = { + type: 'array', + minItems: 2, + items: { type: 'string' } + }; + const result = schemaToFormDataStructure(schema); + expect(result).toEqual(['', '']); + }); + + it('should handle array type fields without minItems', () => { + const schema: SchemaField = { + type: 'array', + items: { type: 'string' } + }; + const result = schemaToFormDataStructure(schema); + expect(result).toEqual([]); + }); + + it('should handle json type fields', () => { + const schema: SchemaField = { + type: 'json' + }; + const result = schemaToFormDataStructure(schema); + expect(result).toEqual({}); + }); + + it('should handle default case for unsupported types', () => { + const schema: SchemaField = { + type: 'string' + }; + const result = schemaToFormDataStructure(schema); + expect(result).toEqual(''); + }); + + it('should handle deeply nested schemas', () => { + const schema: SchemaField = { + type: 'root', + properties: { + users: { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'string' }, + accounts: { + type: 'array', + items: { + type: 'object', + properties: { + accountName: { type: 'string' }, + balance: { type: 'string' } + } + } + } + } + } + } + } + }; + const result = schemaToFormDataStructure(schema); + expect(result).toEqual({ + users: [ + { + name: '', + age: '', + accounts: [] + } + ] + }); + }); +}); diff --git a/packages/data-plugins/lib/utils.test.ts b/packages/data-plugins/lib/utils.test.ts new file mode 100644 index 0000000..cecb856 --- /dev/null +++ b/packages/data-plugins/lib/utils.test.ts @@ -0,0 +1,243 @@ +import { Plugin } from '@stac-manager/data-core'; +import { + fieldIf, + emptyString2Null, + null2EmptyString, + object2Array, + object2Tuple, + array2Object, + tuple2Object, + hasStacExtension, + addStacExtensionOption +} from './utils'; + +describe('Utils', () => { + describe('fieldIf', () => { + it('should return the ifTrue schema when condition is true', () => { + const result = fieldIf(true, 'test', { type: 'string' }); + expect(result).toEqual({ test: { type: 'string' } }); + }); + + it('should return the ifFalse schema when condition is false', () => { + const result = fieldIf( + false, + 'test', + { type: 'string' }, + { type: 'number' } + ); + expect(result).toEqual({ test: { type: 'number' } }); + }); + + it('should return an empty object when condition is false and ifFalse is not provided', () => { + const result = fieldIf(false, 'test', { type: 'string' }); + expect(result).toEqual({}); + }); + }); + + describe('emptyString2Null', () => { + it('should convert empty strings to null', () => { + expect(emptyString2Null(['', 'test', ['']])).toEqual([ + null, + 'test', + [null] + ]); + }); + + it('should leave non-empty values unchanged', () => { + expect(emptyString2Null(['test', 123, null])).toEqual([ + 'test', + 123, + null + ]); + }); + }); + + describe('null2EmptyString', () => { + it('should convert null values to empty strings', () => { + expect(null2EmptyString([null, 'test', [null]])).toEqual([ + '', + 'test', + [''] + ]); + }); + + it('should leave non-null values unchanged', () => { + expect(null2EmptyString(['test', 123, ''])).toEqual(['test', 123, '']); + }); + }); + + describe('object2Array', () => { + it('should convert an object to an array of objects', () => { + const input = { a: { name: 'Alice' }, b: { name: 'Bob' } }; + const result = object2Array(input, 'id'); + expect(result).toEqual([ + { id: 'a', name: 'Alice' }, + { id: 'b', name: 'Bob' } + ]); + }); + + it('should apply the transformation function if provided', () => { + const input = { a: { value: 1 }, b: { value: 2 } }; + const result = object2Array(input, 'id', (v) => ({ value: v.value * 2 })); + expect(result).toEqual([ + { id: 'a', value: 2 }, + { id: 'b', value: 4 } + ]); + }); + }); + + describe('object2Tuple', () => { + it('should convert an object to an array of tuples', () => { + const input = { a: 1, b: 2 }; + const result = object2Tuple(input); + expect(result).toEqual([ + ['a', 1], + ['b', 2] + ]); + }); + }); + + describe('array2Object', () => { + it('should convert an array of objects to an object', () => { + const input = [ + { id: 'a', value: 1 }, + { id: 'b', value: 2 } + ]; + const result = array2Object(input, 'id'); + expect(result).toEqual({ + a: { value: 1 }, + b: { value: 2 } + }); + }); + + it('should apply the transformation function if provided', () => { + const input = [ + { id: 'a', value: 1 }, + { id: 'b', value: 2 } + ]; + const result = array2Object(input, 'id', (v) => ({ value: v.value * 2 })); + expect(result).toEqual({ + a: { value: 2 }, + b: { value: 4 } + }); + }); + }); + + describe('tuple2Object', () => { + it('should convert an array of tuples to an object', () => { + const input = [ + ['a', 1], + ['b', 2] + ] as [string, any][]; + const result = tuple2Object(input); + expect(result).toEqual({ a: 1, b: 2 }); + }); + }); + + describe('hasStacExtension', () => { + it('should return true if the extension exists', () => { + const data = { + stac_extensions: [ + 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json' + ] + }; + const result = hasStacExtension(data, 'item-assets'); + expect(result).toBe(true); + }); + + it('should return false if the extension does not exist', () => { + const data = { + stac_extensions: [ + 'https://stac-extensions.github.io/eo/v1.0.0/schema.json' + ] + }; + const result = hasStacExtension(data, 'item-assets'); + expect(result).toBe(false); + }); + + it('should return true if the extension exists and version matches', () => { + const data = { + stac_extensions: [ + 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json' + ] + }; + const result = hasStacExtension( + data, + 'item-assets', + (v) => v === '1.0.0' + ); + expect(result).toBe(true); + }); + + it('should return false if the extension exists but version does not match', () => { + const data = { + stac_extensions: [ + 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json' + ] + }; + const result = hasStacExtension( + data, + 'item-assets', + (v) => v === '2.0.0' + ); + expect(result).toBe(false); + }); + }); + + describe('addStacExtensionOption', () => { + it('should add a new STAC extension option to the schema', () => { + const mockPlugin = { + registerHook: jest.fn() + } as unknown as Plugin; + + const label = 'New Extension'; + const value = 'https://example.com/new-extension/v1.0.0/schema.json'; + + addStacExtensionOption(mockPlugin, label, value); + + expect(mockPlugin.registerHook).toHaveBeenCalledWith( + expect.any(String), + 'onAfterEditSchema', + expect.any(Function) + ); + + // @ts-expect-error mock doesn't exist because ot os cast as Plugin + const hookCallback = mockPlugin.registerHook.mock.calls[0][2]; + const schema = { + properties: { + stac_extensions: { + items: { + enum: [] + } + } + } + }; + + const result = hookCallback(null, null, schema); + + expect(result.properties.stac_extensions.items.enum).toContainEqual([ + value, + label + ]); + }); + + it('should return the schema unchanged if it is invalid', () => { + const mockPlugin = { + registerHook: jest.fn() + } as unknown as Plugin; + + const label = 'New Extension'; + const value = 'https://example.com/new-extension/v1.0.0/schema.json'; + + addStacExtensionOption(mockPlugin, label, value); + + // @ts-expect-error mock doesn't exist because ot os cast as Plugin + const hookCallback = mockPlugin.registerHook.mock.calls[0][2]; + + const invalidSchema = null; + const result = hookCallback(null, null, invalidSchema); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/data-plugins/lib/utils.ts b/packages/data-plugins/lib/utils.ts index 8a59eca..3b0eb44 100644 --- a/packages/data-plugins/lib/utils.ts +++ b/packages/data-plugins/lib/utils.ts @@ -27,7 +27,7 @@ export function fieldIf( } : ifFalse ? { - [id]: ifTrue + [id]: ifFalse } : {} ) as Record; @@ -171,7 +171,7 @@ export function array2Object( * console.log(result); // { a: 1, b: 2, c: 3 } * ``` */ -export function tuple2Object(stack: string[][]) { +export function tuple2Object(stack: [string, any][]) { return (stack || []).reduce((acc, [key, item]) => { return { ...acc, [key]: item }; }, {}); diff --git a/packages/data-widgets/lib/components/__snapshots__/array-fieldset.elements.test.tsx.snap b/packages/data-widgets/lib/components/__snapshots__/array-fieldset.elements.test.tsx.snap new file mode 100644 index 0000000..4554f5f --- /dev/null +++ b/packages/data-widgets/lib/components/__snapshots__/array-fieldset.elements.test.tsx.snap @@ -0,0 +1,275 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ArrayFieldset renders layout correctly 1`] = ` +
+
+
+ Child Content +
+
+
+`; + +exports[`ArrayFieldset renders layout correctly 2`] = ` +
+
+
+ + Test Label + +
+
+
+
+ Child Content +
+
+
+`; + +exports[`ArrayFieldset renders layout correctly 3`] = ` +
+
+
+ + Test Label + +
+
+
+
+ Child Content +
+
+
+ +
+
+`; + +exports[`ArrayFieldset renders layout correctly 4`] = ` +
+
+
+ + Test Label + +
+
+ +
+
+
+
+ Child Content +
+
+
+ +
+
+`; + +exports[`ArrayFieldset renders layout correctly 5`] = ` +
+
+
+ + Test Label + + +
+
+ +
+
+
+
+ Child Content +
+
+
+ +
+
+`; diff --git a/packages/data-widgets/lib/components/array-fieldset.elements.test.tsx b/packages/data-widgets/lib/components/array-fieldset.elements.test.tsx new file mode 100644 index 0000000..869c19a --- /dev/null +++ b/packages/data-widgets/lib/components/array-fieldset.elements.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import { ArrayFieldset } from './elements'; + +// Custom renderer to add context or providers if needed +const renderWithProviders = ( + ui: React.ReactNode, + { renderOptions = {} } = {} +) => { + // You can wrap the component with any context providers here + return render(ui, { + wrapper: ({ children }) => {children}, + ...renderOptions + }); +}; + +describe('ArrayFieldset', () => { + it('renders the label and children correctly', () => { + renderWithProviders( + +
Child Content
+
+ ); + + expect(screen.getByText('Test Label')).toBeInTheDocument(); + expect(screen.getByText('*')).toBeInTheDocument(); + expect(screen.getByText('Child Content')).toBeInTheDocument(); + }); + + it('calls onRemove when the remove button is clicked', () => { + const onRemove = jest.fn(); + renderWithProviders( + +
Child Content
+
+ ); + + const removeButton = screen.getByLabelText('Remove item'); + fireEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('disables the remove button when removeDisabled is true', () => { + renderWithProviders( + {}} removeDisabled> +
Child Content
+
+ ); + + const removeButton = screen.getByLabelText('Remove item'); + expect(removeButton).toBeDisabled(); + }); + + it('calls onAdd when the add button is clicked', () => { + const onAdd = jest.fn(); + renderWithProviders( + +
Child Content
+
+ ); + + const addButton = screen.getByLabelText('Add item'); + fireEvent.click(addButton); + + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('disables the add button when addDisabled is true', () => { + renderWithProviders( + {}} addDisabled> +
Child Content
+
+ ); + + const addButton = screen.getByLabelText('Add item'); + expect(addButton).toBeDisabled(); + }); + + it('does not render the remove button when onRemove is not provided', () => { + renderWithProviders( + +
Child Content
+
+ ); + + expect(screen.queryByLabelText('Remove item')).not.toBeInTheDocument(); + }); + + it('does not render the add button when onAdd is not provided', () => { + renderWithProviders( + +
Child Content
+
+ ); + + expect(screen.queryByLabelText('Add item')).not.toBeInTheDocument(); + }); + + it('renders layout correctly', () => { + const { rerender, container } = renderWithProviders( + +
Child Content
+
+ ); + const fieldset = container.querySelector('fieldset'); + expect(fieldset).toMatchSnapshot(); + + rerender( + +
Child Content
+
+ ); + expect(fieldset).toMatchSnapshot(); + + rerender( + {}}> +
Child Content
+
+ ); + expect(fieldset).toMatchSnapshot(); + + rerender( + {}} onRemove={() => {}}> +
Child Content
+
+ ); + expect(fieldset).toMatchSnapshot(); + + rerender( + {}} + onRemove={() => {}} + isRequired + > +
Child Content
+
+ ); + expect(fieldset).toMatchSnapshot(); + }); +}); diff --git a/packages/data-widgets/lib/components/elements.tsx b/packages/data-widgets/lib/components/elements.tsx index d69f63d..6f8a2f4 100644 --- a/packages/data-widgets/lib/components/elements.tsx +++ b/packages/data-widgets/lib/components/elements.tsx @@ -95,7 +95,7 @@ export const FieldsetDeleteBtn = forwardRef( ); interface ArrayFieldsetProps { - label: React.ReactNode; + label?: React.ReactNode; isRequired?: boolean; children: React.ReactNode; onRemove?: () => void; diff --git a/packages/data-widgets/lib/components/object-property.test.tsx b/packages/data-widgets/lib/components/object-property.test.tsx new file mode 100644 index 0000000..0f28940 --- /dev/null +++ b/packages/data-widgets/lib/components/object-property.test.tsx @@ -0,0 +1,102 @@ +import { + inferFieldType, + getFieldSchema, + replaceObjectKeyAt +} from './object-property'; + +describe('Utility Functions', () => { + describe('inferFieldType', () => { + it('infers "number" for numeric values', () => { + expect(inferFieldType(42)).toBe('number'); + }); + + it('infers "string[]" for arrays of strings', () => { + expect(inferFieldType(['a', 'b', 'c'])).toBe('string[]'); + }); + + it('infers "number[]" for arrays of numbers', () => { + expect(inferFieldType([1, 2, 3])).toBe('number[]'); + }); + + it('infers "json" for arrays with mixed types', () => { + expect(inferFieldType([1, 'a', true])).toBe('json'); + }); + + it('infers "json" for objects', () => { + expect(inferFieldType({ key: 'value' })).toBe('json'); + }); + + it('infers "string" for other types', () => { + expect(inferFieldType('hello')).toBe('string'); + }); + }); + + describe('getFieldSchema', () => { + it('returns schema for "string"', () => { + expect(getFieldSchema('string')).toEqual({ + type: 'string', + label: 'Value' + }); + }); + + it('returns schema for "number"', () => { + expect(getFieldSchema('number')).toEqual({ + type: 'number', + label: 'Value' + }); + }); + + it('returns schema for "string[]"', () => { + expect(getFieldSchema('string[]')).toEqual({ + type: 'array', + label: 'Value', + minItems: 1, + items: { type: 'string' } + }); + }); + + it('returns schema for "number[]"', () => { + expect(getFieldSchema('number[]')).toEqual({ + type: 'array', + label: 'Value', + minItems: 1, + items: { type: 'number' } + }); + }); + + it('returns schema for "json"', () => { + expect(getFieldSchema('json')).toEqual({ type: 'json', label: 'Value' }); + }); + + it('returns null for unknown types', () => { + expect(getFieldSchema('unknown' as any)).toBeNull(); + }); + }); + + describe('replaceObjectKeyAt', () => { + it('replaces a key at the root level', () => { + const obj = { oldKey: 'value' }; + const result = replaceObjectKeyAt(obj, 'oldKey', 'newKey'); + expect(result).toEqual({ newKey: 'value' }); + }); + + it('replaces a key at a nested path', () => { + const obj = { nested: { oldKey: 'value' } }; + const result = replaceObjectKeyAt(obj, 'nested.oldKey', 'newKey'); + expect(result).toEqual({ nested: { newKey: 'value' } }); + }); + + it('preserves other keys in the object', () => { + const obj = { oldKey: 'value', anotherKey: 'anotherValue' }; + const result = replaceObjectKeyAt(obj, 'oldKey', 'newKey'); + expect(result).toEqual({ newKey: 'value', anotherKey: 'anotherValue' }); + }); + + it('does not mutate the original object', () => { + const obj = { oldKey: 'value' }; + const result = replaceObjectKeyAt(obj, 'oldKey', 'newKey'); + expect(obj).toEqual({ oldKey: 'value' }); + expect(result).not.toBe(obj); + }); + }); +}); diff --git a/packages/data-widgets/lib/components/object-property.tsx b/packages/data-widgets/lib/components/object-property.tsx index b2a10be..3608937 100644 --- a/packages/data-widgets/lib/components/object-property.tsx +++ b/packages/data-widgets/lib/components/object-property.tsx @@ -22,12 +22,7 @@ import { } from '@chakra-ui/react'; import { CollecticonTag } from '@devseed-ui/collecticons-chakra'; import { useFormikContext } from 'formik'; -import get from 'lodash-es/get'; -import set from 'lodash-es/set'; -import unset from 'lodash-es/unset'; -import mapKeys from 'lodash-es/mapKeys'; -import toPath from 'lodash-es/toPath'; -import cloneDeep from 'lodash-es/cloneDeep'; +import { get, set, unset, mapKeys, toPath, cloneDeep } from 'lodash-es'; const fieldTypes = [ { value: 'string', label: 'String' }, @@ -52,22 +47,16 @@ type FieldTypes = (typeof fieldTypes)[number]['value']; * - 'json' for arrays with mixed types or objects. * - 'string' for all other types. */ -const inferFieldType = (value: any): FieldTypes => { +export const inferFieldType = (value: any): FieldTypes => { if (typeof value === 'number') { return 'number'; } - // if (typeof value === 'boolean') { - // return 'boolean'; - // } - if (Array.isArray(value)) { if (value.every((v) => typeof v === 'number')) { return 'number[]'; } - // if (value.every((v) => typeof v === 'boolean')) { - // return 'boolean[]'; - // } + if (value.every((v) => typeof v === 'string')) { return 'string[]'; } @@ -88,7 +77,7 @@ const inferFieldType = (value: any): FieldTypes => { * 'number[]', or 'json'. * @returns A SchemaField object if the type is recognized, otherwise null. */ -const getFieldSchema = (type: FieldTypes): SchemaField | null => { +export const getFieldSchema = (type: FieldTypes): SchemaField | null => { if (type === 'string') { return { type: 'string', @@ -134,7 +123,7 @@ const getFieldSchema = (type: FieldTypes): SchemaField | null => { * @param newKey - The new key that will replace the old key. * @returns A new object with the key replaced at the specified path. */ -const replaceObjectKeyAt = (obj: any, path: string, newKey: string) => { +export const replaceObjectKeyAt = (obj: any, path: string, newKey: string) => { const parts = toPath(path); const last = parts.pop()!; const isRoot = !parts.length; diff --git a/packages/data-widgets/lib/utils/__snapshots__/utils.test.ts.snap b/packages/data-widgets/lib/utils/__snapshots__/utils.test.ts.snap new file mode 100644 index 0000000..defdfd5 --- /dev/null +++ b/packages/data-widgets/lib/utils/__snapshots__/utils.test.ts.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getArrayLabel should return Item if label is undefined 1`] = ` + + + Item + + + + 01 + + +`; + +exports[`getArrayLabel should return a label with a number suffix for string labels 1`] = ` + + + Label + + + + 10 + + +`; diff --git a/packages/data-widgets/lib/utils/utils.test.ts b/packages/data-widgets/lib/utils/utils.test.ts new file mode 100644 index 0000000..795a18b --- /dev/null +++ b/packages/data-widgets/lib/utils/utils.test.ts @@ -0,0 +1,40 @@ +import { getArrayLabel } from './index'; +import { SchemaField } from '@stac-manager/data-core'; + +describe('getArrayLabel', () => { + it('should return a label with a number suffix for string labels', () => { + const field: SchemaField = { label: 'Label' } as any; + const result = getArrayLabel(field, 9); + + expect(result).toEqual({ + label: 'Label', + num: 10, // 1-based index + formatted: expect.anything() + }); + expect(result?.formatted).toMatchSnapshot(); + }); + + it('should cycle through array labels based on index', () => { + const field: SchemaField = { label: ['One', 'Two', 'Three'] } as any; + expect(getArrayLabel(field, 0)?.label).toBe('One'); + expect(getArrayLabel(field, 3)?.label).toBe('One'); + expect(getArrayLabel(field, 4)?.label).toBe('Two'); + }); + + it('should return null if label is null', () => { + const field: SchemaField = { label: null } as any; + expect(getArrayLabel(field, 0)).toBeNull(); + }); + + it('should return Item if label is undefined', () => { + const field: SchemaField = { label: undefined } as any; + const result = getArrayLabel(field, 0); + + expect(result).toEqual({ + label: 'Item', + num: 1, + formatted: expect.anything() + }); + expect(result?.formatted).toMatchSnapshot(); + }); +});