github-issue-tower-defence-management 1.179.0 → 1.180.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 (70) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/adapter/entry-points/console/consoleOperationApi.js +42 -1
  3. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  6. package/bin/adapter/entry-points/console/webServer.js +6 -0
  7. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  8. package/bin/adapter/repositories/SystemDateRepository.js +10 -10
  9. package/bin/adapter/repositories/SystemDateRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +23 -4
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  12. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +1 -1
  13. package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -1
  14. package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
  15. package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
  16. package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js +1 -1
  17. package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js.map +1 -1
  18. package/bin/domain/usecases/CreateEstimationIssueUseCase.js +3 -3
  19. package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
  20. package/bin/domain/usecases/HandleScheduledEventUseCase.js +6 -6
  21. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  22. package/bin/domain/usecases/IssueRejectionEvaluator.js +3 -1
  23. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  24. package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
  25. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  26. package/bin/domain/usecases/issueReactivationTriggerIsPending.js +2 -2
  27. package/bin/domain/usecases/issueReactivationTriggerIsPending.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +230 -0
  30. package/src/adapter/entry-points/console/consoleOperationApi.ts +48 -1
  31. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +36 -0
  32. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +25 -1
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.stories.tsx +30 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.test.tsx +111 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.tsx +91 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +21 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.test.ts +24 -2
  38. package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.ts +3 -1
  39. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +60 -11
  40. package/src/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
  41. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  42. package/src/adapter/entry-points/console/webServer.ts +11 -0
  43. package/src/adapter/repositories/SystemDateRepository.test.ts +60 -0
  44. package/src/adapter/repositories/SystemDateRepository.ts +10 -10
  45. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +268 -2
  46. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +33 -4
  47. package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +1 -1
  48. package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +80 -0
  49. package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
  50. package/src/domain/usecases/ClearPastNextActionDateHourUseCase.ts +6 -4
  51. package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +225 -0
  52. package/src/domain/usecases/CreateEstimationIssueUseCase.ts +3 -3
  53. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +107 -0
  54. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -7
  55. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +83 -0
  56. package/src/domain/usecases/IssueRejectionEvaluator.ts +3 -1
  57. package/src/domain/usecases/StartPreparationUseCase.ts +5 -3
  58. package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +33 -33
  59. package/src/domain/usecases/issueReactivationTriggerIsPending.ts +6 -4
  60. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -1
  61. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  62. package/types/adapter/entry-points/console/webServer.d.ts +2 -0
  63. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  64. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  65. package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts.map +1 -1
  66. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  67. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  68. package/types/domain/usecases/issueReactivationTriggerIsPending.d.ts.map +1 -1
  69. package/bin/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
  70. package/src/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
@@ -12,6 +12,7 @@ import {
12
12
  handleComment,
13
13
  handleCreateIssue,
14
14
  handleIntmux,
15
+ handleReorderStory,
15
16
  handleReview,
16
17
  handleReviewComment,
17
18
  handleTriage,
@@ -110,6 +111,7 @@ describe('consoleOperationApi', () => {
110
111
  isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
111
112
  consoleDataOutputDir: baseDir,
112
113
  issueAttachmentRepository: null,
114
+ updateStoryList: null,
113
115
  };
114
116
  });
115
117
 
@@ -122,6 +124,7 @@ describe('consoleOperationApi', () => {
122
124
  isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
123
125
  consoleDataOutputDir: baseDir,
124
126
  issueAttachmentRepository: null,
127
+ updateStoryList: null,
125
128
  });
126
129
 
127
130
  afterEach(() => {
@@ -864,6 +867,7 @@ describe('consoleOperationApi', () => {
864
867
  isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
865
868
  consoleDataOutputDir: baseDir,
866
869
  issueAttachmentRepository: null,
870
+ updateStoryList: null,
867
871
  };
868
872
  });
869
873
 
@@ -1109,6 +1113,7 @@ describe('consoleOperationApi', () => {
1109
1113
  pjcode === 'acme' || pjcode === 'globex',
1110
1114
  consoleDataOutputDir: baseDir,
1111
1115
  issueAttachmentRepository: null,
1116
+ updateStoryList: null,
1112
1117
  };
1113
1118
  const response = await handleTriage(multiContext, {
1114
1119
  pjcode: 'globex',
@@ -1677,4 +1682,229 @@ describe('consoleOperationApi', () => {
1677
1682
  });
1678
1683
  });
1679
1684
  });
