Skip to content
This repository was archived by the owner on Apr 18, 2024. It is now read-only.

Commit 3161d10

Browse files
authored
fix: LSDV-5213: Fix Ranker format for rank mode (#1423)
* fix: LSDV-5213: Fix Ranker format for rank mode Rank mode (simple List+Ranker without Buckets) should produce results with the same structure: dict under "ranker" key in "value", keys of the dict are Buckets' names. For rank mode it's Ranker's name. * Add unit test for Ranker in all modes * Always add all columns to result, even empty * Add more checks to Ranker unit test Now check results as well * Also store empty columns if no actions made * Use ORIGINAL_ITEMS_KEY instead of unclear "_" * Add List to result by default in pick mode as well
1 parent e11265a commit 3161d10

File tree

4 files changed

+204
-36
lines changed

4 files changed

+204
-36
lines changed

src/components/Ranker/Ranker.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ const Ranker = ({ inputData, handleChange, readonly }: BoardProps) => {
4949
[source.droppableId]: newCol,
5050
};
5151

52-
// delete newItemIds['_'];
53-
5452
const newData = {
5553
...data,
5654
itemIds: newItemIds,
@@ -77,8 +75,6 @@ const Ranker = ({ inputData, handleChange, readonly }: BoardProps) => {
7775
[destination.droppableId]: endItemIds,
7876
};
7977

80-
// delete newItemIds['_'];
81-
8278
const newData = {
8379
...data,
8480
itemIds: newItemIds,

src/tags/control/Ranker.js

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import { ReadOnlyControlMixin } from '../../mixins/ReadOnlyMixin';
1111
import { guidGenerator } from '../../utils/unique';
1212
import Base from './Base';
1313

14+
// column to display items from original List, when there are no default Bucket
15+
const ORIGINAL_ITEMS_KEY = '_';
16+
1417
/**
1518
* The `Ranker` tag is used to rank items in a `List` tag or pick relevant items from a `List`, depending on using nested `Bucket` tags.
16-
* In simple case of `List` + `Ranker` tags the first one becomes interactive and saved result is an array of ids in new order.
19+
* In simple case of `List` + `Ranker` tags the first one becomes interactive and saved result is a dict with the only key of tag's name and with value of array of ids in new order.
1720
* With `Bucket`s any items from the `List` can be moved to these buckets, and resulting groups will be exported as a dict `{ bucket-name-1: [array of ids in this bucket], ... }`
1821
* By default all items will sit in `List` and will not be exported, unless they are moved to a bucket. But with `default="true"` parameter you can specify a bucket where all items will be placed by default, so exported result will always have all items from the list, grouped by buckets.
1922
* Columns and items can be styled in `Style` tag by using respective `.htx-ranker-column` and `.htx-ranker-item` classes. Titles of columns are defined in `title` parameter of `Bucket` tag.
@@ -41,7 +44,7 @@ import Base from './Base';
4144
* "from_name": "rank",
4245
* "to_name": "results",
4346
* "type": "ranker",
44-
* "value": { "ranker": ["mdn", "wiki", "blog"] }
47+
* "value": { "ranker": { "rank": ["mdn", "wiki", "blog"] } }
4548
* }
4649
* @example
4750
* <!-- Example of using Buckets with Ranker tag -->
@@ -86,9 +89,16 @@ const Model = types
8689
get buckets() {
8790
return Tree.filterChildrenOfType(self, 'BucketModel');
8891
},
89-
/** @returns {string | undefined} */
92+
/**
93+
* rank mode: tag's name
94+
* pick mode: undefined
95+
* group mode: name of the Bucket with default=true
96+
* @returns {string | undefined}
97+
*/
9098
get defaultBucket() {
91-
return self.buckets.find(b => b.default)?.name;
99+
return self.buckets.length > 0
100+
? self.buckets.find(b => b.default)?.name
101+
: self.name;
92102
},
93103
get rankOnly() {
94104
return !self.buckets.length;
@@ -100,7 +110,7 @@ const Model = types
100110

101111
const columns = self.buckets.map(b => ({ id: b.name, title: b.title ?? '' }));
102112

103-
if (!self.defaultBucket) columns.unshift({ id: '_', title: self.list.title });
113+
if (!self.defaultBucket) columns.unshift({ id: ORIGINAL_ITEMS_KEY, title: self.list.title });
104114

105115
return columns;
106116
},
@@ -112,29 +122,35 @@ const Model = types
112122
const ids = Object.keys(items);
113123
const columns = self.columns;
114124
/** @type {Record<string, string[]>} */
125+
const columnStubs = Object.fromEntries(self.columns.map(c => [c.id, []]));
126+
/** @type {Record<string, string[]>} */
115127
const result = self.result?.value.ranker;
116128
let itemIds = {};
117129

118130
if (!data) return [];
119-
// one array of items sitting in List tag, just reorder them if result is given
120-
if (self.rankOnly) {
121-
//
122-
itemIds = { [self.name]: result ? [...result] : ids };
123-
} else if (!result) {
124-
itemIds = { [self.defaultBucket ?? '_']: ids };
131+
if (!result) {
132+
itemIds = { ...columnStubs, [self.defaultBucket ?? ORIGINAL_ITEMS_KEY]: ids };
125133
} else {
126-
itemIds = { ...result };
127-
128-
// original list is shown, but there are no such column in result,
129-
// so create it from results not groupped into buckets
130-
if (!self.defaultBucket && !result['_']) {
131-
const selected = Object.values(result).flat();
134+
itemIds = { ...columnStubs, ...result };
135+
136+
// original list is displayed, but there are no such column in result,
137+
// so create it from results not groupped into buckets;
138+
// also if there are unknown columns in result they'll go there too.
139+
if (!self.defaultBucket) {
140+
const columnNames = self.columns.map(c => c.id);
141+
// all items in known columns, including original list (_)
142+
const selected = Object.entries(result)
143+
.filter(([key]) => columnNames.includes(key))
144+
.map(([_, values]) => values)
145+
.flat();
146+
// all undistributed items or items from unknown columns
132147
const left = ids.filter(id => !selected.includes(id));
133148

134-
itemIds['_'] = left;
149+
if (left.length) {
150+
// there are might be already some items in result
151+
itemIds[ORIGINAL_ITEMS_KEY] = [...(itemIds[ORIGINAL_ITEMS_KEY] ?? []), ...left];
152+
}
135153
}
136-
// @todo what if there are items in data that are not presented in result?
137-
// @todo they must likely should go into _ bucket as well
138154
}
139155

140156
return { items, columns, itemIds };
@@ -149,10 +165,6 @@ const Model = types
149165
},
150166

151167
updateResult(newData) {
152-
if (self.rankOnly) {
153-
newData = newData[self.name];
154-
}
155-
156168
// check if result exists already, since only one instance of it can exist at a time
157169
if (self.result) {
158170
self.result.setValue(newData);
@@ -169,12 +181,13 @@ const Model = types
169181
if (self.result) return;
170182

171183
const ids = Object.keys(self.list?.items);
184+
// empty array for every column
185+
const data = Object.fromEntries(self.columns.map(c => [c.id, []]));
172186

173-
if (self.rankOnly) {
174-
self.createResult(ids);
175-
} else if (self.defaultBucket) {
176-
self.createResult({ [self.defaultBucket]: ids });
177-
}
187+
// List items should also be stored at the beginning for consistency, we add them to result
188+
data[self.defaultBucket ?? ORIGINAL_ITEMS_KEY] = ids;
189+
190+
self.createResult(data);
178191
},
179192
}));
180193

@@ -184,7 +197,6 @@ const HtxRanker = inject('store')(
184197
observer(({ item }) => {
185198
const data = item.dataSource;
186199

187-
188200
if (!data) return null;
189201

190202
return (
@@ -217,4 +229,4 @@ Registry.addTag('ranker', RankerModel, HtxRanker);
217229
Registry.addTag('bucket', BucketModel, HtxBucket);
218230
Registry.addObjectType(RankerModel);
219231

220-
export { HtxRanker, RankerModel };
232+
export { BucketModel, HtxRanker, RankerModel };
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { types } from 'mobx-state-tree';
2+
3+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
4+
// @ts-ignore
5+
import { ListModel } from '../../object/List';
6+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
7+
// @ts-ignore
8+
import { RankerModel } from '../Ranker';
9+
10+
const MockAnnotationStore = types.model('Annotation', {
11+
names: types.map(types.union(RankerModel, ListModel)),
12+
}).volatile(() => ({
13+
results: [] as any[],
14+
}));
15+
16+
const MockStore = types
17+
.model({
18+
annotationStore: types.model({
19+
selected: MockAnnotationStore,
20+
}),
21+
})
22+
.volatile(() => ({
23+
task: { dataObj: {} },
24+
}));
25+
26+
const items = [
27+
{ 'id': 'item1', 'title': '111' },
28+
{ 'id': 'item2', 'title': '222' },
29+
{ 'id': 'item3', 'title': '333' },
30+
];
31+
32+
describe('List + Ranker (rank mode)', () => {
33+
const list = ListModel.create({ name: 'list', value: '$items', title: 'Test List' });
34+
const ranker = RankerModel.create({ name: 'rank', toname: 'list' });
35+
const store = MockStore.create({ annotationStore: { selected: { names: { list, ranker } } } });
36+
37+
store.task.dataObj = { items };
38+
list.updateValue(store);
39+
40+
it('List and Ranker should get values from the task', () => {
41+
expect(list._value).toEqual(items);
42+
expect(Object.keys(ranker.list.items)).toEqual(['item1', 'item2', 'item3']);
43+
});
44+
45+
it('Ranker should have proper columns and other getters', () => {
46+
expect(ranker.buckets).toEqual([]);
47+
expect(ranker.defaultBucket).toEqual('rank');
48+
expect(ranker.rankOnly).toEqual(true);
49+
expect(ranker.columns).toEqual([{ id: 'rank', title: 'Test List' }]);
50+
});
51+
});
52+
53+
describe('List + Ranker + Buckets (pick mode)', () => {
54+
const list = ListModel.create({ name: 'list', value: '$items', title: 'Test List' });
55+
const ranker = RankerModel.create({ name: 'rank', toname: 'list', children: [
56+
{ id: 'B1', type: 'bucket', name: 'B1', title: 'Bucket 1' },
57+
{ id: 'B2', type: 'bucket', name: 'B2', title: 'Bucket 2' },
58+
] });
59+
const store = MockStore.create({ annotationStore: { selected: { names: { list, ranker } } } });
60+
61+
store.task.dataObj = { items };
62+
list.updateValue(store);
63+
64+
const columns = [
65+
{ id: '_', title: 'Test List' },
66+
{ id: 'B1', title: 'Bucket 1' },
67+
{ id: 'B2', title: 'Bucket 2' },
68+
];
69+
const result: any = {
70+
from_name: ranker,
71+
to_name: list,
72+
type: 'ranker',
73+
value: { ranker: { B1: ['item2'] } },
74+
};
75+
76+
it('List and Ranker should get values from the task', () => {
77+
expect(list._value).toEqual(items);
78+
expect(Object.keys(ranker.list.items)).toEqual(['item1', 'item2', 'item3']);
79+
});
80+
81+
it('Ranker should have proper columns and other getters', () => {
82+
expect(ranker.buckets.map((b: any) => b.name)).toEqual(['B1', 'B2']);
83+
expect(ranker.defaultBucket).toEqual(undefined);
84+
expect(ranker.rankOnly).toEqual(false);
85+
expect(ranker.columns).toEqual(columns);
86+
});
87+
88+
it('Ranker puts all items into _ bucket if there is no result', () => {
89+
expect(ranker.dataSource).toEqual({
90+
items: ranker.list.items,
91+
columns,
92+
itemIds: { _: ['item1', 'item2', 'item3'], B1: [], B2: [] },
93+
});
94+
});
95+
96+
it('Ranker returns items according to result and puts the rest to _ bucket', () => {
97+
store.annotationStore.selected.results.push(result);
98+
99+
expect(ranker.result).toBeTruthy();
100+
expect(ranker.dataSource).toEqual({
101+
items: ranker.list.items,
102+
columns,
103+
itemIds: { B1: ['item2'], B2: [], _: ['item1', 'item3'] },
104+
});
105+
});
106+
});
107+
108+
describe('List + Ranker + Buckets + default (group mode)', () => {
109+
const list = ListModel.create({ name: 'list', value: '$items', title: 'Test List' });
110+
const ranker = RankerModel.create({ name: 'rank', toname: 'list', children: [
111+
{ id: 'B1', type: 'bucket', name: 'B1', title: 'Bucket 1' },
112+
{ id: 'B2', type: 'bucket', name: 'B2', title: 'Bucket 2', default: true },
113+
] });
114+
const store = MockStore.create({ annotationStore: { selected: { names: { list, ranker } } } });
115+
116+
store.task.dataObj = { items };
117+
list.updateValue(store);
118+
119+
const columns = [
120+
{ id: 'B1', title: 'Bucket 1' },
121+
{ id: 'B2', title: 'Bucket 2' },
122+
];
123+
const result: any = {
124+
from_name: ranker,
125+
to_name: list,
126+
type: 'ranker',
127+
value: { ranker: { B1: ['item2'], B2: ['item1', 'item3'] } },
128+
};
129+
130+
it('List and Ranker should get values from the task', () => {
131+
expect(list._value).toEqual(items);
132+
expect(Object.keys(ranker.list.items)).toEqual(['item1', 'item2', 'item3']);
133+
});
134+
135+
it('Ranker should have proper columns and other getters', () => {
136+
expect(ranker.buckets.map((b: any) => b.name)).toEqual(['B1', 'B2']);
137+
expect(ranker.defaultBucket).toEqual('B2');
138+
expect(ranker.rankOnly).toEqual(false);
139+
expect(ranker.columns).toEqual(columns);
140+
});
141+
142+
it('Ranker puts all items into default bucket if there is no result', () => {
143+
expect(ranker.dataSource).toEqual({
144+
items: ranker.list.items,
145+
columns,
146+
itemIds: { B1: [], B2: ['item1', 'item2', 'item3'] },
147+
});
148+
});
149+
150+
it('Ranker returns items according to result', () => {
151+
store.annotationStore.selected.results.push(result);
152+
153+
expect(ranker.result).toBeTruthy();
154+
expect(ranker.dataSource).toEqual({
155+
items: ranker.list.items,
156+
columns,
157+
itemIds: { B1: ['item2'], B2: ['item1', 'item3'] },
158+
});
159+
});
160+
});

src/tags/object/List.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import Base from './Base';
4141
*/
4242
const Model = types
4343
.model({
44-
type: 'ranker',
44+
type: 'list',
4545
value: types.maybeNull(types.string),
4646
_value: types.frozen([]),
4747
title: types.optional(types.string, ''),

0 commit comments

Comments
 (0)