@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.100",
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/i18n": "0.1.3",
124
- "@startsimpli/realtime": "0.0.2"
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
- {rows.map((row) => (
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 = new Set(approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, '')));
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) => !approved.has(h) || FORUM_SOCIAL.test(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.