1685
+
1686
+ describe('handleReorderStory', () => {
1687
+ const projectWithOrderedStories = (): Project => ({
1688
+ ...project,
1689
+ story: {
1690
+ name: 'Story',
1691
+ fieldId: 'storyField',
1692
+ databaseId: 1,
1693
+ stories: [
1694
+ { id: 'opt_a', name: 'Alpha', color: 'BLUE', description: '' },
1695
+ { id: 'opt_b', name: 'Beta', color: 'GREEN', description: '' },
1696
+ { id: 'opt_c', name: 'Gamma', color: 'RED', description: '' },
1697
+ ],
1698
+ workflowManagementStory: { id: 'wms', name: 'workflow' },
1699
+ },
1700
+ });
1701
+
1702
+ const contextWithUpdateStoryList = (
1703
+ updateStoryList: ConsoleOperationContext['updateStoryList'],
1704
+ baseProject: Project = projectWithOrderedStories(),
1705
+ ): ConsoleOperationContext => ({
1706
+ ...contextForProject(baseProject),
1707
+ updateStoryList,
1708
+ });
1709
+
1710
+ it('returns 400 when pjcode is missing', async () => {
1711
+ const response = await handleReorderStory(context, {
1712
+ storyOptionId: 'opt_b',
1713
+ direction: 'up',
1714
+ });
1715
+ expect(response).toEqual({
1716
+ statusCode: 400,
1717
+ body: { error: 'pjcode is required' },
1718
+ });
1719
+ });
1720
+
1721
+ it('returns 400 when storyOptionId is missing', async () => {
1722
+ const response = await handleReorderStory(context, {
1723
+ pjcode: 'acme',
1724
+ direction: 'up',
1725
+ });
1726
+ expect(response).toEqual({
1727
+ statusCode: 400,
1728
+ body: { error: 'storyOptionId is required' },
1729
+ });
1730
+ });
1731
+
1732
+ it('returns 400 when direction is not "up" or "down"', async () => {
1733
+ const response = await handleReorderStory(context, {
1734
+ pjcode: 'acme',
1735
+ storyOptionId: 'opt_b',
1736
+ direction: 'left',
1737
+ });
1738
+ expect(response).toEqual({
1739
+ statusCode: 400,
1740
+ body: { error: 'direction must be "up" or "down"' },
1741
+ });
1742
+ });
1743
+
1744
+ it('returns 400 when project is not found', async () => {
1745
+ const response = await handleReorderStory(
1746
+ contextWithUpdateStoryList(jest.fn()),
1747
+ {
1748
+ pjcode: 'unknown',
1749
+ storyOptionId: 'opt_b',
1750
+ direction: 'up',
1751
+ },
1752
+ );
1753
+ expect(response).toEqual({
1754
+ statusCode: 400,
1755
+ body: { error: 'no project configured for pjcode "unknown"' },
1756
+ });
1757
+ });
1758
+
1759
+ it('returns 400 when story option is not found', async () => {
1760
+ const response = await handleReorderStory(
1761
+ contextWithUpdateStoryList(jest.fn()),
1762
+ {
1763
+ pjcode: 'acme',
1764
+ storyOptionId: 'opt_nonexistent',
1765
+ direction: 'up',
1766
+ },
1767
+ );
1768
+ expect(response).toEqual({
1769
+ statusCode: 400,
1770
+ body: { error: 'story option not found' },
1771
+ });
1772
+ });
1773
+
1774
+ it('returns 400 when direction is "up" and the option is already first', async () => {
1775
+ const response = await handleReorderStory(
1776
+ contextWithUpdateStoryList(jest.fn()),
1777
+ {
1778
+ pjcode: 'acme',
1779
+ storyOptionId: 'opt_a',
1780
+ direction: 'up',
1781
+ },
1782
+ );
1783
+ expect(response).toEqual({
1784
+ statusCode: 400,
1785
+ body: { error: 'cannot move in that direction' },
1786
+ });
1787
+ });
1788
+
1789
+ it('returns 400 when direction is "down" and the option is already last', async () => {
1790
+ const response = await handleReorderStory(
1791
+ contextWithUpdateStoryList(jest.fn()),
1792
+ {
1793
+ pjcode: 'acme',
1794
+ storyOptionId: 'opt_c',
1795
+ direction: 'down',
1796
+ },
1797
+ );
1798
+ expect(response).toEqual({
1799
+ statusCode: 400,
1800
+ body: { error: 'cannot move in that direction' },
1801
+ });
1802
+ });
1803
+
1804
+ it('returns 400 when updateStoryList is not configured', async () => {
1805
+ const response = await handleReorderStory(
1806
+ contextWithUpdateStoryList(null),
1807
+ {
1808
+ pjcode: 'acme',
1809
+ storyOptionId: 'opt_b',
1810
+ direction: 'up',
1811
+ },
1812
+ );
1813
+ expect(response).toEqual({
1814
+ statusCode: 400,
1815
+ body: { error: 'updateStoryList is not configured' },
1816
+ });
1817
+ });
1818
+
1819
+ it('swaps the target option with its neighbor, calls updateStoryList with reordered list, and returns 200', async () => {
1820
+ const updateStoryList = jest.fn().mockResolvedValue([]);
1821
+ const response = await handleReorderStory(
1822
+ contextWithUpdateStoryList(updateStoryList),
1823
+ {
1824
+ pjcode: 'acme',
1825
+ storyOptionId: 'opt_b',
1826
+ direction: 'up',
1827
+ },
1828
+ );
1829
+ expect(response).toEqual({ statusCode: 200, body: { ok: true } });
1830
+ expect(updateStoryList).toHaveBeenCalledTimes(1);
1831
+ expect(updateStoryList).toHaveBeenCalledWith(
1832
+ expect.objectContaining({ id: projectWithOrderedStories().id }),
1833
+ [
1834
+ expect.objectContaining({ id: 'opt_b' }),
1835
+ expect.objectContaining({ id: 'opt_a' }),
1836
+ expect.objectContaining({ id: 'opt_c' }),
1837
+ ],
1838
+ );
1839
+ });
1840
+
1841
+ it('returns 400 when project does not have a story field', async () => {
1842
+ const projectWithoutStory = {
1843
+ ...projectWithOrderedStories(),
1844
+ story: null,
1845
+ };
1846
+ const response = await handleReorderStory(
1847
+ contextWithUpdateStoryList(jest.fn(), projectWithoutStory),
1848
+ {
1849
+ pjcode: 'acme',
1850
+ storyOptionId: 'opt_b',
1851
+ direction: 'up',
1852
+ },
1853
+ );
1854
+ expect(response).toEqual({
1855
+ statusCode: 400,
1856
+ body: { error: 'project does not have a story field' },
1857
+ });
1858
+ });
1859
+
1860
+ it('passes the full story list including GRAY options to updateStoryList', async () => {
1861
+ const projectWithGray = {
1862
+ ...projectWithOrderedStories(),
1863
+ story: {
1864
+ name: 'Story',
1865
+ fieldId: 'storyField',
1866
+ databaseId: 1,
1867
+ stories: [
1868
+ {
1869
+ id: 'opt_a',
1870
+ name: 'Alpha',
1871
+ color: 'BLUE' as const,
1872
+ description: '',
1873
+ },
1874
+ {
1875
+ id: 'opt_gray',
1876
+ name: 'Archived',
1877
+ color: 'GRAY' as const,
1878
+ description: '',
1879
+ },
1880
+ {
1881
+ id: 'opt_b',
1882
+ name: 'Beta',
1883
+ color: 'GREEN' as const,
1884
+ description: '',
1885
+ },
1886
+ ],
1887
+ workflowManagementStory: { id: 'wms', name: 'workflow' },
1888
+ },
1889
+ };
1890
+ const updateStoryList = jest.fn().mockResolvedValue([]);
1891
+ const response = await handleReorderStory(
1892
+ contextWithUpdateStoryList(updateStoryList, projectWithGray),
1893
+ {
1894
+ pjcode: 'acme',
1895
+ storyOptionId: 'opt_b',
1896
+ direction: 'up',
1897
+ },
1898
+ );
1899
+ expect(response).toEqual({ statusCode: 200, body: { ok: true } });
1900
+ expect(updateStoryList).toHaveBeenCalledWith(
1901
+ expect.objectContaining({ id: projectWithGray.id }),
1902
+ [
1903
+ expect.objectContaining({ id: 'opt_a' }),
1904
+ expect.objectContaining({ id: 'opt_b' }),
1905
+ expect.objectContaining({ id: 'opt_gray' }),
1906
+ ],
1907
+ );
1908
+ });
1909
+ });
1680
1910
  });
