github-issue-tower-defence-management 2.1.1 → 2.2.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 +14 -0
- package/README.md +8 -0
- package/bin/adapter/entry-points/cli/fleetConfig.js +30 -12
- package/bin/adapter/entry-points/cli/fleetConfig.js.map +1 -1
- package/bin/adapter/entry-points/cli/index.js +5 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js +6 -4
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-lT5OKx7e.js → index-BVyeR7xJ.js} +2 -2
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +0 -4
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +13 -12
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/fleetConfig.test.ts +96 -0
- package/src/adapter/entry-points/cli/fleetConfig.ts +51 -6
- package/src/adapter/entry-points/cli/index.test.ts +2 -0
- package/src/adapter/entry-points/cli/index.ts +16 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +60 -32
- package/src/adapter/entry-points/console/consoleOperationApi.ts +6 -7
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +12 -17
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +85 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +73 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +53 -16
- package/src/adapter/entry-points/console/ui-dist/assets/{index-lT5OKx7e.js → index-BVyeR7xJ.js} +2 -2
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.ts +0 -8
- package/src/domain/usecases/StartPreparationUseCase.test.ts +97 -0
- package/src/domain/usecases/StartPreparationUseCase.ts +13 -3
- package/types/adapter/entry-points/cli/fleetConfig.d.ts +6 -0
- package/types/adapter/entry-points/cli/fleetConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -2
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/console/webServer.d.ts +0 -2
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
|
@@ -114,7 +114,6 @@ describe('consoleOperationApi', () => {
|
|
|
114
114
|
isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
|
|
115
115
|
consoleDataOutputDir: baseDir,
|
|
116
116
|
issueAttachmentRepository: null,
|
|
117
|
-
updateStoryList: null,
|
|
118
117
|
resolveProjectRepository: null,
|
|
119
118
|
invalidateProject: null,
|
|
120
119
|
updateProjectCacheEntry: null,
|
|
@@ -130,7 +129,6 @@ describe('consoleOperationApi', () => {
|
|
|
130
129
|
isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
|
|
131
130
|
consoleDataOutputDir: baseDir,
|
|
132
131
|
issueAttachmentRepository: null,
|
|
133
|
-
updateStoryList: null,
|
|
134
132
|
resolveProjectRepository: null,
|
|
135
133
|
invalidateProject: null,
|
|
136
134
|
updateProjectCacheEntry: null,
|
|
@@ -917,7 +915,6 @@ describe('consoleOperationApi', () => {
|
|
|
917
915
|
isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
|
|
918
916
|
consoleDataOutputDir: baseDir,
|
|
919
917
|
issueAttachmentRepository: null,
|
|
920
|
-
updateStoryList: null,
|
|
921
918
|
resolveProjectRepository: null,
|
|
922
919
|
invalidateProject: null,
|
|
923
920
|
updateProjectCacheEntry: null,
|
|
@@ -1166,7 +1163,6 @@ describe('consoleOperationApi', () => {
|
|
|
1166
1163
|
pjcode === 'acme' || pjcode === 'globex',
|
|
1167
1164
|
consoleDataOutputDir: baseDir,
|
|
1168
1165
|
issueAttachmentRepository: null,
|
|
1169
|
-
updateStoryList: null,
|
|
1170
1166
|
resolveProjectRepository: null,
|
|
1171
1167
|
invalidateProject: null,
|
|
1172
1168
|
updateProjectCacheEntry: null,
|
|
@@ -1755,12 +1751,12 @@ describe('consoleOperationApi', () => {
|
|
|
1755
1751
|
},
|
|
1756
1752
|
});
|
|
1757
1753
|
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1754
|
+
const contextWithProjectRepository = (
|
|
1755
|
+
resolveProjectRepository: ConsoleOperationContext['resolveProjectRepository'],
|
|
1760
1756
|
baseProject: Project = projectWithOrderedStories(),
|
|
1761
1757
|
): ConsoleOperationContext => ({
|
|
1762
1758
|
...contextForProject(baseProject),
|
|
1763
|
-
|
|
1759
|
+
resolveProjectRepository,
|
|
1764
1760
|
});
|
|
1765
1761
|
|
|
1766
1762
|
it('returns 400 when pjcode is missing', async () => {
|
|
@@ -1797,9 +1793,25 @@ describe('consoleOperationApi', () => {
|
|
|
1797
1793
|
});
|
|
1798
1794
|
});
|
|
1799
1795
|
|
|
1796
|
+
it('returns 502 when resolveProjectRepository is null', async () => {
|
|
1797
|
+
const response = await handleReorderStory(
|
|
1798
|
+
contextWithProjectRepository(null),
|
|
1799
|
+
{
|
|
1800
|
+
pjcode: 'acme',
|
|
1801
|
+
storyOptionId: 'opt_b',
|
|
1802
|
+
direction: 'up',
|
|
1803
|
+
},
|
|
1804
|
+
);
|
|
1805
|
+
expect(response).toEqual({
|
|
1806
|
+
statusCode: 502,
|
|
1807
|
+
body: { error: 'project repository is not configured' },
|
|
1808
|
+
});
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1800
1811
|
it('returns 400 when project is not found', async () => {
|
|
1812
|
+
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1801
1813
|
const response = await handleReorderStory(
|
|
1802
|
-
|
|
1814
|
+
contextWithProjectRepository(() => ({ updateStoryList })),
|
|
1803
1815
|
{
|
|
1804
1816
|
pjcode: 'unknown',
|
|
1805
1817
|
storyOptionId: 'opt_b',
|
|
@@ -1813,8 +1825,9 @@ describe('consoleOperationApi', () => {
|
|
|
1813
1825
|
});
|
|
1814
1826
|
|
|
1815
1827
|
it('returns 400 when story option is not found', async () => {
|
|
1828
|
+
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1816
1829
|
const response = await handleReorderStory(
|
|
1817
|
-
|
|
1830
|
+
contextWithProjectRepository(() => ({ updateStoryList })),
|
|
1818
1831
|
{
|
|
1819
1832
|
pjcode: 'acme',
|
|
1820
1833
|
storyOptionId: 'opt_nonexistent',
|
|
@@ -1828,8 +1841,9 @@ describe('consoleOperationApi', () => {
|
|
|
1828
1841
|
});
|
|
1829
1842
|
|
|
1830
1843
|
it('returns 400 when direction is "up" and the option is already first', async () => {
|
|
1844
|
+
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1831
1845
|
const response = await handleReorderStory(
|
|
1832
|
-
|
|
1846
|
+
contextWithProjectRepository(() => ({ updateStoryList })),
|
|
1833
1847
|
{
|
|
1834
1848
|
pjcode: 'acme',
|
|
1835
1849
|
storyOptionId: 'opt_a',
|
|
@@ -1843,8 +1857,9 @@ describe('consoleOperationApi', () => {
|
|
|
1843
1857
|
});
|
|
1844
1858
|
|
|
1845
1859
|
it('returns 400 when direction is "down" and the option is already last', async () => {
|
|
1860
|
+
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1846
1861
|
const response = await handleReorderStory(
|
|
1847
|
-
|
|
1862
|
+
contextWithProjectRepository(() => ({ updateStoryList })),
|
|
1848
1863
|
{
|
|
1849
1864
|
pjcode: 'acme',
|
|
1850
1865
|
storyOptionId: 'opt_c',
|
|
@@ -1857,25 +1872,11 @@ describe('consoleOperationApi', () => {
|
|
|
1857
1872
|
});
|
|
1858
1873
|
});
|
|
1859
1874
|
|
|
1860
|
-
it('
|
|
1861
|
-
const response = await handleReorderStory(
|
|
1862
|
-
contextWithUpdateStoryList(null),
|
|
1863
|
-
{
|
|
1864
|
-
pjcode: 'acme',
|
|
1865
|
-
storyOptionId: 'opt_b',
|
|
1866
|
-
direction: 'up',
|
|
1867
|
-
},
|
|
1868
|
-
);
|
|
1869
|
-
expect(response).toEqual({
|
|
1870
|
-
statusCode: 400,
|
|
1871
|
-
body: { error: 'updateStoryList is not configured' },
|
|
1872
|
-
});
|
|
1873
|
-
});
|
|
1874
|
-
|
|
1875
|
-
it('swaps the target option with its neighbor, calls updateStoryList with reordered list, and returns 200', async () => {
|
|
1875
|
+
it('swaps the target option with its neighbor, calls resolveProjectRepository updateStoryList with reordered list, and returns 200', async () => {
|
|
1876
1876
|
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1877
|
+
const resolveProjectRepository = jest.fn(() => ({ updateStoryList }));
|
|
1877
1878
|
const response = await handleReorderStory(
|
|
1878
|
-
|
|
1879
|
+
contextWithProjectRepository(resolveProjectRepository),
|
|
1879
1880
|
{
|
|
1880
1881
|
pjcode: 'acme',
|
|
1881
1882
|
storyOptionId: 'opt_b',
|
|
@@ -1883,6 +1884,9 @@ describe('consoleOperationApi', () => {
|
|
|
1883
1884
|
},
|
|
1884
1885
|
);
|
|
1885
1886
|
expect(response).toEqual({ statusCode: 200, body: { ok: true } });
|
|
1887
|
+
expect(resolveProjectRepository).toHaveBeenCalledWith(
|
|
1888
|
+
projectWithOrderedStories().url,
|
|
1889
|
+
);
|
|
1886
1890
|
expect(updateStoryList).toHaveBeenCalledTimes(1);
|
|
1887
1891
|
expect(updateStoryList).toHaveBeenCalledWith(
|
|
1888
1892
|
expect.objectContaining({ id: projectWithOrderedStories().id }),
|
|
@@ -1894,13 +1898,35 @@ describe('consoleOperationApi', () => {
|
|
|
1894
1898
|
);
|
|
1895
1899
|
});
|
|
1896
1900
|
|
|
1901
|
+
it('calls invalidateProject with pjcode after successful reorder', async () => {
|
|
1902
|
+
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1903
|
+
const invalidateProject = jest.fn();
|
|
1904
|
+
const response = await handleReorderStory(
|
|
1905
|
+
{
|
|
1906
|
+
...contextWithProjectRepository(() => ({ updateStoryList })),
|
|
1907
|
+
invalidateProject,
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
pjcode: 'acme',
|
|
1911
|
+
storyOptionId: 'opt_b',
|
|
1912
|
+
direction: 'up',
|
|
1913
|
+
},
|
|
1914
|
+
);
|
|
1915
|
+
expect(response).toEqual({ statusCode: 200, body: { ok: true } });
|
|
1916
|
+
expect(invalidateProject).toHaveBeenCalledWith('acme');
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1897
1919
|
it('returns 400 when project does not have a story field', async () => {
|
|
1898
1920
|
const projectWithoutStory = {
|
|
1899
1921
|
...projectWithOrderedStories(),
|
|
1900
1922
|
story: null,
|
|
1901
1923
|
};
|
|
1924
|
+
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1902
1925
|
const response = await handleReorderStory(
|
|
1903
|
-
|
|
1926
|
+
contextWithProjectRepository(
|
|
1927
|
+
() => ({ updateStoryList }),
|
|
1928
|
+
projectWithoutStory,
|
|
1929
|
+
),
|
|
1904
1930
|
{
|
|
1905
1931
|
pjcode: 'acme',
|
|
1906
1932
|
storyOptionId: 'opt_b',
|
|
@@ -1913,7 +1939,7 @@ describe('consoleOperationApi', () => {
|
|
|
1913
1939
|
});
|
|
1914
1940
|
});
|
|
1915
1941
|
|
|
1916
|
-
it('passes the full story list including GRAY options to updateStoryList', async () => {
|
|
1942
|
+
it('passes the full story list including GRAY options to resolveProjectRepository updateStoryList', async () => {
|
|
1917
1943
|
const projectWithGray = {
|
|
1918
1944
|
...projectWithOrderedStories(),
|
|
1919
1945
|
story: {
|
|
@@ -1945,7 +1971,10 @@ describe('consoleOperationApi', () => {
|
|
|
1945
1971
|
};
|
|
1946
1972
|
const updateStoryList = jest.fn().mockResolvedValue([]);
|
|
1947
1973
|
const response = await handleReorderStory(
|
|
1948
|
-
|
|
1974
|
+
contextWithProjectRepository(
|
|
1975
|
+
() => ({ updateStoryList }),
|
|
1976
|
+
projectWithGray,
|
|
1977
|
+
),
|
|
1949
1978
|
{
|
|
1950
1979
|
pjcode: 'acme',
|
|
1951
1980
|
storyOptionId: 'opt_b',
|
|
@@ -2156,7 +2185,6 @@ describe('consoleOperationApi', () => {
|
|
|
2156
2185
|
isPjcodeConfigured: (pjcode: string) => pjcode === 'acme',
|
|
2157
2186
|
consoleDataOutputDir: baseDir,
|
|
2158
2187
|
issueAttachmentRepository: null,
|
|
2159
|
-
updateStoryList: null,
|
|
2160
2188
|
resolveProjectRepository: jest.fn(() => ({
|
|
2161
2189
|
updateStoryList: consecutiveUpdateStoryList,
|
|
2162
2190
|
})),
|
|
@@ -50,9 +50,6 @@ export type ConsoleOperationContext = {
|
|
|
50
50
|
isPjcodeConfigured: ConsolePjcodeValidator;
|
|
51
51
|
consoleDataOutputDir: string | null;
|
|
52
52
|
issueAttachmentRepository: IssueAttachmentRepository | null;
|
|
53
|
-
updateStoryList:
|
|
54
|
-
| ((project: Project, stories: FieldOption[]) => Promise<FieldOption[]>)
|
|
55
|
-
| null;
|
|
56
53
|
resolveProjectRepository: ConsoleProjectRepositoryResolver | null;
|
|
57
54
|
invalidateProject: ((pjcode: string) => void) | null;
|
|
58
55
|
updateProjectCacheEntry:
|
|
@@ -851,6 +848,9 @@ export const handleReorderStory = async (
|
|
|
851
848
|
if (direction !== 'up' && direction !== 'down') {
|
|
852
849
|
return badRequest('direction must be "up" or "down"');
|
|
853
850
|
}
|
|
851
|
+
if (context.resolveProjectRepository === null) {
|
|
852
|
+
return badGateway('project repository is not configured');
|
|
853
|
+
}
|
|
854
854
|
const binding = await context.resolveProject(pjcode);
|
|
855
855
|
if (binding === null) {
|
|
856
856
|
return badRequest(`no project configured for pjcode "${pjcode}"`);
|
|
@@ -859,9 +859,6 @@ export const handleReorderStory = async (
|
|
|
859
859
|
if (project.story === null) {
|
|
860
860
|
return badRequest('project does not have a story field');
|
|
861
861
|
}
|
|
862
|
-
if (context.updateStoryList === null) {
|
|
863
|
-
return badRequest('updateStoryList is not configured');
|
|
864
|
-
}
|
|
865
862
|
const stories = project.story.stories;
|
|
866
863
|
const index = stories.findIndex((s) => s.id === storyOptionId);
|
|
867
864
|
if (index === -1) {
|
|
@@ -875,7 +872,9 @@ export const handleReorderStory = async (
|
|
|
875
872
|
const temp = reordered[index];
|
|
876
873
|
reordered[index] = reordered[swapIndex];
|
|
877
874
|
reordered[swapIndex] = temp;
|
|
878
|
-
|
|
875
|
+
const projectRepository = context.resolveProjectRepository(project.url);
|
|
876
|
+
await projectRepository.updateStoryList(project, reordered);
|
|
877
|
+
context.invalidateProject?.(pjcode);
|
|
879
878
|
return ok();
|
|
880
879
|
};
|
|
881
880
|
|
|
@@ -703,25 +703,20 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
|
|
|
703
703
|
storyColorCalls,
|
|
704
704
|
commentCalls,
|
|
705
705
|
),
|
|
706
|
-
projectRepository: {
|
|
707
|
-
updateStoryList: async (_updatedProject, stories) => {
|
|
708
|
-
reorderStoryCalls.push({
|
|
709
|
-
storyOptionIds: stories
|
|
710
|
-
.map((s) => s.id)
|
|
711
|
-
.filter((id): id is string => id !== null),
|
|
712
|
-
});
|
|
713
|
-
const result = stories as FieldOption[];
|
|
714
|
-
if (project.story !== null) {
|
|
715
|
-
project.story.stories = result;
|
|
716
|
-
}
|
|
717
|
-
return result;
|
|
718
|
-
},
|
|
719
|
-
},
|
|
720
706
|
resolveProjectRepository: (_projectUrl) => ({
|
|
721
707
|
updateStoryList: async (_updatedProject, stories) => {
|
|
722
|
-
const
|
|
723
|
-
if (
|
|
724
|
-
|
|
708
|
+
const currentCount = project.story?.stories.length ?? 0;
|
|
709
|
+
if (stories.length > currentCount) {
|
|
710
|
+
const newStory = stories.find((s) => s.id === null);
|
|
711
|
+
if (newStory !== undefined) {
|
|
712
|
+
addStoryCalls.push({ storyName: newStory.name });
|
|
713
|
+
}
|
|
714
|
+
} else {
|
|
715
|
+
reorderStoryCalls.push({
|
|
716
|
+
storyOptionIds: stories
|
|
717
|
+
.map((s) => s.id)
|
|
718
|
+
.filter((id): id is string => id !== null),
|
|
719
|
+
});
|
|
725
720
|
}
|
|
726
721
|
const result = stories as FieldOption[];
|
|
727
722
|
if (project.story !== null) {
|
|
@@ -67,6 +67,7 @@ const meta: Meta<typeof ConsoleStoryList> = {
|
|
|
67
67
|
onAddStory: () => Promise.resolve(),
|
|
68
68
|
onSelectColor: () => undefined,
|
|
69
69
|
onToggleGray: () => undefined,
|
|
70
|
+
onReorderStory: () => Promise.resolve(),
|
|
70
71
|
optimisticColors: {},
|
|
71
72
|
colorChangeInFlight: null,
|
|
72
73
|
colorErrors: {},
|
|
@@ -38,6 +38,7 @@ const defaultProps = {
|
|
|
38
38
|
onAddStory: () => Promise.resolve(),
|
|
39
39
|
onSelectColor: () => undefined,
|
|
40
40
|
onToggleGray: () => undefined,
|
|
41
|
+
onReorderStory: () => Promise.resolve(),
|
|
41
42
|
optimisticColors: {} as Record<string, ConsoleColor>,
|
|
42
43
|
colorChangeInFlight: null as string | null,
|
|
43
44
|
colorErrors: {} as Record<string, string>,
|
|
@@ -466,4 +467,88 @@ describe('ConsoleStoryList', () => {
|
|
|
466
467
|
expect(getByText('No active stories')).toBeInTheDocument();
|
|
467
468
|
});
|
|
468
469
|
});
|
|
470
|
+
|
|
471
|
+
describe('reorder buttons', () => {
|
|
472
|
+
it('renders Move up and Move down buttons for each visible story', () => {
|
|
473
|
+
const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
|
|
474
|
+
const upButtons = getAllByRole('button', { name: 'Move up' });
|
|
475
|
+
const downButtons = getAllByRole('button', { name: 'Move down' });
|
|
476
|
+
expect(upButtons).toHaveLength(storyEntries.length);
|
|
477
|
+
expect(downButtons).toHaveLength(storyEntries.length);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('disables the Move up button for the first story', () => {
|
|
481
|
+
const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
|
|
482
|
+
const upButtons = getAllByRole('button', { name: 'Move up' });
|
|
483
|
+
expect(upButtons[0]).toBeDisabled();
|
|
484
|
+
expect(upButtons[1]).not.toBeDisabled();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('disables the Move down button for the last story', () => {
|
|
488
|
+
const { getAllByRole } = render(<ConsoleStoryList {...defaultProps} />);
|
|
489
|
+
const downButtons = getAllByRole('button', { name: 'Move down' });
|
|
490
|
+
expect(downButtons[0]).not.toBeDisabled();
|
|
491
|
+
expect(downButtons[1]).toBeDisabled();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('calls onReorderStory with storyOptionId and up when Move up is clicked', async () => {
|
|
495
|
+
const onReorderStory = jest.fn().mockResolvedValue(undefined);
|
|
496
|
+
const { getAllByRole } = render(
|
|
497
|
+
<ConsoleStoryList {...defaultProps} onReorderStory={onReorderStory} />,
|
|
498
|
+
);
|
|
499
|
+
const upButtons = getAllByRole('button', { name: 'Move up' });
|
|
500
|
+
await act(async () => {
|
|
501
|
+
fireEvent.click(upButtons[1]);
|
|
502
|
+
});
|
|
503
|
+
expect(onReorderStory).toHaveBeenCalledWith('564803ee', 'up');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('calls onReorderStory with storyOptionId and down when Move down is clicked', async () => {
|
|
507
|
+
const onReorderStory = jest.fn().mockResolvedValue(undefined);
|
|
508
|
+
const { getAllByRole } = render(
|
|
509
|
+
<ConsoleStoryList {...defaultProps} onReorderStory={onReorderStory} />,
|
|
510
|
+
);
|
|
511
|
+
const downButtons = getAllByRole('button', { name: 'Move down' });
|
|
512
|
+
await act(async () => {
|
|
513
|
+
fireEvent.click(downButtons[0]);
|
|
514
|
+
});
|
|
515
|
+
expect(onReorderStory).toHaveBeenCalledWith('1491051e', 'down');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('disables Move up and Move down buttons while reorder is in progress', async () => {
|
|
519
|
+
let resolveReorder: () => void;
|
|
520
|
+
const reorderPromise = new Promise<void>((resolve) => {
|
|
521
|
+
resolveReorder = resolve;
|
|
522
|
+
});
|
|
523
|
+
const onReorderStory = jest.fn().mockReturnValue(reorderPromise);
|
|
524
|
+
const { getAllByRole } = render(
|
|
525
|
+
<ConsoleStoryList {...defaultProps} onReorderStory={onReorderStory} />,
|
|
526
|
+
);
|
|
527
|
+
const downButtons = getAllByRole('button', { name: 'Move down' });
|
|
528
|
+
fireEvent.click(downButtons[0]);
|
|
529
|
+
await waitFor(() => {
|
|
530
|
+
const upButtons = getAllByRole('button', { name: 'Move up' });
|
|
531
|
+
expect(upButtons[0]).toBeDisabled();
|
|
532
|
+
expect(downButtons[0]).toBeDisabled();
|
|
533
|
+
});
|
|
534
|
+
await act(async () => {
|
|
535
|
+
resolveReorder!();
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('shows an error message when onReorderStory throws', async () => {
|
|
540
|
+
const onReorderStory = jest
|
|
541
|
+
.fn()
|
|
542
|
+
.mockRejectedValue(new Error('Reorder failed'));
|
|
543
|
+
const { getAllByRole, findByRole } = render(
|
|
544
|
+
<ConsoleStoryList {...defaultProps} onReorderStory={onReorderStory} />,
|
|
545
|
+
);
|
|
546
|
+
const downButtons = getAllByRole('button', { name: 'Move down' });
|
|
547
|
+
await act(async () => {
|
|
548
|
+
fireEvent.click(downButtons[0]);
|
|
549
|
+
});
|
|
550
|
+
const alert = await findByRole('alert');
|
|
551
|
+
expect(alert).toHaveTextContent('Reorder failed');
|
|
552
|
+
});
|
|
553
|
+
});
|
|
469
554
|
});
|
|
@@ -3,6 +3,11 @@ import { CONSOLE_COLOR_PALETTE, colorFromEnum } from '../../logic/colors';
|
|
|
3
3
|
import type { ConsoleColor, ConsoleStoryEntry } from '../../logic/types';
|
|
4
4
|
import { ConsoleCopyStoryNameButton } from './ConsoleCopyStoryNameButton';
|
|
5
5
|
|
|
6
|
+
type RowReorderState = {
|
|
7
|
+
inProgress: boolean;
|
|
8
|
+
error: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
6
11
|
const ALL_COLORS = Object.keys(CONSOLE_COLOR_PALETTE) as ConsoleColor[];
|
|
7
12
|
|
|
8
13
|
type InlineInputFormProps = {
|
|
@@ -137,6 +142,10 @@ export type ConsoleStoryListProps = {
|
|
|
137
142
|
onAddStory: (storyName: string) => Promise<void>;
|
|
138
143
|
onSelectColor: (storyOptionId: string, newColor: ConsoleColor) => void;
|
|
139
144
|
onToggleGray: () => void;
|
|
145
|
+
onReorderStory: (
|
|
146
|
+
storyOptionId: string,
|
|
147
|
+
direction: 'up' | 'down',
|
|
148
|
+
) => Promise<void>;
|
|
140
149
|
optimisticColors: Record<string, ConsoleColor>;
|
|
141
150
|
colorChangeInFlight: string | null;
|
|
142
151
|
colorErrors: Record<string, string>;
|
|
@@ -151,6 +160,7 @@ export const ConsoleStoryList = ({
|
|
|
151
160
|
onAddStory,
|
|
152
161
|
onSelectColor,
|
|
153
162
|
onToggleGray,
|
|
163
|
+
onReorderStory,
|
|
154
164
|
optimisticColors,
|
|
155
165
|
colorChangeInFlight,
|
|
156
166
|
colorErrors,
|
|
@@ -160,6 +170,37 @@ export const ConsoleStoryList = ({
|
|
|
160
170
|
const [colorPickerOptionId, setColorPickerOptionId] = useState<string | null>(
|
|
161
171
|
null,
|
|
162
172
|
);
|
|
173
|
+
const [rowReorderStates, setRowReorderStates] = useState<
|
|
174
|
+
Record<string, RowReorderState>
|
|
175
|
+
>({});
|
|
176
|
+
|
|
177
|
+
const getRowReorderState = (id: string): RowReorderState =>
|
|
178
|
+
rowReorderStates[id] ?? { inProgress: false, error: null };
|
|
179
|
+
|
|
180
|
+
const handleReorder = async (
|
|
181
|
+
storyOptionId: string,
|
|
182
|
+
direction: 'up' | 'down',
|
|
183
|
+
): Promise<void> => {
|
|
184
|
+
setRowReorderStates((prev) => ({
|
|
185
|
+
...prev,
|
|
186
|
+
[storyOptionId]: { inProgress: true, error: null },
|
|
187
|
+
}));
|
|
188
|
+
try {
|
|
189
|
+
await onReorderStory(storyOptionId, direction);
|
|
190
|
+
setRowReorderStates((prev) => ({
|
|
191
|
+
...prev,
|
|
192
|
+
[storyOptionId]: { inProgress: false, error: null },
|
|
193
|
+
}));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
setRowReorderStates((prev) => ({
|
|
196
|
+
...prev,
|
|
197
|
+
[storyOptionId]: {
|
|
198
|
+
inProgress: false,
|
|
199
|
+
error: err instanceof Error ? err.message : String(err),
|
|
200
|
+
},
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
};
|
|
163
204
|
|
|
164
205
|
if (error !== null) {
|
|
165
206
|
return (
|
|
@@ -212,7 +253,7 @@ export const ConsoleStoryList = ({
|
|
|
212
253
|
<p className="console-list-empty">No active stories</p>
|
|
213
254
|
) : (
|
|
214
255
|
<ul className="console-story-list">
|
|
215
|
-
{visibleStories.map((entry) => {
|
|
256
|
+
{visibleStories.map((entry, index) => {
|
|
216
257
|
const displayColor: ConsoleColor =
|
|
217
258
|
optimisticColors[entry.storyOptionId] ?? entry.color;
|
|
218
259
|
const palette = colorFromEnum(displayColor);
|
|
@@ -220,6 +261,10 @@ export const ConsoleStoryList = ({
|
|
|
220
261
|
const isPickerOpen = colorPickerOptionId === entry.storyOptionId;
|
|
221
262
|
const isInFlight = colorChangeInFlight === entry.storyOptionId;
|
|
222
263
|
const colorError = colorErrors[entry.storyOptionId] ?? null;
|
|
264
|
+
const { inProgress: reorderInProgress, error: reorderError } =
|
|
265
|
+
getRowReorderState(entry.storyOptionId);
|
|
266
|
+
const isFirst = index === 0;
|
|
267
|
+
const isLast = index === visibleStories.length - 1;
|
|
223
268
|
return (
|
|
224
269
|
<li key={entry.storyOptionId} className="console-story-list-row">
|
|
225
270
|
<div className="console-story-list-row-main">
|
|
@@ -268,7 +313,34 @@ export const ConsoleStoryList = ({
|
|
|
268
313
|
>
|
|
269
314
|
Change color
|
|
270
315
|
</button>
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
className="console-op-button"
|
|
319
|
+
aria-label="Move up"
|
|
320
|
+
disabled={isFirst || reorderInProgress}
|
|
321
|
+
onClick={() =>
|
|
322
|
+
void handleReorder(entry.storyOptionId, 'up')
|
|
323
|
+
}
|
|
324
|
+
>
|
|
325
|
+
↑
|
|
326
|
+
</button>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
className="console-op-button"
|
|
330
|
+
aria-label="Move down"
|
|
331
|
+
disabled={isLast || reorderInProgress}
|
|
332
|
+
onClick={() =>
|
|
333
|
+
void handleReorder(entry.storyOptionId, 'down')
|
|
334
|
+
}
|
|
335
|
+
>
|
|
336
|
+
↓
|
|
337
|
+
</button>
|
|
271
338
|
</div>
|
|
339
|
+
{reorderError !== null && (
|
|
340
|
+
<p role="alert" className="console-list-error">
|
|
341
|
+
{reorderError}
|
|
342
|
+
</p>
|
|
343
|
+
)}
|
|
272
344
|
{isPickerOpen && (
|
|
273
345
|
<ColorPalette
|
|
274
346
|
onSelectColor={(newColor) =>
|
|
@@ -52,6 +52,7 @@ import type {
|
|
|
52
52
|
ConsoleColor,
|
|
53
53
|
ConsoleListItem,
|
|
54
54
|
ConsoleOverlayStatus,
|
|
55
|
+
ConsoleStoryEntry,
|
|
55
56
|
ConsoleTabName,
|
|
56
57
|
} from '../logic/types';
|
|
57
58
|
import { CONSOLE_TABS } from '../logic/types';
|
|
@@ -185,6 +186,11 @@ export const ConsolePage = () => {
|
|
|
185
186
|
stories: typeof storyOptions;
|
|
186
187
|
} | null>(null);
|
|
187
188
|
|
|
189
|
+
const [localStoryEntriesOverride, setLocalStoryEntriesOverride] = useState<{
|
|
190
|
+
generatedAt: string | undefined;
|
|
191
|
+
stories: ConsoleStoryEntry[];
|
|
192
|
+
} | null>(null);
|
|
193
|
+
|
|
188
194
|
const triageStoryOptions =
|
|
189
195
|
localStoryOptionsOverride !== null &&
|
|
190
196
|
localStoryOptionsOverride.generatedAt === activeSnapshot?.generatedAt
|
|
@@ -347,7 +353,12 @@ export const ConsolePage = () => {
|
|
|
347
353
|
}, []);
|
|
348
354
|
|
|
349
355
|
const storiesSnapshot = snapshots.stories;
|
|
350
|
-
const
|
|
356
|
+
const rawStoryEntries = storiesSnapshot?.stories ?? [];
|
|
357
|
+
const storyEntries =
|
|
358
|
+
localStoryEntriesOverride !== null &&
|
|
359
|
+
localStoryEntriesOverride.generatedAt === storiesSnapshot?.generatedAt
|
|
360
|
+
? localStoryEntriesOverride.stories
|
|
361
|
+
: rawStoryEntries;
|
|
351
362
|
const defaultNameWithOwner = storiesSnapshot?.defaultNameWithOwner ?? null;
|
|
352
363
|
|
|
353
364
|
const handleCreateIssue = useCallback(
|
|
@@ -374,24 +385,49 @@ export const ConsolePage = () => {
|
|
|
374
385
|
throw new Error('No project specified in the URL path.');
|
|
375
386
|
}
|
|
376
387
|
await postConsoleReorderStory({ pjcode, storyOptionId, direction });
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
const triageIndex = triageStoryOptions.findIndex(
|
|
389
|
+
(o) => o.id === storyOptionId,
|
|
390
|
+
);
|
|
391
|
+
if (triageIndex !== -1) {
|
|
392
|
+
const triageSwapIndex = triageIndex + (direction === 'up' ? -1 : 1);
|
|
393
|
+
if (
|
|
394
|
+
triageSwapIndex >= 0 &&
|
|
395
|
+
triageSwapIndex < triageStoryOptions.length
|
|
396
|
+
) {
|
|
397
|
+
const nextTriage = [...triageStoryOptions];
|
|
398
|
+
const triageTemp = nextTriage[triageIndex];
|
|
399
|
+
nextTriage[triageIndex] = nextTriage[triageSwapIndex];
|
|
400
|
+
nextTriage[triageSwapIndex] = triageTemp;
|
|
401
|
+
setLocalStoryOptionsOverride({
|
|
402
|
+
generatedAt: activeSnapshot?.generatedAt,
|
|
403
|
+
stories: nextTriage,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
380
406
|
}
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
407
|
+
const entryIndex = storyEntries.findIndex(
|
|
408
|
+
(e) => e.storyOptionId === storyOptionId,
|
|
409
|
+
);
|
|
410
|
+
if (entryIndex !== -1) {
|
|
411
|
+
const entrySwapIndex = entryIndex + (direction === 'up' ? -1 : 1);
|
|
412
|
+
if (entrySwapIndex >= 0 && entrySwapIndex < storyEntries.length) {
|
|
413
|
+
const nextEntries = [...storyEntries];
|
|
414
|
+
const entryTemp = nextEntries[entryIndex];
|
|
415
|
+
nextEntries[entryIndex] = nextEntries[entrySwapIndex];
|
|
416
|
+
nextEntries[entrySwapIndex] = entryTemp;
|
|
417
|
+
setLocalStoryEntriesOverride({
|
|
418
|
+
generatedAt: storiesSnapshot?.generatedAt,
|
|
419
|
+
stories: nextEntries,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
384
422
|
}
|
|
385
|
-
const next = [...triageStoryOptions];
|
|
386
|
-
const temp = next[index];
|
|
387
|
-
next[index] = next[swapIndex];
|
|
388
|
-
next[swapIndex] = temp;
|
|
389
|
-
setLocalStoryOptionsOverride({
|
|
390
|
-
generatedAt: activeSnapshot?.generatedAt,
|
|
391
|
-
stories: next,
|
|
392
|
-
});
|
|
393
423
|
},
|
|
394
|
-
[
|
|
424
|
+
[
|
|
425
|
+
pjcode,
|
|
426
|
+
triageStoryOptions,
|
|
427
|
+
storyEntries,
|
|
428
|
+
activeSnapshot?.generatedAt,
|
|
429
|
+
storiesSnapshot?.generatedAt,
|
|
430
|
+
],
|
|
395
431
|
);
|
|
396
432
|
|
|
397
433
|
const handleStoryAdd = useCallback(
|
|
@@ -523,6 +559,7 @@ export const ConsolePage = () => {
|
|
|
523
559
|
onAddStory={handleStoryAdd}
|
|
524
560
|
onSelectColor={handleSelectColor}
|
|
525
561
|
onToggleGray={handleToggleGray}
|
|
562
|
+
onReorderStory={handleReorderStory}
|
|
526
563
|
optimisticColors={storyOptimisticColors}
|
|
527
564
|
colorChangeInFlight={storyColorChangeInFlight}
|
|
528
565
|
colorErrors={storyColorErrors}
|