Skip to content

Commit

Permalink
feat(Beans): Port Bean Configuration UI
Browse files Browse the repository at this point in the history
Fixes: #8
  • Loading branch information
igarashitm committed Sep 28, 2023
1 parent 517a16d commit 4b6cc23
Show file tree
Hide file tree
Showing 32 changed files with 1,745 additions and 1 deletion.
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

0 comments on commit 4b6cc23

Please sign in to comment.