@startsimpli/ui 0.4.109 → 0.4.110

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.109",
3
+ "version": "0.4.110",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -45,9 +45,9 @@
45
45
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
46
46
  "react": "^18.0.0 || ^19.0.0",
47
47
  "react-dom": "^18.0.0 || ^19.0.0",
48
- "@startsimpli/api": "^0.5.58",
49
48
  "@startsimpli/auth": "^0.4.35",
50
- "@startsimpli/i18n": "^0.1.3"
49
+ "@startsimpli/i18n": "^0.1.3",
50
+ "@startsimpli/api": "^0.5.58"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@startsimpli/api": {
@@ -223,3 +223,64 @@ describe('reviewHostOf / fieldStr', () => {
223
223
  expect(fieldStr(undefined, 'x')).toBe('');
224
224
  });
225
225
  });
226
+
227
+ // ---- decision vocabulary (bd startsim-b313v) ----
228
+ //
229
+ // THE DEFECT THESE COVER. The three labels were string literals inside
230
+ // `reviewDecisions`, so every tenant's every reviewable type said the same three
231
+ // words. On the OGMC tenant that collided in the customer's hands: approving a
232
+ // TOPIC ("this subject is worth writing") and approving a DRAFT ("this piece is
233
+ // publishable") are different KINDS of decision, and both rendered "Approve".
234
+ // Her question, mid-walkthrough: "so if I go, I click approve now, does it
235
+ // approve all of them?"
236
+
237
+ describe('decision labels are the consumer’s to name', () => {
238
+ const type = {
239
+ id: 't',
240
+ key: 'topic',
241
+ label: 'Topic',
242
+ attributes: [
243
+ {
244
+ id: 'a',
245
+ name: 'status',
246
+ dataType: 'enum' as const,
247
+ required: false,
248
+ config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
249
+ },
250
+ ],
251
+ };
252
+
253
+ it('keeps the generic words when a consumer supplies none', () => {
254
+ expect(reviewDecisions(resolveReviewConfig(type)).map((d) => d.label)).toEqual([
255
+ 'Approve',
256
+ 'Needs work',
257
+ 'Reject',
258
+ ]);
259
+ });
260
+
261
+ it('lets a consumer name what the decision is ABOUT', () => {
262
+ const cfg = resolveReviewConfig(type, {
263
+ decisionLabels: { approve: 'Approve topic', reject: 'Reject topic' },
264
+ });
265
+ const byKey = Object.fromEntries(reviewDecisions(cfg).map((d) => [d.key, d.label]));
266
+ expect(byKey.approve).toBe('Approve topic');
267
+ expect(byKey.reject).toBe('Reject topic');
268
+ // Unnamed decisions keep the default rather than going blank.
269
+ expect(byKey.needs_work).toBe('Needs work');
270
+ });
271
+
272
+ it('ignores a blank override instead of rendering an unlabelled button', () => {
273
+ const cfg = resolveReviewConfig(type, { decisionLabels: { approve: ' ' } });
274
+ expect(reviewDecisions(cfg).find((d) => d.key === 'approve')?.label).toBe('Approve');
275
+ });
276
+
277
+ it('leaves every other part of the decision alone — this is copy, not behaviour', () => {
278
+ const plain = reviewDecisions(resolveReviewConfig(type));
279
+ const named = reviewDecisions(
280
+ resolveReviewConfig(type, { decisionLabels: { approve: 'Approve topic' } }),
281
+ );
282
+ expect(named.map((d) => [d.key, d.status, d.verdict, d.tone, d.advance, d.shortcut])).toEqual(
283
+ plain.map((d) => [d.key, d.status, d.verdict, d.tone, d.advance, d.shortcut]),
284
+ );
285
+ });
286
+ });
@@ -13,6 +13,8 @@ export {
13
13
  resolveReviewConfig,
14
14
  deriveReviewTransitions,
15
15
  reviewDecisions,
16
+ decisionLabel,
17
+ DEFAULT_DECISION_LABELS,
16
18
  applyDecisionToData,
17
19
  type ReviewDecision,
18
20
  pickStatusAttr,
@@ -169,6 +169,23 @@ export interface ReviewConfig {
169
169
  sourceAttrs?: string[];
170
170
  /** Drop the middle "Needs work" decision — a binary accept/reject flow (default false). */
171
171
  omitNeedsWork?: boolean;
172
+ /**
173
+ * What each decision is CALLED, by decision key ('approve' | 'needs_work' |
174
+ * 'reject'). Anything omitted keeps the generic default.
175
+ *
176
+ * WHY THIS EXISTS (bd startsim-b313v). The labels used to be string literals
177
+ * in `reviewDecisions` below, so every tenant's every reviewable type rendered
178
+ * the same three words. On OGMC that collided in the customer's hands: a
179
+ * reviewer approving a TOPIC ("this subject is worth writing") and a reviewer
180
+ * approving a DRAFT ("this piece is publishable") both pressed a button
181
+ * labelled "Approve", and she asked, mid-walkthrough, "so if I go, I click
182
+ * approve now, does it approve all of them?"
183
+ *
184
+ * A workspace cannot answer that from here — only the consumer knows what its
185
+ * records ARE — so the vocabulary is the consumer's to supply. Defaults are
186
+ * unchanged, so nothing that does not pass this renders differently.
187
+ */
188
+ decisionLabels?: Record<string, string>;
172
189
  }
173
190
 
174
191
  export interface ResolvedReview {
@@ -185,6 +202,7 @@ export interface ResolvedReview {
185
202
  metaAttrs: string[];
186
203
  sourceAttrs: string[];
187
204
  omitNeedsWork: boolean;
205
+ decisionLabels: Record<string, string>;
188
206
  }
189
207
 
190
208
  const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
@@ -216,9 +234,23 @@ export function resolveReviewConfig(
216
234
  // Keep source attrs the caller asked for; default to the ones this type declares.
217
235
  sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
218
236
  omitNeedsWork: config.omitNeedsWork ?? false,
237
+ decisionLabels: config.decisionLabels ?? {},
219
238
  };
220
239
  }
221
240
 
241
+ /** The default name of a decision, when the consumer supplies none. */
242
+ export const DEFAULT_DECISION_LABELS: Record<string, string> = {
243
+ approve: 'Approve',
244
+ needs_work: 'Needs work',
245
+ reject: 'Reject',
246
+ };
247
+
248
+ /** What a decision is called on THIS surface — the consumer's word, else the default. */
249
+ export function decisionLabel(key: string, cfg: Pick<ResolvedReview, 'decisionLabels'>): string {
250
+ const supplied = cfg.decisionLabels?.[key];
251
+ return supplied && supplied.trim() ? supplied : DEFAULT_DECISION_LABELS[key] ?? key;
252
+ }
253
+
222
254
  // ---- the single review decision (Approve / Needs work / Reject) ----
223
255
  //
224
256
  // status and team_verdict are two fields with different consumers (status = the
@@ -262,7 +294,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
262
294
  if (r.transitions.approve) {
263
295
  out.push({
264
296
  key: 'approve',
265
- label: 'Approve',
297
+ label: decisionLabel('approve', r),
266
298
  status: r.transitions.approve,
267
299
  verdict: verdictOf('good'),
268
300
  tone: 'good',
@@ -276,7 +308,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
276
308
  if (!r.omitNeedsWork && entry && (editVerdict || entry !== r.transitions.approve)) {
277
309
  out.push({
278
310
  key: 'needs_work',
279
- label: 'Needs work',
311
+ label: decisionLabel('needs_work', r),
280
312
  status: entry,
281
313
  verdict: editVerdict,
282
314
  tone: 'neutral',
@@ -289,7 +321,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
289
321
  if (r.transitions.reject) {
290
322
  out.push({
291
323
  key: 'reject',
292
- label: 'Reject',
324
+ label: decisionLabel('reject', r),
293
325
  status: r.transitions.reject,
294
326
  verdict: verdictOf('bad'),
295
327
  tone: 'bad',