github-issue-tower-defence-management 1.185.1 → 1.186.0

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -0
  3. package/bin/adapter/entry-points/cli/index.js +2 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleOperationApi.js +43 -1
  6. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleProjectResolver.js +7 -1
  8. package/bin/adapter/entry-points/console/consoleProjectResolver.js.map +1 -1
  9. package/bin/adapter/entry-points/console/ui-dist/assets/index-wj6o8qvo.js +86 -0
  10. package/bin/adapter/entry-points/console/ui-dist/assets/index-yZy1TUOo.css +2 -0
  11. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  12. package/bin/adapter/entry-points/console/webServer.js +3 -0
  13. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +5 -1
  15. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  16. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  17. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  18. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +36 -0
  19. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  20. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -0
  21. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  22. package/bin/domain/entities/Project.js +11 -1
  23. package/bin/domain/entities/Project.js.map +1 -1
  24. package/bin/domain/usecases/HandleScheduledEventUseCase.js +12 -1
  25. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  26. package/bin/domain/usecases/IssueNoStatusUpdateUseCase.js +36 -0
  27. package/bin/domain/usecases/IssueNoStatusUpdateUseCase.js.map +1 -0
  28. package/bin/domain/usecases/IssueRejectionEvaluator.js +19 -0
  29. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  30. package/bin/domain/usecases/QualityCheckAdvanceUseCase.js +1 -0
  31. package/bin/domain/usecases/QualityCheckAdvanceUseCase.js.map +1 -1
  32. package/bin/domain/usecases/ReopenedDoneIssueRevertUseCase.js +36 -0
  33. package/bin/domain/usecases/ReopenedDoneIssueRevertUseCase.js.map +1 -0
  34. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +2 -5
  35. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/adapter/entry-points/cli/index.ts +14 -10
  38. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +199 -0
  39. package/src/adapter/entry-points/console/consoleOperationApi.ts +65 -0
  40. package/src/adapter/entry-points/console/consoleProjectResolver.test.ts +26 -0
  41. package/src/adapter/entry-points/console/consoleProjectResolver.ts +8 -1
  42. package/src/adapter/entry-points/console/dashboardEmitterComposerKey.test.ts +1 -0
  43. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +40 -0
  44. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +18 -0
  45. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +4 -0
  46. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +114 -141
  47. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +84 -3
  48. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +23 -0
  49. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +73 -0
  50. package/src/adapter/entry-points/console/ui/src/index.css +35 -0
  51. package/src/adapter/entry-points/console/ui-dist/assets/index-wj6o8qvo.js +86 -0
  52. package/src/adapter/entry-points/console/ui-dist/assets/index-yZy1TUOo.css +2 -0
  53. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  54. package/src/adapter/entry-points/console/webServer.ts +7 -0
  55. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  56. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.test.ts +1 -0
  57. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +1 -0
  58. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +1 -0
  59. package/src/adapter/entry-points/handlers/inTmuxByHumanSessionReconciler.test.ts +1 -0
  60. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  61. package/src/adapter/entry-points/handlers/ownerCallFileCleaner.test.ts +1 -0
  62. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +1 -0
  63. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +1 -0
  64. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +1 -0
  65. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  66. package/src/adapter/repositories/GitHubIssueCommentRepository.test.ts +1 -0
  67. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +90 -0
  68. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +48 -2
  69. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +107 -0
  70. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +20 -0
  71. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +1 -0
  72. package/src/domain/entities/Issue.ts +1 -0
  73. package/src/domain/entities/Project.ts +11 -2
  74. package/src/domain/usecases/AgentDesignationLabelAdoptUseCase.test.ts +1 -0
  75. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +1 -0
  76. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  77. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +144 -0
  78. package/src/domain/usecases/HandleScheduledEventUseCase.ts +16 -0
  79. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
  80. package/src/domain/usecases/IssueNoStatusUpdateUseCase.test.ts +165 -0
  81. package/src/domain/usecases/IssueNoStatusUpdateUseCase.ts +44 -0
  82. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +120 -0
  83. package/src/domain/usecases/IssueRejectionEvaluator.ts +21 -0
  84. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +1 -0
  85. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +1 -0
  86. package/src/domain/usecases/QualityCheckAdvanceUseCase.test.ts +34 -0
  87. package/src/domain/usecases/QualityCheckAdvanceUseCase.ts +1 -0
  88. package/src/domain/usecases/ReopenedDoneIssueRevertUseCase.test.ts +188 -0
  89. package/src/domain/usecases/ReopenedDoneIssueRevertUseCase.ts +57 -0
  90. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +44 -0
  91. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +4 -12
  92. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  93. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +1 -0
  94. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -0
  95. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -0
  96. package/src/domain/usecases/TriagerApprovalDispatchUseCase.test.ts +1 -0
  97. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -0
  98. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +1 -0
  99. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +1 -0
  100. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +1 -0
  101. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +1 -0
  102. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +1 -0
  103. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  104. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +2 -0
  105. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  106. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts +1 -0
  107. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts.map +1 -1
  108. package/types/adapter/entry-points/console/webServer.d.ts +2 -0
  109. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  110. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  111. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  112. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  113. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  114. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +1 -0
  115. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  116. package/types/domain/entities/Issue.d.ts +1 -0
  117. package/types/domain/entities/Issue.d.ts.map +1 -1
  118. package/types/domain/entities/Project.d.ts +2 -1
  119. package/types/domain/entities/Project.d.ts.map +1 -1
  120. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +6 -1
  121. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  122. package/types/domain/usecases/IssueNoStatusUpdateUseCase.d.ts +12 -0
  123. package/types/domain/usecases/IssueNoStatusUpdateUseCase.d.ts.map +1 -0
  124. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +1 -0
  125. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  126. package/types/domain/usecases/QualityCheckAdvanceUseCase.d.ts.map +1 -1
  127. package/types/domain/usecases/ReopenedDoneIssueRevertUseCase.d.ts +12 -0
  128. package/types/domain/usecases/ReopenedDoneIssueRevertUseCase.d.ts.map +1 -0
  129. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  130. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -0
  131. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  132. package/bin/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +0 -2
  133. package/bin/adapter/entry-points/console/ui-dist/assets/index-DQT4HL0G.js +0 -86
  134. package/src/adapter/entry-points/console/ui-dist/assets/index-8Ni5iu-C.css +0 -2
  135. package/src/adapter/entry-points/console/ui-dist/assets/index-DQT4HL0G.js +0 -86
