From 29fa6305d1709664c7dace1bcaaa1b5c739f0f53 Mon Sep 17 00:00:00 2001 From: Alex Simonok Date: Thu, 6 Jul 2023 11:10:28 +0300 Subject: [PATCH 1/8] Add tests for FieldsEditor --- .../FieldsEditor/FieldsEditor.test.tsx | 243 +++++++++++++++++- src/components/FieldsEditor/FieldsEditor.tsx | 226 ++++++++++------ src/constants/tests.ts | 4 + 3 files changed, 391 insertions(+), 82 deletions(-) diff --git a/src/components/FieldsEditor/FieldsEditor.test.tsx b/src/components/FieldsEditor/FieldsEditor.test.tsx index de28fc5..b0316b2 100644 --- a/src/components/FieldsEditor/FieldsEditor.test.tsx +++ b/src/components/FieldsEditor/FieldsEditor.test.tsx @@ -1,21 +1,252 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { FieldType } from '@grafana/data'; +import { render, screen, within, fireEvent } from '@testing-library/react'; import { TestIds } from '../../constants'; import { FieldsEditor } from './FieldsEditor'; +/** + * Mock @grafana/ui + */ +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + /** + * Mock Select component + */ + Select: jest.fn().mockImplementation(({ options, onChange, value, ...restProps }) => ( + + )), +})); + +type Props = React.ComponentProps; + /** * Editor */ describe('Editor', () => { - const model = { fields: [] }; + const model = { fields: [], rows: [] }; + const onChange = jest.fn(); + const onRunQuery = jest.fn(); - it('Should find component with Button', async () => { - const getComponent = ({ query = {}, ...restProps }: any) => { - return ; - }; + /** + * Get Tested Component + * @param query + * @param restProps + */ + const getComponent = ({ query, ...restProps }: Partial) => { + return ; + }; + + beforeEach(() => { + onChange.mockClear(); + onRunQuery.mockClear(); + }); + it('Should add field if there is no any fields', async () => { render(getComponent({ model })); expect(screen.getByTestId(TestIds.fieldsEditor.buttonAdd)).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId(TestIds.fieldsEditor.buttonAdd)); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + frame: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + type: FieldType.string, + name: 'Field 1', + }), + ]), + }), + }) + ); + }); + + it('Should render fields with values', () => { + const field1 = { + name: 'name', + type: FieldType.string, + }; + const field2 = { + name: 'amount', + type: FieldType.number, + }; + render( + getComponent({ + model: { + fields: [field1, field2] as any, + rows: [], + }, + }) + ); + + const items = screen.getAllByTestId(TestIds.fieldsEditor.item); + + /** + * Check name + */ + expect(items[0]).toBeInTheDocument(); + + const item1Selectors = within(items[0]); + + expect(item1Selectors.getByTestId(TestIds.fieldsEditor.fieldName)).toHaveValue(field1.name); + expect(item1Selectors.getByLabelText(TestIds.fieldsEditor.fieldType)).toHaveValue(field1.type); + + /** + * Check amount + */ + expect(items[1]).toBeInTheDocument(); + + const item2Selectors = within(items[1]); + + expect(item2Selectors.getByTestId(TestIds.fieldsEditor.fieldName)).toHaveValue(field2.name); + expect(item2Selectors.getByLabelText(TestIds.fieldsEditor.fieldType)).toHaveValue(field2.type); + }); + + it('Should change name', () => { + const field1 = { + name: 'name', + type: FieldType.string, + }; + const field2 = { + name: 'amount', + type: FieldType.number, + }; + render( + getComponent({ + model: { + fields: [field1, field2] as any, + rows: [], + }, + }) + ); + + const items = screen.getAllByTestId(TestIds.fieldsEditor.item); + expect(items[0]).toBeInTheDocument(); + + const item1Selectors = within(items[0]); + + fireEvent.change(item1Selectors.getByTestId(TestIds.fieldsEditor.fieldName), { target: { value: 'hello' } }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + frame: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: 'hello', + }), + ]), + }), + }) + ); + }); + + it('Should change type', () => { + const field1 = { + name: 'name', + type: FieldType.string, + }; + const field2 = { + name: 'amount', + type: FieldType.number, + }; + render( + getComponent({ + model: { + fields: [field1, field2] as any, + rows: [], + }, + }) + ); + + const items = screen.getAllByTestId(TestIds.fieldsEditor.item); + expect(items[0]).toBeInTheDocument(); + + const item1Selectors = within(items[0]); + + fireEvent.change(item1Selectors.getByLabelText(TestIds.fieldsEditor.fieldType), { + target: { value: FieldType.geo }, + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + frame: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + type: FieldType.geo, + }), + ]), + }), + }) + ); + }); + + it('Should add field', () => { + const field1 = { + name: 'name', + type: FieldType.string, + }; + render( + getComponent({ + model: { + fields: [field1] as any, + rows: [['some data']], + }, + }) + ); + + fireEvent.click(screen.getByTestId(TestIds.fieldsEditor.buttonAdd)); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + frame: expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + type: FieldType.string, + name: 'Field 2', + }), + ]), + }), + }) + ); + }); + + it('Should remove field', () => { + const field1 = { + name: 'name', + type: FieldType.string, + }; + render( + getComponent({ + model: { + fields: [field1] as any, + rows: [['some data']], + }, + }) + ); + + fireEvent.click(screen.getByTestId(TestIds.fieldsEditor.buttonRemove)); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + frame: expect.objectContaining({ + fields: [], + }), + }) + ); }); }); diff --git a/src/components/FieldsEditor/FieldsEditor.tsx b/src/components/FieldsEditor/FieldsEditor.tsx index 22a9c7e..ab5ce3b 100644 --- a/src/components/FieldsEditor/FieldsEditor.tsx +++ b/src/components/FieldsEditor/FieldsEditor.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Field, FieldType } from '@grafana/data'; import { Button, InlineField, InlineFieldRow, Input, Select } from '@grafana/ui'; import { FieldTypes, TestIds } from '../../constants'; @@ -41,90 +41,150 @@ export const FieldsEditor = ({ query, model, onChange, onRunQuery }: Props) => { /** * Add Field */ - const addField = (index: number) => { - /** - * Insert a field after the current position. - */ - model.fields.splice(index + 1, 0, { - name: '', - type: FieldType.string, - } as Field); - - /** - * Rebuild rows with the added field. - */ - model.rows.forEach((row: any) => { - row.splice(index + 1, 0, ''); - }); - - /** - * Change - */ - onChange({ ...query, frame: convertToDataFrame(model) }); - onRunQuery(); - }; + const addField = useCallback( + (index: number) => { + /** + * Create another object to prevent mutations + */ + const updatedModel = { + ...model, + fields: [...model.fields], + rows: [...model.rows], + }; + + /** + * Insert a field after the current position. + */ + updatedModel.fields.splice(index + 1, 0, { + name: '', + type: FieldType.string, + } as Field); + + /** + * Rebuild rows with the added field. + */ + updatedModel.rows.forEach((row: any) => { + row.splice(index + 1, 0, ''); + }); + + /** + * Change + */ + onChange({ ...query, frame: convertToDataFrame(updatedModel) }); + onRunQuery(); + }, + [model, onChange, onRunQuery, query] + ); /** * Remove Field */ - const removeField = (index: number) => { - /** - * Remove the field at given position. - */ - model.fields.splice(index, 1); - - /** - * Rebuild rows without the removed field. - */ - model.rows.forEach((row) => { - row.splice(index, 1); - }); - - /** - * Remove all rows if there are no fields. - */ - if (!model.fields.length) { - model.rows = []; - } - - /** - * Change - */ - onChange({ ...query, frame: convertToDataFrame(model) }); - onRunQuery(); - }; + const removeField = useCallback( + (index: number) => { + /** + * Create another object to prevent mutations + */ + const updatedModel = { + ...model, + fields: [...model.fields], + rows: [...model.rows], + }; + + /** + * Remove the field at given position. + */ + updatedModel.fields.splice(index, 1); + + /** + * Rebuild rows without the removed field. + */ + updatedModel.rows.forEach((row) => { + row.splice(index, 1); + }); + + /** + * Remove all rows if there are no fields. + */ + if (!updatedModel.fields.length) { + updatedModel.rows = []; + } + + /** + * Change + */ + onChange({ ...query, frame: convertToDataFrame(updatedModel) }); + onRunQuery(); + }, + [model, onChange, onRunQuery, query] + ); /** * Rename Field */ - const renameField = (name: string, index: number) => { - /** - * Rename - */ - model.fields[index].name = name; - - /** - * Change - */ - onChange({ ...query, frame: convertToDataFrame(model) }); - onRunQuery(); - }; + const renameField = useCallback( + (name: string, updatedIndex: number) => { + /** + * Create another object to prevent mutations + */ + const updatedModel = { + ...model, + fields: [...model.fields], + }; + + /** + * Rename + */ + updatedModel.fields = updatedModel.fields.map((field, index) => + index === updatedIndex + ? { + ...field, + name, + } + : field + ); + + /** + * Change + */ + onChange({ ...query, frame: convertToDataFrame(updatedModel) }); + onRunQuery(); + }, + [model, onChange, onRunQuery, query] + ); /** * Change Field Type */ - const changeFieldType = (fieldType: FieldType, index: number) => { - /** - * Set Field Type - */ - model.fields[index].type = fieldType; - - /** - * Change - */ - onChange({ ...query, frame: convertToDataFrame(model) }); - onRunQuery(); - }; + const changeFieldType = useCallback( + (fieldType: FieldType, updatedIndex: number) => { + /** + * Create another object to prevent mutations + */ + const updatedModel = { + ...model, + fields: [...model.fields], + }; + + /** + * Set Field Type + */ + updatedModel.fields = updatedModel.fields.map((field, index) => + index === updatedIndex + ? { + ...field, + type: fieldType, + } + : field + ); + + /** + * Change + */ + onChange({ ...query, frame: convertToDataFrame(updatedModel) }); + onRunQuery(); + }, + [model, onChange, onRunQuery, query] + ); /** * No rows found @@ -150,13 +210,14 @@ export const FieldsEditor = ({ query, model, onChange, onRunQuery }: Props) => { return ( <> {model.fields.map((field, i) => ( - + { renameField(e.currentTarget.value, i); }} + data-testid={TestIds.fieldsEditor.fieldName} /> @@ -171,15 +232,28 @@ export const FieldsEditor = ({ query, model, onChange, onRunQuery }: Props) => { label: t[0].toUpperCase() + t.substring(1), value: t, }))} + aria-label={TestIds.fieldsEditor.fieldType} /> - + + @@ -145,7 +184,7 @@ export const ValuesEditor = ({ model, query, onChange, onRunQuery }: Props) => { return ( <> {model.rows.map((row, i) => ( - + {row.map((value: NullableString, index: number) => ( { ))} -