diff --git a/hivemq-edge/src/frontend/src/__test-utils__/rjsf/rjsf.mocks.tsx b/hivemq-edge/src/frontend/src/__test-utils__/rjsf/rjsf.mocks.tsx new file mode 100644 index 0000000000..e47acc716c --- /dev/null +++ b/hivemq-edge/src/frontend/src/__test-utils__/rjsf/rjsf.mocks.tsx @@ -0,0 +1,46 @@ +import { FC, FormEvent } from 'react' +import validator from '@rjsf/validator-ajv8' +import { RJSFSchema, UiSchema, RJSFValidationError, RegistryFieldsType } from '@rjsf/utils' +import Form from '@rjsf/chakra-ui' +import { IChangeEvent } from '@rjsf/core' + +import { ObjectFieldTemplate } from '@/components/rjsf/ObjectFieldTemplate.tsx' +import { FieldTemplate } from '@/components/rjsf/FieldTemplate.tsx' +import { BaseInputTemplate } from '@/components/rjsf/BaseInputTemplate.tsx' +import { ArrayFieldTemplate } from '@/components/rjsf/ArrayFieldTemplate.tsx' +import { ArrayFieldItemTemplate } from '@/components/rjsf/ArrayFieldItemTemplate.tsx' + +interface RjsfMocksProps { + schema: RJSFSchema + uiSchema?: UiSchema | undefined + onSubmit?: (data: IChangeEvent, event: FormEvent) => void + onError?: (errors: RJSFValidationError[]) => void + formData?: unknown + fields?: RegistryFieldsType +} + +const RjsfMocks: FC = ({ schema, uiSchema, formData, fields, onSubmit, onError }) => { + return ( +
+ ) +} + +export default RjsfMocks diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Fields/CompactArrayField.spec.cy.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Fields/CompactArrayField.spec.cy.tsx new file mode 100644 index 0000000000..d79a6ea8fa --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Fields/CompactArrayField.spec.cy.tsx @@ -0,0 +1,124 @@ +/// +import { RJSFSchema, UiSchema } from '@rjsf/utils' + +import RjsfMocks from '@/__test-utils__/rjsf/rjsf.mocks.tsx' +import { adapterJSFFields } from '@/modules/ProtocolAdapters/utils/uiSchema.utils.ts' + +const MOCK_SCHEMA: RJSFSchema = { + title: 'User Properties', + description: 'Arbitrary properties to associate with the subscription', + maxItems: 3, + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Property Name', + description: 'Name of the associated property', + }, + value: { + type: 'string', + title: 'Property Value', + description: 'Value of the associated property', + }, + }, + required: ['name', 'value'], + }, +} + +const MOCK_UI_SCHEMA: UiSchema = { + 'ui:field': 'compactTable', +} + +const MOCK_DATA = [ + { + name: 'name1', + value: '1', + }, + { + name: 'name2', + value: 'value', + }, +] + +describe('CompactArrayField', () => { + beforeEach(() => { + cy.viewport(800, 900) + }) + + it('should render the compact table ', () => { + const onSubmit = cy.stub().as('onSubmit') + cy.mountWithProviders( + + ) + + cy.get('[role="group"] h5').should('contain.text', 'User Properties') + cy.get('[role="group"] p').should('contain.text', 'Arbitrary properties to associate with the subscription') + cy.get('table thead tr th').should('have.length', 3) + cy.get('table thead tr th').eq(0).should('contain.text', 'Property Name') + cy.get('table thead tr th').eq(1).should('contain.text', 'Property Value') + cy.get('table thead tr th').eq(2).should('contain.text', 'Actions') + + cy.get('table tbody tr').should('have.length', 2) + cy.get('table tbody td input').should('have.length', 4) + cy.get('table tbody td input').eq(0).should('have.value', 'name1') + cy.get('table tbody td input').eq(1).should('have.value', '1') + cy.get('table tbody td input').eq(2).should('have.value', 'name2') + cy.get('table tbody td input').eq(3).should('have.value', 'value') + + cy.get("button[type='submit']").click() + cy.get('@onSubmit').should( + 'have.been.calledWith', + Cypress.sinon.match({ + formData: MOCK_DATA, + }) + ) + }) + + it('should add and remove items', () => { + cy.mountWithProviders( + + ) + + cy.get('table tbody tr').should('have.length', 2) + cy.getByTestId('compact-add-item').click() + cy.get('table tbody tr').should('have.length', 3) + cy.get('table tbody td input') + .eq(4) + .should('have.value', '') + .should('have.attr', 'required', 'required') + .should('have.attr', 'aria-invalid', 'true') + cy.get('[role="alert"] ul li').should('have.length', 2) + + cy.getByTestId('compact-delete-item').eq(2).click() + cy.get('table tbody tr').should('have.length', 2) + }) + + it('should be accessible ', () => { + cy.injectAxe() + + cy.mountWithProviders( + console.log(e)} + fields={adapterJSFFields} + /> + ) + + cy.checkAccessibility(undefined, { + rules: { + // h5 used for sections is not in order. Not detected on other tests + 'heading-order': { enabled: false }, + }, + }) + }) +}) diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Fields/CompactArrayField.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Fields/CompactArrayField.tsx new file mode 100644 index 0000000000..8580675bd0 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Fields/CompactArrayField.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react' +import { FieldProps } from '@rjsf/utils' +import { RJSFSchema } from '@rjsf/utils/src/types.ts' + +import { AdapterContext } from '@/modules/ProtocolAdapters/types.ts' +import { CompactArrayFieldTemplate } from '@/components/rjsf/Templates/CompactArrayFieldTemplate.tsx' +import { CompactFieldTemplate } from '@/components/rjsf/Templates/CompactFieldTemplate.tsx' +import { CompactBaseInputTemplate } from '@/components/rjsf/Templates/CompactBaseInputTemplate.tsx' +import { CompactObjectFieldTemplate } from '@/components/rjsf/Templates/CompactObjectFieldTemplate.tsx' +import { CompactArrayFieldItemTemplate } from '@/components/rjsf/Templates/CompactArrayFieldItemTemplate.tsx' + +const CompactArrayField: FC> = (props) => { + const { registry } = props + + registry.templates = { + ...registry.templates, + ArrayFieldTemplate: CompactArrayFieldTemplate, + ArrayFieldItemTemplate: CompactArrayFieldItemTemplate, + FieldTemplate: CompactFieldTemplate, + BaseInputTemplate: CompactBaseInputTemplate, + ObjectFieldTemplate: CompactObjectFieldTemplate, + } + + return +} + +export default CompactArrayField diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Fields/types.ts b/hivemq-edge/src/frontend/src/components/rjsf/Fields/types.ts new file mode 100644 index 0000000000..0605c1b238 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Fields/types.ts @@ -0,0 +1,18 @@ +import { JSONSchema7 } from 'json-schema' +import { Dispatch, SetStateAction } from 'react' + +export type CustomPropertyValue = string +export type FormDataItem = Record +export type CustomPropertyForm = FormDataItem[] + +export interface DataGridProps { + data: CustomPropertyForm + columnTypes: [string, JSONSchema7][] + isDisabled?: boolean + required?: string[] + maxItems?: number + onHandleDeleteItem?: (index: number) => void + onHandleAddItem?: () => void + onUpdateData?: (rowIndex: number, columnId: string, value: CustomPropertyValue) => void + onSetData?: Dispatch> +} diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactArrayFieldItemTemplate.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactArrayFieldItemTemplate.tsx new file mode 100644 index 0000000000..e202a3e951 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactArrayFieldItemTemplate.tsx @@ -0,0 +1,73 @@ +import { FC, useMemo } from 'react' +import { ArrayFieldTemplateItemType } from '@rjsf/utils' +import { ButtonGroup, Td, Tr } from '@chakra-ui/react' +import { CopyButton, MoveDownButton, MoveUpButton, RemoveButton } from '@/components/rjsf/__internals/IconButton.tsx' + +export const CompactArrayFieldItemTemplate: FC = (props) => { + const { + children, + index, + hasMoveUp, + hasRemove, + hasMoveDown, + hasCopy, + disabled, + readonly, + onCopyIndexClick, + onDropIndexClick, + onReorderClick, + uiSchema, + registry, + } = props + + const onCopyClick = useMemo(() => onCopyIndexClick(index), [index, onCopyIndexClick]) + const onRemoveClick = useMemo(() => onDropIndexClick(index), [index, onDropIndexClick]) + const onArrowUpClick = useMemo(() => onReorderClick(index, index - 1), [index, onReorderClick]) + const onArrowDownClick = useMemo(() => onReorderClick(index, index + 1), [index, onReorderClick]) + + return ( + + {children} + + + {(hasMoveUp || hasMoveDown) && ( + + )} + {(hasMoveUp || hasMoveDown) && ( + + )} + {hasCopy && ( + + )} + {hasRemove && ( + + )} + + + + ) +} diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactArrayFieldTemplate.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactArrayFieldTemplate.tsx new file mode 100644 index 0000000000..a432a497b9 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactArrayFieldTemplate.tsx @@ -0,0 +1,76 @@ +import { FC } from 'react' +import { JSONSchema7 } from 'json-schema' +import { RJSFSchema, ArrayFieldTemplateProps, getTemplate, getUiOptions, ArrayFieldTemplateItemType } from '@rjsf/utils' +import { Box, HStack, Table, Tbody, Th, Thead, Tr } from '@chakra-ui/react' + +import AddButton from '@/components/rjsf/__internals/AddButton.tsx' +import { AdapterContext } from '@/modules/ProtocolAdapters/types.ts' + +export const CompactArrayFieldTemplate: FC> = (props) => { + const { canAdd, disabled, idSchema, uiSchema, items, onAddClick, readonly, registry, required, schema, title } = props + const uiOptions = getUiOptions(uiSchema) + const ArrayFieldDescriptionTemplate = getTemplate<'ArrayFieldDescriptionTemplate'>( + 'ArrayFieldDescriptionTemplate', + registry, + uiOptions + ) + const ArrayFieldItemTemplate = getTemplate<'ArrayFieldItemTemplate'>('ArrayFieldItemTemplate', registry, uiOptions) + const ArrayFieldTitleTemplate = getTemplate<'ArrayFieldTitleTemplate'>('ArrayFieldTitleTemplate', registry, uiOptions) + + const { items: SchemaItems } = schema as JSONSchema7 + const { properties } = SchemaItems as JSONSchema7 + + // Better approach to unidentified headers? + if (!properties) return null + + return ( + + + + <> + + + + {Object.entries(properties).map(([key, values]) => { + const { title } = values as JSONSchema7 + return + })} + + + + + {items?.length > 0 && + items?.map(({ key, ...itemProps }: ArrayFieldTemplateItemType) => { + return + })} + +
{title}Actions
+ {canAdd && ( + + + + )} + +
+ ) +} diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactBaseInputTemplate.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactBaseInputTemplate.tsx new file mode 100644 index 0000000000..c910a996c6 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactBaseInputTemplate.tsx @@ -0,0 +1,45 @@ +import { ChangeEvent, FC } from 'react' +import { BaseInputTemplateProps, getInputProps } from '@rjsf/utils' +import { FormControl, Input } from '@chakra-ui/react' + +export const CompactBaseInputTemplate: FC = (props) => { + const { + id, + type, + value, + label, + schema, + onChange, + onChangeOverride, + onBlur, + onFocus, + options, + required, + readonly, + rawErrors, + autofocus, + placeholder, + } = props + const inputProps = getInputProps(schema, type, options) + + const _onChange = ({ target: { value } }: ChangeEvent) => + onChange(value === '' ? options.emptyValue : value) + + return ( + 0}> + onBlur(id, target && target.value)} + onFocus={({ target }) => onFocus(id, target && target.value)} + autoFocus={autofocus} + placeholder={placeholder} + {...inputProps} + aria-label={label} + /> + + ) +} diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactFieldTemplate.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactFieldTemplate.tsx new file mode 100644 index 0000000000..ac70cf05d4 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactFieldTemplate.tsx @@ -0,0 +1,8 @@ +import { FC } from 'react' +import { FieldTemplateProps } from '@rjsf/utils' + +export const CompactFieldTemplate: FC = (props) => { + const { children } = props + + return children +} diff --git a/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactObjectFieldTemplate.tsx b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactObjectFieldTemplate.tsx new file mode 100644 index 0000000000..26e6a719f4 --- /dev/null +++ b/hivemq-edge/src/frontend/src/components/rjsf/Templates/CompactObjectFieldTemplate.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' +import { ObjectFieldTemplateProps, RJSFSchema } from '@rjsf/utils' +import { Td } from '@chakra-ui/react' + +import { AdapterContext } from '@/modules/ProtocolAdapters/types.ts' + +export const CompactObjectFieldTemplate: FC> = ( + props +) => { + const { idSchema, properties } = props + + return ( + <> + {properties.map((element, index) => ( + {element.content} + ))} + + ) +} diff --git a/hivemq-edge/src/frontend/src/locales/en/components.json b/hivemq-edge/src/frontend/src/locales/en/components.json index 11ccdc5f98..f63ab1e7f5 100755 --- a/hivemq-edge/src/frontend/src/locales/en/components.json +++ b/hivemq-edge/src/frontend/src/locales/en/components.json @@ -72,6 +72,16 @@ } }, "rjsf": { + "CompactArrayField": { + "action": { + "add": "Add item", + "delete": "Delete item" + }, + "table": { + "actions": "Actions", + "cell": "value for property {{ column }} on row {{ row }}" + } + }, "ArrayFieldItem": { "Buttons": { "expanded_true": "Collapse Item", diff --git a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx index 874cb6af63..855ae25316 100644 --- a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx +++ b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/components/drawers/AdapterInstanceDrawer.tsx @@ -33,7 +33,11 @@ import { BaseInputTemplate } from '@/components/rjsf/BaseInputTemplate.tsx' import { ArrayFieldTemplate } from '@/components/rjsf/ArrayFieldTemplate.tsx' import { ArrayFieldItemTemplate } from '@/components/rjsf/ArrayFieldItemTemplate.tsx' import { customFormatsValidator, customValidate } from '@/modules/ProtocolAdapters/utils/validation-utils.ts' -import { adapterJSFWidgets, getRequiredUiSchema } from '@/modules/ProtocolAdapters/utils/uiSchema.utils.ts' +import { + adapterJSFFields, + adapterJSFWidgets, + getRequiredUiSchema, +} from '@/modules/ProtocolAdapters/utils/uiSchema.utils.ts' import { AdapterContext } from '@/modules/ProtocolAdapters/types.ts' interface AdapterInstanceDrawerProps { @@ -154,6 +158,7 @@ const AdapterInstanceDrawer: FC = ({ transformErrors={filterUnboundErrors} formContext={context} widgets={adapterJSFWidgets} + fields={adapterJSFFields} /> )} diff --git a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts index a6a1d101d0..b1852cfbd3 100644 --- a/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts +++ b/hivemq-edge/src/frontend/src/modules/ProtocolAdapters/utils/uiSchema.utils.ts @@ -1,4 +1,5 @@ -import { RegistryWidgetsType, UiSchema } from '@rjsf/utils' +import { RegistryFieldsType, RegistryWidgetsType, UiSchema } from '@rjsf/utils' +import CompactArrayField from '@/components/rjsf/Fields/CompactArrayField.tsx' export const getRequiredUiSchema = (uiSchema: UiSchema | undefined, isNewAdapter: boolean): UiSchema => { const { ['ui:submitButtonOptions']: submitButtonOptions, id, ...rest } = uiSchema || {} @@ -21,3 +22,5 @@ export const adapterJSFWidgets: RegistryWidgetsType = { // @ts-ignore [24369] Turn discovery browser off (and replace by regular text input) 'discovery:tagBrowser': 'text', } + +export const adapterJSFFields: RegistryFieldsType = { compactTable: CompactArrayField } diff --git a/hivemq-edge/src/main/resources/simulation-adapter-ui-schema.json b/hivemq-edge/src/main/resources/simulation-adapter-ui-schema.json index f978f58417..a5ea263e03 100644 --- a/hivemq-edge/src/main/resources/simulation-adapter-ui-schema.json +++ b/hivemq-edge/src/main/resources/simulation-adapter-ui-schema.json @@ -31,6 +31,9 @@ "ui:order": [ "destination", "qos", "*"], "ui:collapsable": { "titleKey": "destination" + }, + "userProperties": { + "ui:field": "compactTable" } } } diff --git a/modules/hivemq-edge-module-http/src/main/resources/http-adapter-ui-schema.json b/modules/hivemq-edge-module-http/src/main/resources/http-adapter-ui-schema.json index 9baa8d20ba..e20af738b7 100644 --- a/modules/hivemq-edge-module-http/src/main/resources/http-adapter-ui-schema.json +++ b/modules/hivemq-edge-module-http/src/main/resources/http-adapter-ui-schema.json @@ -40,5 +40,8 @@ "httpRequestBody": { "ui:widget": "textarea" }, + "httpHeaders": { + "ui:field": "compactTable" + }, "ui:order": ["id", "*"] } diff --git a/modules/hivemq-edge-module-modbus/src/main/resources/modbus-adapter-ui-schema.json b/modules/hivemq-edge-module-modbus/src/main/resources/modbus-adapter-ui-schema.json index ca0b4c4f2a..d2e095b2a2 100644 --- a/modules/hivemq-edge-module-modbus/src/main/resources/modbus-adapter-ui-schema.json +++ b/modules/hivemq-edge-module-modbus/src/main/resources/modbus-adapter-ui-schema.json @@ -37,6 +37,9 @@ "startIdx": { "ui:widget": "discovery:tagBrowser" } + }, + "userProperties": { + "ui:field": "compactTable" } } } diff --git a/modules/hivemq-edge-module-opcua/src/main/resources/opcua-adapter-ui-schema.json b/modules/hivemq-edge-module-opcua/src/main/resources/opcua-adapter-ui-schema.json index 2606dd756b..f2e2b1f5e2 100644 --- a/modules/hivemq-edge-module-opcua/src/main/resources/opcua-adapter-ui-schema.json +++ b/modules/hivemq-edge-module-opcua/src/main/resources/opcua-adapter-ui-schema.json @@ -34,6 +34,9 @@ }, "node": { "ui:widget": "discovery:tagBrowser" + }, + "userProperties": { + "ui:field": "compactTable" } } }, diff --git a/modules/hivemq-edge-module-plc4x/src/main/resources/s7-adapter-ui-schema.json b/modules/hivemq-edge-module-plc4x/src/main/resources/s7-adapter-ui-schema.json index c4dd32d8fd..c96a84abff 100644 --- a/modules/hivemq-edge-module-plc4x/src/main/resources/s7-adapter-ui-schema.json +++ b/modules/hivemq-edge-module-plc4x/src/main/resources/s7-adapter-ui-schema.json @@ -45,6 +45,9 @@ "ui:order": ["node", "mqtt-topic", "destination", "qos", "*"], "ui:collapsable": { "titleKey": "destination" + }, + "userProperties": { + "ui:field": "compactTable" } } }