Skip to content

Commit 08e1d19

Browse files
authored
[SDESK-7811] Fix mark for desks in authoring-react (#5229)
* Fix mark for desks in authoring-react The modal posted a plural marked_desks array, but the backend endpoint only accepts a single marked_desk per request and treats each POST as a toggle. So marking never worked. The modal now toggles one desk per selection change instead of waiting for a Save button, matching the toggle endpoint and the legacy Angular behaviour. Both the modal and the popover patch authoring state in place after the toggle resolves, since reloading the article would race with the archive index refresh and read back stale data. Also forwards data-test-value through Spacer and adds an e2e spec. SDESK-7811 * Harden mark for desks: clear-all, deleted desks, request failures Address review feedback on the mark-for-desks fix: - The selection handler assumed TreeSelect only ever changes one desk at a time, but its clear-all button empties the whole selection in a single change. Diff the full previous and next id sets and toggle every change, then reconcile local state and authoring data from the toggles that actually persisted so a partial failure cannot desync the UI. - Surface a notify.error when a toggle request fails (modal and popover); previously failures were silent unhandled rejections. - A marked desk can be deleted while the article keeps the stale id. Add a shared resolveDesks helper that drops missing ids so the modal and popover no longer render undefined entries (the popover crashed on destructure). Adds a unit spec for resolveDesks and an e2e case asserting clear-all unmarks every desk. * Address follow-up review on mark for desks - Popover: compute the next marks inside the toggle's success handler so fast repeated removals do not start from stale props and reintroduce a removed desk. - Modal: build the persisted list in the user's selection order, appending any desks that stayed marked because their unmark failed, so chips do not reorder.
1 parent 9646799 commit 08e1d19

7 files changed

Lines changed: 254 additions & 44 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {test, expect} from '@playwright/test';
2+
import {Authoring} from './page-object-models/authoring';
3+
import {Monitoring} from './page-object-models/monitoring';
4+
import {restoreDatabaseSnapshot, s} from './utils';
5+
import {getStorageState} from './utils/storage-state';
6+
import {TreeSelectDriver} from './utils/tree-select-driver';
7+
8+
test.use({
9+
storageState: getStorageState({}, {authoringReact: true}),
10+
});
11+
12+
test('mark for desks: per-action toggle via modal (authoring-react)', async ({page}) => {
13+
await restoreDatabaseSnapshot();
14+
const monitoring = new Monitoring(page);
15+
const authoring = new Authoring(page);
16+
17+
await page.goto('/#/workspace/monitoring');
18+
await monitoring.selectDeskOrWorkspace('Sports');
19+
20+
await monitoring.executeActionOnMonitoringItem(
21+
page.locator(s('monitoring-group=Sports / Working Stage', 'article-item=test sports story')),
22+
'Edit',
23+
);
24+
25+
await authoring.waitForAuthoringReactToInitialize();
26+
27+
// No desks marked initially: the topbar bell widget should be absent.
28+
const bell = page.locator('#marked-for-desks');
29+
30+
await expect(bell).toHaveCount(0);
31+
32+
await page.getByRole('button', {name: 'Actions menu'}).click();
33+
await page.getByText('Marked for desks', {exact: true}).click();
34+
35+
const modal = page.locator(s('modal-mark-for-desks'));
36+
37+
await expect(modal).toBeVisible();
38+
39+
// Selecting a desk fires the toggle endpoint immediately. No Save button.
40+
await new TreeSelectDriver(page, modal.locator(s('desks-select'))).addValues('Finance');
41+
42+
await expect(bell).toBeVisible();
43+
44+
// Clearing the chip fires the toggle again, unmarking Finance.
45+
await new TreeSelectDriver(page, modal.locator(s('desks-select'))).setValues();
46+
47+
await expect(bell).toHaveCount(0);
48+
});
49+
50+
test('mark for desks: clear-all unmarks every desk, not just one (authoring-react)', async ({page}) => {
51+
await restoreDatabaseSnapshot();
52+
const monitoring = new Monitoring(page);
53+
const authoring = new Authoring(page);
54+
55+
await page.goto('/#/workspace/monitoring');
56+
await monitoring.selectDeskOrWorkspace('Sports');
57+
58+
await monitoring.executeActionOnMonitoringItem(
59+
page.locator(s('monitoring-group=Sports / Working Stage', 'article-item=test sports story')),
60+
'Edit',
61+
);
62+
63+
await authoring.waitForAuthoringReactToInitialize();
64+
65+
const bell = page.locator('#marked-for-desks');
66+
67+
await expect(bell).toHaveCount(0);
68+
69+
await page.getByRole('button', {name: 'Actions menu'}).click();
70+
await page.getByText('Marked for desks', {exact: true}).click();
71+
72+
const modal = page.locator(s('modal-mark-for-desks'));
73+
74+
await expect(modal).toBeVisible();
75+
76+
const desksSelect = new TreeSelectDriver(page, modal.locator(s('desks-select')));
77+
78+
await desksSelect.addValues('Finance', 'Education');
79+
80+
await expect(bell).toBeVisible();
81+
82+
// Clear-all empties the whole selection in one change. The handler must toggle every marked
83+
// desk, not just the first one, so the article ends up with no marked desks.
84+
await desksSelect.setValues();
85+
86+
await expect(bell).toHaveCount(0);
87+
expect(await desksSelect.getValues()).toEqual([]);
88+
});
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
1+
import {OrderedMap} from 'immutable';
2+
import {IDesk} from 'superdesk-api';
13
import {httpRequestJsonLocal} from 'core/helpers/network';
24

3-
export function setMarkedDesks(deskIds: Array<string>, articleId: string) {
5+
/**
6+
* The /marked_for_desks endpoint toggles a single desk per call: posting
7+
* with an already-marked desk unmarks it. Callers must diff their selection
8+
* and issue one request per change.
9+
*/
10+
export function toggleMarkedDesk(deskId: string, articleId: string) {
411
return httpRequestJsonLocal({
512
method: 'POST',
613
path: '/marked_for_desks',
714
payload: {
8-
marked_desks: deskIds,
15+
marked_desk: deskId,
916
marked_item: articleId,
1017
},
1118
});
1219
}
20+
21+
/**
22+
* Turns marked desk ids into desk objects, dropping ids that no longer exist. A desk can be
23+
* deleted while an article keeps the stale id, which would otherwise render as undefined.
24+
*/
25+
export function resolveDesks(deskIds: Array<string>, allDesks: OrderedMap<IDesk['_id'], IDesk>): Array<IDesk> {
26+
return deskIds
27+
.map((id) => allDesks.get(id))
28+
.filter((desk): desk is IDesk => desk != null);
29+
}

scripts/apps/authoring-react/toolbar/mark-for-desks/mark-for-desks-modal.tsx

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,109 @@
11
import React from 'react';
22
import {gettext} from 'core/utils';
33
import {IArticle, IDesk} from 'superdesk-api';
4-
import {Alert, Button, Modal, TreeSelect} from 'superdesk-ui-framework/react';
4+
import {Modal, TreeSelect} from 'superdesk-ui-framework/react';
55
import {sdApi} from 'api';
6-
import {nameof} from 'core/helpers/typescript-helpers';
7-
import {Spacer} from 'core/ui/components/Spacer';
8-
import {setMarkedDesks} from './helper';
6+
import {dispatchInternalEvent} from 'core/internal-events';
7+
import {notify} from 'core/notify/notify';
8+
import {resolveDesks, toggleMarkedDesk} from './helper';
9+
10+
type IMarkedDesk = NonNullable<IArticle['marked_desks']>[number];
911

1012
interface IProps {
1113
article: IArticle;
1214
closeModal(): void;
1315
}
1416

1517
interface IState {
16-
selectedDesks: Array<string> | null;
18+
selectedDesks: Array<IMarkedDesk>;
1719
}
1820

1921
export class MarkForDesksModal extends React.PureComponent<IProps, IState> {
2022
constructor(props: IProps) {
2123
super(props);
2224

2325
this.state = {
24-
selectedDesks: this.props.article.marked_desks?.map((x) => x.desk_id),
26+
selectedDesks: this.props.article.marked_desks ?? [],
27+
};
28+
29+
this.handleSelectionChange = this.handleSelectionChange.bind(this);
30+
}
31+
32+
private markFor(deskId: string): IMarkedDesk {
33+
return {
34+
desk_id: deskId,
35+
user_marked: sdApi.user.getCurrentUserId(),
36+
date_marked: new Date().toISOString(),
2537
};
2638
}
2739

40+
private handleSelectionChange(nextValue: Array<IDesk>): void {
41+
const articleId = this.props.article._id;
42+
const previousSelected = this.state.selectedDesks;
43+
const previousIds = previousSelected.map((m) => m.desk_id);
44+
const nextIds = nextValue.map((desk) => desk._id);
45+
46+
// TreeSelect gives us the whole next value (its clear-all empties everything at once), so
47+
// toggle every added or removed desk, not just one.
48+
const changedIds = [
49+
...nextIds.filter((id) => !previousIds.includes(id)),
50+
...previousIds.filter((id) => !nextIds.includes(id)),
51+
];
52+
53+
if (changedIds.length === 0) {
54+
return;
55+
}
56+
57+
const existingById = new Map(previousSelected.map((m) => [m.desk_id, m]));
58+
const nextSelected = nextValue.map((desk) => existingById.get(desk._id) ?? this.markFor(desk._id));
59+
60+
this.setState({selectedDesks: nextSelected});
61+
62+
Promise.allSettled(
63+
changedIds.map((deskId) => toggleMarkedDesk(deskId, articleId)),
64+
).then((results) => {
65+
// Each toggle flips one desk. Apply only the ones that succeeded so the UI matches the server.
66+
const persistedIds = new Set(previousIds);
67+
68+
changedIds.forEach((deskId, index) => {
69+
if (results[index].status === 'rejected') {
70+
return;
71+
}
72+
73+
if (persistedIds.has(deskId)) {
74+
persistedIds.delete(deskId);
75+
return;
76+
}
77+
78+
persistedIds.add(deskId);
79+
});
80+
81+
// Keep the user's order; put desks that stayed marked after a failed unmark at the end.
82+
const persistedOrder = [
83+
...nextIds.filter((id) => persistedIds.has(id)),
84+
...Array.from(persistedIds).filter((id) => !nextIds.includes(id)),
85+
];
86+
const persisted = persistedOrder.map((id) => existingById.get(id) ?? this.markFor(id));
87+
88+
// Patch state directly instead of reloading; a reload would race the index and read stale
89+
// data. marked_desks has no editor input, so there are no unsaved edits to lose.
90+
this.setState({selectedDesks: persisted});
91+
dispatchInternalEvent('dangerouslyOverwriteAuthoringData', {
92+
item: {
93+
_id: articleId,
94+
marked_desks: persisted,
95+
},
96+
});
97+
98+
if (results.some((result) => result.status === 'rejected')) {
99+
notify.error(gettext('Some desks could not be updated.'));
100+
}
101+
});
102+
}
103+
28104
render(): JSX.Element {
29105
const allDesks = sdApi.desks.getAllDesks();
30-
const selectedDesks = (this.state.selectedDesks ?? []).map((id) => allDesks.get(id));
106+
const treeSelectValue = resolveDesks(this.state.selectedDesks.map((m) => m.desk_id), allDesks);
31107

32108
return (
33109
<Modal
@@ -36,40 +112,19 @@ export class MarkForDesksModal extends React.PureComponent<IProps, IState> {
36112
size="medium"
37113
headerTemplate={gettext('Marked for desks')}
38114
>
39-
<Spacer v gap="8">
115+
<div data-test-id="modal-mark-for-desks">
40116
<TreeSelect
41117
kind="synchronous"
42118
allowMultiple
43119
label={gettext('Select desks')}
44-
value={selectedDesks}
45-
onChange={(value) => {
46-
this.setState({
47-
...this.state,
48-
selectedDesks: value.map((desk) => desk._id),
49-
});
50-
}}
120+
value={treeSelectValue}
121+
onChange={this.handleSelectionChange}
51122
getId={(desk) => desk.name}
52123
getLabel={(desk) => desk.name}
53124
getOptions={() => allDesks.toArray().map((item) => ({value: item}))}
125+
data-test-id="desks-select"
54126
/>
55-
56-
<Spacer h gap="8" justifyContent="end" noWrap>
57-
<Button
58-
onClick={() => {
59-
setMarkedDesks(this.state.selectedDesks, this.props.article._id)
60-
.then(() => this.props.closeModal());
61-
}}
62-
text={gettext('Save')}
63-
type="primary"
64-
style="filled"
65-
/>
66-
<Button
67-
onClick={this.props.closeModal}
68-
text={gettext('Cancel')}
69-
style="hollow"
70-
/>
71-
</Spacer>
72-
</Spacer>
127+
</div>
73128
</Modal>
74129
);
75130
}

scripts/apps/authoring-react/toolbar/mark-for-desks/mark-for-desks-popover.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {sdApi} from 'api';
22
import {dispatchInternalEvent} from 'core/internal-events';
3+
import {notify} from 'core/notify/notify';
34
import {Spacer} from 'core/ui/components/Spacer';
45
import {gettext} from 'core/utils';
56
import React from 'react';
67
import {IArticle} from 'superdesk-api';
78
import {Button, Text} from 'superdesk-ui-framework/react';
8-
import {setMarkedDesks} from './helper';
9+
import {resolveDesks, toggleMarkedDesk} from './helper';
910

1011
interface IProps {
1112
article: IArticle;
@@ -19,27 +20,44 @@ export class MarkedDesks extends React.PureComponent<IProps> {
1920
this.getSelectedDeskIds = this.getSelectedDeskIds.bind(this);
2021
}
2122

22-
private getSelectedDeskIds(): Array<string> | null {
23+
private getSelectedDeskIds(): Array<string> {
2324
return (this.props.article.marked_desks ?? []).map((x) => x.desk_id);
2425
}
2526

2627
private unMarkDesks(deskId: string): void {
27-
const deskIds = this.getSelectedDeskIds().filter((id) => id !== deskId);
28+
const articleId = this.props.article._id;
2829

29-
setMarkedDesks(deskIds, this.props.article._id).then(() => {
30-
this.setState({
31-
selectedDeskIds: deskIds,
30+
toggleMarkedDesk(deskId, articleId).then(() => {
31+
// Read the marks here, not at call time, so fast repeated removals do not start from stale
32+
// props and bring a removed desk back. Patch state directly instead of reloading; a reload
33+
// would race the index and read stale data.
34+
const nextMarks = (this.props.article.marked_desks ?? []).filter((m) => m.desk_id !== deskId);
35+
36+
dispatchInternalEvent('dangerouslyOverwriteAuthoringData', {
37+
item: {
38+
_id: articleId,
39+
marked_desks: nextMarks,
40+
},
3241
});
33-
dispatchInternalEvent('dangerouslyForceReloadAuthoring', undefined);
42+
}).catch(() => {
43+
notify.error(gettext('Could not remove desk.'));
3444
});
3545
}
3646

3747
render(): React.ReactNode {
3848
const allDesks = sdApi.desks.getAllDesks();
39-
const selectedDesks = this.getSelectedDeskIds().map((id) => allDesks.get(id));
49+
const selectedDesks = resolveDesks(this.getSelectedDeskIds(), allDesks);
4050

4151
return selectedDesks.map(({name, _id}) => (
42-
<Spacer gap="32" h key={_id} justifyContent="space-between" alignItems="stretch">
52+
<Spacer
53+
gap="32"
54+
h
55+
key={_id}
56+
justifyContent="space-between"
57+
alignItems="stretch"
58+
data-test-id="marked-desk"
59+
data-test-value={name}
60+
>
4361
<Text size="small">{name}</Text>
4462
<Button
4563
size="small"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {OrderedMap} from 'immutable';
2+
import {IDesk} from 'superdesk-api';
3+
import {resolveDesks} from '../helper';
4+
5+
function desk(id: string, name: string): IDesk {
6+
return {_id: id, name} as IDesk;
7+
}
8+
9+
describe('resolveDesks', () => {
10+
const allDesks = OrderedMap<IDesk['_id'], IDesk>([
11+
['desk-1', desk('desk-1', 'Sports')],
12+
['desk-2', desk('desk-2', 'Finance')],
13+
]);
14+
15+
it('resolves ids to desk objects, preserving order', () => {
16+
expect(resolveDesks(['desk-2', 'desk-1'], allDesks).map((d) => d.name)).toEqual(['Finance', 'Sports']);
17+
});
18+
19+
it('drops ids that no longer exist instead of returning undefined entries', () => {
20+
const result = resolveDesks(['desk-1', 'deleted-desk', 'desk-2'], allDesks);
21+
22+
expect(result.map((d) => d.name)).toEqual(['Sports', 'Finance']);
23+
expect(result.every((d) => d != null)).toBe(true);
24+
});
25+
26+
it('returns an empty array when nothing matches', () => {
27+
expect(resolveDesks(['gone'], allDesks)).toEqual([]);
28+
expect(resolveDesks([], allDesks)).toEqual([]);
29+
});
30+
});

scripts/core/superdesk-api.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2555,6 +2555,7 @@ declare module 'superdesk-api' {
25552555
children: Array<React.ReactNode>;
25562556

25572557
'data-test-id'?: string;
2558+
'data-test-value'?: string;
25582559
}
25592560

25602561
type ILiveQueryChildrenOptions<T> = {loading: true} | {loading: false; data: IRestApiResponse<T>};

scripts/core/ui/components/Spacer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class Spacer extends React.PureComponent<IPropsSpacer> {
2222
...(this.props.style ?? {}),
2323
}}
2424
data-test-id={this.props['data-test-id']}
25+
data-test-value={this.props['data-test-value']}
2526
>
2627
{this.props.children.map((el, i) => noWrap ? el : (
2728
<div

0 commit comments

Comments
 (0)