github-issue-tower-defence-management 1.178.1 → 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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -0
- package/bin/adapter/entry-points/cli/index.js +2 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js +42 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +6 -0
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/repositories/SystemDateRepository.js +10 -10
- package/bin/adapter/repositories/SystemDateRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +23 -4
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +1 -1
- package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
- package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js +1 -1
- package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js.map +1 -1
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js +3 -3
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +6 -6
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js +3 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +13 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/issueReactivationTriggerIsPending.js +2 -2
- package/bin/domain/usecases/issueReactivationTriggerIsPending.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +2 -0
- package/src/adapter/entry-points/cli/index.ts +6 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +230 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +48 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +36 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +25 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.stories.tsx +30 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.test.tsx +111 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.tsx +91 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +21 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.test.ts +24 -2
- package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.ts +3 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +60 -11
- package/src/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.ts +11 -0
- package/src/adapter/repositories/SystemDateRepository.test.ts +60 -0
- package/src/adapter/repositories/SystemDateRepository.ts +10 -10
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +268 -2
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +33 -4
- package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +1 -1
- package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +80 -0
- package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
- package/src/domain/usecases/ClearPastNextActionDateHourUseCase.ts +6 -4
- package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +225 -0
- package/src/domain/usecases/CreateEstimationIssueUseCase.ts +3 -3
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +107 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -7
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +83 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +3 -1
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +136 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +42 -1
- package/src/domain/usecases/StartPreparationUseCase.ts +5 -3
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +35 -33
- package/src/domain/usecases/issueReactivationTriggerIsPending.ts +6 -4
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -1
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/console/webServer.d.ts +2 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts.map +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +3 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/issueReactivationTriggerIsPending.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
- 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 {
|
|
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
|
+
};
|