Skip to content

Commit 37e31c1

Browse files
authored
Merge pull request #15 from fhlavac/provider
Add DataViewContext and events provider
2 parents 7dd3591 + 833848d commit 37e31c1

File tree

7 files changed

+376
-0
lines changed

7 files changed

+376
-0
lines changed

cypress/e2e/DataViewEvents.spec.cy.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
describe('Test the Data view docs page', () => {
2+
3+
it('displays a table and opens detail', () => {
4+
const ouiaId = 'ContextExample';
5+
6+
cy.visit('http://localhost:8006/extensions/data-view/context');
7+
8+
cy.get(`[data-ouia-component-id="${ouiaId}-th-0"]`).contains('Repositories');
9+
cy.get(`[data-ouia-component-id="${ouiaId}-th-4"]`).contains('Last commit');
10+
11+
cy.get(`[data-ouia-component-id="${ouiaId}-td-0-0"]`).contains('one');
12+
cy.get(`[data-ouia-component-id="${ouiaId}-td-4-4"]`).contains('five - 5');
13+
cy.get(`[data-ouia-component-id="${ouiaId}-td-7-4"]`).should('not.exist');
14+
15+
// click the first row
16+
cy.get(`[data-ouia-component-id="${ouiaId}-tr-0"]`).first().click();
17+
cy.get(`[data-ouia-component-id="detail-drawer"]`).should('exist');
18+
cy.get(`[data-ouia-component-id="detail-drawer-title"]`).contains('Detail of repository one');
19+
cy.get(`[data-ouia-component-id="detail-drawer-close-btn"]`).should('be.visible');
20+
21+
// click the first row again
22+
cy.get(`[data-ouia-component-id="${ouiaId}-tr-0"]`).first().click({ force: true });
23+
cy.get(`[data-ouia-component-id="detail-drawer-title"]`).should('not.be.visible');
24+
25+
// click the second row
26+
cy.get(`[data-ouia-component-id="${ouiaId}-tr-1"]`).first().click();
27+
cy.get(`[data-ouia-component-id="detail-drawer"]`).should('be.visible');
28+
cy.get(`[data-ouia-component-id="detail-drawer-title"]`).contains('Detail of repository one - 2');
29+
30+
// click the close button
31+
cy.get(`[data-ouia-component-id="detail-drawer-close-btn"]`).first().click({ force: true });
32+
cy.get(`[data-ouia-component-id="detail-drawer-title"]`).should('not.be.visible');
33+
})
34+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
# Sidenav top-level section
3+
# should be the same for all markdown files
4+
section: extensions
5+
subsection: Data view
6+
# Sidenav secondary level section
7+
# should be the same for all markdown files
8+
id: Context
9+
# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility)
10+
source: react
11+
# If you use typescript, the name of the interface to display props for
12+
# These are found through the sourceProps function provided in patternfly-docs.source.js
13+
sortValue: 3
14+
sourceLink: https://github.com/patternfly/react-data-view/blob/main/packages/module/patternfly-docs/content/extensions/data-view/examples/Events/Events.md
15+
---
16+
import { useState, useEffect, useContext, useRef } from 'react';
17+
import { Table, Tbody, Th, Thead, Tr, Td } from '@patternfly/react-table';
18+
import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView';
19+
import DataViewContext, { DataViewProvider, EventTypes } from '@patternfly/react-data-view/dist/dynamic/DataViewContext';
20+
import { Drawer, DrawerContent, DrawerContentBody } from '@patternfly/react-core';
21+
22+
The **data view** context provides a way to share data across the entire data view tree without having to pass props manually at every level. It also provides a way of listening to the data view events from the outside of the component.
23+
24+
25+
### Events sharing example
26+
The following example demonstrates how to use the `DataViewContext` to manage shared state and handle events. The `DataViewProvider` is used to wrap components that need access to the shared context. This example illustrates how to set up a layout that listens for data view row click events and displays detailed information about the selected row in a [drawer component](/components/drawer).
27+
28+
29+
```js file="./EventsExample.tsx"
30+
31+
```
32+
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import React, { useContext, useEffect, useState, useRef } from 'react';
2+
import { Drawer, DrawerActions, DrawerCloseButton, DrawerContent, DrawerContentBody, DrawerHead, DrawerPanelContent, Title, Text } from '@patternfly/react-core';
3+
import { Table, Tbody, Th, Thead, Tr, Td } from '@patternfly/react-table';
4+
import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView';
5+
import DataViewContext, { DataViewProvider, EventTypes } from '@patternfly/react-data-view/dist/dynamic/DataViewContext';
6+
7+
interface Repository {
8+
name: string;
9+
branches: string | null;
10+
prs: string | null;
11+
workspaces: string;
12+
lastCommit: string;
13+
}
14+
15+
const repositories: Repository[] = [
16+
{ name: 'one', branches: 'two', prs: 'three', workspaces: 'four', lastCommit: 'five' },
17+
{ name: 'one - 2', branches: null, prs: null, workspaces: 'four - 2', lastCommit: 'five - 2' },
18+
{ name: 'one - 3', branches: 'two - 3', prs: 'three - 3', workspaces: 'four - 3', lastCommit: 'five - 3' },
19+
{ name: 'one - 4', branches: 'two - 4', prs: 'null', workspaces: 'four - 4', lastCommit: 'five - 4' },
20+
{ name: 'one - 5', branches: 'two - 5', prs: 'three - 5', workspaces: 'four - 5', lastCommit: 'five - 5' },
21+
{ name: 'one - 6', branches: 'two - 6', prs: 'three - 6', workspaces: 'four - 6', lastCommit: 'five - 6' }
22+
];
23+
24+
const cols: Record<keyof Repository, string> = {
25+
name: 'Repositories',
26+
branches: 'Branches',
27+
prs: 'Pull requests',
28+
workspaces: 'Workspaces',
29+
lastCommit: 'Last commit'
30+
};
31+
32+
const ouiaId = 'ContextExample';
33+
34+
interface RepositoryDetailProps {
35+
selectedRepo?: Repository;
36+
setSelectedRepo: React.Dispatch<React.SetStateAction<Repository | undefined>>;
37+
}
38+
39+
const RepositoryDetail: React.FunctionComponent<RepositoryDetailProps> = ({ selectedRepo, setSelectedRepo }) => {
40+
const context = useContext(DataViewContext);
41+
42+
useEffect(() => {
43+
const unsubscribe = context.subscribe(EventTypes.rowClick, (repo: Repository) => {
44+
setSelectedRepo(repo);
45+
});
46+
47+
return () => unsubscribe();
48+
// eslint-disable-next-line react-hooks/exhaustive-deps
49+
}, []);
50+
51+
return (
52+
<DrawerPanelContent>
53+
<DrawerHead>
54+
<Title className="pf-v5-u-mb-md" headingLevel="h2" ouiaId="detail-drawer-title">
55+
Detail of repository {selectedRepo?.name}
56+
</Title>
57+
<Text>Branches: {selectedRepo?.branches}</Text>
58+
<Text>Pull requests: {selectedRepo?.prs}</Text>
59+
<Text>Workspaces: {selectedRepo?.workspaces}</Text>
60+
<Text>Last commit: {selectedRepo?.lastCommit}</Text>
61+
<DrawerActions>
62+
<DrawerCloseButton onClick={() => setSelectedRepo(undefined)} data-ouia-component-id="detail-drawer-close-btn"/>
63+
</DrawerActions>
64+
</DrawerHead>
65+
</DrawerPanelContent>
66+
);
67+
};
68+
69+
interface RepositoriesTableProps {
70+
selectedRepo?: Repository;
71+
}
72+
73+
const RepositoriesTable: React.FunctionComponent<RepositoriesTableProps> = ({ selectedRepo = undefined }) => {
74+
const { trigger } = useContext(DataViewContext);
75+
76+
const handleRowClick = (repo: Repository | undefined) => {
77+
trigger(EventTypes.rowClick, repo);
78+
};
79+
80+
return (
81+
<DataView>
82+
<Table aria-label="Repositories table" ouiaId={ouiaId}>
83+
<Thead data-ouia-component-id={`${ouiaId}-thead`}>
84+
<Tr ouiaId={`${ouiaId}-tr-head`}>
85+
{Object.values(cols).map((column, index) => (
86+
<Th key={index} data-ouia-component-id={`${ouiaId}-th-${index}`}>
87+
{column}
88+
</Th>
89+
))}
90+
</Tr>
91+
</Thead>
92+
<Tbody>
93+
{repositories.map((repo, rowIndex) => (
94+
<Tr
95+
isClickable
96+
key={repo.name}
97+
ouiaId={`${ouiaId}-tr-${rowIndex}`}
98+
onRowClick={() => handleRowClick(selectedRepo?.name === repo.name ? undefined : repo)}
99+
isRowSelected={selectedRepo?.name === repo.name}
100+
>
101+
{Object.keys(cols).map((column, colIndex) => (
102+
<Td key={colIndex} data-ouia-component-id={`${ouiaId}-td-${rowIndex}-${colIndex}`}>
103+
{repo[column as keyof Repository]}
104+
</Td>
105+
))}
106+
</Tr>
107+
))}
108+
</Tbody>
109+
</Table>
110+
</DataView>
111+
);
112+
};
113+
114+
export const BasicExample: React.FunctionComponent = () => {
115+
const [ selectedRepo, setSelectedRepo ] = useState<Repository>();
116+
const drawerRef = useRef<HTMLDivElement>(null);
117+
118+
return (
119+
<DataViewProvider>
120+
<Drawer isExpanded={Boolean(selectedRepo)} onExpand={() => drawerRef.current?.focus()} data-ouia-component-id="detail-drawer" >
121+
<DrawerContent
122+
panelContent={<RepositoryDetail selectedRepo={selectedRepo} setSelectedRepo={setSelectedRepo} />}
123+
>
124+
<DrawerContentBody>
125+
<RepositoriesTable selectedRepo={selectedRepo} />
126+
</DrawerContentBody>
127+
</DrawerContent>
128+
</Drawer>
129+
</DataViewProvider>
130+
);
131+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React from 'react';
2+
import { render, fireEvent } from '@testing-library/react';
3+
import { DataViewContext, DataViewProvider, EventTypes } from './DataViewContext';
4+
5+
let id = 0;
6+
7+
beforeAll(() => {
8+
Object.defineProperty(global, 'crypto', {
9+
value: {
10+
randomUUID: jest.fn(() => `mocked-uuid-${id++}`),
11+
},
12+
});
13+
});
14+
15+
describe('DataViewContext', () => {
16+
test('should provide context value and allow subscriptions', () => {
17+
const callback = jest.fn();
18+
19+
const TestComponent = () => {
20+
const { subscribe, trigger } = React.useContext(DataViewContext);
21+
22+
React.useEffect(() => {
23+
const unsubscribe = subscribe(EventTypes.rowClick, callback);
24+
return () => unsubscribe();
25+
// eslint-disable-next-line react-hooks/exhaustive-deps
26+
}, []);
27+
28+
return (
29+
<button onClick={() => trigger(EventTypes.rowClick, 'some payload')}>Trigger</button>
30+
);
31+
};
32+
33+
const { getByText } = render(
34+
<DataViewProvider>
35+
<TestComponent />
36+
</DataViewProvider>
37+
);
38+
39+
fireEvent.click(getByText('Trigger'));
40+
expect(callback).toHaveBeenCalledWith('some payload');
41+
});
42+
43+
test('should handle unsubscribing correctly', () => {
44+
const callback = jest.fn();
45+
46+
const TestComponent = () => {
47+
const { subscribe, trigger } = React.useContext(DataViewContext);
48+
49+
React.useEffect(() => {
50+
const unsubscribe = subscribe(EventTypes.rowClick, callback);
51+
unsubscribe();
52+
// eslint-disable-next-line react-hooks/exhaustive-deps
53+
}, []);
54+
55+
return (
56+
<button onClick={() => trigger(EventTypes.rowClick, 'some payload')}>Trigger</button>
57+
);
58+
};
59+
60+
const { getByText } = render(
61+
<DataViewProvider>
62+
<TestComponent />
63+
</DataViewProvider>
64+
);
65+
66+
fireEvent.click(getByText('Trigger'));
67+
68+
expect(callback).not.toHaveBeenCalled();
69+
});
70+
71+
test('should handle multiple subscriptions and trigger events correctly', () => {
72+
const callback1 = jest.fn();
73+
const callback2 = jest.fn();
74+
75+
const TestComponent = () => {
76+
const { subscribe, trigger } = React.useContext(DataViewContext);
77+
78+
React.useEffect(() => {
79+
const unsubscribe1 = subscribe(EventTypes.rowClick, callback1);
80+
const unsubscribe2 = subscribe(EventTypes.rowClick, callback2);
81+
82+
return () => {
83+
unsubscribe1();
84+
unsubscribe2();
85+
};
86+
// eslint-disable-next-line react-hooks/exhaustive-deps
87+
}, []);
88+
89+
return (
90+
<button onClick={() => trigger(EventTypes.rowClick, 'some payload')}>Trigger</button>
91+
);
92+
};
93+
94+
const { getByText } = render(
95+
<DataViewProvider>
96+
<TestComponent />
97+
</DataViewProvider>
98+
);
99+
100+
fireEvent.click(getByText('Trigger'));
101+
102+
expect(callback1).toHaveBeenCalledWith('some payload');
103+
expect(callback2).toHaveBeenCalledWith('some payload');
104+
});
105+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React, {
2+
PropsWithChildren,
3+
createContext,
4+
useCallback,
5+
useState
6+
} from "react";
7+
8+
export const EventTypes = {
9+
rowClick: 'rowClick'
10+
} as const;
11+
12+
export type DataViewEvent = typeof EventTypes[keyof typeof EventTypes];
13+
14+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
15+
type Callback = (...args: any[]) => void;
16+
interface Subscriptions { [id: string]: Callback }
17+
type ContextType = { [event in DataViewEvent]: Subscriptions };
18+
type Subscribe = (event: DataViewEvent, callback: Callback) => () => void;
19+
20+
export const DataViewContext = createContext<{
21+
subscribe: Subscribe;
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
trigger: (event: DataViewEvent, ...payload: any[]) => void;
24+
}>({
25+
subscribe: () => () => null,
26+
trigger: () => null
27+
});
28+
29+
export const DataViewProvider = ({ children }: PropsWithChildren) => {
30+
const [ subscriptions, setSubscriptions ] = useState<ContextType>({
31+
[EventTypes.rowClick]: {}
32+
});
33+
34+
const subscribe: Subscribe = (event, callback) => {
35+
const id = crypto.randomUUID();
36+
37+
// set new subscription
38+
setSubscriptions((prevSubscriptions) => ({
39+
...prevSubscriptions,
40+
[event]: { ...prevSubscriptions[event], [id]: callback }
41+
}));
42+
43+
// return unsubscribe function
44+
return () => {
45+
setSubscriptions((prevSubscriptions) => {
46+
const updatedSubscriptions = { ...prevSubscriptions };
47+
delete updatedSubscriptions[event][id];
48+
return updatedSubscriptions;
49+
});
50+
};
51+
};
52+
53+
const trigger = useCallback(
54+
(event: DataViewEvent, ...payload: unknown[]) => {
55+
Object.values(subscriptions[event]).forEach((callback) => {
56+
callback(...payload);
57+
});
58+
},
59+
[ subscriptions ]
60+
);
61+
62+
return (
63+
<DataViewContext.Provider value={{ subscribe, trigger }}>
64+
{children}
65+
</DataViewContext.Provider>
66+
);
67+
};
68+
69+
export default DataViewContext;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './DataViewContext';
2+
export * from './DataViewContext';

packages/module/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ export * from './Hooks';
44
export { default as DataViewToolbar } from './DataViewToolbar';
55
export * from './DataViewToolbar';
66

7+
export { default as DataViewContext } from './DataViewContext';
8+
export * from './DataViewContext';
9+
710
export { default as DataView } from './DataView';
811
export * from './DataView';

0 commit comments

Comments
 (0)