@@ -3,7 +3,7 @@ import {
3
3
  PullRequestReviewCommentSide,
4
4
  } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
5
5
  import { IssueAttachmentRepository } from '../../../domain/usecases/adapter-interfaces/IssueAttachmentRepository';
6
- import { Project } from '../../../domain/entities/Project';
6
+ import { FieldOption, Project } from '../../../domain/entities/Project';
7
7
  import { Issue } from '../../../domain/entities/Issue';
8
8
  import {
9
9
  recordDoneProjectItemIdAcrossTabs,
@@ -38,6 +38,9 @@ export type ConsoleOperationContext = {
38
38
  isPjcodeConfigured: ConsolePjcodeValidator;
39
39
  consoleDataOutputDir: string | null;
40
40
  issueAttachmentRepository: IssueAttachmentRepository | null;
41
+ updateStoryList:
42
+ | ((project: Project, stories: FieldOption[]) => Promise<FieldOption[]>)
43
+ | null;
41
44
  };
42
45
 
43
46
  export type ConsoleOperationResponse = {
@@ -722,3 +725,47 @@ export const handleIntmux = async (
722
725
  recordDoneForStatusChange(context, pjcode, projectItemId);
723
726
  return ok();
724
727
  };
728
+
729
+ export const handleReorderStory = async (
730
+ context: ConsoleOperationContext,
731
+ body: Record<string, unknown>,
732
+ ): Promise<ConsoleOperationResponse> => {
733
+ const pjcode = body.pjcode;
734
+ const storyOptionId = body.storyOptionId;
735
+ const direction = body.direction;
736
+ if (!isNonEmptyString(pjcode)) {
737
+ return badRequest('pjcode is required');
738
+ }
739
+ if (!isNonEmptyString(storyOptionId)) {
740
+ return badRequest('storyOptionId is required');
741
+ }
742
+ if (direction !== 'up' && direction !== 'down') {
743
+ return badRequest('direction must be "up" or "down"');
744
+ }
745
+ const binding = await context.resolveProject(pjcode);
746
+ if (binding === null) {
747
+ return badRequest(`no project configured for pjcode "${pjcode}"`);
748
+ }
749
+ const { project } = binding;
750
+ if (project.story === null) {
751
+ return badRequest('project does not have a story field');
752
+ }
753
+ if (context.updateStoryList === null) {
754
+ return badRequest('updateStoryList is not configured');
755
+ }
756
+ const stories = project.story.stories;
757
+ const index = stories.findIndex((s) => s.id === storyOptionId);
758
+ if (index === -1) {
759
+ return badRequest('story option not found');
760
+ }
761
+ const swapIndex = index + (direction === 'up' ? -1 : 1);
762
+ if (swapIndex < 0 || swapIndex >= stories.length) {
763
+ return badRequest('cannot move in that direction');
764
+ }
765
+ const reordered = [...stories];
766
+ const temp = reordered[index];
767
+ reordered[index] = reordered[swapIndex];
768
+ reordered[swapIndex] = temp;
769
+ await context.updateStoryList(project, reordered);
770
+ return ok();
771
+ };
@@ -395,3 +395,39 @@ test('creates an issue for a story when the add-task button and form are used',
395
395
  'npm-cli-github-issue-tower-defence-management',
396
396
  );
397
397
  });
