Skip to content

Commit 08da65b

Browse files
ElliotElliot ParkdbchristopherLuke Bowerman
authored
Action List: General Layout and Actions (#576)
* Added ActionList and sub-components * Added ActionList test file and playground demo * Modified existing Menu and Tooltip components / hooks to allow for clean showing and hiding of Actions button Co-authored-by: Elliot Park <[email protected]> Co-authored-by: Daniel Christopher <[email protected]> Co-authored-by: Luke Bowerman <[email protected]>
1 parent 2911656 commit 08da65b

22 files changed

+1046
-21
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222

2323
- `InputDate` and `InputDateRange` test mocks
2424

25+
## [0.7.23] - 2020-03-06
26+
27+
### Added
28+
29+
- `ActionList` and related sub-components - general layout and base functionality added; currently renders a list with data in columns and associated actions at the item level
30+
31+
### Changed
32+
33+
- `IconButton` - tooltip callbacks no longer override passed in callbacks
34+
- `Menu` - renamed isHovered variable to showDisclosure to make this prop's use alongside `MenuDisclosure` more obvious
35+
- `MenuContext` - renamed isHovered property to showDisclosure
36+
- `MenuDisclosure` - now has focus and blur handlers, which allows for tab-traversal to hidden `MenuDisclosure`
37+
2538
## [0.7.22] - 2020-02-27
2639

2740
### Fixed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import React from 'react'
28+
import { renderWithTheme } from '@looker/components-test-utils'
29+
import { fireEvent } from '@testing-library/react'
30+
import {
31+
ActionList,
32+
ActionListColumns,
33+
ActionListItem,
34+
ActionListItemAction,
35+
ActionListItemColumn,
36+
ActionListHeaderColumn,
37+
} from '.'
38+
39+
const columns: ActionListColumns = [
40+
{
41+
children: 'ID',
42+
id: 'id',
43+
primaryKey: true,
44+
type: 'number',
45+
widthPercent: 10,
46+
},
47+
{
48+
children: 'Name',
49+
id: 'name',
50+
type: 'string',
51+
widthPercent: 45,
52+
},
53+
{
54+
children: 'Role',
55+
id: 'role',
56+
type: 'string',
57+
widthPercent: 45,
58+
},
59+
]
60+
61+
const data = [
62+
{
63+
id: 1,
64+
name: 'Richard Garfield',
65+
type: 'Game Designer',
66+
},
67+
]
68+
69+
const header = (
70+
<>
71+
<ActionListHeaderColumn>Foo</ActionListHeaderColumn>
72+
<ActionListHeaderColumn>Bar</ActionListHeaderColumn>
73+
<ActionListHeaderColumn>FooBar</ActionListHeaderColumn>
74+
</>
75+
)
76+
77+
const items = data.map(({ id, name, type }) => {
78+
const availableActions = (
79+
<>
80+
<ActionListItemAction>View Profile</ActionListItemAction>
81+
</>
82+
)
83+
84+
return (
85+
<ActionListItem key={id} actions={availableActions}>
86+
<ActionListItemColumn>{id}</ActionListItemColumn>
87+
<ActionListItemColumn>{name}</ActionListItemColumn>
88+
<ActionListItemColumn>{type}</ActionListItemColumn>
89+
</ActionListItem>
90+
)
91+
})
92+
93+
const actionListWithGeneratedHeader = (
94+
<ActionList columns={columns}>{items}</ActionList>
95+
)
96+
97+
const actionListWithProvidedHeader = (
98+
<ActionList columns={columns} header={header}>
99+
{items}
100+
</ActionList>
101+
)
102+
103+
const actionListWithNoHeader = (
104+
<ActionList columns={columns} header={false}>
105+
{items}
106+
</ActionList>
107+
)
108+
109+
describe('<ActionList /> : General Layout', () => {
110+
let rafSpy: jest.SpyInstance<number, [FrameRequestCallback]>
111+
112+
beforeEach(() => {
113+
rafSpy = jest
114+
.spyOn(window, 'requestAnimationFrame')
115+
.mockImplementation((cb: any) => cb())
116+
})
117+
118+
afterEach(() => {
119+
rafSpy.mockRestore()
120+
})
121+
122+
test('Renders a generated header and list item', () => {
123+
const { getByText } = renderWithTheme(actionListWithGeneratedHeader)
124+
125+
expect(getByText('ID')).toBeInTheDocument()
126+
expect(getByText('Name')).toBeInTheDocument()
127+
expect(getByText('Role')).toBeInTheDocument()
128+
129+
expect(getByText('1')).toBeInTheDocument()
130+
expect(getByText('Richard Garfield')).toBeInTheDocument()
131+
expect(getByText('Game Designer')).toBeInTheDocument()
132+
})
133+
134+
test('Renders a provided header and list item', () => {
135+
const { getByText, queryByText } = renderWithTheme(
136+
actionListWithProvidedHeader
137+
)
138+
139+
expect(queryByText('ID')).not.toBeInTheDocument()
140+
expect(queryByText('Name')).not.toBeInTheDocument()
141+
expect(queryByText('Role')).not.toBeInTheDocument()
142+
143+
expect(getByText('Foo')).toBeInTheDocument()
144+
expect(getByText('Bar')).toBeInTheDocument()
145+
expect(getByText('FooBar')).toBeInTheDocument()
146+
147+
expect(getByText('1')).toBeInTheDocument()
148+
expect(getByText('Richard Garfield')).toBeInTheDocument()
149+
expect(getByText('Game Designer')).toBeInTheDocument()
150+
})
151+
152+
test('Renders no header if header prop value is false', () => {
153+
const { getByText, queryByText } = renderWithTheme(actionListWithNoHeader)
154+
155+
expect(queryByText('ID')).not.toBeInTheDocument()
156+
expect(queryByText('Name')).not.toBeInTheDocument()
157+
expect(queryByText('Role')).not.toBeInTheDocument()
158+
159+
expect(getByText('1')).toBeInTheDocument()
160+
expect(getByText('Richard Garfield')).toBeInTheDocument()
161+
expect(getByText('Game Designer')).toBeInTheDocument()
162+
})
163+
164+
test('Renders action menu on button click and handles clicks on list item and action', () => {
165+
const handleActionClick = jest.fn()
166+
const handleListItemClick = jest.fn()
167+
168+
const clickableItems = data.map(({ id, name, type }) => {
169+
const availableActions = (
170+
<>
171+
<ActionListItemAction onClick={handleActionClick}>
172+
View Profile
173+
</ActionListItemAction>
174+
</>
175+
)
176+
177+
return (
178+
<ActionListItem
179+
key={id}
180+
actions={availableActions}
181+
onClick={handleListItemClick}
182+
>
183+
<ActionListItemColumn>{id}</ActionListItemColumn>
184+
<ActionListItemColumn>{name}</ActionListItemColumn>
185+
<ActionListItemColumn>{type}</ActionListItemColumn>
186+
</ActionListItem>
187+
)
188+
})
189+
190+
const { getByRole, getByText, queryByText } = renderWithTheme(
191+
<ActionList columns={columns}>{clickableItems}</ActionList>
192+
)
193+
194+
const listItemId = getByText('1')
195+
196+
expect(handleListItemClick.mock.calls.length).toBe(0)
197+
fireEvent.click(listItemId)
198+
expect(handleListItemClick.mock.calls.length).toBe(1)
199+
200+
fireEvent(
201+
listItemId,
202+
new MouseEvent('mouseenter', {
203+
bubbles: true,
204+
cancelable: true,
205+
})
206+
)
207+
208+
const listItemButton = getByRole('button')
209+
expect(queryByText('View Profile')).not.toBeInTheDocument()
210+
211+
fireEvent.click(listItemButton)
212+
const viewProfileAction = getByText('View Profile')
213+
expect(viewProfileAction).toBeInTheDocument()
214+
215+
expect(handleActionClick.mock.calls.length).toBe(0)
216+
fireEvent.click(viewProfileAction)
217+
expect(handleActionClick.mock.calls.length).toBe(1)
218+
219+
fireEvent.click(listItemButton)
220+
expect(queryByText('View Profile')).not.toBeInTheDocument()
221+
})
222+
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import styled, { css } from 'styled-components'
28+
import React, { FC, ReactNode } from 'react'
29+
import {
30+
ActionListHeader,
31+
ActionListHeaderColumn,
32+
generateActionListHeaderColumns,
33+
} from './ActionListHeader'
34+
import { ActionListItemColumn } from './ActionListItemColumn'
35+
import { ActionListRowColumns } from './ActionListRow'
36+
37+
export type ActionListColumns = ActionListColumn[]
38+
export interface ActionListColumn {
39+
children: ReactNode
40+
id: string
41+
/**
42+
* Determines whether a given column is a primary key or not
43+
* @default false
44+
*/
45+
primaryKey?: boolean
46+
/**
47+
* In some locales, we may change horizontal alignment of 'number'
48+
* @default 'string'
49+
*/
50+
type?: 'string' | 'number'
51+
/**
52+
* Determines how much of a row's width this column should take up
53+
*/
54+
widthPercent?: number
55+
}
56+
57+
export interface ActionListProps {
58+
columns: ActionListColumns
59+
className?: string
60+
/**
61+
* default: true
62+
*/
63+
header?: boolean | ReactNode
64+
}
65+
66+
export const ActionListLayout: FC<ActionListProps> = ({
67+
className,
68+
header = true,
69+
children,
70+
columns,
71+
}) => {
72+
const actionListHeader =
73+
header === true ? (
74+
<ActionListHeader>
75+
{generateActionListHeaderColumns(columns)}
76+
</ActionListHeader>
77+
) : header === false ? null : (
78+
<ActionListHeader>{header}</ActionListHeader>
79+
)
80+
81+
return (
82+
<div className={className}>
83+
{actionListHeader}
84+
<div>{children}</div>
85+
</div>
86+
)
87+
}
88+
89+
const textAlignRightCSS = css<ActionListProps>`
90+
${props =>
91+
props.columns.map((column: ActionListColumn, index: number) =>
92+
column.type === 'number'
93+
? `
94+
${ActionListItemColumn}:nth-child(${index + 1}),
95+
${ActionListHeaderColumn}:nth-child(${index + 1}) {
96+
text-align: right;
97+
}
98+
`
99+
: ''
100+
)}
101+
`
102+
103+
const primaryKeyColumnCSS = css<ActionListProps>`
104+
${props =>
105+
props.columns.map((column: ActionListColumn, index: number) =>
106+
column.primaryKey
107+
? `
108+
${ActionListItemColumn}:nth-child(${index + 1}) {
109+
color: ${props.theme.colors.palette.charcoal900};
110+
font-size: ${props.theme.fontSizes.small};
111+
}
112+
`
113+
: `
114+
${ActionListItemColumn}:nth-child(${index + 1}) {
115+
color: ${props.theme.colors.palette.charcoal700};
116+
font-size: ${props.theme.fontSizes.xsmall};
117+
}
118+
`
119+
)}
120+
`
121+
122+
export const ActionList = styled(ActionListLayout)<ActionListProps>`
123+
${textAlignRightCSS}
124+
125+
${primaryKeyColumnCSS}
126+
127+
${ActionListRowColumns} {
128+
display: grid;
129+
grid-template-columns: ${props =>
130+
props.columns.map(column => `${column.widthPercent}%`).join(' ')};
131+
align-items: center;
132+
}
133+
`

0 commit comments

Comments
 (0)