Skip to content
This repository has been archived by the owner on Nov 27, 2023. It is now read-only.

feat(metadata): Metadata configuration UI #1951

Merged
merged 2 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/api/apiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions src/components/KaotoToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -325,6 +326,10 @@ export const KaotoToolbar = ({

<ToolbarItem variant="separator" />

<MetadataToolbarItems />

<ToolbarItem variant="separator" />

{/* DEPLOYMENT STATUS */}
{deployment.crd ? (
<ToolbarItem alignment={{ default: 'alignRight' }}>
Expand Down
9 changes: 7 additions & 2 deletions src/components/Visualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export * from './VisualizationControls';
export * from './VisualizationStepViews';
export * from './Visualization';
export * from './VisualizationStep';
export { MetadataToolbarItems } from './metadata/MetadataToolbarItems';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The content of this file is only exporting one single function, in which case, wouldn't be enough to do something like below?

Suggested change
export { MetadataToolbarItems } from './metadata/MetadataToolbarItems';
export * from './metadata/MetadataToolbarItems';

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if it's preferred in typescript? Generally the wildcard is not preferred in Java coding standards I know

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, it's ok as we're exposing tokens and not necessarily mean that they will be incorporated in the final bundle, is mostly convenience I would say.

35 changes: 35 additions & 0 deletions src/components/metadata/AddPropertyButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AddPropertyButtons
path={['foo', 'bar']}
createPlaceholder={(isObject) => 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(
<AddPropertyButtons
path={['foo', 'bar']}
createPlaceholder={(isObject) => 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();
});
});
52 changes: 52 additions & 0 deletions src/components/metadata/AddPropertyButtons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Split>
<SplitItem>
<Tooltip content="Add string property">
<Button
data-testid={`properties-add-string-property-${path.join('-')}-btn`}
variant={'link'}
icon={<PlusCircleIcon />}
isDisabled={disabled}
onClick={() => createPlaceholder(false)}
>
{showLabel && 'Add string property'}
</Button>
</Tooltip>
</SplitItem>
<SplitItem>
<Tooltip content="Add object property">
<Button
data-testid={`properties-add-object-property-${path.join('-')}-btn`}
variant={'link'}
icon={<FolderPlusIcon />}
isDisabled={disabled}
onClick={() => createPlaceholder(true)}
>
{showLabel && 'Add object property'}
</Button>
</Tooltip>
</SplitItem>
</Split>
);
}
25 changes: 25 additions & 0 deletions src/components/metadata/MetadataEditorBridge.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any> {
const field = super.getField(name);
const { defaultValue, description, ...props } = field;
const revisedField: Record<string, any> = {
labelIcon: description
? FieldLabelIcon({ defaultValue, description, disabled: false })
: undefined,
...props,
};
if (revisedField.type === 'object' && !revisedField.properties) {
revisedField.uniforms = {
component: PropertiesField,
};
}
return revisedField;
}
}
19 changes: 19 additions & 0 deletions src/components/metadata/MetadataEditorModal.css
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions src/components/metadata/MetadataEditorModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{ margin: '3em' }}>
<Story />
</div>
),
],
argTypes: { handleCloseModal: { action: 'clicked' } },
} as Meta<typeof MetadataEditorModal>;

const Template: StoryFn<typeof MetadataEditorModal> = (args) => {
const [{ isModalOpen }, updateArgs] = useArgs();
const handleClose = () => updateArgs({ isModalOpen: !isModalOpen });
return (
<>
<button onClick={() => updateArgs({ isModalOpen: !isModalOpen })}>
Open Metadata Editor Modal
</button>
<MetadataEditorModal {...args} handleCloseModal={handleClose} />
</>
);
};

export const BeansArray = Template.bind({});
BeansArray.args = {
name: 'beans',
schema: mockSchema.beans,
};

export const SingleObject = Template.bind({});
SingleObject.args = {
name: 'singleObject',
schema: mockSchema.single,
};
105 changes: 105 additions & 0 deletions src/components/metadata/MetadataEditorModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MetadataEditorModal
handleCloseModal={jest.fn()}
isModalOpen={true}
name="beans"
schema={mockSchema.beans}
/>,
);
const element = screen.queryByTestId('metadata-beans-modal');
expect(element).toBeInTheDocument();
});

test('component does not render if closed', () => {
useFlowsStore.getState().setMetadata('beans', []);
render(
<MetadataEditorModal
handleCloseModal={jest.fn()}
isModalOpen={false}
name="beans"
schema={mockSchema.beans}
/>,
);
const element = screen.queryByTestId('metadata-beans-modal');
expect(element).not.toBeInTheDocument();
});

test('Details disabled if empty', async () => {
useFlowsStore.getState().setMetadata('beans', []);
render(
<MetadataEditorModal
handleCloseModal={jest.fn()}
isModalOpen={true}
name="beans"
schema={mockSchema.beans}
/>,
);
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(
<MetadataEditorModal
handleCloseModal={jest.fn()}
isModalOpen={true}
name="beans"
schema={mockSchema.beans}
/>,
);
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(
<MetadataEditorModal
handleCloseModal={jest.fn()}
isModalOpen={true}
name="beans"
schema={mockSchema.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);
});
});
Loading