398
+
399
+ test('reorders stories on the triage tab with up/down buttons', async ({
400
+ page,
401
+ }) => {
402
+ await page.goto(harness.appRootUrl);
403
+
404
+ await tabByLabel(page, 'Triage').click();
405
+
406
+ const reorderPanel = page.locator('.console-story-reorder-panel');
407
+ await expect(reorderPanel).toBeVisible();
408
+
409
+ const rows = reorderPanel.locator('.console-story-reorder-row');
410
+ await expect(rows).toHaveCount(3);
411
+
412
+ await expect(rows.nth(0)).toContainText('TDPM Console port');
413
+ await expect(rows.nth(1)).toContainText('Publish product documentation site');
414
+ await expect(rows.nth(2)).toContainText('regular / WORKFLOW BLOCKER');
415
+
416
+ await rows.nth(0).locator('[aria-label="Move down"]').click();
417
+
418
+ await expect
419
+ .poll(() => harness.reorderStoryCalls.length, { timeout: 5000 })
420
+ .toBe(1);
421
+
422
+ await expect(rows.nth(0)).toContainText('Publish product documentation site');
423
+ await expect(rows.nth(1)).toContainText('TDPM Console port');
424
+
425
+ await rows.nth(1).locator('[aria-label="Move up"]').click();
426
+
427
+ await expect
428
+ .poll(() => harness.reorderStoryCalls.length, { timeout: 5000 })
429
+ .toBe(2);
430
+
431
+ await expect(rows.nth(0)).toContainText('TDPM Console port');
432
+ await expect(rows.nth(1)).toContainText('Publish product documentation site');
433
+ });
@@ -3,7 +3,10 @@ import type * as http from 'node:http';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import type { Issue } from '../../../../../domain/entities/Issue';
6
- import type { Project } from '../../../../../domain/entities/Project';
6
+ import type {
7
+ FieldOption,
8
+ Project,
9
+ } from '../../../../../domain/entities/Project';
7
10
  import type {
8
11
  IssueComment,
9
12
  IssueRepository,
@@ -611,6 +614,10 @@ const createStubIssueRepository = (
611
614
  setIssueAgentField: async (): Promise<void> => undefined,
612
615
  });
