@startsimpli/ui 0.4.157 → 0.4.158

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.157",
3
+ "version": "0.4.158",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -55,9 +55,9 @@
55
55
  "@codemirror/language": "^6.0.0",
56
56
  "@codemirror/state": "^6.0.0",
57
57
  "@codemirror/view": "^6.0.0",
58
- "@startsimpli/auth": "^0.4.42",
59
58
  "@startsimpli/i18n": "^0.1.3",
60
- "@startsimpli/api": "^0.5.68"
59
+ "@startsimpli/api": "^0.5.68",
60
+ "@startsimpli/auth": "^0.4.42"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@startsimpli/api": {
@@ -32,6 +32,7 @@ import {
32
32
  activeDecisionKey,
33
33
  applyDecisionToData,
34
34
  fieldStr,
35
+ isDecisionInForce,
35
36
  resolveReviewConfig,
36
37
  reviewDecisions,
37
38
  type ReviewConfig,
@@ -126,19 +127,24 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
126
127
  function iconButton(d: ReviewDecision, extraOnClick?: () => void) {
127
128
  const Icon = ICON[d.tone];
128
129
  const active = d.key === appliedKey;
130
+ // The decision already in force writes nothing (bd startsim-3c7h8). Not
131
+ // `disabled`: that dims the lit tick and it would read as un-approved.
132
+ const inert = isDecisionInForce(d, appliedKey);
129
133
  return (
130
134
  <button
131
135
  type="button"
132
136
  title={d.label}
133
137
  aria-label={d.label}
134
138
  aria-pressed={active}
139
+ aria-disabled={inert || undefined}
135
140
  disabled={busy === d.key}
136
141
  onClick={(e) => {
137
142
  e.stopPropagation();
143
+ if (inert) return;
138
144
  if (extraOnClick) extraOnClick();
139
145
  else void apply(d);
140
146
  }}
141
- className={cn(BTN, active ? ACTIVE[d.tone] : IDLE[d.tone])}
147
+ className={cn(BTN, active ? ACTIVE[d.tone] : IDLE[d.tone], inert && 'cursor-default')}
142
148
  >
143
149
  <Icon className="h-4 w-4" />
144
150
  </button>
@@ -46,6 +46,7 @@ import { declaredBlob, isVersionNumber, readData, writeData } from './data';
46
46
  import { parseSources } from './drafts';
47
47
  import {
48
48
  activeDecisionKey,
49
+ isDecisionInForce,
49
50
  resolveReviewConfig,
50
51
  reviewDecisions,
51
52
  shouldIgnoreShortcut,
@@ -217,6 +218,13 @@ function ReviewDrawerInner({
217
218
 
218
219
  /** One decision → write status AND verdict together (the coherent pair). */
219
220
  function applyDecision(d: ReviewDecision) {
221
+ // Restating the decision in force writes nothing (bd startsim-3c7h8) — the
222
+ // button and its shortcut both land here. It still walks the queue, so
223
+ // `a` over already-approved work keeps moving the reviewer on.
224
+ if (isDecisionInForce(d, activeKey)) {
225
+ if (d.advance && next) onNavigate(next);
226
+ return;
227
+ }
220
228
  const changes: Record<string, unknown> = {};
221
229
  if (d.status) changes[cfg.statusName] = d.status;
222
230
  if (d.verdict) changes[cfg.verdictAttr] = d.verdict;
@@ -494,6 +502,7 @@ function DecisionButton({
494
502
  type="button"
495
503
  onClick={onClick}
496
504
  disabled={busy}
505
+ aria-disabled={(active && !decision.promptNote) || undefined}
497
506
  title={`${decision.label} (${decision.shortcut})`}
498
507
  className={cn(
499
508
  'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md border px-3 py-2 text-sm font-medium transition disabled:opacity-60',
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The decision already in force is inert (bd startsim-3c7h8, startsim-tpz67,
3
+ * startsim-nyqbw).
4
+ *
5
+ * A lit Approve means the topic is `ready` or past it (`written`). Clicking it
6
+ * again used to write `status: ready` over a `written` topic, which moved it
7
+ * backwards and made the host treat the save as a fresh approval: it navigated
8
+ * to the story page and dispatched a writer the server then refused
9
+ * (drafts_exist). Clicking or pressing the decision that is already in force
10
+ * must write nothing.
11
+ */
12
+ import * as React from 'react';
13
+ import { render, screen, waitFor } from '@testing-library/react';
14
+ import userEvent from '@testing-library/user-event';
15
+
16
+ import { InlineReviewActions } from '../InlineReviewActions';
17
+ import { ReviewDrawer } from '../ReviewDrawer';
18
+ import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
19
+
20
+ const TOPIC_TYPE: EntityTypeDef = {
21
+ id: 1,
22
+ key: 'topic',
23
+ label: 'Topic',
24
+ attributes: [
25
+ {
26
+ id: 'status',
27
+ name: 'status',
28
+ dataType: 'enum',
29
+ required: false,
30
+ config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
31
+ },
32
+ { id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
33
+ { id: 'team_verdict', name: 'team_verdict', dataType: 'text', required: false, config: {} },
34
+ ],
35
+ };
36
+
37
+ function topic(id: number, status: string): EntityRecord {
38
+ return {
39
+ id,
40
+ entityType: 'topic',
41
+ externalId: null,
42
+ name: `Topic ${id}`,
43
+ data: { title: `Topic ${id}`, status, team_verdict: 'good' },
44
+ createdAt: '2026-09-01T00:00:00Z',
45
+ };
46
+ }
47
+
48
+ function page<T>(results: T[]): Paginated<T> {
49
+ return { count: results.length, next: null, previous: null, results };
50
+ }
51
+
52
+ function stubClient(): CollectionClient & { updateEntity: jest.Mock } {
53
+ const updateEntity = jest.fn(async (id: number | string, input: { data?: Record<string, unknown> }) => ({
54
+ ...topic(Number(id), 'x'),
55
+ data: input.data ?? {},
56
+ }));
57
+ return {
58
+ listTypes: jest.fn(async () => page([TOPIC_TYPE])),
59
+ listAllEntities: jest.fn(async () => []),
60
+ updateEntity,
61
+ } as CollectionClient & { updateEntity: jest.Mock };
62
+ }
63
+
64
+ describe('InlineReviewActions: the lit decision writes nothing', () => {
65
+ it.each(['written', 'ready'])('clicking the lit Approve on a %s topic does not save', async (status) => {
66
+ const user = userEvent.setup();
67
+ const client = stubClient();
68
+ const onSaved = jest.fn();
69
+ render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(1, status)} onSaved={onSaved} />);
70
+
71
+ const approve = screen.getByRole('button', { name: /Approve/ });
72
+ expect(approve).toHaveAttribute('aria-pressed', 'true');
73
+ expect(approve).toHaveAttribute('aria-disabled', 'true');
74
+
75
+ await user.click(approve);
76
+
77
+ expect(client.updateEntity).not.toHaveBeenCalled();
78
+ expect(onSaved).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('a decision that is NOT in force still saves', async () => {
82
+ const user = userEvent.setup();
83
+ const client = stubClient();
84
+ render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(1, 'written')} />);
85
+
86
+ await user.click(screen.getByRole('button', { name: /Reject/ }));
87
+
88
+ await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
89
+ expect(client.updateEntity.mock.calls[0][1].data).toMatchObject({ status: 'rejected' });
90
+ });
91
+
92
+ it('Approve on a suggested topic still saves ready', async () => {
93
+ const user = userEvent.setup();
94
+ const client = stubClient();
95
+ render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(1, 'suggested')} />);
96
+
97
+ const approve = screen.getByRole('button', { name: /Approve/ });
98
+ expect(approve).toHaveAttribute('aria-pressed', 'false');
99
+ await user.click(approve);
100
+
101
+ await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
102
+ expect(client.updateEntity.mock.calls[0][1].data).toMatchObject({ status: 'ready' });
103
+ });
104
+ });
105
+
106
+ describe('ReviewDrawer: the decision in force writes nothing', () => {
107
+ function renderDrawer(status: string) {
108
+ const client = stubClient();
109
+ const onNavigate = jest.fn();
110
+ const onSaved = jest.fn();
111
+ const first = topic(1, status);
112
+ render(
113
+ <ReviewDrawer
114
+ client={client}
115
+ type={TOPIC_TYPE}
116
+ records={[first, topic(2, 'suggested')]}
117
+ record={first}
118
+ onClose={jest.fn()}
119
+ onNavigate={onNavigate}
120
+ onSaved={onSaved}
121
+ />,
122
+ );
123
+ return { client, onNavigate, onSaved };
124
+ }
125
+
126
+ it('clicking Approve on a written topic does not save, and still walks the queue', async () => {
127
+ const user = userEvent.setup();
128
+ const { client, onSaved, onNavigate } = renderDrawer('written');
129
+
130
+ await user.click(screen.getByRole('button', { name: /Approve/ }));
131
+
132
+ expect(client.updateEntity).not.toHaveBeenCalled();
133
+ expect(onSaved).not.toHaveBeenCalled();
134
+ expect(onNavigate).toHaveBeenCalledTimes(1);
135
+ expect(onNavigate.mock.calls[0][0].id).toBe(2);
136
+ });
137
+
138
+ it('the bare-key shortcut for the decision in force does not save either', async () => {
139
+ const user = userEvent.setup();
140
+ const { client } = renderDrawer('written');
141
+
142
+ await user.keyboard('a');
143
+
144
+ expect(client.updateEntity).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('a different decision from the drawer still saves', async () => {
148
+ const user = userEvent.setup();
149
+ const { client } = renderDrawer('written');
150
+
151
+ await user.click(screen.getByRole('button', { name: /Reject/ }));
152
+
153
+ await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
154
+ });
155
+ });
@@ -14,6 +14,7 @@ export {
14
14
  deriveReviewTransitions,
15
15
  reviewDecisions,
16
16
  activeDecisionKey,
17
+ isDecisionInForce,
17
18
  isTerminalStatus,
18
19
  decisionLabel,
19
20
  DEFAULT_DECISION_LABELS,
@@ -475,6 +475,20 @@ export function activeDecisionKey(
475
475
  return null;
476
476
  }
477
477
 
478
+ /**
479
+ * True when clicking `d` would only restate the decision already in force, so
480
+ * the click must write nothing (bd startsim-3c7h8). A lit Approve on a
481
+ * `written` topic used to write `status: ready` over it: the topic went
482
+ * backwards and the host read the save as a fresh approval, navigating to a
483
+ * story page that claimed a writer was running when the server had refused one.
484
+ *
485
+ * A note-prompting decision is never inert: re-opening it is how a reviewer
486
+ * edits the note that came with it.
487
+ */
488
+ export function isDecisionInForce(d: ReviewDecision, activeKey: string | null): boolean {
489
+ return !d.promptNote && activeKey != null && d.key === activeKey;
490
+ }
491
+
478
492
  // ---- small read helpers used by the component (kept here so they're tested) ----
479
493
 
480
494
  /** String read of an attr off a record's data blob ('' when missing). */