@startsimpli/ui 0.4.100 → 0.4.102
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.102",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
45
45
|
"react": "^18.0.0 || ^19.0.0",
|
|
46
46
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
47
|
-
"@startsimpli/api": "^0.5.56",
|
|
48
47
|
"@startsimpli/auth": "^0.4.35",
|
|
49
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
48
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
49
|
+
"@startsimpli/api": "^0.5.56"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@startsimpli/api": {
|
|
@@ -120,8 +120,8 @@
|
|
|
120
120
|
"tailwindcss": "^3.4.19",
|
|
121
121
|
"tsup": "^8.5.1",
|
|
122
122
|
"typescript": "^6.0.3",
|
|
123
|
-
"@startsimpli/
|
|
124
|
-
"@startsimpli/
|
|
123
|
+
"@startsimpli/realtime": "0.0.2",
|
|
124
|
+
"@startsimpli/i18n": "0.1.3"
|
|
125
125
|
},
|
|
126
126
|
"keywords": [
|
|
127
127
|
"ui",
|
|
@@ -20,6 +20,18 @@ export interface SourceFreshnessProps {
|
|
|
20
20
|
staleAfterHours?: number
|
|
21
21
|
/** Compare against this instant (injectable for tests); defaults to Date.now(). */
|
|
22
22
|
now?: number
|
|
23
|
+
/**
|
|
24
|
+
* Cap how many rows are LISTED. The roll-up (tone, "N stale") still counts every
|
|
25
|
+
* source, so capping hides rows, never facts.
|
|
26
|
+
*
|
|
27
|
+
* This card is the one health block that used to have no cap, and it got handed
|
|
28
|
+
* the one unbounded list: a tenant with 55 declared sources rendered 55 rows,
|
|
29
|
+
* and in a `DashboardGrid` (CSS grid, `align-items: stretch` by default) that one
|
|
30
|
+
* very tall card silently set the row height for every card beside it — the
|
|
31
|
+
* short neighbour then padded the difference with blank space. Capping the list
|
|
32
|
+
* is the cheap half of that fix; a grid that does not stretch is the other half.
|
|
33
|
+
*/
|
|
34
|
+
limit?: number
|
|
23
35
|
emptyMessage?: string
|
|
24
36
|
className?: string
|
|
25
37
|
}
|
|
@@ -46,6 +58,7 @@ export function SourceFreshness({
|
|
|
46
58
|
sources,
|
|
47
59
|
staleAfterHours = 48,
|
|
48
60
|
now,
|
|
61
|
+
limit,
|
|
49
62
|
emptyMessage = 'No sources configured.',
|
|
50
63
|
className,
|
|
51
64
|
}: SourceFreshnessProps) {
|
|
@@ -54,6 +67,10 @@ export function SourceFreshness({
|
|
|
54
67
|
const staleCount = rows.filter((r) => r.resolved === 'warn' || r.resolved === 'critical').length
|
|
55
68
|
const status = worstStatus(rows.map((r) => r.resolved))
|
|
56
69
|
const statusLabel = sources.length === 0 ? undefined : staleCount > 0 ? `${staleCount} stale` : 'All fresh'
|
|
70
|
+
// Cap the LIST only — staleCount/status above are computed over every source, so
|
|
71
|
+
// a stale feed below the cut still pulls the card's tone and its badge count.
|
|
72
|
+
const listed = limit ? rows.slice(0, limit) : rows
|
|
73
|
+
const hidden = rows.length - listed.length
|
|
57
74
|
|
|
58
75
|
return (
|
|
59
76
|
<HealthCard
|
|
@@ -66,7 +83,7 @@ export function SourceFreshness({
|
|
|
66
83
|
className={className}
|
|
67
84
|
>
|
|
68
85
|
<ul className="space-y-2" aria-label={`${title} sources`}>
|
|
69
|
-
{
|
|
86
|
+
{listed.map((row) => (
|
|
70
87
|
<li key={row.name} className="flex items-center justify-between gap-3 text-sm">
|
|
71
88
|
<span className="flex items-center gap-2 min-w-0">
|
|
72
89
|
<HealthDot status={row.resolved} />
|
|
@@ -83,6 +100,11 @@ export function SourceFreshness({
|
|
|
83
100
|
</li>
|
|
84
101
|
))}
|
|
85
102
|
</ul>
|
|
103
|
+
{hidden > 0 && (
|
|
104
|
+
<p className="text-xs text-gray-500">
|
|
105
|
+
+{hidden} more source{hidden === 1 ? '' : 's'} not shown
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
86
108
|
</HealthCard>
|
|
87
109
|
)
|
|
88
110
|
}
|
|
@@ -96,6 +96,68 @@ describe('SourceFreshness', () => {
|
|
|
96
96
|
expect(screen.getByText('2 stale')).toBeInTheDocument()
|
|
97
97
|
expect(screen.getByText('never')).toBeInTheDocument()
|
|
98
98
|
})
|
|
99
|
+
it('caps the listed rows at `limit` and says how many are hidden', () => {
|
|
100
|
+
render(
|
|
101
|
+
<SourceFreshness
|
|
102
|
+
now={NOW}
|
|
103
|
+
limit={2}
|
|
104
|
+
sources={[
|
|
105
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
106
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(2) },
|
|
107
|
+
{ name: 'RSS C', lastUpdated: hoursAgo(3) },
|
|
108
|
+
{ name: 'RSS D', lastUpdated: hoursAgo(4) },
|
|
109
|
+
]}
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
expect(screen.getByText('RSS A')).toBeInTheDocument()
|
|
113
|
+
expect(screen.getByText('RSS B')).toBeInTheDocument()
|
|
114
|
+
expect(screen.queryByText('RSS C')).not.toBeInTheDocument()
|
|
115
|
+
expect(screen.getByText('+2 more sources not shown')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
it('rolls up tone and the stale count over EVERY source, not just the listed ones', () => {
|
|
118
|
+
render(
|
|
119
|
+
<SourceFreshness
|
|
120
|
+
now={NOW}
|
|
121
|
+
staleAfterHours={48}
|
|
122
|
+
limit={1}
|
|
123
|
+
sources={[
|
|
124
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
125
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(200) },
|
|
126
|
+
{ name: 'RSS C', lastUpdated: null },
|
|
127
|
+
]}
|
|
128
|
+
/>,
|
|
129
|
+
)
|
|
130
|
+
// B and C are below the cut; hiding a row must never hide the fact.
|
|
131
|
+
expect(screen.getByText('2 stale')).toBeInTheDocument()
|
|
132
|
+
expect(screen.queryByText('RSS B')).not.toBeInTheDocument()
|
|
133
|
+
expect(screen.getByText('+2 more sources not shown')).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
it('lists everything, with no hidden-count line, when no limit is given', () => {
|
|
136
|
+
render(
|
|
137
|
+
<SourceFreshness
|
|
138
|
+
now={NOW}
|
|
139
|
+
sources={[
|
|
140
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
141
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(2) },
|
|
142
|
+
]}
|
|
143
|
+
/>,
|
|
144
|
+
)
|
|
145
|
+
expect(screen.getByText('RSS B')).toBeInTheDocument()
|
|
146
|
+
expect(screen.queryByText(/more sources? not shown/)).not.toBeInTheDocument()
|
|
147
|
+
})
|
|
148
|
+
it('says "source" (singular) when exactly one row is hidden', () => {
|
|
149
|
+
render(
|
|
150
|
+
<SourceFreshness
|
|
151
|
+
now={NOW}
|
|
152
|
+
limit={1}
|
|
153
|
+
sources={[
|
|
154
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(1) },
|
|
155
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(2) },
|
|
156
|
+
]}
|
|
157
|
+
/>,
|
|
158
|
+
)
|
|
159
|
+
expect(screen.getByText('+1 more source not shown')).toBeInTheDocument()
|
|
160
|
+
})
|
|
99
161
|
})
|
|
100
162
|
|
|
101
163
|
describe('DeliveryHealth', () => {
|
|
@@ -128,6 +128,30 @@ describe('no-hype', () => {
|
|
|
128
128
|
describe('approved sources', () => {
|
|
129
129
|
const approvedHosts = ['example.com', 'gov.uk'];
|
|
130
130
|
|
|
131
|
+
it('accepts a SUBDOMAIN of an approved domain', () => {
|
|
132
|
+
// Same organisation, own subdomain. Exact-host matching rejected
|
|
133
|
+
// documents.worldbank.org while worldbank.org was approved — on the live
|
|
134
|
+
// OGMC corpus that was the last thing blocking approval on 3 drafts.
|
|
135
|
+
const c = find(
|
|
136
|
+
runContentChecks(
|
|
137
|
+
{ sources: 'https://docs.example.com/a https://a.b.gov.uk/c' },
|
|
138
|
+
{ approvedHosts },
|
|
139
|
+
),
|
|
140
|
+
'approved-sources',
|
|
141
|
+
);
|
|
142
|
+
expect(c?.status).toBe('pass');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does NOT accept a domain that merely ENDS WITH an approved one', () => {
|
|
146
|
+
// The dot is load-bearing: `notexample.com` must not pass as `example.com`.
|
|
147
|
+
const c = find(
|
|
148
|
+
runContentChecks({ sources: 'https://notexample.com/a' }, { approvedHosts }),
|
|
149
|
+
'approved-sources',
|
|
150
|
+
);
|
|
151
|
+
expect(c?.status).toBe('fail');
|
|
152
|
+
expect(c?.detail).toContain('notexample.com');
|
|
153
|
+
});
|
|
154
|
+
|
|
131
155
|
it('is skipped entirely when approvedHosts is omitted', () => {
|
|
132
156
|
expect(find(runContentChecks({ sources: 'https://example.com/a' }), 'approved-sources')).toBeUndefined();
|
|
133
157
|
});
|
|
@@ -287,7 +287,22 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
287
287
|
|
|
288
288
|
// Approved sources — only when the app supplied an allow-list.
|
|
289
289
|
if (approvedHosts !== undefined) {
|
|
290
|
-
const approved =
|
|
290
|
+
const approved = approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, ''));
|
|
291
|
+
/**
|
|
292
|
+
* A host is approved when it IS an approved domain or is a SUBDOMAIN of one.
|
|
293
|
+
*
|
|
294
|
+
* Exact equality alone rejected `documents.worldbank.org` while
|
|
295
|
+
* `worldbank.org` was approved, and `eservices.misa.gov.sa` while
|
|
296
|
+
* `misa.gov.sa` was — the same organisation publishing on its own
|
|
297
|
+
* subdomain. On the live OGMC corpus that was the last thing blocking
|
|
298
|
+
* approval on 3 drafts after the source list itself was fixed.
|
|
299
|
+
*
|
|
300
|
+
* The dot in `.${domain}` is load-bearing: without it `notworldbank.org`
|
|
301
|
+
* would match `worldbank.org`, which is exactly the attack a naive
|
|
302
|
+
* `endsWith` invites.
|
|
303
|
+
*/
|
|
304
|
+
const isApproved = (host: string): boolean =>
|
|
305
|
+
approved.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
291
306
|
const urls = extractUrls(fields.sources);
|
|
292
307
|
const hosts = urls.map(hostOf).filter(Boolean);
|
|
293
308
|
if (hosts.length === 0) {
|
|
@@ -299,7 +314,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
299
314
|
locations: [{ field: 'sources' }],
|
|
300
315
|
});
|
|
301
316
|
} else {
|
|
302
|
-
const bad = Array.from(new Set(hosts.filter((h) => !
|
|
317
|
+
const bad = Array.from(new Set(hosts.filter((h) => !isApproved(h) || FORUM_SOCIAL.test(h))));
|
|
303
318
|
// Highlight the offending URLs as they appear in the sources text — the
|
|
304
319
|
// reviewer pastes URLs, not bare hosts, so matching on the host alone
|
|
305
320
|
// would highlight nothing.
|