Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Beans): Port Bean Configuration UI #166

Merged
merged 1 commit into from
Oct 2, 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
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.4.0",
"@types/lodash.clonedeep": "^4.5.7",
"@types/lodash.get": "^4.4.2",
"@types/lodash.set": "^4.3.7",
"@types/node": "^18.0.0",
Expand All @@ -88,6 +89,7 @@
"eslint-plugin-react-refresh": "^0.4.3",
"jest": "^29.4.2",
"jest-environment-jsdom": "^29.4.2",
"lodash.clonedeep": "^4.5.0",
"prettier": "^3.0.0",
"react-test-renderer": "^18.2.0",
"sass": "^1.63.6",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/camel-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './camel-to-tabs.adapter';
export * from './is-camel-route';
export * from './is-kamelet-binding';
export * from './is-pipe';
export * from './is-beans';
14 changes: 14 additions & 0 deletions packages/ui/src/camel-utils/is-beans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { isBeans } from './is-beans';
import { beansJson } from '../stubs/beans';

describe('isBeans', () => {
it.each([
[beansJson, true],
[undefined, false],
[null, false],
[true, false],
[false, false],
])('should mark %s as isBeans: %s', (beans, result) => {
expect(isBeans(beans)).toEqual(result);
});
});
11 changes: 11 additions & 0 deletions packages/ui/src/camel-utils/is-beans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { BeansDeserializer } from '@kaoto-next/camel-catalog/types';
import { isDefined } from '../utils';

/** Very basic check to determine whether this object is a Beans */
export const isBeans = (rawEntity: unknown): rawEntity is {beans: BeansDeserializer} => {
if (!isDefined(rawEntity) || Array.isArray(rawEntity) || typeof rawEntity !== 'object') {
return false;
}

return 'beans' in rawEntity! && Array.isArray((rawEntity! as any).beans);
};
6 changes: 6 additions & 0 deletions packages/ui/src/components/Form/CustomAutoField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { createAutoField } from 'uniforms';
import { CustomNestField } from './CustomNestField';
import { DisabledField } from './DisabledField';
import { PropertiesField } from "./properties/PropertiesField";

/**
* Custom AutoField that supports all the fields from Uniforms PatternFly
Expand All @@ -24,6 +25,11 @@ export const CustomAutoField = createAutoField((props) => {
return DisabledField;
}

// Assuming generic object field without amy children to use PropertiesField
if (props.fieldType === Object && (props.field as any)?.type === 'object' && !(props.field as any)?.properties) {
return PropertiesField;
}

switch (props.fieldType) {
case Array:
return ListField;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { AddPropertyButtons } from './AddPropertyButtons.tsx';
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 packages/ui/src/components/Form/properties/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>
);
}
236 changes: 236 additions & 0 deletions packages/ui/src/components/Form/properties/PropertiesField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import {AddPropertyButtons} from './AddPropertyButtons.tsx';
import {PropertyRow} from './PropertyRow.tsx';
import {wrapField} from '@kaoto-next/uniforms-patternfly';
import {
EmptyState,
EmptyStateBody, ExpandableSection,
Stack,
StackItem
} from '@patternfly/react-core';
import {
Table,
TableVariant,
Tbody,
Td,
TdProps,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import {ReactNode, useState} from 'react';
import {HTMLFieldProps, connectField} from 'uniforms';

export type PropertiesFieldProps = HTMLFieldProps<any, HTMLDivElement>;

/**
* 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
*/
export const PropertiesField = connectField((props: PropertiesFieldProps) => {
const [isFieldExpanded, setFieldExpanded] = useState<boolean>(false);
const [expandedNodes, setExpandedNodes] = useState<string[]>([]);
const [placeholderState, setPlaceholderState] = useState<PlaceholderState | null>(null);
const propertiesModel = props.value ? {...props.value} : {};

type PlaceholderState = {
isObject: boolean;
parentNodeId: string;
};

function handleModelChange() {
setPlaceholderState(null);
props.onChange(propertiesModel, props.name);
}

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
const placeholderTreeRow: TdProps['treeRow'] = {
rowIndex,
onCollapse: () => {
},
props: {
isRowSelected: true,
isExpanded: false,
isHidden: false,
'aria-level': level,
'aria-posinset': posinset,
'aria-setsize': 0,
},
};

return placeholderState && placeholderState.parentNodeId === getNodeId(parentPath)
? [
<PropertyRow
isPlaceholder={true}
key="placeholder"
propertyName={props.name}
nodeName=""
nodeValue={placeholderState.isObject ? {} : ''}
path={parentPath}
parentModel={parentModel}
treeRow={placeholderTreeRow}
isObject={placeholderState.isObject}
onChangeModel={handleModelChange}
createPlaceholder={() => {
}}
/>,
]
: [];
}

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 [
<PropertyRow
key={`${props.name}-${getNodeId(path)}`}
propertyName={props.name}
nodeName={nodeName}
nodeValue={nodeValue}
path={path}
parentModel={parentModel}
treeRow={treeRow}
isObject={typeof nodeValue === 'object'}
onChangeModel={handleModelChange}
createPlaceholder={(isObject) => {
handleCreatePlaceHolder({
isObject: isObject,
parentNodeId: getNodeId(path),
});
}}
/>,
...childRows,
...siblingRows,
];
}

return wrapField(
props,
<ExpandableSection
toggleText={''}
onToggle={(_event, isExpanded) => setFieldExpanded(isExpanded)}
isExpanded={isFieldExpanded}
>
<Stack hasGutter>
<StackItem isFilled>
<Table
isTreeTable={true}
aria-label={props.name}
variant={TableVariant.compact}
borders={true}
isStickyHeader
>
<Thead>
<Tr key={`${props.name}-header`}>
<Th modifier="nowrap">NAME</Th>
<Th modifier="nowrap">VALUE</Th>
<Td modifier="nowrap" isActionCell>
<AddPropertyButtons
path={[]}
disabled={props.disabled}
createPlaceholder={(isObject) =>
handleCreatePlaceHolder({
isObject: isObject,
parentNodeId: '',
})
}
/>
</Td>
</Tr>
</Thead>
<Tbody>
{Object.keys(propertiesModel).length > 0 || placeholderState
? renderRows(Object.entries(propertiesModel), propertiesModel)
: !props.disabled && (
<Tr key={`${props.name}-empty`}>
<Td colSpan={3}>
<EmptyState>
<EmptyStateBody>No {props.name}</EmptyStateBody>
<AddPropertyButtons
showLabel={true}
path={[]}
disabled={props.disabled}
createPlaceholder={(isObject) =>
handleCreatePlaceHolder({
isObject: isObject,
parentNodeId: '',
})
}
/>
</EmptyState>
</Td>
</Tr>
)}
</Tbody>
</Table>
</StackItem>
</Stack>
</ExpandableSection>,
);
});
Loading