613
616
 
617
+ export type ConsoleE2eReorderStoryCall = {
618
+ storyOptionIds: string[];
619
+ };
620
+
614
621
  export type ConsoleE2eHarness = {
615
622
  baseUrl: string;
616
623
  appUrl: string;
@@ -619,6 +626,7 @@ export type ConsoleE2eHarness = {
619
626
  reviewCommentCalls: ConsoleE2eReviewCommentCall[];
620
627
  requestChangesCalls: ConsoleE2eRequestChangesCall[];
621
628
  createIssueCalls: ConsoleE2eCreateIssueCall[];
629
+ reorderStoryCalls: ConsoleE2eReorderStoryCall[];
622
630
  stop: () => Promise<void>;
623
631
  };
624
632
 
@@ -640,6 +648,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
640
648
  const reviewCommentCalls: ConsoleE2eReviewCommentCall[] = [];
641
649
  const requestChangesCalls: ConsoleE2eRequestChangesCall[] = [];
642
650
  const createIssueCalls: ConsoleE2eCreateIssueCall[] = [];
651
+ const reorderStoryCalls: ConsoleE2eReorderStoryCall[] = [];
643
652
 
644
653
  const server = await startWebServer({
645
654
  accessToken: CONSOLE_E2E_TOKEN,
@@ -650,6 +659,20 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
650
659
  requestChangesCalls,
651
660
  createIssueCalls,
652
661
  ),
662
+ projectRepository: {
663
+ updateStoryList: async (_updatedProject, stories) => {
664
+ reorderStoryCalls.push({
665
+ storyOptionIds: stories
666
+ .map((s) => s.id)
667
+ .filter((id): id is string => id !== null),
668
+ });
669
+ const result = stories as FieldOption[];
670
+ if (project.story !== null) {
671
+ project.story.stories = result;
672
+ }
673
+ return result;
674
+ },
675
+ },
653
676
  resolveProject,
654
677
  isPjcodeConfigured,
655
678
  issueTitleStateCache: new IssueTitleStateCache(),
@@ -679,6 +702,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
679
702
  reviewCommentCalls,
680
703
  requestChangesCalls,
681
704
  createIssueCalls,
705
+ reorderStoryCalls,
682
706
  stop: async (): Promise<void> => {
683
707
  await closeServer(server);
684
708
  fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -0,0 +1,30 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsoleStoryReorderPanel } from './ConsoleStoryReorderPanel';
3
+
4
+ const stories = [
5
+ { id: '1491051e', name: 'TDPM Console port', color: 'BLUE' as const },
6
+ {
7
+ id: 'f7cd5cbc',
8
+ name: 'Publish product documentation site',
9
+ color: 'GREEN' as const,
10
+ },
11
+ { id: 'a3b9c4d2', name: 'regular / WORKFLOW BLOCKER', color: 'RED' as const },
12
+ ];
13
+
14
+ const meta: Meta<typeof ConsoleStoryReorderPanel> = {
15
+ title: 'Console/ConsoleStoryReorderPanel',
16
+ component: ConsoleStoryReorderPanel,
17
+ args: { onReorderStory: () => Promise.resolve() },
18
+ };
19
+
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof ConsoleStoryReorderPanel>;
23
+
24
+ export const Default: Story = {
25
+ args: { stories },
26
+ };
27
+
28
+ export const SingleStory: Story = {
29
+ args: { stories: [stories[0]] },
30
+ };
@@ -0,0 +1,111 @@
1
+ import { fireEvent, render, waitFor } from '@testing-library/react';
2
+ import type { ConsoleFieldOption } from '../../logic/types';
3
+ import { ConsoleStoryReorderPanel } from './ConsoleStoryReorderPanel';
4
+
5
+ const stories: ConsoleFieldOption[] = [
6
+ { id: 'opt-a', name: 'Alpha story', color: 'BLUE' },
7
+ { id: 'opt-b', name: 'Beta story', color: 'GREEN' },
8
+ { id: 'opt-c', name: 'Gamma story', color: 'RED' },
9
+ ];
10
+
11
+ describe('ConsoleStoryReorderPanel', () => {
12
+ it('renders story names in the given order', () => {
13
+ const { getAllByRole } = render(
14
+ <ConsoleStoryReorderPanel
15
+ stories={stories}
16
+ onReorderStory={() => Promise.resolve()}
17
+ />,
18
+ );
19
+ const items = getAllByRole('listitem');
20
+ expect(items[0]).toHaveTextContent('Alpha story');
21
+ expect(items[1]).toHaveTextContent('Beta story');
22
+ expect(items[2]).toHaveTextContent('Gamma story');
23
+ });
24
+
25
+ it('disables up-button for the first story and down-button for the last story', () => {
26
+ const { getAllByRole } = render(
27
+ <ConsoleStoryReorderPanel
28
+ stories={stories}
29
+ onReorderStory={() => Promise.resolve()}
30
+ />,
31
+ );
32
+ const upButtons = getAllByRole('button', { name: 'Move up' });
33
+ const downButtons = getAllByRole('button', { name: 'Move down' });
34
+ expect(upButtons[0]).toBeDisabled();
35
+ expect(upButtons[1]).not.toBeDisabled();
36
+ expect(upButtons[2]).not.toBeDisabled();
37
+ expect(downButtons[0]).not.toBeDisabled();
38
+ expect(downButtons[1]).not.toBeDisabled();
39
+ expect(downButtons[2]).toBeDisabled();
40
+ });
41
+
42
+ it('calls onReorderStory with storyOptionId and "up" when up-button clicked on a non-first story', async () => {
43
+ const onReorderStory = jest.fn().mockResolvedValue(undefined);
44
+ const { getAllByRole } = render(
45
+ <ConsoleStoryReorderPanel
46
+ stories={stories}
47
+ onReorderStory={onReorderStory}
48
+ />,
49
+ );
50
+ const upButtons = getAllByRole('button', { name: 'Move up' });
51
+ fireEvent.click(upButtons[1]);
52
+ await waitFor(() => expect(onReorderStory).toHaveBeenCalledTimes(1));
53
+ expect(onReorderStory).toHaveBeenCalledWith('opt-b', 'up');
54
+ });
55
+
56
+ it('calls onReorderStory with storyOptionId and "down" when down-button clicked on a non-last story', async () => {
57
+ const onReorderStory = jest.fn().mockResolvedValue(undefined);
58
+ const { getAllByRole } = render(
59
+ <ConsoleStoryReorderPanel
60
+ stories={stories}
61
+ onReorderStory={onReorderStory}
62
+ />,
63
+ );
64
+ const downButtons = getAllByRole('button', { name: 'Move down' });
65
+ fireEvent.click(downButtons[1]);
66
+ await waitFor(() => expect(onReorderStory).toHaveBeenCalledTimes(1));
67
+ expect(onReorderStory).toHaveBeenCalledWith('opt-b', 'down');
68
+ });
69
+
70
+ it('disables both buttons while onReorderStory is in progress', async () => {
71
+ let resolveReorder: (() => void) | undefined;
72
+ const onReorderStory = jest.fn(
73
+ () =>
74
+ new Promise<void>((resolve) => {
75
+ resolveReorder = resolve;
76
+ }),
77
+ );
78
+ const { getAllByRole } = render(
79
+ <ConsoleStoryReorderPanel
80
+ stories={stories}
81
+ onReorderStory={onReorderStory}
82
+ />,
83
+ );
84
+ const downButtons = getAllByRole('button', { name: 'Move down' });
85
+ fireEvent.click(downButtons[1]);
86
+ await waitFor(() => expect(onReorderStory).toHaveBeenCalledTimes(1));
87
+ const upButtonsDuring = getAllByRole('button', { name: 'Move up' });
88
+ const downButtonsDuring = getAllByRole('button', { name: 'Move down' });
89
+ expect(upButtonsDuring[1]).toBeDisabled();
90
+ expect(downButtonsDuring[1]).toBeDisabled();
91
+ resolveReorder?.();
92
+ await waitFor(() => expect(upButtonsDuring[1]).not.toBeDisabled());
93
+ });
94
+
95
+ it('shows an error message when onReorderStory rejects', async () => {
96
+ const onReorderStory = jest
97
+ .fn()
98
+ .mockRejectedValue(new Error('server error'));
99
+ const { getAllByRole, getByRole } = render(
100
+ <ConsoleStoryReorderPanel
101
+ stories={stories}
102
+ onReorderStory={onReorderStory}
103
+ />,
104
+ );
105
+ const downButtons = getAllByRole('button', { name: 'Move down' });
106
+ fireEvent.click(downButtons[0]);
107
+ await waitFor(() =>
108
+ expect(getByRole('alert')).toHaveTextContent('server error'),
109
+ );
110
+ });
111
+ });
@@ -0,0 +1,91 @@
1
+ import { useState } from 'react';
2
+ import type { ConsoleFieldOption } from '../../logic/types';
3
+
4
+ export type ConsoleStoryReorderPanelProps = {
5
+ stories: ConsoleFieldOption[];
6
+ onReorderStory: (
7
+ storyOptionId: string,
8
+ direction: 'up' | 'down',
9
+ ) => Promise<void>;
10
+ };
11
+
12
+ type RowState = {
13
+ inProgress: boolean;
14
+ error: string | null;
15
+ };
16
+
17
+ export const ConsoleStoryReorderPanel = ({
18
+ stories,
19
+ onReorderStory,
20
+ }: ConsoleStoryReorderPanelProps) => {
21
+ const [rowStates, setRowStates] = useState<Record<string, RowState>>({});
22
+
23
+ const getRowState = (id: string): RowState =>
24
+ rowStates[id] ?? { inProgress: false, error: null };
25
+
26
+ const handleReorder = async (
27
+ storyOptionId: string,
28
+ direction: 'up' | 'down',
29
+ ): Promise<void> => {
30
+ setRowStates((prev) => ({
31
+ ...prev,
32
+ [storyOptionId]: { inProgress: true, error: null },
33
+ }));
34
+ try {
35
+ await onReorderStory(storyOptionId, direction);
36
+ setRowStates((prev) => ({
37
+ ...prev,
38
+ [storyOptionId]: { inProgress: false, error: null },
39
+ }));
40
+ } catch (err) {
41
+ setRowStates((prev) => ({
42
+ ...prev,
43
+ [storyOptionId]: {
44
+ inProgress: false,
45
+ error: err instanceof Error ? err.message : String(err),
46
+ },
47
+ }));
48
+ }
49
+ };
50
+
51
+ return (
52
+ <ul className="console-story-reorder-panel">
53
+ {stories.map((story, index) => {
54
+ const { inProgress, error } = getRowState(story.id);
55
+ const isFirst = index === 0;
56
+ const isLast = index === stories.length - 1;
57
+ return (
58
+ <li key={story.id} className="console-story-reorder-row">
59
+ <button
60
+ type="button"
61
+ className="console-op-button"
62
+ aria-label="Move up"
63
+ disabled={isFirst || inProgress}
64
+ onClick={() => void handleReorder(story.id, 'up')}
65
+ >
66
+ ↑
67
+ </button>
68
+ <button
69
+ type="button"
70
+ className="console-op-button"
71
+ aria-label="Move down"
72
+ disabled={isLast || inProgress}
73
+ onClick={() => void handleReorder(story.id, 'down')}
74
+ >
75
+ ↓
76
+ </button>
77
+ <span className="console-story-reorder-name">{story.name}</span>
78
+ {inProgress && (
79
+ <span className="console-story-reorder-progress">…</span>
80
+ )}
81
+ {error !== null && (
82
+ <span role="alert" className="console-story-reorder-error">
83
+ {error}
84
+ </span>
85
+ )}
86
+ </li>
87
+ );
88
+ })}
89
+ </ul>
90
+ );
91
+ };