From 4400fb29ba8388fdc3de4625d234df9a27f9b9be Mon Sep 17 00:00:00 2001 From: Tomohisa Igarashi Date: Tue, 30 May 2023 15:27:53 -0400 Subject: [PATCH] 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) => {