@@ -1,6 +1,7 @@
1
1
  import type { Issue } from '../../../domain/entities/Issue';
2
2
  import {
3
3
  buildStoryListWithNew,
4
+ FIELD_OPTION_COLORS,
4
5
  type FieldOption,
5
6
  type Project,
6
7
  } from '../../../domain/entities/Project';
@@ -52,6 +53,8 @@ export type ConsoleOperationContext = {
52
53
  | null;
53
54
  resolveProjectRepository: ConsoleProjectRepositoryResolver | null;
54
55
  invalidateProject: ((pjcode: string) => void) | null;
56
+ updateProjectCacheEntry:
57
+ ((pjcode: string, updatedProject: Project) => void) | null;
55
58
  };
56
59
 
57
60
  export type ConsoleOperationResponse = {
@@ -138,6 +141,7 @@ const projectItemReference = (
138
141
  author: '',
139
142
  closingIssueReferenceUrls: [],
140
143
  agent: null,
144
+ stateReason: null,
141
145
  });
142
146
 
143
147
  const recordDone = (
@@ -698,6 +702,67 @@ export const handleReviewComment = async (
698
702
  return ok();
699
703
  };
700
704
 
705
+ const isStoryColor = (value: unknown): value is FieldOption['color'] =>
706
+ FIELD_OPTION_COLORS.some((c) => c === value);
707
+
708
+ export const handleStoryColor = async (
709
+ context: ConsoleOperationContext,
710
+ body: Record<string, unknown>,
711
+ ): Promise<ConsoleOperationResponse> => {
712
+ const storyOptionId = body.storyOptionId;
713
+ const newColor = body.newColor;
714
+ const nameWithOwner = body.nameWithOwner;
715
+
716
+ if (!isNonEmptyString(storyOptionId)) {
717
+ return badRequest('storyOptionId is required');
718
+ }
719
+ if (!isStoryColor(newColor)) {
720
+ return badRequest(
721
+ 'newColor must be one of GRAY, BLUE, GREEN, YELLOW, ORANGE, RED, PINK, PURPLE',
722
+ );
723
+ }
724
+ if (!isNonEmptyString(nameWithOwner)) {
725
+ return badRequest('nameWithOwner is required');
726
+ }
727
+
728
+ const binding = await resolveBinding(context, body);
729
+ if (isOperationResponse(binding)) {
730
+ return binding;
731
+ }
732
+ const { project, pjcode } = binding;
733
+
734
+ if (project.story === null) {
735
+ return badRequest('project does not have a story field');
736
+ }
737
+
738
+ const storyOption = project.story.stories.find((s) => s.id === storyOptionId);
739
+ if (storyOption === undefined) {
740
+ return badRequest(`story option "${storyOptionId}" not found in project`);
741
+ }
742
+
743
+ const proxyUrl = `https://github.com/${nameWithOwner}/issues/0`;
744
+ await context
745
+ .resolveIssueRepository(proxyUrl)
746
+ .updateStoryOptionColor(
747
+ { ...project, story: project.story },
748
+ storyOptionId,
749
+ newColor,
750
+ );
751
+
752
+ if (context.updateProjectCacheEntry !== null) {
753
+ const updatedStories = project.story.stories.map((s) =>
754
+ s.id === storyOptionId ? { ...s, color: newColor } : s,
755
+ );
756
+ const updatedProject: Project = {
757
+ ...project,
758
+ story: { ...project.story, stories: updatedStories },
759
+ };
760
+ context.updateProjectCacheEntry(pjcode, updatedProject);
761
+ }
762
+
763
+ return ok();
764
+ };
765
+
701
766
  export const handleIntmux = async (
702
767
  context: ConsoleOperationContext,
703
768
  body: Record<string, unknown>,
@@ -127,6 +127,32 @@ describe('createConsoleProjectResolver', () => {
127
127
  });
128
128
  expect(loadProject).toHaveBeenCalledTimes(2);
129
129
  });
