@startsimpli/ui 0.4.74 → 0.4.75

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.74",
3
+ "version": "0.4.75",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -42,8 +42,8 @@
42
42
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
- "@startsimpli/auth": "^0.4.31",
46
- "@startsimpli/api": "^0.5.46"
45
+ "@startsimpli/api": "^0.5.46",
46
+ "@startsimpli/auth": "^0.4.31"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@startsimpli/api": {
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { useMemo, useState } from 'react';
12
12
  import { useQuery, useQueryClient } from '@tanstack/react-query';
13
- import { ShieldCheck, AlertTriangle, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
13
+ import { ShieldCheck, AlertTriangle, ExternalLink, CheckCircle2, Circle, ChevronDown } from 'lucide-react';
14
14
 
15
15
  import { Button } from '../ui/button';
16
16
  import { Badge } from '../ui/badge';
@@ -28,6 +28,7 @@ import {
28
28
  wordTargets,
29
29
  sourceOverlapCheck,
30
30
  pickChanges,
31
+ isRejectedTopicGroup,
31
32
  type DraftGroup,
32
33
  type DraftSource,
33
34
  type CheckStatus,
@@ -66,6 +67,22 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
66
67
  });
67
68
  const records = recordsQ.data ?? [];
68
69
 
70
+ // A story whose topic was rejected shouldn't keep cluttering the review — its
71
+ // drafts collapse into the "Rejected" section, mirroring the topic list
72
+ // (startsim-ay9l follow-up). draft.topic_ref is the topic's entity id.
73
+ const topicsQ = useQuery({
74
+ queryKey: ['entities', 'topic', 'all'],
75
+ queryFn: () => client.listAllEntities('topic'),
76
+ enabled: !!draftType,
77
+ });
78
+ const rejectedTopicIds = useMemo(() => {
79
+ const s = new Set<string>();
80
+ for (const t of topicsQ.data ?? []) {
81
+ if (String(readData(t.data, 'status') ?? '') === 'rejected') s.add(String(t.id));
82
+ }
83
+ return s;
84
+ }, [topicsQ.data]);
85
+
69
86
  const sourcesQ = useQuery({
70
87
  queryKey: ['entities', 'source', 'all'],
71
88
  queryFn: () => client.listAllEntities('source'),
@@ -82,8 +99,17 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
82
99
  }, [sourcesQ.data]);
83
100
 
84
101
  const groups = useMemo(() => groupDrafts(records), [records]);
102
+ const { activeGroups, rejectedGroups } = useMemo(() => {
103
+ const act: DraftGroup[] = [];
104
+ const rej: DraftGroup[] = [];
105
+ for (const g of groups) {
106
+ (isRejectedTopicGroup(g, rejectedTopicIds) ? rej : act).push(g);
107
+ }
108
+ return { activeGroups: act, rejectedGroups: rej };
109
+ }, [groups, rejectedTopicIds]);
85
110
  const [openId, setOpenId] = useState<string | null>(null);
86
111
  const [busyKey, setBusyKey] = useState<string | null>(null);
112
+ const [showRejected, setShowRejected] = useState(false);
87
113
 
88
114
  async function writeEntity(record: EntityRecord, changes: Record<string, unknown>) {
89
115
  const data: Record<string, unknown> = { ...record.data };
@@ -128,8 +154,22 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
128
154
  return <p className="text-sm text-gray-500">No “draft” type is defined for this app.</p>;
129
155
  }
130
156
 
131
- const pickedCount = groups.filter((g) => g.chosen).length;
132
- const readyCount = groups.filter((g) => g.ready).length;
157
+ const pickedCount = activeGroups.filter((g) => g.chosen).length;
158
+ const readyCount = activeGroups.filter((g) => g.ready).length;
159
+
160
+ const renderGroup = (g: DraftGroup) => (
161
+ <StoryGroup
162
+ key={g.key}
163
+ group={g}
164
+ tierByDomain={tierByDomain}
165
+ openId={openId}
166
+ onToggleOpen={(id) => setOpenId((cur) => (cur === id ? null : id))}
167
+ onPick={pick}
168
+ onMarkReady={markReady}
169
+ busy={busyKey === g.key}
170
+ busyId={busyKey}
171
+ />
172
+ );
133
173
 
