Skip to content

Commit c0f3acd

Browse files
Merge pull request #110 from datum-cloud/feat/empty-content-action-flexibility
feat(empty-content)!: flexible action API — as-discriminated union with RBAC props
2 parents 8c100b3 + fc4f1d8 commit c0f3acd

5 files changed

Lines changed: 302 additions & 60 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@datum-cloud/datum-ui": major
3+
---
4+
5+
`EmptyContent` actions now use an `as`-discriminated union that forwards the full `Button`
6+
(and anchor) prop surface, plus RBAC-friendly `hidden` and `tooltip` props.
7+
8+
Migration:
9+
10+
- `type` (action kind) → `as` (`'button' | 'link' | 'external-link'`).
11+
- `variant: 'default' | 'destructive' | 'outline'` → Button's `type` + `theme`
12+
(e.g. `destructive``type: 'danger'`; `outline``theme: 'outline'`).
13+
- `iconPosition: 'start' | 'end'``'left' | 'right'`.
14+
- New per-action props: `disabled`, `hidden`, `tooltip`, `tooltipSide`, and any other
15+
`Button` prop (`loading`, `theme`, `block`, …).
16+
- Disabled `link`/`external-link` actions now render as a plain disabled button (no anchor).

apps/docs/content/docs/components/features/empty-content.mdx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { EmptyContent } from '@datum-cloud/datum-ui/empty-content'
3535
subtitle="Create your first project to get started."
3636
actions={[
3737
{
38-
type: 'link',
38+
as: 'link',
3939
label: 'Create Project',
4040
to: '#',
4141
},
@@ -49,7 +49,7 @@ import { EmptyContent } from '@datum-cloud/datum-ui/empty-content'
4949
subtitle="Create your first project to get started."
5050
actions={[
5151
{
52-
type: 'button',
52+
as: 'button',
5353
label: 'Create Project',
5454
onClick: () => console.log('Create clicked'),
5555
},
@@ -100,12 +100,12 @@ Actions can render as links or external links instead of buttons.
100100
title="no documentation yet."
101101
actions={[
102102
{
103-
type: 'link',
103+
as: 'link',
104104
label: 'View Docs',
105105
to: '/docs',
106106
},
107107
{
108-
type: 'external-link',
108+
as: 'external-link',
109109
label: 'GitHub',
110110
to: 'https://github.com/datum-cloud',
111111
},
@@ -130,12 +130,23 @@ Actions can render as links or external links instead of buttons.
130130

131131
### EmptyContentAction
132132

133+
Actions are a discriminated union keyed on `as`. A `button` action accepts the full
134+
[`Button`](/docs/components/base/button) prop surface; `link` / `external-link` actions
135+
additionally accept anchor attributes plus `to`.
136+
133137
| Property | Type | Default | Description |
134138
|----------|------|---------|-------------|
135-
| `type` | `'button' \| 'link' \| 'external-link'` | -- | Action type |
139+
| `as` | `'button' \| 'link' \| 'external-link'` | `'button'` | Action kind (discriminator) |
136140
| `label` | `string` | -- | Button or link text |
137-
| `onClick` | `() => void` | -- | Click handler (for `button` type) |
138-
| `to` | `string` | -- | URL (for `link` and `external-link` types) |
139-
| `variant` | `'default' \| 'destructive' \| 'outline'` | -- | Button style variant |
141+
| `to` | `string` | -- | URL (required for `link` and `external-link`) |
142+
| `disabled` | `boolean` | `false` | Disables the action; a disabled link renders as a plain disabled button |
143+
| `hidden` | `boolean` | `false` | When true the action is not rendered (useful for RBAC) |
144+
| `tooltip` | `ReactNode` | -- | Tooltip shown on hover/focus (works while disabled) |
145+
| `tooltipSide` | `'top' \| 'right' \| 'bottom' \| 'left'` | -- | Tooltip placement |
146+
| `type` | `'primary' \| 'secondary' \| 'danger' \| …` | `'secondary'` | Button color/semantic type (any `Button` `type`) |
147+
| `theme` | `'solid' \| 'light' \| 'outline' \| …` | `'solid'` | Button theme (any `Button` `theme`) |
140148
| `icon` | `ReactNode` | -- | Icon element |
141-
| `iconPosition` | `'start' \| 'end'` | `'start'` | Icon placement |
149+
| `iconPosition` | `'left' \| 'right'` | `'left'` | Icon placement |
150+
| `onClick` | `(e) => void` | -- | Click handler |
151+
152+
Any other [`Button`](/docs/components/base/button) prop (`loading`, `block`, …) is also accepted.

apps/storybook/stories/features/empty-content.stories.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,42 @@ export default meta
3838
type Story = StoryObj<typeof EmptyContent>
3939

4040
export const Default: Story = {}
41+
42+
export const WithActions: Story = {
43+
args: {
44+
actions: [
45+
{ as: 'button', label: 'Create item', type: 'primary' },
46+
{ as: 'link', label: 'Read the docs', to: '/docs' },
47+
],
48+
},
49+
}
50+
51+
export const ExternalLink: Story = {
52+
args: {
53+
actions: [
54+
{ as: 'external-link', label: 'Open status page', to: 'https://status.datum.net' },
55+
],
56+
},
57+
}
58+
59+
export const DisabledWithTooltip: Story = {
60+
args: {
61+
actions: [
62+
{
63+
as: 'button',
64+
label: 'Create item',
65+
disabled: true,
66+
tooltip: 'You don\'t have permission to create items.',
67+
},
68+
],
69+
},
70+
}
71+
72+
export const HiddenAction: Story = {
73+
args: {
74+
actions: [
75+
{ as: 'button', label: 'Visible action' },
76+
{ as: 'button', label: 'Hidden action', hidden: true },
77+
],
78+
},
79+
}

packages/datum-ui/src/components/features/empty-content/__tests__/empty-content.test.tsx

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// <reference types="@testing-library/jest-dom/vitest" />
2-
import { render, screen } from '@testing-library/react'
2+
import { fireEvent, render, screen } from '@testing-library/react'
33
import { describe, expect, it, vi } from 'vitest'
44
import { EmptyContent } from '../empty-content'
55

@@ -19,25 +19,148 @@ describe('emptyContent', () => {
1919
expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument()
2020
})
2121

22-
it('renders action buttons', () => {
23-
const handleClick = vi.fn()
22+
it('merges custom className', () => {
23+
const { container } = render(
24+
<EmptyContent title="No data found" className="custom-class" />,
25+
)
26+
expect(container.firstChild).toHaveClass('custom-class')
27+
})
28+
29+
it('renders button and link actions', () => {
30+
const onClick = vi.fn()
2431
render(
2532
<EmptyContent
2633
title="No data found"
2734
actions={[
28-
{ type: 'button', label: 'Add Item', onClick: handleClick },
29-
{ type: 'link', label: 'Learn More', to: '/docs' },
35+
{ as: 'button', label: 'Add Item', onClick },
36+
{ as: 'link', label: 'Learn More', to: '/docs' },
3037
]}
3138
/>,
3239
)
33-
expect(screen.getByText('Add Item')).toBeInTheDocument()
34-
expect(screen.getByText('Learn More')).toBeInTheDocument()
40+
expect(screen.getByRole('button', { name: 'Add Item' })).toBeInTheDocument()
41+
expect(screen.getByRole('link', { name: 'Learn More' })).toBeInTheDocument()
3542
})
3643

37-
it('merges custom className', () => {
38-
const { container } = render(
39-
<EmptyContent title="No data found" className="custom-class" />,
44+
it('fires onClick for button actions', () => {
45+
const onClick = vi.fn()
46+
render(
47+
<EmptyContent title="x" actions={[{ as: 'button', label: 'Add', onClick }]} />,
4048
)
41-
expect(container.firstChild).toHaveClass('custom-class')
49+
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
50+
expect(onClick).toHaveBeenCalledOnce()
51+
})
52+
53+
it('renders external-link with target and rel', () => {
54+
render(
55+
<EmptyContent
56+
title="x"
57+
actions={[{ as: 'external-link', label: 'Ext', to: 'https://example.com' }]}
58+
/>,
59+
)
60+
const link = screen.getByRole('link', { name: 'Ext' })
61+
expect(link).toHaveAttribute('href', 'https://example.com')
62+
expect(link).toHaveAttribute('target', '_blank')
63+
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
64+
})
65+
66+
it('renders internal link with _self target', () => {
67+
render(
68+
<EmptyContent title="x" actions={[{ as: 'link', label: 'In', to: '/in' }]} />,
69+
)
70+
const link = screen.getByRole('link', { name: 'In' })
71+
expect(link).toHaveAttribute('href', '/in')
72+
expect(link).toHaveAttribute('target', '_self')
73+
})
74+
75+
it('does not fire onClick when a button action is disabled', () => {
76+
const onClick = vi.fn()
77+
render(
78+
<EmptyContent
79+
title="x"
80+
actions={[{ as: 'button', label: 'Add', onClick, disabled: true }]}
81+
/>,
82+
)
83+
const btn = screen.getByRole('button', { name: 'Add' })
84+
expect(btn).toBeDisabled()
85+
fireEvent.click(btn)
86+
expect(onClick).not.toHaveBeenCalled()
87+
})
88+
89+
it('renders a disabled link as a plain disabled button without an anchor', () => {
90+
render(
91+
<EmptyContent
92+
title="x"
93+
actions={[{ as: 'link', label: 'Go', to: '/x', disabled: true }]}
94+
/>,
95+
)
96+
expect(screen.getByRole('button', { name: 'Go' })).toBeDisabled()
97+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
98+
})
99+
100+
it('does not leak anchor attributes onto a disabled link button', () => {
101+
render(
102+
<EmptyContent
103+
title="x"
104+
actions={[{ as: 'link', label: 'Go', to: '/x', disabled: true }]}
105+
/>,
106+
)
107+
const btn = screen.getByRole('button', { name: 'Go' })
108+
expect(btn).not.toHaveAttribute('to')
109+
expect(btn).not.toHaveAttribute('href')
110+
expect(btn).not.toHaveAttribute('target')
111+
expect(btn).not.toHaveAttribute('rel')
112+
})
113+
114+
it('does not render hidden actions', () => {
115+
render(
116+
<EmptyContent
117+
title="x"
118+
actions={[
119+
{ as: 'button', label: 'Visible' },
120+
{ as: 'button', label: 'Secret', hidden: true },
121+
]}
122+
/>,
123+
)
124+
expect(screen.getByText('Visible')).toBeInTheDocument()
125+
expect(screen.queryByText('Secret')).not.toBeInTheDocument()
126+
})
127+
128+
it('does not render the actions container when every action is hidden', () => {
129+
render(
130+
<EmptyContent
131+
title="x"
132+
actions={[{ as: 'button', label: 'Secret', hidden: true }]}
133+
/>,
134+
)
135+
expect(screen.queryByText('Secret')).not.toBeInTheDocument()
136+
})
137+
138+
it('wraps the control in a hover target span so a disabled action can still show its tooltip', () => {
139+
render(
140+
<EmptyContent
141+
title="x"
142+
actions={[
143+
{ as: 'button', label: 'Locked', disabled: true, tooltip: 'No permission' },
144+
]}
145+
/>,
146+
)
147+
const btn = screen.getByRole('button', { name: 'Locked' })
148+
// renderAction wraps the control in <span class="inline-flex"> before handing it
149+
// to <Tooltip>, so the disabled button still has an element that receives hover.
150+
expect(btn.closest('span.inline-flex')).not.toBeNull()
151+
})
152+
153+
it('forwards Button props (icon and semantic type) to the underlying button', () => {
154+
render(
155+
<EmptyContent
156+
title="x"
157+
actions={[
158+
{ as: 'button', label: 'Delete', type: 'danger', icon: <svg data-testid="trash-icon" /> },
159+
]}
160+
/>,
161+
)
162+
expect(screen.getByTestId('trash-icon')).toBeInTheDocument()
163+
// type='danger' + default theme='solid' compiles to the danger solid class.
164+
expect(screen.getByRole('button', { name: 'Delete' }).className).toContain('bg-btn-danger')
42165
})
43166
})

0 commit comments

Comments
 (0)