From 4400fb29ba8388fdc3de4625d234df9a27f9b9be Mon Sep 17 00:00:00 2001 From: Tomohisa Igarashi Date: Tue, 30 May 2023 15:27:53 -0400 Subject: [PATCH 1/2] feat(metadata): Metadata configuration UI Fixes: #1722 --- src/api/apiService.ts | 33 ++++ src/components/KaotoToolbar.tsx | 5 + src/components/Visualization.tsx | 2 +- src/components/index.ts | 1 + src/components/metadata/AddPropertyButton.tsx | 174 ++++++++++++++++++ .../metadata/MetadataEditorBridge.tsx | 17 ++ .../metadata/MetadataEditorModal.css | 9 + .../metadata/MetadataEditorModal.stories.tsx | 83 +++++++++ .../metadata/MetadataEditorModal.test.tsx | 107 +++++++++++ .../metadata/MetadataEditorModal.tsx | 158 ++++++++++++++++ .../metadata/MetadataToolbarItems.test.tsx | 23 +++ .../metadata/MetadataToolbarItems.tsx | 63 +++++++ src/components/metadata/PropertiesField.tsx | 162 ++++++++++++++++ src/components/metadata/PropertyRow.tsx | 83 +++++++++ .../metadata/ToopmostArrayTable.tsx | 124 +++++++++++++ src/store/FlowsStore.ts | 9 + 16 files changed, 1052 insertions(+), 1 deletion(-) create mode 100644 src/components/metadata/AddPropertyButton.tsx create mode 100644 src/components/metadata/MetadataEditorBridge.tsx create mode 100644 src/components/metadata/MetadataEditorModal.css create mode 100644 src/components/metadata/MetadataEditorModal.stories.tsx create mode 100644 src/components/metadata/MetadataEditorModal.test.tsx create mode 100644 src/components/metadata/MetadataEditorModal.tsx create mode 100644 src/components/metadata/MetadataToolbarItems.test.tsx create mode 100644 src/components/metadata/MetadataToolbarItems.tsx create mode 100644 src/components/metadata/PropertiesField.tsx create mode 100644 src/components/metadata/PropertyRow.tsx create mode 100644 src/components/metadata/ToopmostArrayTable.tsx diff --git a/src/api/apiService.ts b/src/api/apiService.ts index 81fde2c8b..d362e05a1 100644 --- a/src/api/apiService.ts +++ b/src/api/apiService.ts @@ -238,6 +238,39 @@ export async function fetchIntegrationSourceCode(flowsWrapper: IFlowsWrapper, na } } +/** + * @todo Fetch this from backend + * @param dsl + */ +export async function fetchMetadataSchema(dsl: string): Promise<{ [key: string]: any }> { + if (['Kamelet', 'Camel Route', 'Integration'].includes(dsl)) { + return Promise.resolve({ + beans: { + title: 'Beans', + description: 'Beans Configuration', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + type: { + type: 'string', + }, + properties: { + type: 'object', + }, + }, + required: ['name', 'type'], + }, + }, + }); + } + return Promise.resolve({}); +} + export async function fetchStepDetails(id?: string, namespace?: string) { try { const resp = await RequestService.get({ diff --git a/src/components/KaotoToolbar.tsx b/src/components/KaotoToolbar.tsx index 8283dc9d4..5cf1dcbbe 100644 --- a/src/components/KaotoToolbar.tsx +++ b/src/components/KaotoToolbar.tsx @@ -7,6 +7,7 @@ import { DeploymentsModal } from './DeploymentsModal'; import { ExportCanvasToPng } from './ExportCanvasToPng'; import { FlowsMenu } from './Flows/FlowsMenu'; import { SettingsModal } from './SettingsModal'; +import { MetadataToolbarItems } from './metadata/MetadataToolbarItems'; import { fetchDefaultNamespace, startDeployment } from '@kaoto/api'; import { LOCAL_STORAGE_UI_THEME_KEY, THEME_DARK_CLASS } from '@kaoto/constants'; import { @@ -325,6 +326,10 @@ export const KaotoToolbar = ({ + + + + {/* DEPLOYMENT STATUS */} {deployment.crd ? ( diff --git a/src/components/Visualization.tsx b/src/components/Visualization.tsx index 4b713f70f..78fa073f4 100644 --- a/src/components/Visualization.tsx +++ b/src/components/Visualization.tsx @@ -101,7 +101,7 @@ const Visualization = () => { } else { fetchTheSourceCode({ flows, properties, metadata }, settings); } - }, [flows, properties]); + }, [flows, properties, metadata]); const fetchTheSourceCode = (currentFlowsWrapper: IFlowsWrapper, settings: ISettings) => { const updatedFlowWrapper = { diff --git a/src/components/index.ts b/src/components/index.ts index afc0059f3..382dbb848 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,3 +20,4 @@ export * from './VisualizationControls'; export * from './VisualizationStepViews'; export * from './Visualization'; export * from './VisualizationStep'; +export { MetadataToolbarItems } from './metadata/MetadataToolbarItems'; diff --git a/src/components/metadata/AddPropertyButton.tsx b/src/components/metadata/AddPropertyButton.tsx new file mode 100644 index 000000000..33a596868 --- /dev/null +++ b/src/components/metadata/AddPropertyButton.tsx @@ -0,0 +1,174 @@ +import { + Button, + FormGroup, + HelperText, + HelperTextItem, + Popover, + Radio, + Split, + SplitItem, + Stack, + StackItem, + TextInput, + Title, + Tooltip, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { useState } from 'react'; + +type AddPropertyPopoverProps = { + textLabel?: string; + model: any; + path: string[]; + disabled?: boolean; + onChangeModel: (model: any) => void; +}; + +/** + * Add property button shows a popover to receive user inputs for new property name and type, + * as well as to validate if the property name already exists before actually adding it + * into the model object. + * @param props + * @constructor + */ +export function AddPropertyButton({ + textLabel = '', + model, + path, + disabled = false, + onChangeModel, +}: AddPropertyPopoverProps) { + const [isVisible, doSetVisible] = useState(false); + const [propertyType, setPropertyType] = useState<'string' | 'object'>('string'); + const [propertyName, setPropertyName] = useState(''); + const [propertyValue, setPropertyValue] = useState(''); + + function isReadyToAdd() { + return !!(propertyName && model[propertyName] == null); + } + + function isDuplicate() { + if (!model || !propertyName) { + return false; + } + return model[propertyName] != null; + } + function setVisible(visible: boolean) { + if (!model) { + onChangeModel({}); + } + doSetVisible(visible); + } + + function handleAddProperty() { + if (propertyType === 'object') { + model[propertyName] = {}; + } else { + model[propertyName] = propertyValue; + } + onChangeModel(model); + setPropertyName(''); + setPropertyValue(''); + setPropertyType('string'); + setVisible(false); + } + + return ( + setVisible(true)} + shouldClose={() => setVisible(false)} + bodyContent={ + + + + Name + setPropertyName(value)} + /> + {isDuplicate() && ( + + + Please specify a unique property name + + + )} + + + + + + + + + checked && setPropertyType('string')} + /> + + + checked && setPropertyType('object')} + /> + + + + + + + + + Value + setPropertyValue(value)} + /> + + + + + + + } + > + + + + + ); +} diff --git a/src/components/metadata/MetadataEditorBridge.tsx b/src/components/metadata/MetadataEditorBridge.tsx new file mode 100644 index 000000000..0da9c7f3d --- /dev/null +++ b/src/components/metadata/MetadataEditorBridge.tsx @@ -0,0 +1,17 @@ +import PropertiesField from './PropertiesField'; +import JSONSchemaBridge from 'uniforms-bridge-json-schema'; + +/** + * Add {@link PropertiesField} custom field for adding generic properties editor. + */ +export class MetadataEditorBridge extends JSONSchemaBridge { + getField(name: string): Record { + const field = super.getField(name); + if (field.type === 'object' && !field.properties) { + field.uniforms = { + component: PropertiesField, + }; + } + return field; + } +} diff --git a/src/components/metadata/MetadataEditorModal.css b/src/components/metadata/MetadataEditorModal.css new file mode 100644 index 000000000..9ac868444 --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.css @@ -0,0 +1,9 @@ +.metadataEditorModal { + --pf-c-modal-box--ZIndex: 500; + --pf-c-modal-box--Height: 90vh; + --pf-c-modal-box--Width: 90vw; + --pf-c-modal-box__body--MinHeight: 90vh; + --pf-c-modal-box__body--MaxHeight: 90vh; + --pf-c-modal-box__body--MinWidth: 90vw; + --pf-c-modal-box__body--MaxWidth: 90vw; +} diff --git a/src/components/metadata/MetadataEditorModal.stories.tsx b/src/components/metadata/MetadataEditorModal.stories.tsx new file mode 100644 index 000000000..215259b4b --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.stories.tsx @@ -0,0 +1,83 @@ +import { MetadataEditorModal } from './MetadataEditorModal'; +import { useArgs } from '@storybook/client-api'; +import { StoryFn, Meta } from '@storybook/react'; + +export default { + title: 'Metadata/MetadataEditorModal', + component: MetadataEditorModal, + excludeStories: ['schemaMock'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { handleCloseModal: { action: 'clicked' } }, +} as Meta; + +const Template: StoryFn = (args) => { + const [{ isModalOpen }, updateArgs] = useArgs(); + const handleClose = () => updateArgs({ isModalOpen: !isModalOpen }); + return ( + <> + + + + ); +}; + +export const schemaMock = { + beans: { + title: 'Beans', + description: 'Beans Configuration', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + type: { + type: 'string', + }, + properties: { + type: 'object', + }, + }, + required: ['name', 'type'], + }, + }, + single: { + title: 'Single Object', + description: 'Single Object Configuration', + type: 'object', + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + type: { + type: 'string', + }, + properties: { + type: 'object', + }, + }, + }, +}; + +export const BeansArray = Template.bind({}); +BeansArray.args = { + name: 'beans', + schema: schemaMock.beans, +}; + +export const SingleObject = Template.bind({}); +SingleObject.args = { + name: 'singleObject', + schema: schemaMock.single, +}; diff --git a/src/components/metadata/MetadataEditorModal.test.tsx b/src/components/metadata/MetadataEditorModal.test.tsx new file mode 100644 index 000000000..26d10aae6 --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.test.tsx @@ -0,0 +1,107 @@ +import { MetadataEditorModal } from './MetadataEditorModal'; +import { schemaMock } from './MetadataEditorModal.stories'; +import { screen } from '@testing-library/dom'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +describe('MetadataEditorModal.tsx', () => { + test('component renders if open', () => { + render( + , + ); + const element = screen.queryByTestId('metadata-beans-modal'); + expect(element).toBeInTheDocument(); + }); + + test('component does not render if closed', () => { + render( + , + ); + const element = screen.queryByTestId('metadata-beans-modal'); + expect(element).not.toBeInTheDocument(); + }); + + test('editor works', async () => { + render( + , + ); + const inputs = screen + .getAllByTestId('text-field') + .filter((input) => input.getAttribute('name') === 'name'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toBeDisabled(); + const addPropsBtn = screen.getByTestId('properties-add-property--popover-btn'); + expect(addPropsBtn).toBeDisabled(); + screen.getByText('No beans'); + + const addBeansBtns = screen.getAllByTestId('metadata-add-beans-btn'); + expect(addBeansBtns.length).toBe(2); + fireEvent.click(addBeansBtns[1]); + const noBeans2 = screen.queryByText('No beans'); + expect(noBeans2).not.toBeInTheDocument(); + const inputs2 = screen + .getAllByTestId('text-field') + .filter((input) => input.getAttribute('name') === 'name'); + expect(inputs2.length).toBe(1); + expect(inputs2[0]).toBeEnabled(); + screen.getByText('No properties'); + + const addPropsBtns = screen.getAllByTestId('properties-add-property--popover-btn'); + expect(addPropsBtns.length).toBe(2); + fireEvent.click(addPropsBtns[1]); + await waitFor(() => screen.getByTestId('properties-add-property--popover')); + const propNameInput = screen.getByTestId('properties-add-property--name-input'); + fireEvent.input(propNameInput, { target: { value: 'propObj' } }); + const propTypeObjectRadio = screen.getByTestId('properties-add-property--type-object'); + fireEvent.click(propTypeObjectRadio); + const propAddBtn = screen.getByTestId('properties-add-property--add-btn'); + fireEvent.click(propAddBtn); + + const addPropsPropObjBtn = await waitFor(() => + screen.getByTestId('properties-add-property-propObj-popover-btn'), + ); + fireEvent.click(addPropsPropObjBtn); + const propNamePropObjInput = await waitFor(() => + screen.getByTestId('properties-add-property-propObj-name-input'), + ); + fireEvent.input(propNamePropObjInput, { target: { value: 'subPropName' } }); + const propValueInput = screen.getByTestId('properties-add-property-propObj-value-input'); + fireEvent.input(propValueInput, { target: { value: 'subPropValue' } }); + const propAddBtn2 = screen.getByTestId('properties-add-property-propObj-add-btn'); + fireEvent.click(propAddBtn2); + const noProps2 = screen.queryByText('No properties'); + expect(noProps2).not.toBeInTheDocument(); + + const expandBtn = screen.getByLabelText('Expand row 0'); + fireEvent.click(expandBtn); + const subPropInput = await waitFor(() => + screen.getByTestId('properties-propObj-subPropName-value-input'), + ); + fireEvent.input(subPropInput, { target: { value: 'subPropValueModified' } }); + + const deletePropObjBtn = screen.getByTestId('properties-delete-property-propObj-btn'); + fireEvent.click(deletePropObjBtn); + const deletePropObjBtn2 = screen.queryByTestId('properties-delete-property-propObj-btn'); + expect(deletePropObjBtn2).not.toBeInTheDocument(); + screen.getByText('No properties'); + const deleteBeansBtn = screen.getByTestId('metadata-delete-0-btn'); + fireEvent.click(deleteBeansBtn); + const deleteBeansBtn2 = screen.queryByTestId('metadata-delete-0-btn'); + expect(deleteBeansBtn2).not.toBeInTheDocument(); + screen.getByText('No beans'); + }); +}); diff --git a/src/components/metadata/MetadataEditorModal.tsx b/src/components/metadata/MetadataEditorModal.tsx new file mode 100644 index 000000000..2e01870fe --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.tsx @@ -0,0 +1,158 @@ +import { createValidator } from '../JsonSchemaConfigurator'; +import { MetadataEditorBridge } from './MetadataEditorBridge'; +import './MetadataEditorModal.css'; +import { TopmostArrayTable } from './ToopmostArrayTable'; +import { StepErrorBoundary } from '@kaoto/components'; +import { useFlowsStore } from '@kaoto/store'; +import { AutoFields, AutoForm, ErrorsField } from '@kie-tools/uniforms-patternfly/dist/esm'; +import { + Modal, + ModalVariant, + Split, + SplitItem, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { useState } from 'react'; +import { shallow } from 'zustand/shallow'; + +export interface IMetadataEditorModalProps { + handleCloseModal: () => void; + isModalOpen: boolean; + name: string; + schema: any; +} + +/** + * Metadata editor modal which shows: + *
    + *
  • Topmost array metadata: 2 pane layout form, selectable table view at left side + * and details editor form at right side + *
  • Non-array metadata: editor form for single object + *