134
174
  return (
135
175
  <div className="space-y-6">
@@ -142,30 +182,41 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
142
182
  </p>
143
183
  </div>
144
184
  <span className="text-sm text-gray-500">
145
- {groups.length} stories · {pickedCount} picked · {readyCount} ready to post
185
+ {activeGroups.length} stories · {pickedCount} picked · {readyCount} ready to post
146
186
  </span>
147
187
  </div>
148
188
 
149
- {groups.length === 0 ? (
189
+ {activeGroups.length === 0 && rejectedGroups.length === 0 ? (
150
190
  <p className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-sm text-gray-500">
151
191
  No drafts yet. Move a topic to “ready” and the writer will generate candidates here.
152
192
  </p>
153
193
  ) : (
154
- <ol className="space-y-4">
155
- {groups.map((g) => (
156
- <StoryGroup
157
- key={g.key}
158
- group={g}
159
- tierByDomain={tierByDomain}
160
- openId={openId}
161
- onToggleOpen={(id) => setOpenId((cur) => (cur === id ? null : id))}
162
- onPick={pick}
163
- onMarkReady={markReady}
164
- busy={busyKey === g.key}
165
- busyId={busyKey}
166
- />
167
- ))}
168
- </ol>
194
+ <>
195
+ {activeGroups.length > 0 ? (
196
+ <ol className="space-y-4">{activeGroups.map(renderGroup)}</ol>
197
+ ) : (
198
+ <p className="rounded-lg border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500">
199
+ No active stories — the rest are rejected (below).
200
+ </p>
201
+ )}
202
+
203
+ {rejectedGroups.length > 0 && (
204
+ <div className="rounded-lg border border-border">
205
+ <button
206
+ type="button"
207
+ onClick={() => setShowRejected((v) => !v)}
208
+ className="flex w-full items-center justify-between rounded-lg px-4 py-2.5 text-sm font-medium text-gray-500 hover:bg-gray-50"
209
+ aria-expanded={showRejected}
210
+ >
211
+ <span>Rejected ({rejectedGroups.length})</span>
212
+ <ChevronDown className={`h-4 w-4 transition-transform ${showRejected ? 'rotate-180' : ''}`} />
213
+ </button>
214
+ {showRejected && (
215
+ <ol className="space-y-4 border-t border-border p-4">{rejectedGroups.map(renderGroup)}</ol>
216
+ )}
217
+ </div>
218
+ )}
219
+ </>
169
220
  )}
170
221
  </div>
171
222
  );
@@ -1,4 +1,4 @@
1
- import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges } from '../drafts';
1
+ import { parseSources, normUrl, groupKeyOf, groupDrafts, candidateTitle, gradeWords, wordTargets, sourceOverlapCheck, pickChanges, isRejectedTopicGroup } from '../drafts';
2
2
  import type { EntityRecord } from '../types';
3
3
 
4
4
  function rec(id: string, name: string, data: Record<string, unknown>): EntityRecord {
@@ -134,3 +134,20 @@ describe('pickChanges (startsim-yvd3 — pick must not write an invalid status)'
134
134
  expect(pickChanges(7 as unknown as string, 8 as unknown as string)).toEqual({ chosen: false });
135
135
  });
136
136
  });
137
+
138
+ describe('isRejectedTopicGroup (startsim-ay9l — collapse drafts of rejected topics)', () => {
139
+ const grp = (topicRef: string) => groupDrafts([
140
+ rec('d1', 'S', { candidate_index: 1, topic_ref: topicRef, topic_title: 'Story', sources: 'x (2026-01-01) https://a.com/1' }),
141
+ ])[0];
142
+
143
+ it('is true when the group topic id is in the rejected set', () => {
144
+ expect(isRejectedTopicGroup(grp('topic-uuid-1'), new Set(['topic-uuid-1']))).toBe(true);
145
+ });
146
+ it('is false when the topic is not rejected', () => {
147
+ expect(isRejectedTopicGroup(grp('topic-uuid-2'), new Set(['topic-uuid-1']))).toBe(false);
148
+ });
149
+ it('is false when the group has no topic_ref', () => {
150
+ const g = groupDrafts([rec('d2', 'S2', { candidate_index: 1, sources: 'x (2026-01-01) https://b.com/1' })])[0];
151
+ expect(isRejectedTopicGroup(g, new Set(['']))).toBe(false);
152
+ });
153
+ });
@@ -138,6 +138,16 @@ export function groupDrafts(records: EntityRecord[]): DraftGroup[] {
138
138
  return groups.sort((a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label));
139
139
  }
140
140
 
141
+ /**
142
+ * True when a draft story-group belongs to a REJECTED topic — used to collapse
143
+ * those stories out of the active review (startsim-ay9l). A draft's `topic_ref`
144
+ * is the topic's entity id (String), so callers pass a set of rejected topic ids.
145
+ */
146
+ export function isRejectedTopicGroup(group: DraftGroup, rejectedTopicIds: Set<string>): boolean {
147
+ const topicRef = str(readData(group.candidates[0]?.data, 'topic_ref'));
148
+ return topicRef !== '' && rejectedTopicIds.has(topicRef);
149
+ }
150
+
141
151
  /** Word-count targets by content type, from the OGMC skill specs (generic defaults). */
142
152
  export function wordTargets(contentType: string): { blog: [number, number]; linkedin: [number, number] } {
143
153
  switch (contentType) {