130
+
131
+ it('updateEntry replaces the cached project so the next resolve returns the updated project', async () => {
132
+ const updatedProject: Project = { ...acmeProject, id: 'PVT_acme_updated' };
133
+ const loadProject = jest.fn(async () => acmeProject);
134
+ const { resolve, updateEntry } = createConsoleProjectResolver(
135
+ { acme: 'https://github.com/orgs/acme/projects/1' },
136
+ loadProject,
137
+ );
138
+ await resolve('acme');
139
+ updateEntry('acme', updatedProject);
140
+ const result = await resolve('acme');
141
+ expect(result?.project).toBe(updatedProject);
142
+ expect(loadProject).toHaveBeenCalledTimes(1);
143
+ });
144
+
145
+ it('updateEntry is a no-op for a pjcode that was never resolved', async () => {
146
+ const updatedProject: Project = { ...acmeProject, id: 'PVT_acme_updated' };
147
+ const loadProject = jest.fn(async () => acmeProject);
148
+ const { resolve, updateEntry } = createConsoleProjectResolver(
149
+ { acme: 'https://github.com/orgs/acme/projects/1' },
150
+ loadProject,
151
+ );
152
+ updateEntry('acme', updatedProject);
153
+ const result = await resolve('acme');
154
+ expect(result?.project).toBe(acmeProject);
155
+ });
130
156
  });
131
157
 
