Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/jsx-runtime",
"prettier"
],
"overrides": [
Expand Down Expand Up @@ -95,6 +96,7 @@
"react-hooks/exhaustive-deps": "warn",
"react/no-unescaped-entities": ["error", { "forbid": [">", "}"] }],
"spaced-comment": "error",
"use-isnan": "error"
"use-isnan": "error",
"react/react-in-jsx-scope": "off"
}
}
47 changes: 34 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This repo contains a set of opinionated react component groups used to standardize functionality and look and feel across products. The components are based on PatternFly with some additional functionality.

### Branches

`main` - PatternFly 6 implementation

`v5` - PatternFly 5 implementation
Expand All @@ -14,22 +15,27 @@ This repo contains a set of opinionated react component groups used to standardi
---

### Migration from [RedHatInsights/frontend-components](https://github.com/RedHatInsights/frontend-components) to [patternfly/react-component-groups](https://github.com/patternfly/react-component-groups)

Please see the [migration guide](./migration.md)

---

## Contribution guide

### Before adding a new component:

- make sure your use case is new/complex enough to be added to this extension
- the component should bring a value value above and beyond existing PatternFly components

### To add a new component:

1. create a folder in `src/` matching its name (for example `src/MyComponent`)
2. to the new folder add a new `.tsx` file named after the component (for example `src/MyComponent/MyComponent.tsx`)
3. to the same folder include an `index.ts` which will export the component as a default and then all necessary interfaces
4. if this file structure is not met, your component won't be exposed correctly

#### Example component:

```
import * as React from 'react';
import { Content } from '@patternfly/react-core';
Expand All @@ -49,7 +55,8 @@ const useStyles = createUseStyles({
})

// do not use the named export of your component, just a default one
const MyComponent: React.FunctionComponent<MyComponentProps> = () => {
import { FunctionComponent } from 'react';
const MyComponent: FunctionComponent<MyComponentProps> = () => {
const classes = useStyles();

return (
Expand All @@ -60,42 +67,49 @@ const MyComponent: React.FunctionComponent<MyComponentProps> = () => {
};

export default MyComponent;
```
```

#### Index file example:

```
export { default } from './MyComponent';
export * from './MyComponent';
```
```

#### Component directory structure example:

```
src
|- MyComponent
|- index.ts
|- MyComponent.tsx
```
```

### Component's API rules:

- prop names comply with PatternFly components naming standards (`variant`, `onClick`, `position`, etc.)
- the API is maximally simplified and all props are provided with a description
- it is built on top of existing PatternFly types without prop omitting
- it is well documented using the PatternFly documentation (`/packages/module/patternfly-docs/content/extensions/component-groups/examples/MyComponent/MyComponent.md`) with examples of all possible use cases (`packages/module/patternfly-docs/content/extensions/component-groups/examples/MyComponent/MyComponent[...]Example.tsx`)
- do not unnecessarily use external libraries in your component - rather, delegate the necessary logic to the component's user using the component's API

#### Component API definition example:

```

import { FunctionComponent } from 'react';

// when possible, extend available PatternFly types
export interface MyComponentProps extends ButtonProps {
customLabel: Boolean
};

export const MyComponent: React.FunctionComponent<MyComponentProps> = ({ customLabel, ...props }) => ( ... );
export const MyComponent: FunctionComponent<MyComponentProps> = ({ customLabel, ...props }) => ( ... );
```


#### Markdown file example:
```

````
---
section: Component groups
subsection: My component's category
Expand All @@ -113,36 +127,42 @@ MyComponent has been created to demo contributing to this repository.

```js file="./MyComponentExample.tsx"```

```
````

#### Component usage file example: (`MyComponentExample.tsx`)

```
import React from 'react';
import { FunctionComponent } from 'react';

