· 4 years ago · Aug 18, 2021, 08:56 AM
1import { action, computed, makeObservable, observable } from 'mobx';
2import { Merge } from 'type-fest';
3
4import { ApiUtils } from './ApiUtils';
5import { ArrayHelper } from './ArrayHelper';
6import { ListReloader } from './ListReloader';
7import { Item, ItemId, StoreHelper } from './StoreHelper';
8
9export type BasicListOptions<Item> = {
10 DeserializedItem: Item;
11 SerializedItem: Item;
12 DataForCreate: Item;
13 DataForUpdate: Item;
14 ItemExtended: Item;
15 DataForRemove: ItemId<Item>;
16 DataForLoadItem: ItemId<Item>;
17 ID: ItemId<Item>;
18};
19export abstract class BasicListStore<
20 DeserializedItem extends Item,
21 ArgOptions extends Partial<
22 BasicListOptions<any>
23 > = BasicListOptions<DeserializedItem>,
24 Options extends ArgOptions = Merge<
25 BasicListOptions<DeserializedItem>,
26 ArgOptions
27 >,
28> {
29 protected refreshListAfterAction = true;
30 protected refreshListAfterCreation = true;
31
32 protected idKey: keyof Options['DeserializedItem'] = 'id';
33
34 @observable protected _list: Options['ItemExtended'][] = [];
35
36 @observable protected _isLoaded = false;
37 @observable protected _isLoading = false;
38 @observable protected _hasError = false;
39
40 @computed get list() {
41 return [...this._list]; // клонируем чтоб не работал list.push(item)
42 }
43
44 @computed get isLoading() {
45 return this._isLoading;
46 }
47
48 @computed get isLoaded() {
49 return this._isLoaded;
50 }
51
52 @computed get hasError() {
53 return this._hasError;
54 }
55
56 @computed get loadingStatus() {
57 return {
58 isLoading: this.isLoading,
59 isLoaded: this.isLoaded,
60 hasError: this.hasError,
61 };
62 }
63
64 @computed get moduleState() {
65 return {
66 ...this.loadingStatus,
67 list: this.list,
68 };
69 }
70
71 protected listReloader: ListReloader | null = null;
72
73 // todo: fix type later
74 protected abstract api: Partial<{
75 loadList: any;
76 createItem: any;
77 updateItem: any;
78 removeItem: any;
79 loadItem: any;
80 }>;
81
82 constructor() {
83 this._removeItems = StoreHelper.multiplyAction((id: Options['ID']) =>
84 this.removeItem(id, { needCallLoadList: false }),
85 );
86
87 makeObservable && makeObservable(this);
88 }
89
90 @action.bound async loadList({
91 listDataKey,
92 linksDataKey,
93 ...params
94 }: {
95 listDataKey?: any;
96 linksDataKey?: any;
97 [key: string]: unknown;
98 } = {}) {
99 if (!this.isLoaded) {
100 this._isLoading = true;
101 this._hasError = false;
102 }
103
104 try {
105 const items = await ApiUtils.loadFullList({
106 loadFn: this.api.loadList,
107 listDataKey,
108 linksDataKey,
109 params,
110 });
111 this._list = this.handleList(
112 // @ts-ignore
113 items.map((item) =>
114 this.handleItem(this.deserializeListItem(item)),
115 ),
116 );
117 this._isLoaded = true;
118 this._isLoading = false;
119 this._hasError = false;
120 return this.list;
121 } catch (error) {
122 this._hasError = !this.isLoaded;
123 this._isLoading = false;
124 throw error;
125 }
126 }
127
128 @action.bound loadListIfNotLoaded() {
129 if (!this.isLoaded) {
130 return this.loadList();
131 }
132 }
133
134 @action.bound async createItem(data: Options['DataForCreate']) {
135 const item = this.deserializeDetailedItem(
136 await this.api.createItem(this.serializeDetailedItem(data)),
137 );
138
139 if (this.refreshListAfterCreation) {
140 await this.loadList();
141 } else if (item) {
142 this._list.push(this.handleItem(item));
143 }
144
145 return this.handleItem(item);
146 }
147
148 @action.bound mergeModuleState({
149 isLoading,
150 hasError,
151 isLoaded,
152 list,
153 }: {
154 isLoading?: boolean;
155 isLoaded?: boolean;
156 hasError?: boolean;
157 list?: Options['ItemExtended'][];
158 }) {
159 this._isLoading = isLoading ?? this._isLoading;
160 this._isLoaded = isLoaded ?? this._isLoaded;
161 this._hasError = hasError ?? this._hasError;
162 this._list = list ?? this._list;
163 }
164
165 @action.bound addConsumer() {
166 this.addListReloaderIfHasnt();
167 return this.listReloader!.addConsumerNoRedux();
168 }
169
170 @action.bound removeConsumer() {
171 this.addListReloaderIfHasnt();
172 this.listReloader!.removeConsumerNoRedux();
173 }
174
175 @action.bound addListReloaderIfHasnt() {
176 if (!this.listReloader) {
177 this.listReloader = new ListReloader({
178 loadList: this.loadList,
179 noRedux: true,
180 });
181 }
182 }
183
184 // @ts-ignore (7022) FIXME: '_removeItems' implicitly has type 'any' because i... Remove this comment to see the full error message
185 @action.bound _removeItems;
186
187 @action.bound async updateItem(
188 id: Options['ID'],
189 data: Options['DataForUpdate'],
190 ) {
191 const item = this.deserializeDetailedItem(
192 await this.api.updateItem(id, this.serializeListItem(data)),
193 );
194 if (this.refreshListAfterAction) {
195 this.loadList();
196 } else if (item) {
197 this._list = ArrayHelper.getListWithInsertedItem({
198 item,
199 list: this._list,
200 idKey: this.idKey,
201 });
202 }
203
204 return item;
205 }
206
207 @action.bound async removeItem(
208 id: Options['DataForRemove'],
209 { needCallLoadList = true }: { needCallLoadList: boolean } = {
210 needCallLoadList: true,
211 },
212 ): Promise<Options['DeserializedItem'] | void> {
213 const removedItem: Options['SerializedItem'] =
214 await this.api.removeItem(id);
215
216 if (needCallLoadList) {
217 await this.loadList();
218 }
219
220 return removedItem && this.deserializeListItem(removedItem);
221 }
222
223 @action.bound async removeItems(ids: Options['DataForRemove'][]) {
224 let error: any = {};
225 let result: any = null;
226 try {
227 result = await this._removeItems(ids);
228 } catch (err) {
229 error = err;
230 if (!err.failedIds) {
231 console.error(err);
232 }
233 }
234
235 const removedIds = error.removedIds || ids;
236
237 if (removedIds.length) {
238 if (this.refreshListAfterAction) {
239 this.loadList();
240 } else {
241 this._list = this._list.filter(
242 (item) => !removedIds.includes(item[this.idKey]),
243 );
244 }
245 }
246
247 if (error.failedIds) {
248 throw error;
249 }
250
251 return result;
252 }
253
254 @action.bound async loadItem(
255 id: Options['DataForLoadItem'],
256 { dontInsertToList = false }: { dontInsertToList?: boolean } = {
257 dontInsertToList: false,
258 },
259 ) {
260 const newItem = this.handleItem(
261 this.deserializeDetailedItem(await this.api.loadItem(id)),
262 );
263
264 if (!dontInsertToList) {
265 this._list = ArrayHelper.getListWithInsertedItem({
266 idKey: this.idKey as string | number,
267 list: this._list,
268 item: newItem,
269 });
270 }
271
272 return newItem;
273 }
274
275 protected handleItem(item: DeserializedItem): Options['ItemExtended'] {
276 return item as unknown as Options['ItemExtended'];
277 }
278
279 protected handleList(
280 list: Options['ItemExtended'][],
281 ): Options['ItemExtended'][] {
282 return list;
283 }
284
285 getItem(id: Options['ID']) {
286 return StoreHelper.getItem(this.list, id, this.idKey);
287 }
288
289 getItemSurely(id: Options['ID']) {
290 return StoreHelper.getItemSurely(this.list, id, this.idKey);
291 }
292
293 getItems = (ids: Item['id'][]) => {
294 return this.list.filter((item) => ids.includes(item[this.idKey]));
295 };
296
297 itemNameGetter = (item: Item) => {
298 return (item as any).name;
299 };
300
301 deserializeListItem = (item: any) => {
302 return item as DeserializedItem;
303 };
304
305 serializeListItem = (item: any) => {
306 return item as Options['SerializedItem'];
307 };
308
309 deserializeDetailedItem = (item: any) => {
310 return this.deserializeListItem(item) as DeserializedItem;
311 };
312
313 serializeDetailedItem = (item: any) => {
314 return this.serializeListItem(item) as Options['SerializedItem'];
315 };
316}
317