132
158
  describe('createConsoleProjectLoader', () => {
@@ -71,6 +71,7 @@ export const createPjcodeConfigChecker = (
71
71
  export type ConsoleProjectResolverBundle = {
72
72
  resolve: ConsoleProjectResolver;
73
73
  invalidate: (pjcode: string) => void;
74
+ updateEntry: (pjcode: string, updatedProject: Project) => void;
74
75
  };
75
76
 
76
77
  export const createConsoleProjectResolver = (
@@ -100,5 +101,11 @@ export const createConsoleProjectResolver = (
100
101
  const invalidate = (pjcode: string): void => {
101
102
  cache.delete(pjcode);
102
103
  };
103
- return { resolve, invalidate };
104
+ const updateEntry = (pjcode: string, updatedProject: Project): void => {
105
+ const existing = cache.get(pjcode);
106
+ if (existing !== undefined) {
107
+ cache.set(pjcode, { ...existing, project: updatedProject });
108
+ }
109
+ };
110
+ return { resolve, invalidate, updateEntry };
104
111
  };
@@ -48,6 +48,7 @@ const makeIssue = (overrides: Partial<Issue>): Issue => {
48
48
  author: 'someone',
49
49
  closingIssueReferenceUrls: [],
50
50
  agent: null,
51
+ stateReason: null,
51
52
  ...overrides,
52
53
  };
53
54
  };
@@ -514,6 +514,46 @@ test('creates a new story when the add-story button and form are used', async ({
514
514
  ).toHaveCount(0);
515
515
  });
516
516
 
517
+ test('changes the color of a story row via the color palette in the stories tab', async ({
518
+ page,
519
+ }) => {
520
+ await page.goto(harness.appRootUrl);
521
+
522
+ await tabByLabel(page, 'Stories').click();
523
+
524
+ const tdpmRow = page.locator('.console-story-list-row', {
525
+ hasText: 'TDPM Console port',
526
+ });
527
+ await expect(tdpmRow).toBeVisible();
528
+
529
+ const changeColorButton = tdpmRow.locator('.console-op-button', {
530
+ hasText: 'Change color',
531
+ });
532
+ await expect(changeColorButton).toBeVisible();
533
+
534
+ await changeColorButton.click();
535
+
536
+ const palette = tdpmRow.locator('.console-story-color-palette');
537
+ await expect(palette).toBeVisible();
538
+
539
+ const swatches = palette.locator('.console-story-color-swatch');
540
+ await expect(swatches).toHaveCount(8);
541
+
542
+ const graySwatch = swatches.filter({ hasText: 'disable' });
543
+ await expect(graySwatch).toHaveCount(1);
544
+
545
+ const greenSwatch = palette.locator('[aria-label="GREEN"]');
546
+ await greenSwatch.click();
547
+
548
+ await expect
549
+ .poll(() => harness.storyColorCalls.length, { timeout: 10000 })
550
+ .toBe(1);
551
+ expect(harness.storyColorCalls[0].storyOptionId).toBe('1491051e');
552
+ expect(harness.storyColorCalls[0].newColor).toBe('GREEN');
553
+
554
+ await expect(palette).toHaveCount(0);
555
+ });
556
+
517
557
  test('reorders stories on the triage tab with up/down buttons', async ({
518
558
  page,
519
559
  }) => {
@@ -47,6 +47,11 @@ export type ConsoleE2eCreateIssueCall = {
47
47
  title: string;
48
48
  };
49
49
 
50
+ export type ConsoleE2eStoryColorCall = {
51
+ storyOptionId: string;
52
+ newColor: string;
53
+ };
54
+
50
55
  type ConsoleFixtureListItem = {
51
56
  number: number;
52
57
  title: string;
@@ -391,6 +396,7 @@ const buildIssueForUrl = (url: string): Issue => ({
391
396
  author: 'HiromiShikata',
392
397
  closingIssueReferenceUrls: [],
393
398
  agent: null,
399
+ stateReason: null,
394
400
  });
395
401
 
396
402
  const notImplemented = (method: string): never => {
@@ -453,6 +459,7 @@ const createStubIssueRepository = (
453
459
  reviewCommentCalls: ConsoleE2eReviewCommentCall[],
454
460
  requestChangesCalls: ConsoleE2eRequestChangesCall[],
455
461
  createIssueCalls: ConsoleE2eCreateIssueCall[],
462
+ storyColorCalls: ConsoleE2eStoryColorCall[],
456
463
  ): IssueRepository => ({
457
464
  getAllIssues: () => notImplemented('getAllIssues'),
458
465
  getIssueByUrl: async (url: string): Promise<Issue | null> =>
@@ -615,6 +622,13 @@ const createStubIssueRepository = (
615
622
  }
616
623
  : null,
617
624
  setIssueAgentField: async (): Promise<void> => undefined,
625
+ updateStoryOptionColor: async (
626
+ _project,
627
+ storyOptionId: string,
628
+ newColor: string,
629
+ ): Promise<void> => {
630
+ storyColorCalls.push({ storyOptionId, newColor });
631
+ },
618
632
  });
619
633
 
620
634
  export type ConsoleE2eReorderStoryCall = {
@@ -635,6 +649,7 @@ export type ConsoleE2eHarness = {
635
649
  createIssueCalls: ConsoleE2eCreateIssueCall[];
636
650
  reorderStoryCalls: ConsoleE2eReorderStoryCall[];
637
651
  addStoryCalls: ConsoleE2eAddStoryCall[];
652
+ storyColorCalls: ConsoleE2eStoryColorCall[];
638
653
  stop: () => Promise<void>;
639
654
  };
640
655
 
@@ -658,6 +673,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
658
673
  const createIssueCalls: ConsoleE2eCreateIssueCall[] = [];
659
674
  const reorderStoryCalls: ConsoleE2eReorderStoryCall[] = [];
660
675
  const addStoryCalls: ConsoleE2eAddStoryCall[] = [];
676
+ const storyColorCalls: ConsoleE2eStoryColorCall[] = [];
661
677
 
662
678
  const server = await startWebServer({
663
679
  accessToken: CONSOLE_E2E_TOKEN,
@@ -667,6 +683,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
667
683
  reviewCommentCalls,
668
684
  requestChangesCalls,
669
685
  createIssueCalls,
686
+ storyColorCalls,
670
687
  ),
671
688
  projectRepository: {
672
689
  updateStoryList: async (_updatedProject, stories) => {
@@ -726,6 +743,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
726
743
  createIssueCalls,
727
744
  reorderStoryCalls,
728
745
  addStoryCalls,
746
+ storyColorCalls,
729
747
  stop: async (): Promise<void> => {
730
748
  await closeServer(server);
731
749
  fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -53,6 +53,10 @@ const meta: Meta<typeof ConsoleStoryList> = {
53
53
  args: {
54
54
  onCreateIssue: () => Promise.resolve(),
55
55
  onAddStory: () => Promise.resolve(),
56
+ onSelectColor: () => undefined,
57
+ optimisticColors: {},
58
+ colorChangeInFlight: null,
59
+ colorErrors: {},
56
60
  },
57
61
  };
58
62
 
@@ -1,5 +1,5 @@
1
1
  import { fireEvent, render, waitFor } from '@testing-library/react';
2
- import type { ConsoleStoryEntry } from '../../logic/types';
2
+ import type { ConsoleColor, ConsoleStoryEntry } from '../../logic/types';
3
3
  import { ConsoleStoryList } from './ConsoleStoryList';
4
4
 
5
5
  const storyEntries: ConsoleStoryEntry[] = [
@@ -20,20 +20,20 @@ const storyEntries: ConsoleStoryEntry[] = [
20
20
  ];
21
21
 
22
22
  const defaultProps = {
23
+ stories: storyEntries,
24
+ isLoading: false,
25
+ error: null,
23
26
  onCreateIssue: () => Promise.resolve(),
24
27
  onAddStory: () => Promise.resolve(),
28
+ onSelectColor: () => undefined,
29
+ optimisticColors: {} as Record<string, ConsoleColor>,
30
+ colorChangeInFlight: null as string | null,
31
+ colorErrors: {} as Record<string, string>,
25
32
  };
26
33
 
27
34
  describe('ConsoleStoryList', () => {
28
35
  it('renders each story name and its open item count', () => {
29
- const { getByText } = render(
30
- <ConsoleStoryList
31
- stories={storyEntries}
32
- isLoading={false}
33
- error={null}
34
- {...defaultProps}
35
- />,
36
- );
36
+ const { getByText } = render(<ConsoleStoryList {...defaultProps} />);
37
37
  expect(getByText('TDPM Console port')).toBeInTheDocument();
38
38
  expect(getByText('12')).toBeInTheDocument();
39
39
  expect(getByText('Move to Okinawa')).toBeInTheDocument();
@@ -41,26 +41,14 @@ describe('ConsoleStoryList', () => {
41
41
  });
42
42
 
43
43
  it('renders an Add task button for each story row', () => {
44
- const { getAllByRole } = render(
45
- <ConsoleStoryList
46
- stories={storyEntries}
47
- isLoading={false}
48
- error={null}
49
- {...defaultProps}
50
- />,
51
- );
44
+ const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
52
45
  const buttons = getAllByRole('button', { name: 'Add task' });
53
46
  expect(buttons).toHaveLength(storyEntries.length);
54
47
  });
55
48
 
56
49
  it('shows the create form when Add task is clicked', () => {
57
50
  const { getAllByRole, getByPlaceholderText } = render(
58
- <ConsoleStoryList
59
- stories={storyEntries}
60
- isLoading={false}
61
- error={null}
62
- {...defaultProps}
63
- />,
51
+ <ConsoleStoryList {...defaultProps} />,
64
52
  );
65
53
  const [firstButton] = getAllByRole('button', { name: 'Add task' });
66
54
  fireEvent.click(firstButton);
@@ -69,12 +57,7 @@ describe('ConsoleStoryList', () => {
69
57
 
70
58
  it('hides the form when Add task is clicked again on the same row', () => {
71
59
  const { getAllByRole, queryByPlaceholderText } = render(
72
- <ConsoleStoryList
73
- stories={storyEntries}
74
- isLoading={false}
75
- error={null}
76
- {...defaultProps}
77
- />,
60
+ <ConsoleStoryList {...defaultProps} />,
78
61
  );
79
62
  const [firstButton] = getAllByRole('button', { name: 'Add task' });
80
63
  fireEvent.click(firstButton);
@@ -85,13 +68,7 @@ describe('ConsoleStoryList', () => {
85
68
  it('calls onCreateIssue with the correct storyOptionId and title', async () => {
86
69
  const onCreateIssue = jest.fn().mockResolvedValue(undefined);
87
70
  const { getAllByRole, getByPlaceholderText, getByRole } = render(
88
- <ConsoleStoryList
89
- stories={storyEntries}
90
- isLoading={false}
91
- error={null}
92
- onCreateIssue={onCreateIssue}
93
- onAddStory={() => Promise.resolve()}
94
- />,
71
+ <ConsoleStoryList {...defaultProps} onCreateIssue={onCreateIssue} />,
95
72
  );
96
73
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
97
74
  fireEvent.change(getByPlaceholderText('Issue title'), {
@@ -108,12 +85,7 @@ describe('ConsoleStoryList', () => {
108
85
 
109
86
  it('shows a validation error when Create is clicked with an empty title', () => {
110
87
  const { getAllByRole, getByRole, getByText } = render(
111
- <ConsoleStoryList
112
- stories={storyEntries}
113
- isLoading={false}
114
- error={null}
115
- {...defaultProps}
116
- />,
88
+ <ConsoleStoryList {...defaultProps} />,
117
89
  );
118
90
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
119
91
  fireEvent.click(getByRole('button', { name: 'Create' }));
@@ -128,13 +100,7 @@ describe('ConsoleStoryList', () => {
128
100
  getByRole,
129
101
  queryByPlaceholderText,
130
102
  } = render(
131
- <ConsoleStoryList
132
- stories={storyEntries}
133
- isLoading={false}
134
- error={null}
135
- onCreateIssue={onCreateIssue}
136
- onAddStory={() => Promise.resolve()}
137
- />,
103
+ <ConsoleStoryList {...defaultProps} onCreateIssue={onCreateIssue} />,
138
104
  );
139
105
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
140
106
  fireEvent.change(getByPlaceholderText('Issue title'), {
@@ -151,13 +117,7 @@ describe('ConsoleStoryList', () => {
151
117
  .fn()
152
118
  .mockRejectedValue(new Error('Network error'));
153
119
  const { getAllByRole, getByPlaceholderText, getByRole, getByText } = render(
154
- <ConsoleStoryList
155
- stories={storyEntries}
156
- isLoading={false}
157
- error={null}
158
- onCreateIssue={onCreateIssue}
159
- onAddStory={() => Promise.resolve()}
160
- />,
120
+ <ConsoleStoryList {...defaultProps} onCreateIssue={onCreateIssue} />,
161
121
  );
162
122
  fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
163
123
  fireEvent.change(getByPlaceholderText('Issue title'), {
@@ -169,36 +129,21 @@ describe('ConsoleStoryList', () => {
169
129
 
170
130
  it('shows a loading message when isLoading is true', () => {
171
131
  const { getByText } = render(
172
- <ConsoleStoryList
173
- stories={[]}
174
- isLoading={true}
175
- error={null}
176
- {...defaultProps}
177
- />,
132
+ <ConsoleStoryList {...defaultProps} stories={[]} isLoading={true} />,
178
133
  );
179
134
  expect(getByText('Loading stories...')).toBeInTheDocument();
180
135
  });
181
136
 
182
137
  it('shows an empty message when there are no stories', () => {
183
138
  const { getByText } = render(
184
- <ConsoleStoryList
185
- stories={[]}
186
- isLoading={false}
187
- error={null}
188
- {...defaultProps}
189
- />,
139
+ <ConsoleStoryList {...defaultProps} stories={[]} />,
190
140
  );
191
141
  expect(getByText('No active stories')).toBeInTheDocument();
192
142
  });
193
143
 
194
144
  it('shows an error message when error is set', () => {
195
145
  const { getByRole, getByText } = render(
196
- <ConsoleStoryList
197
- stories={[]}
198
- isLoading={false}
199
- error="HTTP 503"
200
- {...defaultProps}
201
- />,
146
+ <ConsoleStoryList {...defaultProps} stories={[]} error="HTTP 503" />,
202
147
  );
203
148
  expect(getByRole('alert')).toBeInTheDocument();
204
149
  expect(getByText(/HTTP 503/)).toBeInTheDocument();
@@ -212,28 +157,14 @@ describe('ConsoleStoryList', () => {
212
157
  openItemCount: 4,
213
158
  } as unknown as ConsoleStoryEntry;
214
159
  const { getByText } = render(
215
- <ConsoleStoryList
216
- stories={[oldFormatEntry]}
217
- isLoading={false}
218
- error={null}
219
- onCreateIssue={() => Promise.resolve()}
220
- onAddStory={() => Promise.resolve()}
221
- />,
160
+ <ConsoleStoryList {...defaultProps} stories={[oldFormatEntry]} />,
222
161
  );
223
162
  const nameEl = getByText('TDPM Console port');
224
163
  expect(nameEl.tagName).toBe('SPAN');
225
164
  });
226
165
 
227
166
  it('renders story name as a span when storyViewUrl is null', () => {
228
- const { getByText } = render(
229
- <ConsoleStoryList
230
- stories={storyEntries}
231
- isLoading={false}
232
- error={null}
233
- onCreateIssue={() => Promise.resolve()}
234
- onAddStory={() => Promise.resolve()}
235
- />,
236
- );
167
+ const { getByText } = render(<ConsoleStoryList {...defaultProps} />);
237
168
  const nameEl = getByText('TDPM Console port');
238
169
  expect(nameEl.tagName).toBe('SPAN');
239
170
  });
@@ -247,13 +178,7 @@ describe('ConsoleStoryList', () => {
247
178
  },
248
179
  ];
249
180
  const { getByText } = render(
250
- <ConsoleStoryList
251
- stories={entriesWithUrl}
252
- isLoading={false}
253
- error={null}
254
- onCreateIssue={() => Promise.resolve()}
255
- onAddStory={() => Promise.resolve()}
256
- />,
181
+ <ConsoleStoryList {...defaultProps} stories={entriesWithUrl} />,
257
182
  );
258
183
  const nameEl = getByText('TDPM Console port');
259
184
  expect(nameEl.tagName).toBe('A');
@@ -266,25 +191,13 @@ describe('ConsoleStoryList', () => {
266
191
  });
267
192
 
268
193
  it('renders an Add story button', () => {
269
- const { getByRole } = render(
270
- <ConsoleStoryList
271
- stories={storyEntries}
272
- isLoading={false}
273
- error={null}
274
- {...defaultProps}
275
- />,
276
- );
194
+ const { getByRole } = render(<ConsoleStoryList {...defaultProps} />);
277
195
  expect(getByRole('button', { name: 'Add story' })).toBeInTheDocument();
278
196
  });
279
197
 
280
198
  it('shows the Add story form when Add story button is clicked', () => {
281
199
  const { getByRole, getByPlaceholderText } = render(
282
- <ConsoleStoryList
283
- stories={storyEntries}
284
- isLoading={false}
285
- error={null}
286
- {...defaultProps}
287
- />,
200
+ <ConsoleStoryList {...defaultProps} />,
288
201
  );
289
202
  fireEvent.click(getByRole('button', { name: 'Add story' }));
290
203
  expect(getByPlaceholderText('Story name')).toBeInTheDocument();
@@ -292,12 +205,7 @@ describe('ConsoleStoryList', () => {
292
205
 
293
206
  it('hides the Add story form when Add story button is clicked again', () => {
294
207
  const { getByRole, queryByPlaceholderText } = render(
295
- <ConsoleStoryList
296
- stories={storyEntries}
297
- isLoading={false}
298
- error={null}
299
- {...defaultProps}
300
- />,
208
+ <ConsoleStoryList {...defaultProps} />,
301
209
  );
302
210
  fireEvent.click(getByRole('button', { name: 'Add story' }));
303
211
  fireEvent.click(getByRole('button', { name: 'Add story' }));
@@ -307,13 +215,7 @@ describe('ConsoleStoryList', () => {
307
215
  it('calls onAddStory with the story name and closes the form on success', async () => {
308
216
  const onAddStory = jest.fn().mockResolvedValue(undefined);
309
217
  const { getByRole, getByPlaceholderText, queryByPlaceholderText } = render(
310
- <ConsoleStoryList
311
- stories={storyEntries}
312
- isLoading={false}
313
- error={null}
314
- onCreateIssue={() => Promise.resolve()}
315
- onAddStory={onAddStory}
316
- />,
218
+ <ConsoleStoryList {...defaultProps} onAddStory={onAddStory} />,
317
219
  );
318
220
  fireEvent.click(getByRole('button', { name: 'Add story' }));
319
221
  fireEvent.change(getByPlaceholderText('Story name'), {
@@ -330,12 +232,7 @@ describe('ConsoleStoryList', () => {
330
232
 
331
233
  it('shows a validation error when Add story Create is clicked with an empty name', () => {
332
234
  const { getByRole, getByText } = render(
333
- <ConsoleStoryList
334
- stories={storyEntries}
335
- isLoading={false}
336
- error={null}
337
- {...defaultProps}
338
- />,
235
+ <ConsoleStoryList {...defaultProps} />,
339
236
  );
340
237
  fireEvent.click(getByRole('button', { name: 'Add story' }));
341
238
  fireEvent.click(getByRole('button', { name: 'Create' }));
@@ -345,13 +242,7 @@ describe('ConsoleStoryList', () => {
345
242
  it('shows an API error when onAddStory rejects', async () => {
346
243
  const onAddStory = jest.fn().mockRejectedValue(new Error('API failure'));
347
244
  const { getByRole, getByPlaceholderText, getByText } = render(
348
- <ConsoleStoryList
349
- stories={storyEntries}
350
- isLoading={false}
351
- error={null}
352
- onCreateIssue={() => Promise.resolve()}
353
- onAddStory={onAddStory}
354
- />,
245
+ <ConsoleStoryList {...defaultProps} onAddStory={onAddStory} />,
355
246
  );
356
247
  fireEvent.click(getByRole('button', { name: 'Add story' }));
357
248
  fireEvent.change(getByPlaceholderText('Story name'), {
@@ -363,13 +254,95 @@ describe('ConsoleStoryList', () => {
363
254
 
364
255
  it('renders Add story button even when there are no stories', () => {
365
256
  const { getByRole } = render(
257
+ <ConsoleStoryList {...defaultProps} stories={[]} />,
258
+ );
259
+ expect(getByRole('button', { name: 'Add story' })).toBeInTheDocument();
260
+ });
261
+
262
+ it('renders a Change color button for each story row', () => {
263
+ const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
264
+ const buttons = getAllByRole('button', { name: 'Change color' });
265
+ expect(buttons).toHaveLength(storyEntries.length);
266
+ });
267
+
268
+ it('shows the color palette when Change color is clicked', () => {
269
+ const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
270
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
271
+ fireEvent.click(firstColorButton);
272
+ expect(getAllByRole('button', { name: /GRAY \(disable\)/ })).toHaveLength(
273
+ 1,
274
+ );
275
+ });
276
+
277
+ it('hides the palette when Change color is clicked again on the same row', () => {
278
+ const { getAllByRole, queryByRole } = render(
279
+ <ConsoleStoryList {...defaultProps} />,
280
+ );
281
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
282
+ fireEvent.click(firstColorButton);
283
+ fireEvent.click(firstColorButton);
284
+ expect(queryByRole('button', { name: /GRAY \(disable\)/ })).toBeNull();
285
+ });
286
+
287
+ it('calls onSelectColor with the correct storyOptionId and color when a swatch is clicked', () => {
288
+ const onSelectColor = jest.fn();
289
+ const { getAllByRole } = render(
290
+ <ConsoleStoryList {...defaultProps} onSelectColor={onSelectColor} />,
291
+ );
292
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
293
+ fireEvent.click(firstColorButton);
294
+ const greenSwatch = getAllByRole('button', { name: 'GREEN' })[0];
295
+ fireEvent.click(greenSwatch);
296
+ expect(onSelectColor).toHaveBeenCalledWith('1491051e', 'GREEN');
297
+ });
298
+
299
+ it('closes the palette after a swatch is clicked', () => {
300
+ const { getAllByRole, queryByRole } = render(
301
+ <ConsoleStoryList {...defaultProps} />,
302
+ );
303
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
304
+ fireEvent.click(firstColorButton);
305
+ const greenSwatch = getAllByRole('button', { name: 'GREEN' })[0];
306
+ fireEvent.click(greenSwatch);
307
+ expect(queryByRole('button', { name: /GRAY \(disable\)/ })).toBeNull();
308
+ });
309
+
310
+ it('GRAY swatch carries an accessible disable label', () => {
311
+ const { getAllByRole, getByRole } = render(
312
+ <ConsoleStoryList {...defaultProps} />,
313
+ );
314
+ const [firstColorButton] = getAllByRole('button', { name: 'Change color' });
315
+ fireEvent.click(firstColorButton);
316
+ expect(getByRole('button', { name: 'GRAY (disable)' })).toBeInTheDocument();
317
+ });
318
+
319
+ it('disables Change color button when colorChangeInFlight matches that storyOptionId', () => {
320
+ const { getAllByRole } = render(
321
+ <ConsoleStoryList {...defaultProps} colorChangeInFlight="1491051e" />,
322
+ );
323
+ const colorButtons = getAllByRole('button', { name: 'Change color' });
324
+ expect(colorButtons[0]).toBeDisabled();
325
+ expect(colorButtons[1]).not.toBeDisabled();
326
+ });
327
+
328
+ it('displays an optimistic color from optimisticColors prop', () => {
329
+ const { getAllByRole } = render(
366
330
  <ConsoleStoryList
367
- stories={[]}
368
- isLoading={false}
369
- error={null}
370
331
  {...defaultProps}
332
+ optimisticColors={{ '1491051e': 'RED' }}
371
333
  />,
372
334
  );
373
- expect(getByRole('button', { name: 'Add story' })).toBeInTheDocument();
335
+ const colorButtons = getAllByRole('button', { name: 'Change color' });
336
+ expect(colorButtons[0]).toBeInTheDocument();
337
+ });
338
+
339
+ it('shows a per-row error message from colorErrors prop', () => {
340
+ const { getByText } = render(
341
+ <ConsoleStoryList
342
+ {...defaultProps}
343
+ colorErrors={{ '1491051e': 'Color update failed' }}
344
+ />,
345
+ );
346
+ expect(getByText('Color update failed')).toBeInTheDocument();
374
347
  });
375
348
  });