@startsimpli/ui 0.4.56 → 0.4.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
const ENTITIES: Record<string, string> = {
|
|
7
|
+
'&': '&',
|
|
8
|
+
'<': '<',
|
|
9
|
+
'>': '>',
|
|
10
|
+
'"': '"',
|
|
11
|
+
''': "'",
|
|
12
|
+
' ': ' ',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Flatten markdown / HTML / whitespace-noisy text into a single-line preview,
|
|
17
|
+
* clamped to `maxChars` on a word boundary. Board cards get a readable content
|
|
18
|
+
* snippet without pulling in a full markdown renderer (startsim-768w.17.3): the
|
|
19
|
+
* pipeline writes long article bodies to `data.content`, and a raw slice of that
|
|
20
|
+
* shows heading hashes, link syntax and code fences.
|
|
21
|
+
*/
|
|
22
|
+
export function toPlainSnippet(
|
|
23
|
+
input: string | null | undefined,
|
|
24
|
+
maxChars = 240,
|
|
25
|
+
): string {
|
|
26
|
+
if (!input) return ''
|
|
27
|
+
let s = String(input)
|
|
28
|
+
s = s.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
|
|
29
|
+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text
|
|
30
|
+
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> link text
|
|
31
|
+
s = s.replace(/<[^>]+>/g, ' ') // html tags
|
|
32
|
+
s = s.replace(/^[ \t]*[#>\-*+]+[ \t]+/gm, ' ') // heading / list / quote markers
|
|
33
|
+
s = s.replace(/[*_`~]+/g, '') // inline emphasis / code marks
|
|
34
|
+
s = s.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ')
|
|
35
|
+
s = s.replace(/\s+/g, ' ').trim()
|
|
36
|
+
if (s.length <= maxChars) return s
|
|
37
|
+
const cut = s.slice(0, maxChars)
|
|
38
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
39
|
+
// cut back to the last whole word when that boundary is in the back half,
|
|
40
|
+
// otherwise hard-cut (e.g. a single very long token has no usable boundary)
|
|
41
|
+
const body = lastSpace > maxChars * 0.5 ? cut.slice(0, lastSpace) : cut
|
|
42
|
+
return body.trimEnd() + '…'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LINE_CLAMP: Record<number, string> = {
|
|
46
|
+
2: 'line-clamp-2',
|
|
47
|
+
3: 'line-clamp-3',
|
|
48
|
+
4: 'line-clamp-4',
|
|
49
|
+
5: 'line-clamp-5',
|
|
50
|
+
6: 'line-clamp-6',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BoardCardMeta {
|
|
54
|
+
label: string
|
|
55
|
+
value: React.ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BoardCardProps {
|
|
59
|
+
/** Card heading (record name / title). */
|
|
60
|
+
title: React.ReactNode
|
|
61
|
+
/** Raw content (markdown / HTML / plain); shown as a clamped plain-text preview. */
|
|
62
|
+
snippet?: string | null
|
|
63
|
+
/** Max plain-text characters for the snippet (default 240). */
|
|
64
|
+
snippetMaxChars?: number
|
|
65
|
+
/** Tailwind line-clamp count for the snippet (default 3). */
|
|
66
|
+
snippetLines?: 2 | 3 | 4 | 5 | 6
|
|
67
|
+
/** Source / host label or link, shown under the snippet. */
|
|
68
|
+
source?: React.ReactNode
|
|
69
|
+
/** Compact key/value chips for short declared attributes. */
|
|
70
|
+
meta?: BoardCardMeta[]
|
|
71
|
+
/** Opens the record detail; wraps the title as a button when provided. */
|
|
72
|
+
onOpen?: () => void
|
|
73
|
+
/**
|
|
74
|
+
* Footer slot — e.g. a status `<Select>`. Pointer/click events inside are
|
|
75
|
+
* stopped so interacting with a control never starts a drag on the card.
|
|
76
|
+
*/
|
|
77
|
+
footer?: React.ReactNode
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A content-aware board card: title + readable content snippet + source + a few
|
|
83
|
+
* meta chips + a footer slot. Presentation-only and drag-agnostic — the
|
|
84
|
+
* KanbanBoard wraps it in a Draggable; this just renders the content the epic's
|
|
85
|
+
* News Item board was missing (title-only cards). Reusable for any entity board.
|
|
86
|
+
*/
|
|
87
|
+
export function BoardCard({
|
|
88
|
+
title,
|
|
89
|
+
snippet,
|
|
90
|
+
snippetMaxChars = 240,
|
|
91
|
+
snippetLines = 3,
|
|
92
|
+
source,
|
|
93
|
+
meta,
|
|
94
|
+
onOpen,
|
|
95
|
+
footer,
|
|
96
|
+
className,
|
|
97
|
+
}: BoardCardProps) {
|
|
98
|
+
const preview = toPlainSnippet(snippet, snippetMaxChars)
|
|
99
|
+
const hasSource = source != null && source !== ''
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
'm-2 rounded-md border bg-white p-3 shadow-sm dark:bg-neutral-900',
|
|
104
|
+
className,
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{onOpen ? (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={onOpen}
|
|
111
|
+
className="block w-full text-left text-sm font-medium leading-snug hover:underline"
|
|
112
|
+
>
|
|
113
|
+
{title}
|
|
114
|
+
</button>
|
|
115
|
+
) : (
|
|
116
|
+
<div className="text-sm font-medium leading-snug">{title}</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{preview && (
|
|
120
|
+
<p
|
|
121
|
+
className={cn(
|
|
122
|
+
'mt-1 text-xs leading-snug text-neutral-600 dark:text-neutral-300',
|
|
123
|
+
LINE_CLAMP[snippetLines],
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{preview}
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{hasSource && (
|
|
131
|
+
<div className="mt-1.5 truncate text-xs text-neutral-400">{source}</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{meta && meta.length > 0 && (
|
|
135
|
+
<dl className="mt-1 space-y-0.5">
|
|
136
|
+
{meta.map((m, i) => (
|
|
137
|
+
<div key={i} className="flex gap-1 text-xs text-neutral-600 dark:text-neutral-300">
|
|
138
|
+
<dt className="capitalize text-neutral-400">{m.label}:</dt>
|
|
139
|
+
<dd className="truncate">{m.value}</dd>
|
|
140
|
+
</div>
|
|
141
|
+
))}
|
|
142
|
+
</dl>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{footer != null && (
|
|
146
|
+
<div
|
|
147
|
+
className="mt-2"
|
|
148
|
+
onClick={(e) => e.stopPropagation()}
|
|
149
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
150
|
+
>
|
|
151
|
+
{footer}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { BoardCard, toPlainSnippet } from '../BoardCard'
|
|
3
|
+
|
|
4
|
+
describe('toPlainSnippet', () => {
|
|
5
|
+
it('returns empty string for nullish/empty input', () => {
|
|
6
|
+
expect(toPlainSnippet(null)).toBe('')
|
|
7
|
+
expect(toPlainSnippet(undefined)).toBe('')
|
|
8
|
+
expect(toPlainSnippet('')).toBe('')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('strips markdown headings, emphasis, links and code fences', () => {
|
|
12
|
+
const md = '# Title\n\nSome **bold** and _italic_ with a [link](https://x.io).\n\n```js\ncode()\n```'
|
|
13
|
+
const out = toPlainSnippet(md)
|
|
14
|
+
expect(out).toBe('Title Some bold and italic with a link.')
|
|
15
|
+
expect(out).not.toMatch(/[#*_`]|\]\(|```/)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('strips html tags and decodes common entities', () => {
|
|
19
|
+
expect(toPlainSnippet('<p>Gold & silver <up></p>')).toBe('Gold & silver <up>')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('collapses whitespace to single spaces', () => {
|
|
23
|
+
expect(toPlainSnippet('a\n\n b\t c')).toBe('a b c')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('truncates on a word boundary with an ellipsis when over the limit', () => {
|
|
27
|
+
// cut(12) = "one two thre"; last boundary at 7 -> back to the whole word
|
|
28
|
+
expect(toPlainSnippet('one two three four five six', 12)).toBe('one two…')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('hard-cuts a single long token with no usable word boundary', () => {
|
|
32
|
+
expect(toPlainSnippet('supercalifragilisticexpialidocious', 10)).toBe('supercalif…')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not truncate when within the limit', () => {
|
|
36
|
+
expect(toPlainSnippet('short', 240)).toBe('short')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('BoardCard', () => {
|
|
41
|
+
it('renders the title and a plain-text content snippet (markdown stripped)', () => {
|
|
42
|
+
render(<BoardCard title="Gold hits record" snippet={'## Lead\n\nPrices **surged** today.'} />)
|
|
43
|
+
expect(screen.getByText('Gold hits record')).toBeInTheDocument()
|
|
44
|
+
expect(screen.getByText('Lead Prices surged today.')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders source and meta chips', () => {
|
|
48
|
+
render(
|
|
49
|
+
<BoardCard
|
|
50
|
+
title="T"
|
|
51
|
+
source="reuters.com"
|
|
52
|
+
meta={[{ label: 'market', value: 'UAE' }]}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
expect(screen.getByText('reuters.com')).toBeInTheDocument()
|
|
56
|
+
expect(screen.getByText('market:')).toBeInTheDocument()
|
|
57
|
+
expect(screen.getByText('UAE')).toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('calls onOpen when the title is clicked', () => {
|
|
61
|
+
const onOpen = jest.fn()
|
|
62
|
+
render(<BoardCard title="Open me" onOpen={onOpen} />)
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open me' }))
|
|
64
|
+
expect(onOpen).toHaveBeenCalledTimes(1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('stops footer clicks from bubbling (so a control never starts a card drag)', () => {
|
|
68
|
+
const outer = jest.fn()
|
|
69
|
+
render(
|
|
70
|
+
<div onClick={outer}>
|
|
71
|
+
<BoardCard title="T" footer={<button type="button">status</button>} />
|
|
72
|
+
</div>,
|
|
73
|
+
)
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: 'status' }))
|
|
75
|
+
expect(outer).not.toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
|
|
2
2
|
export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'
|
|
3
|
+
export { BoardCard, toPlainSnippet } from './BoardCard'
|
|
4
|
+
export type { BoardCardProps, BoardCardMeta } from './BoardCard'
|