const MyComponentExample: React.FunctionComponent = () => (
const MyComponentExample: FunctionComponent = () => (
<MyComponent customLabel="My label">
);

export default MyComponentExample;
```

### Sub-components:
When adding a component for which it is advantageous to divide it into several sub-components make sure:

When adding a component for which it is advantageous to divide it into several sub-components make sure:

- component and all its sub-components are located in separate files and directories straight under the `src/` folder
- sub-components are exported and documented separately from their parent
- parent component should provide a way to pass props to all its sub-components

The aim is to enable the user of our "complex" component to use either complete or take advantage of its sub-components and manage their composition independently.

### Testing:

When adding/making changes to a component, always make sure your code is tested:
- use React Testing Library for unit testing

- use React Testing Library for unit testing
- add unit tests to a `[ComponentName].test.tsx` file to your component's directory
- make sure all the core functionality is covered using Cypress component or E2E tests
- add component tests to `cypress/component/[ComponentName].cy.tsx` file and E2E tests to `cypress/e2e/[ComponentName].spec.cy.ts`
- add `ouiaId` to the component props definition with a default value of the component name (for subcomponents, let's use `ComponentName-element-specification` naming convention e.g. `ouiaId="WarningModal-confirm-button"`)

### Styling:

- for styling always use JSS
- new classNames should be named in camelCase starting with the name of a given component and following with more details clarifying its purpose/component's subsection to which the class is applied (`actionMenu`, `actionMenuDropdown`, `actionMenuDropdownToggle`, etc.)
- do not use `pf-v6-u-XXX` classes, use CSS variables in a custom class instead (styles for the utility classes are not bundled with the standard patternfly.css - it would require the consumer to import also addons.css)
Expand All @@ -153,10 +173,12 @@ When adding/making changes to a component, always make sure your code is tested:
- run `npm run build`

## Development

- run `npm install`
- run `npm run start` to build and start the development server

## Testing and Linting

- run `npm run test` to run the unit tests
- run `npm run cypress:run:cp` to run Cypress component tests
- run `npm run cypress:run:e2e` to run Cypress E2E tests
Expand All @@ -165,4 +187,3 @@ When adding/making changes to a component, always make sure your code is tested:
## A11y testing

- run `npm run build:docs` followed by `npm run serve:docs`, then run `npm run test:a11y` in a new terminal window to run our accessibility tests for the components. Once the accessibility tests have finished running you can run `npm run serve:a11y` to locally view the generated report.

7 changes: 3 additions & 4 deletions cypress/component/Ansible.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import React from 'react';
import { Ansible } from '@patternfly/react-component-groups/dist/dynamic/Ansible';

describe('Ansible', () => {
it('renders supported Ansible', () => {
cy.mount(<Ansible />)
cy.mount(<Ansible />);
cy.get('i').should('have.class', 'ansibleSupported-0-2-2');
});
it('renders unsupported Ansible', () => {
cy.mount(<Ansible isSupported={false}/>)
cy.mount(<Ansible isSupported={false} />);
cy.get('i').should('have.class', 'ansibleUnsupported-0-2-3');
});
});
});
56 changes: 29 additions & 27 deletions cypress/component/BulkSelect.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import React, { useState } from 'react';
import { useState } from 'react';
import BulkSelect, { BulkSelectProps, BulkSelectValue } from '../../packages/module/dist/dynamic/BulkSelect';

interface DataItem {
name: string
};
name: string;
}

const BulkSelectTestComponent = ({ canSelectAll, isDataPaginated }: Omit<BulkSelectProps, 'onSelect' | 'selectedCount' >) => {
const BulkSelectTestComponent = ({
canSelectAll,
isDataPaginated
}: Omit<BulkSelectProps, 'onSelect' | 'selectedCount'>) => {
const [ selected, setSelected ] = useState<DataItem[]>([]);

const allData = [ { name: '1' }, { name: '2' }, { name: '3' }, { name: '4' }, { name: '5' }, { name: '6' } ];
const pageData = [ { name: '1' }, { name: '2' }, { name: '3' }, { name: '4' }, { name: '5' } ];
const pageDataNames = pageData.map((item) => item.name);
const pageSelected = pageDataNames.every(item => selected.find(selectedItem => selectedItem.name === item));
const pageSelected = pageDataNames.every((item) => selected.find((selectedItem) => selectedItem.name === item));

const handleBulkSelect = (value: BulkSelectValue) => {
if (value === BulkSelectValue.page) {
const updatedSelection = [ ...selected ];
pageData.forEach(item => !updatedSelection.some(selectedItem => selectedItem.name === item.name) && updatedSelection.push(item));
pageData.forEach(
(item) =>
!updatedSelection.some((selectedItem) => selectedItem.name === item.name) && updatedSelection.push(item)
);
setSelected(updatedSelection);
}
value === BulkSelectValue.nonePage && setSelected(selected.filter(item => !pageDataNames.includes(item.name)))
value === BulkSelectValue.nonePage && setSelected(selected.filter((item) => !pageDataNames.includes(item.name)));
value === BulkSelectValue.none && setSelected([]);
value === BulkSelectValue.all && setSelected(allData);
};
Expand All @@ -32,71 +38,67 @@ const BulkSelectTestComponent = ({ canSelectAll, isDataPaginated }: Omit<BulkSel
totalCount={allData.length}
selectedCount={selected.length}
pageSelected={pageSelected}
pagePartiallySelected={pageDataNames.some(item => selected.find(selectedItem => selectedItem.name === item)) && !pageSelected}
pagePartiallySelected={
pageDataNames.some((item) => selected.find((selectedItem) => selectedItem.name === item)) && !pageSelected
}
onSelect={handleBulkSelect}
/>
);
};

describe('BulkSelect', () => {
it('renders the bulk select without all', () => {
cy.mount(
<BulkSelectTestComponent />
);
cy.mount(<BulkSelectTestComponent />);
cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').should('exist');
cy.get('[data-ouia-component-id="BulkSelect-toggle"]').click();
cy.get('[data-ouia-component-id="BulkSelect-select-all"]').should('not.exist');
cy.get('[data-ouia-component-id="BulkSelect-select-page"]').should('exist');
cy.get('[data-ouia-component-id="BulkSelect-select-none"]').should('exist');

cy.contains('0 selected').should('not.exist');
});

it('renders the bulk select with all and without page', () => {
cy.mount(
<BulkSelectTestComponent canSelectAll isDataPaginated={false} />
);
cy.mount(<BulkSelectTestComponent canSelectAll isDataPaginated={false} />);
cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').should('exist');
cy.get('[data-ouia-component-id="BulkSelect-toggle"]').click();
cy.get('[data-ouia-component-id="BulkSelect-select-all"]').should('exist');
cy.get('[data-ouia-component-id="BulkSelect-select-page"]').should('not.exist');
cy.get('[data-ouia-component-id="BulkSelect-select-none"]').should('exist');

cy.contains('0 selected').should('not.exist');
});

it('renders the bulk select with data', () => {
cy.mount(
<BulkSelectTestComponent canSelectAll />
);

cy.mount(<BulkSelectTestComponent canSelectAll />);

// Initial state
cy.get('input[type="checkbox"]').each(($checkbox) => {
cy.wrap($checkbox).should('not.be.checked');
});

// Checkbox select
cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').first().click();
cy.get('input[type="checkbox"]').should('be.checked');
cy.contains('5 selected').should('exist');

// Select none
cy.get('[data-ouia-component-id="BulkSelect-toggle"]').first().click({ force: true });
cy.get('[data-ouia-component-id="BulkSelect-select-none"]').first().click();
cy.get('input[type="checkbox"]').should('not.be.checked');

// Select all
cy.get('[data-ouia-component-id="BulkSelect-toggle"]').first().click({ force: true });
cy.get('[data-ouia-component-id="BulkSelect-select-all"]').first().click();
cy.contains('6 selected').should('exist');

// Checkbox deselect
cy.get('[data-ouia-component-id="BulkSelect-checkbox"]').first().click({ force: true });
cy.contains('1 selected').should('exist');

// Select page
cy.get('[data-ouia-component-id="BulkSelect-toggle"]').first().click({ force: true });
cy.get('[data-ouia-component-id="BulkSelect-select-page"]').first().click();
cy.contains('6 selected').should('exist');
});
});
});
15 changes: 11 additions & 4 deletions cypress/component/CloseButton.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import React from 'react';
import CloseButton from '../../packages/module/dist/dynamic/CloseButton';

describe('CloseButton', () => {
/* eslint-disable no-console */
it('renders the Close button', () => {
cy.mount(<CloseButton dataTestID="close-button-example" onClick={()=>{console.log('Close button clicked')}} style={{ float: 'none' }}/>)
cy.mount(
<CloseButton
dataTestID="close-button-example"
onClick={() => {
console.log('Close button clicked');
}}
style={{ float: 'none' }}
/>
);
cy.get('[data-test-id="close-button-example"]').should('exist');
});
it('should call callback on click', () => {
const onClickSpy = cy.spy().as('onClickSpy');
cy.mount(<CloseButton dataTestID="close-button-example" onClick={onClickSpy}/>);
cy.mount(<CloseButton dataTestID="close-button-example" onClick={onClickSpy} />);
cy.get('[data-test-id="close-button-example"]').click();
cy.get('@onClickSpy').should('have.been.called');
});
})
});
19 changes: 12 additions & 7 deletions cypress/component/ErrorBoundary.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import React from 'react';
import ErrorBoundary from '../../packages/module/dist/dynamic/ErrorBoundary';

describe('ErrorBoundary', () => {
it('renders the ErrorBoundary ', () => {
cy.mount(<ErrorBoundary headerTitle="My app header" errorTitle="Something wrong happened"><div data-ouia-component-id="test">Test</div></ErrorBoundary>)
cy.mount(
<ErrorBoundary headerTitle="My app header" errorTitle="Something wrong happened">
<div data-ouia-component-id="test">Test</div>
</ErrorBoundary>
);
cy.get('[data-ouia-component-id="test"]').should('have.text', 'Test');
});

it('should expand the details section', () => {
const Surprise = () => {
throw new Error('but a welcome one');
};
cy.mount(<ErrorBoundary headerTitle="My app header" errorTitle="Something wrong happened">
<Surprise />
</ErrorBoundary>)
cy.mount(
<ErrorBoundary headerTitle="My app header" errorTitle="Something wrong happened">
<Surprise />
</ErrorBoundary>
);

cy.get('[data-ouia-component-id="ErrorBoundary-toggle"').click();
cy.get('[class="pf-v5-c-expandable-section__content"]').should('contain.text', 'Error: but a welcome one');
})
})
});
});
Loading