+ * @param props + * @constructor + */ +export function MetadataEditorModal({ + handleCloseModal, + isModalOpen, + name, + schema, +}: IMetadataEditorModalProps) { + const [selected, setSelected] = useState(-1); + const { metadata, setMetadata } = useFlowsStore(({ metadata, setMetadata }) => ({ + metadata: metadata[name] as any, + setMetadata, + shallow, + })); + + function isTopmostArray() { + return schema.type === 'array' && schema.items; + } + + function isFormDisabled() { + if (!isTopmostArray()) { + return false; + } + return selected === -1 || selected > metadata?.length - 1; + } + + function getFormSchema() { + if (isTopmostArray()) { + const itemSchema = schema.items; + itemSchema.title = schema.title; + itemSchema.description = schema.description; + return itemSchema; + } + return schema; + } + + function getFormModel() { + if (isTopmostArray()) { + return selected !== -1 && metadata[selected]; + } + return metadata; + } + + function getSchemaBridge() { + const schemaValidator = createValidator(getFormSchema()); + return new MetadataEditorBridge(getFormSchema(), schemaValidator); + } + + function handleChangeArrayModel(config: any) { + setMetadata(name, config.slice()); + } + + function handleChangeDetails(details: any) { + if (isTopmostArray()) { + const newMetadata = metadata ? metadata.slice() : []; + newMetadata[selected] = details; + setMetadata(name, newMetadata); + } else { + setMetadata(name, typeof details === `object` ? { ...details } : details); + } + } + + function handleSetSelected(index: number) { + setSelected(index); + } + + function renderTopmostArrayView() { + return ( + + + + + + + + + Details + + {renderDetailsForm()} + + + + ); + } + + function renderDetailsForm() { + return ( + handleChangeDetails(model)} + data-testid={'metadata-editor-form-' + name} + placeholder={true} + disabled={isFormDisabled()} + > + + +
+
+ ); + } + + return ( + + + {isTopmostArray() ? renderTopmostArrayView() : renderDetailsForm()} + + + ); +} diff --git a/src/components/metadata/MetadataToolbarItems.test.tsx b/src/components/metadata/MetadataToolbarItems.test.tsx new file mode 100644 index 000000000..97f683ee3 --- /dev/null +++ b/src/components/metadata/MetadataToolbarItems.test.tsx @@ -0,0 +1,23 @@ +import { schemaMock } from './MetadataEditorModal.stories'; +import * as api from '@kaoto/api'; +import { MetadataToolbarItems } from '@kaoto/components'; +import { screen } from '@testing-library/dom'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +jest.mock('@kaoto/api', () => { + return { + __esModule: true, + ...jest.requireActual('@kaoto/api'), + }; +}); + +describe('MetadataToolbarItems.tsx', () => { + test('component renders multiple metadata items', async () => { + jest.spyOn(api, 'fetchMetadataSchema').mockResolvedValue(schemaMock); + render(); + const beansBtn = await waitFor(() => screen.getByTestId('toolbar-metadata-beans-btn')); + await waitFor(() => screen.getByTestId('toolbar-metadata-single-btn')); + fireEvent.click(beansBtn); + await waitFor(() => screen.getByTestId('metadata-beans-modal')); + }); +}); diff --git a/src/components/metadata/MetadataToolbarItems.tsx b/src/components/metadata/MetadataToolbarItems.tsx new file mode 100644 index 000000000..d48887cd5 --- /dev/null +++ b/src/components/metadata/MetadataToolbarItems.tsx @@ -0,0 +1,63 @@ +import { MetadataEditorModal } from './MetadataEditorModal'; +import { fetchMetadataSchema } from '@kaoto/api'; +import { useSettingsStore } from '@kaoto/store'; +import { Button, ToolbarItem, Tooltip } from '@patternfly/react-core'; +import { useEffect, useState } from 'react'; + +/** + * Toolbar items for metadata. Retrieve schema for each metadata through {@link fetchMetadataSchema} + * and create toolbar entries for each metadata. Each toolbar entries are the links to the {@link MetadataEditorModal} + * of corresponding metadata. + * @constructor + */ +export function MetadataToolbarItems() { + const dsl = useSettingsStore((state) => state.settings.dsl.name); + const [metadataSchemaMap, setMetadataSchemaMap] = useState<{ [key: string]: any }>({}); + const [expanded, setExpanded] = useState({} as { [key: string]: boolean }); + + useEffect(() => { + fetchMetadataSchema(dsl).then((schema) => { + setMetadataSchemaMap(schema); + Object.keys(schema).forEach((name) => (expanded[name] = false)); + setExpanded({ ...expanded }); + }); + }, [dsl]); + + function handleSetExpanded(name: string, expand: boolean) { + Object.keys(expanded).forEach((key) => (expanded[key] = false)); + expanded[name] = expand; + setExpanded({ ...expanded }); + } + + function toggleExpanded(name: string) { + handleSetExpanded(name, !expanded[name]); + } + + return ( + <> + {metadataSchemaMap && + Object.entries(metadataSchemaMap).map(([metadataName, metadataSchema]) => { + return ( + + {metadataSchema.description}} position="bottom"> + + + handleSetExpanded(metadataName, false)} + isModalOpen={expanded[metadataName]} + /> + + ); + })} + + ); +} diff --git a/src/components/metadata/PropertiesField.tsx b/src/components/metadata/PropertiesField.tsx new file mode 100644 index 000000000..1addf8daf --- /dev/null +++ b/src/components/metadata/PropertiesField.tsx @@ -0,0 +1,162 @@ +import { AddPropertyButton } from './AddPropertyButton'; +import { PropertyRow } from './PropertyRow'; +import wrapField from '@kie-tools/uniforms-patternfly/dist/cjs/wrapField'; +import { EmptyState, EmptyStateBody, Stack, StackItem } from '@patternfly/react-core'; +import { + InnerScrollContainer, + OuterScrollContainer, + TableComposable, + TableVariant, + Tbody, + Td, + TdProps, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import { ReactNode, useCallback, useState } from 'react'; +import { HTMLFieldProps, connectField } from 'uniforms'; + +export type PropertiesFieldProps = HTMLFieldProps; + +/** + * The uniforms custom field for editing generic properties where it has type "object" in the schema, + * but it doesn't have "properties" declared. + * @param props + * @constructor + */ +function Properties(props: PropertiesFieldProps) { + const [expandedNodes, setExpandedNodes] = useState([]); + + const handleModelChange = useCallback(() => { + props.onChange(props.value ? props.value : {}, props.name); + }, [props.onChange, props.value, props.name]); + + function renderRows( + [node, ...remainingNodes]: [string, any][], + parentModel: any, + parentPath: string[] = [], + level = 1, + posinset = 1, + rowIndex = 0, + isHidden = false, + ): ReactNode[] { + if (!node) { + return []; + } + const nodeName = node[0]; + const nodeValue = node[1]; + const isExpanded = expandedNodes.includes(nodeName); + const path = parentPath.slice(); + path.push(nodeName); + + const treeRow: TdProps['treeRow'] = { + onCollapse: () => + setExpandedNodes((prevExpanded) => { + const otherExpandedNodeNames = prevExpanded.filter((name) => name !== nodeName); + return isExpanded ? otherExpandedNodeNames : [...otherExpandedNodeNames, nodeName]; + }), + rowIndex, + props: { + isExpanded, + isHidden, + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': typeof nodeValue === 'object' ? Object.keys(nodeValue).length : 0, + }, + }; + + const childRows = + typeof nodeValue === 'object' && Object.keys(nodeValue).length + ? renderRows( + Object.entries(nodeValue), + nodeValue, + path, + level + 1, + 1, + rowIndex + 1, + !isExpanded || isHidden, + ) + : []; + + return [ + , + ...childRows, + ...renderRows( + remainingNodes, + parentModel, + parentPath, + level, + posinset + 1, + rowIndex + 1 + childRows.length, + isHidden, + ), + ]; + } + + return wrapField( + props, + + + + + + + + NAME + VALUE + + + + + + + {props.value && Object.keys(props.value).length > 0 + ? renderRows(Object.entries(props.value), props.value) + : !props.disabled && ( + + + + No {props.name} + + + + + )} + + + + + + , + ); +} + +export default connectField(Properties); diff --git a/src/components/metadata/PropertyRow.tsx b/src/components/metadata/PropertyRow.tsx new file mode 100644 index 000000000..fb58b0333 --- /dev/null +++ b/src/components/metadata/PropertyRow.tsx @@ -0,0 +1,83 @@ +import { AddPropertyButton } from './AddPropertyButton'; +import { Button, TextInput, Tooltip, Truncate } from '@patternfly/react-core'; +import { TrashIcon } from '@patternfly/react-icons'; +import { Td, TdProps, TreeRowWrapper } from '@patternfly/react-table'; +import { useState } from 'react'; + +type PropertyRowProps = { + propertyName: string; + nodeName: string; + nodeValue: any; + path: string[]; + parentModel: any; + treeRow: TdProps['treeRow']; + isObject: boolean; + onChangeModel: () => void; +}; + +/** + * Represents a row in the {@link PropertiesField} table. + * @param propertyName + * @param nodeName + * @param nodeValue + * @param path + * @param parentModel + * @param treeRow + * @param isObject + * @param onChangeModel + * @constructor + */ +export function PropertyRow({ + propertyName, + nodeName, + nodeValue, + path, + parentModel, + treeRow, + isObject, + onChangeModel, +}: PropertyRowProps) { + function handleTrashClick(parentModel: any, nodeName: string) { + delete parentModel[nodeName]; + onChangeModel(); + } + const [rowValue, setRowValue] = useState(nodeValue); + + function handleChangeModel() { + parentModel[nodeName] = rowValue; + onChangeModel(); + } + + return ( + + + + + + {isObject ? ( + + ) : ( + setRowValue(value)} + onBlur={handleChangeModel} + /> + )} + + + + + + + + ); +} diff --git a/src/components/metadata/ToopmostArrayTable.tsx b/src/components/metadata/ToopmostArrayTable.tsx new file mode 100644 index 000000000..224e2a4b0 --- /dev/null +++ b/src/components/metadata/ToopmostArrayTable.tsx @@ -0,0 +1,124 @@ +import { Button, EmptyState, EmptyStateBody, Tooltip, Truncate } from '@patternfly/react-core'; +import { PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; +import { + InnerScrollContainer, + OuterScrollContainer, + TableComposable, + TableVariant, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; + +type TopmostArrayTableProps = { + model: any[]; + itemSchema: any; + name: string; + selected: number; + onSelected: (index: number) => void; + onChangeModel: (model: any) => void; +}; + +/** + * The selectable table view for the topmost array metadata. + * @param props + * @constructor + */ +export function TopmostArrayTable(props: TopmostArrayTableProps) { + function handleTrashClick(index: number) { + const newMetadata = props.model ? props.model.slice() : []; + newMetadata.length !== 0 && newMetadata.splice(index, 1); + props.onChangeModel(newMetadata); + props.selected === index && props.onSelected(-1); + } + + function handleAddNew() { + const newMetadata = props.model ? props.model.slice() : []; + newMetadata.push({}); + props.onChangeModel(newMetadata); + props.onSelected(newMetadata.length - 1); + } + + return ( + + + + + + {props.itemSchema.required && + props.itemSchema.required.map((name: string) => ( + + {name.toUpperCase()} + + ))} + + + + + + + + + {!props.model || props.model.length === 0 ? ( + + + + No {props.name} + + + + + ) : ( + props.model.map((item, index) => ( + props.onSelected(index)} + isRowSelected={props.selected === index} + > + {props.itemSchema.required && + props.itemSchema.required.map((name: string) => ( + + + + ))} + + + + + + + )) + )} + + + + + ); +} diff --git a/src/store/FlowsStore.ts b/src/store/FlowsStore.ts index 8bc1675e9..126a225ed 100644 --- a/src/store/FlowsStore.ts +++ b/src/store/FlowsStore.ts @@ -42,6 +42,8 @@ export interface IFlowsStore extends IFlowsStoreData { addNewFlow: (dsl: string, flowId?: string) => void; deleteFlow: (flowId: string) => void; deleteAllFlows: () => void; + + setMetadata: (name: string, metadata: any) => void; } const getInitialState = (previousState: Partial = {}): IFlowsStoreData => { @@ -183,6 +185,13 @@ export const useFlowsStore = create()( set((state) => getInitialState({ ...state, flows: [] })); VisualizationService.removeAllVisibleFlows(); }, + + setMetadata: (name, metadata) => + set((state) => { + const newMetadata = state.metadata; + newMetadata[name] = metadata; + return { ...state, metadata: newMetadata }; + }), }), { partialize: (state) => { From 608732269e75d09be304cd20e1d2468378a6ae04 Mon Sep 17 00:00:00 2001 From: Tomohisa Igarashi Date: Tue, 13 Jun 2023 14:59:50 -0400 Subject: [PATCH 2/2] feat(metadata): Metadata configuration UI * fix: Removed curly brackets for string literal attributes * fix: Prevent resizing * fix: Fixed cursor key behavior in property name/value input * fix: Inlined property add/edit with placeholder, removed popover * fix: Tests --- src/api/apiService.ts | 4 + src/components/Visualization.tsx | 7 +- src/components/metadata/AddPropertyButton.tsx | 174 ------------------ .../metadata/AddPropertyButtons.test.tsx | 35 ++++ .../metadata/AddPropertyButtons.tsx | 52 ++++++ .../metadata/MetadataEditorBridge.tsx | 14 +- .../metadata/MetadataEditorModal.css | 24 ++- .../metadata/MetadataEditorModal.stories.tsx | 46 +---- .../metadata/MetadataEditorModal.test.tsx | 116 ++++++------ .../metadata/MetadataEditorModal.tsx | 26 +-- .../metadata/MetadataToolbarItems.test.tsx | 4 +- src/components/metadata/PropertiesField.tsx | 129 +++++++++---- .../metadata/PropertyPlaceholderRow.test.tsx | 56 ++++++ .../metadata/PropertyPlaceholderRow.tsx | 92 +++++++++ src/components/metadata/PropertyRow.test.tsx | 128 +++++++++++++ src/components/metadata/PropertyRow.tsx | 77 ++++++-- src/components/metadata/TestUtil.tsx | 85 +++++++++ .../metadata/ToopmostArrayTable.tsx | 8 +- .../metadata/TopmostArrayTable.test.tsx | 48 +++++ src/store/FlowsStore.ts | 2 +- 20 files changed, 769 insertions(+), 358 deletions(-) delete mode 100644 src/components/metadata/AddPropertyButton.tsx create mode 100644 src/components/metadata/AddPropertyButtons.test.tsx create mode 100644 src/components/metadata/AddPropertyButtons.tsx create mode 100644 src/components/metadata/PropertyPlaceholderRow.test.tsx create mode 100644 src/components/metadata/PropertyPlaceholderRow.tsx create mode 100644 src/components/metadata/PropertyRow.test.tsx create mode 100644 src/components/metadata/TestUtil.tsx create mode 100644 src/components/metadata/TopmostArrayTable.test.tsx diff --git a/src/api/apiService.ts b/src/api/apiService.ts index d362e05a1..f3e76004a 100644 --- a/src/api/apiService.ts +++ b/src/api/apiService.ts @@ -255,12 +255,16 @@ export async function fetchMetadataSchema(dsl: string): Promise<{ [key: string]: properties: { name: { type: 'string', + description: 'Bean name', }, type: { type: 'string', + description: + 'What type to use for creating the bean. Can be one of: #class,#type,bean,groovy,joor,language,mvel,ognl. #class or #type then the bean is created via the fully qualified classname, such as #class:com.foo.MyBean The others are scripting languages that gives more power to create the bean with an inlined code in the script section, such as using groovy.', }, properties: { type: 'object', + description: 'Optional properties to set on the created local bean', }, }, required: ['name', 'type'], diff --git a/src/components/Visualization.tsx b/src/components/Visualization.tsx index 78fa073f4..7d387dc07 100644 --- a/src/components/Visualization.tsx +++ b/src/components/Visualization.tsx @@ -85,9 +85,14 @@ const Visualization = () => { const stepsService = useMemo(() => new StepsService(), []); const previousFlows = usePrevious(flows); + const previousMetadata = usePrevious(metadata); useEffect(() => { - if (previousFlows === flows || !Array.isArray(flows[0]?.steps)) return; + if ( + (previousFlows === flows && previousMetadata === metadata) || + !Array.isArray(flows[0]?.steps) + ) + return; if (flows[0].dsl !== null && flows[0].dsl !== settings.dsl.name) { fetchCapabilities().then((capabilities: ICapabilities) => { capabilities.dsls.forEach((dsl) => { diff --git a/src/components/metadata/AddPropertyButton.tsx b/src/components/metadata/AddPropertyButton.tsx deleted file mode 100644 index 33a596868..000000000 --- a/src/components/metadata/AddPropertyButton.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { - Button, - FormGroup, - HelperText, - HelperTextItem, - Popover, - Radio, - Split, - SplitItem, - Stack, - StackItem, - TextInput, - Title, - Tooltip, -} from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; -import { useState } from 'react'; - -type AddPropertyPopoverProps = { - textLabel?: string; - model: any; - path: string[]; - disabled?: boolean; - onChangeModel: (model: any) => void; -}; - -/** - * Add property button shows a popover to receive user inputs for new property name and type, - * as well as to validate if the property name already exists before actually adding it - * into the model object. - * @param props - * @constructor - */ -export function AddPropertyButton({ - textLabel = '', - model, - path, - disabled = false, - onChangeModel, -}: AddPropertyPopoverProps) { - const [isVisible, doSetVisible] = useState(false); - const [propertyType, setPropertyType] = useState<'string' | 'object'>('string'); - const [propertyName, setPropertyName] = useState(''); - const [propertyValue, setPropertyValue] = useState(''); - - function isReadyToAdd() { - return !!(propertyName && model[propertyName] == null); - } - - function isDuplicate() { - if (!model || !propertyName) { - return false; - } - return model[propertyName] != null; - } - function setVisible(visible: boolean) { - if (!model) { - onChangeModel({}); - } - doSetVisible(visible); - } - - function handleAddProperty() { - if (propertyType === 'object') { - model[propertyName] = {}; - } else { - model[propertyName] = propertyValue; - } - onChangeModel(model); - setPropertyName(''); - setPropertyValue(''); - setPropertyType('string'); - setVisible(false); - } - - return ( - setVisible(true)} - shouldClose={() => setVisible(false)} - bodyContent={ - - - - Name - setPropertyName(value)} - /> - {isDuplicate() && ( - - - Please specify a unique property name - - - )} - - - - - - - - - checked && setPropertyType('string')} - /> - - - checked && setPropertyType('object')} - /> - - - - - - - - - Value - setPropertyValue(value)} - /> - - - - - - - } - > - - - - - ); -} diff --git a/src/components/metadata/AddPropertyButtons.test.tsx b/src/components/metadata/AddPropertyButtons.test.tsx new file mode 100644 index 000000000..a98ceb79c --- /dev/null +++ b/src/components/metadata/AddPropertyButtons.test.tsx @@ -0,0 +1,35 @@ +import { AddPropertyButtons } from './AddPropertyButtons'; +import { screen } from '@testing-library/dom'; +import { fireEvent, render } from '@testing-library/react'; + +describe('AddPropertyButtons.tsx', () => { + test('Add string property button', () => { + const events: boolean[] = []; + render( + events.push(isObject)} + />, + ); + const element = screen.getByTestId('properties-add-string-property-foo-bar-btn'); + expect(events.length).toBe(0); + fireEvent.click(element); + expect(events.length).toBe(1); + expect(events[0]).toBeFalsy(); + }); + + test('Add object property button', () => { + const events: boolean[] = []; + render( + events.push(isObject)} + />, + ); + const element = screen.getByTestId('properties-add-object-property-foo-bar-btn'); + expect(events.length).toBe(0); + fireEvent.click(element); + expect(events.length).toBe(1); + expect(events[0]).toBeTruthy(); + }); +}); diff --git a/src/components/metadata/AddPropertyButtons.tsx b/src/components/metadata/AddPropertyButtons.tsx new file mode 100644 index 000000000..2ce7eae68 --- /dev/null +++ b/src/components/metadata/AddPropertyButtons.tsx @@ -0,0 +1,52 @@ +import { Button, Split, SplitItem, Tooltip } from '@patternfly/react-core'; +import { FolderPlusIcon, PlusCircleIcon } from '@patternfly/react-icons'; + +type AddPropertyPopoverProps = { + showLabel?: boolean; + path: string[]; + disabled?: boolean; + createPlaceholder: (isObject: boolean) => void; +}; + +/** + * A set of "add string property" and "add object property" buttons which triggers creating a placeholder. + * @param props + * @constructor + */ +export function AddPropertyButtons({ + showLabel = false, + path, + disabled = false, + createPlaceholder, +}: AddPropertyPopoverProps) { + return ( + + + + + + + + + + + + + ); +} diff --git a/src/components/metadata/MetadataEditorBridge.tsx b/src/components/metadata/MetadataEditorBridge.tsx index 0da9c7f3d..95596f7c4 100644 --- a/src/components/metadata/MetadataEditorBridge.tsx +++ b/src/components/metadata/MetadataEditorBridge.tsx @@ -1,3 +1,4 @@ +import { FieldLabelIcon } from '../FieldLabelIcon'; import PropertiesField from './PropertiesField'; import JSONSchemaBridge from 'uniforms-bridge-json-schema'; @@ -7,11 +8,18 @@ import JSONSchemaBridge from 'uniforms-bridge-json-schema'; export class MetadataEditorBridge extends JSONSchemaBridge { getField(name: string): Record { const field = super.getField(name); - if (field.type === 'object' && !field.properties) { - field.uniforms = { + const { defaultValue, description, ...props } = field; + const revisedField: Record = { + labelIcon: description + ? FieldLabelIcon({ defaultValue, description, disabled: false }) + : undefined, + ...props, + }; + if (revisedField.type === 'object' && !revisedField.properties) { + revisedField.uniforms = { component: PropertiesField, }; } - return field; + return revisedField; } } diff --git a/src/components/metadata/MetadataEditorModal.css b/src/components/metadata/MetadataEditorModal.css index 9ac868444..28c9f7689 100644 --- a/src/components/metadata/MetadataEditorModal.css +++ b/src/components/metadata/MetadataEditorModal.css @@ -1,9 +1,19 @@ .metadataEditorModal { - --pf-c-modal-box--ZIndex: 500; - --pf-c-modal-box--Height: 90vh; - --pf-c-modal-box--Width: 90vw; - --pf-c-modal-box__body--MinHeight: 90vh; - --pf-c-modal-box__body--MaxHeight: 90vh; - --pf-c-modal-box__body--MinWidth: 90vw; - --pf-c-modal-box__body--MaxWidth: 90vw; + height: 90vh; + width: 90vw; } + +.metadataEditorModalListView { + width: 50vw; +} + +.metadataEditorModalDetailsView { + width: 50vw; +} + +.propertyRow { + --pf-c-table--m-compact--cell--PaddingTop: 0px; + --pf-c-table--m-compact--cell--PaddingBottom: 0px; + --pf-c-table--m-compact--cell--PaddingRight: 0px; + --pf-c-table--m-compact--cell--PaddingLeft: 0px; +} \ No newline at end of file diff --git a/src/components/metadata/MetadataEditorModal.stories.tsx b/src/components/metadata/MetadataEditorModal.stories.tsx index 215259b4b..938203a6d 100644 --- a/src/components/metadata/MetadataEditorModal.stories.tsx +++ b/src/components/metadata/MetadataEditorModal.stories.tsx @@ -1,4 +1,5 @@ import { MetadataEditorModal } from './MetadataEditorModal'; +import { mockSchema } from './TestUtil'; import { useArgs } from '@storybook/client-api'; import { StoryFn, Meta } from '@storybook/react'; @@ -29,55 +30,14 @@ const Template: StoryFn = (args) => { ); }; -export const schemaMock = { - beans: { - title: 'Beans', - description: 'Beans Configuration', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - name: { - type: 'string', - }, - type: { - type: 'string', - }, - properties: { - type: 'object', - }, - }, - required: ['name', 'type'], - }, - }, - single: { - title: 'Single Object', - description: 'Single Object Configuration', - type: 'object', - additionalProperties: false, - properties: { - name: { - type: 'string', - }, - type: { - type: 'string', - }, - properties: { - type: 'object', - }, - }, - }, -}; - export const BeansArray = Template.bind({}); BeansArray.args = { name: 'beans', - schema: schemaMock.beans, + schema: mockSchema.beans, }; export const SingleObject = Template.bind({}); SingleObject.args = { name: 'singleObject', - schema: schemaMock.single, + schema: mockSchema.single, }; diff --git a/src/components/metadata/MetadataEditorModal.test.tsx b/src/components/metadata/MetadataEditorModal.test.tsx index 26d10aae6..001ade40f 100644 --- a/src/components/metadata/MetadataEditorModal.test.tsx +++ b/src/components/metadata/MetadataEditorModal.test.tsx @@ -1,16 +1,18 @@ import { MetadataEditorModal } from './MetadataEditorModal'; -import { schemaMock } from './MetadataEditorModal.stories'; +import { mockModel, mockSchema } from './TestUtil'; +import { useFlowsStore } from '@kaoto/store'; import { screen } from '@testing-library/dom'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; describe('MetadataEditorModal.tsx', () => { test('component renders if open', () => { + useFlowsStore.getState().setMetadata('beans', []); render( , ); const element = screen.queryByTestId('metadata-beans-modal'); @@ -18,25 +20,27 @@ describe('MetadataEditorModal.tsx', () => { }); test('component does not render if closed', () => { + useFlowsStore.getState().setMetadata('beans', []); render( , ); const element = screen.queryByTestId('metadata-beans-modal'); expect(element).not.toBeInTheDocument(); }); - test('editor works', async () => { + test('Details disabled if empty', async () => { + useFlowsStore.getState().setMetadata('beans', []); render( , ); const inputs = screen @@ -44,64 +48,58 @@ describe('MetadataEditorModal.tsx', () => { .filter((input) => input.getAttribute('name') === 'name'); expect(inputs.length).toBe(1); expect(inputs[0]).toBeDisabled(); - const addPropsBtn = screen.getByTestId('properties-add-property--popover-btn'); - expect(addPropsBtn).toBeDisabled(); - screen.getByText('No beans'); + const addStringPropBtn = screen.getByTestId('properties-add-string-property--btn'); + expect(addStringPropBtn).toBeDisabled(); + const addObjectPropBtn = screen.getByTestId('properties-add-object-property--btn'); + expect(addObjectPropBtn).toBeDisabled(); + }); - const addBeansBtns = screen.getAllByTestId('metadata-add-beans-btn'); - expect(addBeansBtns.length).toBe(2); - fireEvent.click(addBeansBtns[1]); - const noBeans2 = screen.queryByText('No beans'); - expect(noBeans2).not.toBeInTheDocument(); - const inputs2 = screen + test('Details enabled if select', async () => { + useFlowsStore.getState().setMetadata('beans', mockModel.beans); + render( + , + ); + const row = screen.getByTestId('metadata-row-0'); + fireEvent.click(row); + const inputs = screen .getAllByTestId('text-field') .filter((input) => input.getAttribute('name') === 'name'); - expect(inputs2.length).toBe(1); - expect(inputs2[0]).toBeEnabled(); - screen.getByText('No properties'); - - const addPropsBtns = screen.getAllByTestId('properties-add-property--popover-btn'); - expect(addPropsBtns.length).toBe(2); - fireEvent.click(addPropsBtns[1]); - await waitFor(() => screen.getByTestId('properties-add-property--popover')); - const propNameInput = screen.getByTestId('properties-add-property--name-input'); - fireEvent.input(propNameInput, { target: { value: 'propObj' } }); - const propTypeObjectRadio = screen.getByTestId('properties-add-property--type-object'); - fireEvent.click(propTypeObjectRadio); - const propAddBtn = screen.getByTestId('properties-add-property--add-btn'); - fireEvent.click(propAddBtn); - - const addPropsPropObjBtn = await waitFor(() => - screen.getByTestId('properties-add-property-propObj-popover-btn'), - ); - fireEvent.click(addPropsPropObjBtn); - const propNamePropObjInput = await waitFor(() => - screen.getByTestId('properties-add-property-propObj-name-input'), + expect(inputs.length).toBe(1); + expect(inputs[0]).toBeEnabled(); + const addStringPropBtn = screen.getByTestId('properties-add-string-property--btn'); + expect(addStringPropBtn).toBeEnabled(); + const addObjectPropBtn = screen.getByTestId('properties-add-object-property--btn'); + expect(addObjectPropBtn).toBeEnabled(); + const propObj2AddStringPropBtn = screen.getByTestId( + 'properties-add-string-property-propObj1-btn', ); - fireEvent.input(propNamePropObjInput, { target: { value: 'subPropName' } }); - const propValueInput = screen.getByTestId('properties-add-property-propObj-value-input'); - fireEvent.input(propValueInput, { target: { value: 'subPropValue' } }); - const propAddBtn2 = screen.getByTestId('properties-add-property-propObj-add-btn'); - fireEvent.click(propAddBtn2); - const noProps2 = screen.queryByText('No properties'); - expect(noProps2).not.toBeInTheDocument(); + fireEvent.click(propObj2AddStringPropBtn); + const input = screen.getByTestId('properties-propObj1-placeholder-name-input'); + fireEvent.input(input, { target: { value: 'propObj1Child' } }); + fireEvent.blur(input); + }); - const expandBtn = screen.getByLabelText('Expand row 0'); - fireEvent.click(expandBtn); - const subPropInput = await waitFor(() => - screen.getByTestId('properties-propObj-subPropName-value-input'), + test('render properties empty state', async () => { + useFlowsStore.getState().setMetadata('beans', mockModel.beansNoProp); + render( + , ); - fireEvent.input(subPropInput, { target: { value: 'subPropValueModified' } }); - - const deletePropObjBtn = screen.getByTestId('properties-delete-property-propObj-btn'); - fireEvent.click(deletePropObjBtn); - const deletePropObjBtn2 = screen.queryByTestId('properties-delete-property-propObj-btn'); - expect(deletePropObjBtn2).not.toBeInTheDocument(); - screen.getByText('No properties'); - const deleteBeansBtn = screen.getByTestId('metadata-delete-0-btn'); - fireEvent.click(deleteBeansBtn); - const deleteBeansBtn2 = screen.queryByTestId('metadata-delete-0-btn'); - expect(deleteBeansBtn2).not.toBeInTheDocument(); - screen.getByText('No beans'); + const row = screen.getByTestId('metadata-row-0'); + fireEvent.click(row); + let addStringPropBtns = screen.getAllByTestId('properties-add-string-property--btn'); + expect(addStringPropBtns.length).toBe(2); + fireEvent.click(addStringPropBtns[1]); + addStringPropBtns = screen.getAllByTestId('properties-add-string-property--btn'); + expect(addStringPropBtns.length).toBe(1); }); }); diff --git a/src/components/metadata/MetadataEditorModal.tsx b/src/components/metadata/MetadataEditorModal.tsx index 2e01870fe..d69d16182 100644 --- a/src/components/metadata/MetadataEditorModal.tsx +++ b/src/components/metadata/MetadataEditorModal.tsx @@ -101,7 +101,7 @@ export function MetadataEditorModal({ function renderTopmostArrayView() { return ( - + - + Details @@ -142,17 +142,17 @@ export function MetadataEditorModal({ } return ( - - + + {isTopmostArray() ? renderTopmostArrayView() : renderDetailsForm()} - - + + ); } diff --git a/src/components/metadata/MetadataToolbarItems.test.tsx b/src/components/metadata/MetadataToolbarItems.test.tsx index 97f683ee3..93f675779 100644 --- a/src/components/metadata/MetadataToolbarItems.test.tsx +++ b/src/components/metadata/MetadataToolbarItems.test.tsx @@ -1,4 +1,4 @@ -import { schemaMock } from './MetadataEditorModal.stories'; +import { mockSchema } from './TestUtil'; import * as api from '@kaoto/api'; import { MetadataToolbarItems } from '@kaoto/components'; import { screen } from '@testing-library/dom'; @@ -13,7 +13,7 @@ jest.mock('@kaoto/api', () => { describe('MetadataToolbarItems.tsx', () => { test('component renders multiple metadata items', async () => { - jest.spyOn(api, 'fetchMetadataSchema').mockResolvedValue(schemaMock); + jest.spyOn(api, 'fetchMetadataSchema').mockResolvedValue(mockSchema); render(); const beansBtn = await waitFor(() => screen.getByTestId('toolbar-metadata-beans-btn')); await waitFor(() => screen.getByTestId('toolbar-metadata-single-btn')); diff --git a/src/components/metadata/PropertiesField.tsx b/src/components/metadata/PropertiesField.tsx index 1addf8daf..808d907f3 100644 --- a/src/components/metadata/PropertiesField.tsx +++ b/src/components/metadata/PropertiesField.tsx @@ -1,4 +1,5 @@ -import { AddPropertyButton } from './AddPropertyButton'; +import { AddPropertyButtons } from './AddPropertyButtons'; +import { PropertyPlaceholderRow } from './PropertyPlaceholderRow'; import { PropertyRow } from './PropertyRow'; import wrapField from '@kie-tools/uniforms-patternfly/dist/cjs/wrapField'; import { EmptyState, EmptyStateBody, Stack, StackItem } from '@patternfly/react-core'; @@ -27,11 +28,30 @@ export type PropertiesFieldProps = HTMLFieldProps; */ function Properties(props: PropertiesFieldProps) { const [expandedNodes, setExpandedNodes] = useState([]); + const [placeholderState, setPlaceholderState] = useState(null); const handleModelChange = useCallback(() => { + setPlaceholderState(null); props.onChange(props.value ? props.value : {}, props.name); }, [props.onChange, props.value, props.name]); + type PlaceholderState = { + isObject: boolean; + parentNodeId: string; + }; + + function getNodeId(path: string[]) { + return path.join('-'); + } + + function handleCreatePlaceHolder(state: PlaceholderState) { + setPlaceholderState({ ...state }); + if (state.parentNodeId && state.parentNodeId.length > 0) { + expandedNodes.includes(state.parentNodeId) || + setExpandedNodes([...expandedNodes, state.parentNodeId]); + } + } + function renderRows( [node, ...remainingNodes]: [string, any][], parentModel: any, @@ -42,19 +62,59 @@ function Properties(props: PropertiesFieldProps) { isHidden = false, ): ReactNode[] { if (!node) { - return []; + // placeholder is rendered as a last sibling + return placeholderState && placeholderState.parentNodeId === getNodeId(parentPath) + ? [ + , + ] + : []; } + const nodeName = node[0]; const nodeValue = node[1]; - const isExpanded = expandedNodes.includes(nodeName); const path = parentPath.slice(); path.push(nodeName); + const nodeId = getNodeId(path); + const isExpanded = expandedNodes.includes(nodeId); + + const childRows = + typeof nodeValue === 'object' + ? renderRows( + Object.entries(nodeValue), + nodeValue, + path, + level + 1, + 1, + rowIndex + 1, + !isExpanded || isHidden, + ) + : []; + + const siblingRows = renderRows( + remainingNodes, + parentModel, + parentPath, + level, + posinset + 1, + rowIndex + 1 + childRows.length, + isHidden, + ); const treeRow: TdProps['treeRow'] = { onCollapse: () => setExpandedNodes((prevExpanded) => { - const otherExpandedNodeNames = prevExpanded.filter((name) => name !== nodeName); - return isExpanded ? otherExpandedNodeNames : [...otherExpandedNodeNames, nodeName]; + const otherExpandedNodeIds = prevExpanded.filter((id) => id !== nodeId); + return isExpanded ? otherExpandedNodeIds : [...otherExpandedNodeIds, nodeId]; }), rowIndex, props: { @@ -66,22 +126,9 @@ function Properties(props: PropertiesFieldProps) { }, }; - const childRows = - typeof nodeValue === 'object' && Object.keys(nodeValue).length - ? renderRows( - Object.entries(nodeValue), - nodeValue, - path, - level + 1, - 1, - rowIndex + 1, - !isExpanded || isHidden, - ) - : []; - return [ { + handleCreatePlaceHolder({ + isObject: isObject, + parentNodeId: getNodeId(path), + }); + }} />, ...childRows, - ...renderRows( - remainingNodes, - parentModel, - parentPath, - level, - posinset + 1, - rowIndex + 1 + childRows.length, - isHidden, - ), + ...siblingRows, ]; } @@ -121,30 +166,38 @@ function Properties(props: PropertiesFieldProps) { NAME VALUE - - + + handleCreatePlaceHolder({ + isObject: isObject, + parentNodeId: '', + }) + } /> - + - {props.value && Object.keys(props.value).length > 0 + {(props.value && Object.keys(props.value).length > 0) || placeholderState ? renderRows(Object.entries(props.value), props.value) : !props.disabled && ( No {props.name} - + handleCreatePlaceHolder({ + isObject: isObject, + parentNodeId: '', + }) + } /> diff --git a/src/components/metadata/PropertyPlaceholderRow.test.tsx b/src/components/metadata/PropertyPlaceholderRow.test.tsx new file mode 100644 index 000000000..1786a71a7 --- /dev/null +++ b/src/components/metadata/PropertyPlaceholderRow.test.tsx @@ -0,0 +1,56 @@ +import { PropertyPlaceholderRow } from './PropertyPlaceholderRow'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('PropertyPlaceholderRow.tsx', () => { + test('render add string property', () => { + const model: any = {}; + let onChangeModel = 0; + render( + + + onChangeModel++} + /> + +
, + ); + const nameInput = screen.getByTestId('beans-one-two-placeholder-name-input'); + fireEvent.input(nameInput, { target: { value: 'foo' } }); + fireEvent.blur(nameInput); + expect(model.foo).toBe(''); + expect(onChangeModel).toBe(1); + }); + + test('render add object property', () => { + const model: any = {}; + let onChangeModel = 0; + render( + + + onChangeModel++} + /> + +
, + ); + const nameInput = screen.getByTestId('beans-one-two-placeholder-name-input'); + fireEvent.input(nameInput, { target: { value: 'foo' } }); + fireEvent.blur(nameInput); + expect(typeof model.foo).toBe('object'); + expect(onChangeModel).toBe(1); + }); +}); diff --git a/src/components/metadata/PropertyPlaceholderRow.tsx b/src/components/metadata/PropertyPlaceholderRow.tsx new file mode 100644 index 000000000..8e77d78ce --- /dev/null +++ b/src/components/metadata/PropertyPlaceholderRow.tsx @@ -0,0 +1,92 @@ +import './MetadataEditorModal.css'; +import { HelperText, HelperTextItem, TextInput } from '@patternfly/react-core'; +import '@patternfly/react-styles/css/components/Table/table'; +import { Td, TdProps, TreeRowWrapper } from '@patternfly/react-table'; +import { FormEvent, useState } from 'react'; + +type PropertyPlaceholderRowProps = { + propertyName: string; + path: string[]; + parentModel: any; + rowIndex: number; + level: number; + posinset: number; + isObject: boolean; + onChangeModel: () => void; +}; + +/** + * Represents a row in the {@link PropertiesField} table. + * @param propertyName + * @param path + * @param parentModel + * @param treeRow + * @param isObject + * @param onChangeModel + * @constructor + */ +export function PropertyPlaceholderRow({ + propertyName, + path, + parentModel, + rowIndex, + level, + posinset, + isObject, + onChangeModel, +}: PropertyPlaceholderRowProps) { + const [userInputName, setUserInputName] = useState(''); + const [isUserInputNameDuplicate, setUserInputNameDuplicate] = useState(false); + + const treeRow: TdProps['treeRow'] = { + rowIndex, + onCollapse: () => {}, + props: { + isRowSelected: true, + isExpanded: false, + isHidden: false, + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': 0, + }, + }; + + function handleUserInputName(name: string, event: FormEvent) { + event.stopPropagation(); + setUserInputName(name); + setUserInputNameDuplicate(!!(name && parentModel[name] != null)); + } + + function commitUserInputName() { + if (!!userInputName && !isUserInputNameDuplicate) { + parentModel[userInputName] = isObject ? {} : ''; + } + onChangeModel(); + } + + return ( + + + event.stopPropagation()} + onChange={(value, event) => handleUserInputName(value, event)} + onBlur={commitUserInputName} + /> + {isUserInputNameDuplicate && ( + + Please specify a unique property name + + )} + + + + + ); +} diff --git a/src/components/metadata/PropertyRow.test.tsx b/src/components/metadata/PropertyRow.test.tsx new file mode 100644 index 000000000..91963246f --- /dev/null +++ b/src/components/metadata/PropertyRow.test.tsx @@ -0,0 +1,128 @@ +import { PropertyRow } from './PropertyRow'; +import { TdProps } from '@patternfly/react-table'; +import { fireEvent, render, screen } from '@testing-library/react'; + +describe('PropertyRow.tsx', () => { + test('render string property change name and value', () => { + const model: any = { foo: 'bar' }; + let onChangeModel = 0; + let onCreatePlaceholder: boolean[] = []; + const treeRow: TdProps['treeRow'] = { + rowIndex: 0, + onCollapse: () => {}, + props: { + isRowSelected: true, + isExpanded: false, + isHidden: false, + 'aria-level': 0, + 'aria-posinset': 0, + 'aria-setsize': 0, + }, + }; + render( + + + onChangeModel++} + createPlaceholder={(o) => onCreatePlaceholder.push(o)} + /> + +
, + ); + const valueInput = screen.getByTestId('beans-one-two-value-input'); + expect(valueInput).toHaveValue('bar'); + fireEvent.input(valueInput, { target: { value: 'barModified' } }); + fireEvent.blur(valueInput); + expect(model.foo).toBe('barModified'); + expect(onChangeModel).toBe(1); + const nameInput = screen.getByTestId('beans-one-two-name-input'); + expect(nameInput).toHaveValue('foo'); + fireEvent.input(nameInput, { target: { value: 'fooModified' } }); + fireEvent.blur(nameInput); + expect(model.fooModified).toBe('barModified'); + expect(onChangeModel).toBe(2); + }); + + test('render string property delete', () => { + const model: any = { foo: 'bar' }; + let onChangeModel = 0; + let onCreatePlaceholder: boolean[] = []; + const treeRow: TdProps['treeRow'] = { + rowIndex: 0, + onCollapse: () => {}, + props: { + isRowSelected: true, + isExpanded: false, + isHidden: false, + 'aria-level': 0, + 'aria-posinset': 0, + 'aria-setsize': 0, + }, + }; + render( + + + onChangeModel++} + createPlaceholder={(o) => onCreatePlaceholder.push(o)} + /> + +
, + ); + const deleteBtn = screen.getByTestId('properties-delete-property-foo-btn'); + fireEvent.click(deleteBtn); + expect(Object.keys(model).length).toBe(0); + expect(onChangeModel).toBe(1); + }); + + test('render object property', () => { + const model: any = { foo: {} }; + let onChangeModel = 0; + let onCreatePlaceholder: boolean[] = []; + const treeRow: TdProps['treeRow'] = { + rowIndex: 0, + onCollapse: () => {}, + props: { + isRowSelected: true, + isExpanded: false, + isHidden: false, + 'aria-level': 0, + 'aria-posinset': 0, + 'aria-setsize': 0, + }, + }; + render( + + + onChangeModel++} + createPlaceholder={(o) => onCreatePlaceholder.push(o)} + /> + +
, + ); + screen.getByTestId('properties-add-string-property-one-two-btn'); + screen.getByTestId('properties-add-object-property-one-two-btn'); + }); +}); diff --git a/src/components/metadata/PropertyRow.tsx b/src/components/metadata/PropertyRow.tsx index fb58b0333..7a945ccb4 100644 --- a/src/components/metadata/PropertyRow.tsx +++ b/src/components/metadata/PropertyRow.tsx @@ -1,8 +1,9 @@ -import { AddPropertyButton } from './AddPropertyButton'; -import { Button, TextInput, Tooltip, Truncate } from '@patternfly/react-core'; +import { AddPropertyButtons } from './AddPropertyButtons'; +import './MetadataEditorModal.css'; +import { Button, HelperText, HelperTextItem, TextInput, Tooltip } from '@patternfly/react-core'; import { TrashIcon } from '@patternfly/react-icons'; import { Td, TdProps, TreeRowWrapper } from '@patternfly/react-table'; -import { useState } from 'react'; +import { FormEvent, useState } from 'react'; type PropertyRowProps = { propertyName: string; @@ -13,6 +14,7 @@ type PropertyRowProps = { treeRow: TdProps['treeRow']; isObject: boolean; onChangeModel: () => void; + createPlaceholder: (isObject: boolean) => void; }; /** @@ -36,39 +38,86 @@ export function PropertyRow({ treeRow, isObject, onChangeModel, + createPlaceholder, }: PropertyRowProps) { function handleTrashClick(parentModel: any, nodeName: string) { delete parentModel[nodeName]; onChangeModel(); } - const [rowValue, setRowValue] = useState(nodeValue); + const [userInputValue, setUserInputValue] = useState(nodeValue); + const [userInputName, setUserInputName] = useState(nodeName); + const [isUserInputNameDuplicate, setUserInputNameDuplicate] = useState(false); - function handleChangeModel() { - parentModel[nodeName] = rowValue; - onChangeModel(); + function handleUserInputName(name: string, event: FormEvent) { + event.stopPropagation(); + setUserInputName(name); + setUserInputNameDuplicate(!!(name && name !== nodeName && parentModel[name] != null)); + } + + function handleUserInputValue(value: string, event: FormEvent) { + event.stopPropagation(); + setUserInputValue(value); + } + + function commitUserInputName() { + if (userInputName != null && userInputName !== nodeName && !isUserInputNameDuplicate) { + const value = parentModel[nodeName]; + delete parentModel[nodeName]; + parentModel[userInputName] = value; + onChangeModel(); + } else { + setUserInputName(nodeName); + } + } + + function commitUserInputValue() { + if (userInputValue != null && userInputValue !== nodeValue) { + parentModel[nodeName] = userInputValue; + onChangeModel(); + } } return ( - + - + event.stopPropagation()} + onChange={(value, event) => handleUserInputName(value, event)} + onBlur={commitUserInputName} + /> + {isUserInputNameDuplicate && ( + + Please specify a unique property name + + )} {isObject ? ( - + ) : ( setRowValue(value)} - onBlur={handleChangeModel} + value={userInputValue} + onKeyDown={(event) => event.stopPropagation()} + onChange={(value, event) => handleUserInputValue(value, event)} + onBlur={commitUserInputValue} /> )} - + - + @@ -92,6 +93,7 @@ export function TopmostArrayTable(props: TopmostArrayTableProps) { props.model.map((item, index) => ( props.onSelected(index)} @@ -103,7 +105,7 @@ export function TopmostArrayTable(props: TopmostArrayTableProps) { ))} - +