diff --git a/src/api/apiService.ts b/src/api/apiService.ts index 81fde2c8b..f3e76004a 100644 --- a/src/api/apiService.ts +++ b/src/api/apiService.ts @@ -238,6 +238,43 @@ 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', + 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'], + }, + }, + }); + } + 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..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) => { @@ -101,7 +106,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/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 new file mode 100644 index 000000000..95596f7c4 --- /dev/null +++ b/src/components/metadata/MetadataEditorBridge.tsx @@ -0,0 +1,25 @@ +import { FieldLabelIcon } from '../FieldLabelIcon'; +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); + 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 revisedField; + } +} diff --git a/src/components/metadata/MetadataEditorModal.css b/src/components/metadata/MetadataEditorModal.css new file mode 100644 index 000000000..28c9f7689 --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.css @@ -0,0 +1,19 @@ +.metadataEditorModal { + 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 new file mode 100644 index 000000000..938203a6d --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.stories.tsx @@ -0,0 +1,43 @@ +import { MetadataEditorModal } from './MetadataEditorModal'; +import { mockSchema } from './TestUtil'; +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 BeansArray = Template.bind({}); +BeansArray.args = { + name: 'beans', + schema: mockSchema.beans, +}; + +export const SingleObject = Template.bind({}); +SingleObject.args = { + name: 'singleObject', + schema: mockSchema.single, +}; diff --git a/src/components/metadata/MetadataEditorModal.test.tsx b/src/components/metadata/MetadataEditorModal.test.tsx new file mode 100644 index 000000000..001ade40f --- /dev/null +++ b/src/components/metadata/MetadataEditorModal.test.tsx @@ -0,0 +1,105 @@ +import { MetadataEditorModal } from './MetadataEditorModal'; +import { mockModel, mockSchema } from './TestUtil'; +import { useFlowsStore } from '@kaoto/store'; +import { screen } from '@testing-library/dom'; +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'); + expect(element).toBeInTheDocument(); + }); + + test('component does not render if closed', () => { + useFlowsStore.getState().setMetadata('beans', []); + render( + , + ); + const element = screen.queryByTestId('metadata-beans-modal'); + expect(element).not.toBeInTheDocument(); + }); + + test('Details disabled if empty', async () => { + useFlowsStore.getState().setMetadata('beans', []); + render( + , + ); + const inputs = screen + .getAllByTestId('text-field') + .filter((input) => input.getAttribute('name') === 'name'); + expect(inputs.length).toBe(1); + expect(inputs[0]).toBeDisabled(); + const addStringPropBtn = screen.getByTestId('properties-add-string-property--btn'); + expect(addStringPropBtn).toBeDisabled(); + const addObjectPropBtn = screen.getByTestId('properties-add-object-property--btn'); + expect(addObjectPropBtn).toBeDisabled(); + }); + + 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(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.click(propObj2AddStringPropBtn); + const input = screen.getByTestId('properties-propObj1-placeholder-name-input'); + fireEvent.input(input, { target: { value: 'propObj1Child' } }); + fireEvent.blur(input); + }); + + test('render properties empty state', async () => { + useFlowsStore.getState().setMetadata('beans', mockModel.beansNoProp); + render( + , + ); + 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 new file mode 100644 index 000000000..d69d16182 --- /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..93f675779 --- /dev/null +++ b/src/components/metadata/MetadataToolbarItems.test.tsx @@ -0,0 +1,23 @@ +import { mockSchema } from './TestUtil'; +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(mockSchema); + 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..808d907f3 --- /dev/null +++ b/src/components/metadata/PropertiesField.tsx @@ -0,0 +1,215 @@ +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'; +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 [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, + parentPath: string[] = [], + level = 1, + posinset = 1, + rowIndex = 0, + isHidden = false, + ): ReactNode[] { + if (!node) { + // placeholder is rendered as a last sibling + return placeholderState && placeholderState.parentNodeId === getNodeId(parentPath) + ? [ + , + ] + : []; + } + + const nodeName = node[0]; + const nodeValue = node[1]; + 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 otherExpandedNodeIds = prevExpanded.filter((id) => id !== nodeId); + return isExpanded ? otherExpandedNodeIds : [...otherExpandedNodeIds, nodeId]; + }), + rowIndex, + props: { + isExpanded, + isHidden, + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': typeof nodeValue === 'object' ? Object.keys(nodeValue).length : 0, + }, + }; + + return [ + { + handleCreatePlaceHolder({ + isObject: isObject, + parentNodeId: getNodeId(path), + }); + }} + />, + ...childRows, + ...siblingRows, + ]; + } + + return wrapField( + props, + + + + + + + + NAME + VALUE + + + handleCreatePlaceHolder({ + isObject: isObject, + parentNodeId: '', + }) + } + /> + + + + + {(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: '', + }) + } + /> + + + + )} + + + + + + , + ); +} + +export default connectField(Properties); 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 new file mode 100644 index 000000000..7a945ccb4 --- /dev/null +++ b/src/components/metadata/PropertyRow.tsx @@ -0,0 +1,132 @@ +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 { FormEvent, useState } from 'react'; + +type PropertyRowProps = { + propertyName: string; + nodeName: string; + nodeValue: any; + path: string[]; + parentModel: any; + treeRow: TdProps['treeRow']; + isObject: boolean; + onChangeModel: () => void; + createPlaceholder: (isObject: boolean) => 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, + createPlaceholder, +}: PropertyRowProps) { + function handleTrashClick(parentModel: any, nodeName: string) { + delete parentModel[nodeName]; + onChangeModel(); + } + const [userInputValue, setUserInputValue] = useState(nodeValue); + const [userInputName, setUserInputName] = useState(nodeName); + const [isUserInputNameDuplicate, setUserInputNameDuplicate] = useState(false); + + 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 ? ( + + ) : ( + event.stopPropagation()} + onChange={(value, event) => handleUserInputValue(value, event)} + onBlur={commitUserInputValue} + /> + )} + + + + + + + + ); +} diff --git a/src/components/metadata/TestUtil.tsx b/src/components/metadata/TestUtil.tsx new file mode 100644 index 000000000..69820ede9 --- /dev/null +++ b/src/components/metadata/TestUtil.tsx @@ -0,0 +1,85 @@ +export const mockSchema = { + beans: { + title: 'Beans', + description: 'Beans Configuration', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + 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'], + }, + }, + single: { + title: 'Single Object', + description: 'Single Object Configuration', + type: 'object', + additionalProperties: false, + 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', + }, + }, + }, +}; + +export const mockModel = { + beans: [ + { + name: 'bean1', + type: 'type1', + properties: { + prop1: 'value1', + propObj1: { + propObj1Sub: 'valueObj1', + }, + }, + }, + { + name: 'bean2', + type: 'type2', + properties: { + prop2: 'value2', + propObj2: { + propObj2Sub: 'valueObj2', + }, + }, + }, + ], + beansNoProp: [ + { + name: 'bean1', + type: 'type1', + properties: {}, + }, + { + name: 'bean2', + type: 'type2', + properties: {}, + }, + ], +}; diff --git a/src/components/metadata/ToopmostArrayTable.tsx b/src/components/metadata/ToopmostArrayTable.tsx new file mode 100644 index 000000000..e5e7bb3ba --- /dev/null +++ b/src/components/metadata/ToopmostArrayTable.tsx @@ -0,0 +1,126 @@ +import './MetadataEditorModal.css'; +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/components/metadata/TopmostArrayTable.test.tsx b/src/components/metadata/TopmostArrayTable.test.tsx new file mode 100644 index 000000000..6bb13d721 --- /dev/null +++ b/src/components/metadata/TopmostArrayTable.test.tsx @@ -0,0 +1,48 @@ +import { mockModel, mockSchema } from './TestUtil'; +import { TopmostArrayTable } from './ToopmostArrayTable'; +import { screen } from '@testing-library/dom'; +import { fireEvent, render } from '@testing-library/react'; + +describe('TopmostArrayTable.tsx', () => { + test('render empty state', () => { + const model: any[] = []; + let changedModel: any; + let selected: number = -1; + render( + (changedModel = m)} + onSelected={(n) => (selected = n)} + selected={0} + />, + ); + const deleteBtn = screen.queryByTestId('metadata-delete-0-btn'); + expect(deleteBtn).not.toBeInTheDocument(); + const btns = screen.getAllByTestId('metadata-add-beans-btn'); + expect(btns.length).toBe(2); + fireEvent.click(btns[1]); + expect(changedModel.length).toBe(1); + expect(selected).toBe(0); + }); + + test('render beans', () => { + let changedModel: any; + let selected: number = -1; + render( + (changedModel = m)} + onSelected={(n) => (selected = n)} + selected={0} + />, + ); + const btn = screen.getByTestId('metadata-delete-0-btn'); + fireEvent.click(btn); + expect(changedModel.length).toBe(1); + expect(selected).toBe(0); + }); +}); diff --git a/src/store/FlowsStore.ts b/src/store/FlowsStore.ts index 8bc1675e9..65b96f95e 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) => {