/
HubInitiatives.ts
489 lines (455 loc) · 14.8 KB
/
HubInitiatives.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
import { IUserRequestOptions } from "@esri/arcgis-rest-auth";
// Note - we separate these imports so we can cleanly spy on things in tests
import {
createModel,
fetchModelFromItem,
getModel,
updateModel,
} from "../models";
import {
constructSlug,
getItemBySlug,
getUniqueSlug,
setSlugKeyword,
} from "../items/slugs";
import {
isGuid,
cloneObject,
unique,
mapBy,
getProp,
getFamily,
IHubRequestOptions,
setDiscussableKeyword,
IModel,
IHubInitiativeEditor,
camelize,
} from "../index";
import { IQuery } from "../search/types/IHubCatalog";
import {
IItem,
IUserItemOptions,
removeItem,
getItem,
updateGroup,
} from "@esri/arcgis-rest-portal";
import { IRequestOptions } from "@esri/arcgis-rest-request";
import { PropertyMapper } from "../core/_internal/PropertyMapper";
import { IEntityInfo, IHubInitiative } from "../core/types";
import { IHubSearchResult } from "../search";
import { parseInclude } from "../search/_internal/parseInclude";
import { fetchItemEnrichments } from "../items/_enrichments";
import { DEFAULT_INITIATIVE, DEFAULT_INITIATIVE_MODEL } from "./defaults";
import { getPropertyMap } from "./_internal/getPropertyMap";
import { computeProps } from "./_internal/computeProps";
import { applyInitiativeMigrations } from "./_internal/applyInitiativeMigrations";
import { combineQueries } from "../search/_internal/combineQueries";
import { portalSearchItemsAsItems } from "../search/_internal/portalSearchItems";
import { getTypeWithKeywordQuery } from "../associations/internal/getTypeWithKeywordQuery";
import { negateGroupPredicates } from "../search/_internal/negateGroupPredicates";
import { computeLinks } from "./_internal/computeLinks";
import {
deriveLocationFromItem,
getHubRelativeUrl,
} from "../content/_internal/internalContentUtils";
import { setEntityStatusKeyword } from "../utils/internal/setEntityStatusKeyword";
import { editorToMetric } from "../core/schemas/internal/metrics/editorToMetric";
import { setMetricAndDisplay } from "../core/schemas/internal/metrics/setMetricAndDisplay";
import { createId } from "../util";
import { IArcGISContext } from "../ArcGISContext";
import { convertHubGroupToGroup } from "../groups/_internal/convertHubGroupToGroup";
import { IHubGroup } from "../core/types/IHubGroup";
/**
* @private
* Create a new Hub Initiative item
*
* Minimal properties are name and orgUrlKey
*
* @param partialInitiative
* @param requestOptions
*/
export async function createInitiative(
partialInitiative: Partial<IHubInitiative>,
requestOptions: IUserRequestOptions
): Promise<IHubInitiative> {
// merge incoming with the default
// this expansion solves the typing somehow
const initiative = { ...DEFAULT_INITIATIVE, ...partialInitiative };
// Create a slug from the title if one is not passed in
if (!initiative.slug) {
initiative.slug = constructSlug(initiative.name, initiative.orgUrlKey);
}
// Ensure slug is unique
initiative.slug = await getUniqueSlug(
{ slug: initiative.slug },
requestOptions
);
// add slug to keywords
initiative.typeKeywords = setSlugKeyword(
initiative.typeKeywords,
initiative.slug
);
// add the status keyword
initiative.typeKeywords = setEntityStatusKeyword(
initiative.typeKeywords,
initiative.status
);
initiative.typeKeywords = setDiscussableKeyword(
initiative.typeKeywords,
initiative.isDiscussable
);
// Map initiative object onto a default initiative Model
const mapper = new PropertyMapper<Partial<IHubInitiative>, IModel>(
getPropertyMap()
);
// create model from object, using the default model as a starting point
let model = mapper.entityToStore(
initiative,
cloneObject(DEFAULT_INITIATIVE_MODEL)
);
// create the item
model = await createModel(model, requestOptions);
// map the model back into a IHubInitiative
let newInitiative = mapper.storeToEntity(model, {});
newInitiative = computeProps(model, newInitiative, requestOptions);
// and return it
return newInitiative as IHubInitiative;
}
/**
* Convert a IHubInitiativeEditor back to an IHubInitiative
* @param editor
* @param portal
* @returns
*/
export async function editorToInitiative(
editor: IHubInitiativeEditor,
context: IArcGISContext
): Promise<IHubInitiative> {
const _metric = editor._metric;
const _associations = editor._associations;
// 1. remove the ephemeral props we graft onto the editor
delete editor._groups;
delete editor._thumbnail;
delete editor.view?.featuredImage;
delete editor._metric;
delete editor._groups;
delete editor._associations;
// 2. clone into a HubInitiative and ensure there's an orgUrlKey
let initiative = cloneObject(editor) as IHubInitiative;
initiative.orgUrlKey = editor.orgUrlKey
? editor.orgUrlKey
: context.portal.urlKey;
// 3. copy the location extent up one level
initiative.extent = editor.location?.extent;
// 4. handle configured metric:
// a. transform editor values into metric + displayConfig
// b. set metric and displayConfig on initiative
if (_metric && Object.keys(_metric).length) {
const metricId =
_metric.metricId || createId(camelize(`${_metric.cardTitle}_`));
const { metric, displayConfig } = editorToMetric(_metric, metricId, {
metricName: _metric.cardTitle,
});
initiative = setMetricAndDisplay(initiative, metric, displayConfig);
}
// 5. handle association group settings
const assocGroupId = initiative.associations?.groupId;
if (assocGroupId && _associations) {
const associationGroup = convertHubGroupToGroup(_associations as IHubGroup);
// handle group access
if (_associations.groupAccess) {
await updateGroup({
group: {
id: assocGroupId,
access: _associations.groupAccess,
},
authentication: context.hubRequestOptions.authentication,
});
}
// handle membership access
if (_associations.membershipAccess) {
await updateGroup({
group: {
id: assocGroupId,
membershipAccess: associationGroup.membershipAccess,
clearEmptyFields: true,
},
authentication: context.hubRequestOptions.authentication,
});
}
}
return initiative;
}
/**
* @private
* Update a Hub Initiative
* @param initiative
* @param requestOptions
*/
export async function updateInitiative(
initiative: IHubInitiative,
requestOptions: IUserRequestOptions
): Promise<IHubInitiative> {
// verify that the slug is unique, excluding the current initiative
initiative.slug = await getUniqueSlug(
{ slug: initiative.slug, existingId: initiative.id },
requestOptions
);
// update the status keyword
initiative.typeKeywords = setEntityStatusKeyword(
initiative.typeKeywords,
initiative.status
);
initiative.typeKeywords = setDiscussableKeyword(
initiative.typeKeywords,
initiative.isDiscussable
);
// get the backing item & data
const model = await getModel(initiative.id, requestOptions);
// create the PropertyMapper
const mapper = new PropertyMapper<Partial<IHubInitiative>, IModel>(
getPropertyMap()
);
// Note: Although we are fetching the model, and applying changes onto it,
// we are not attempting to handle "concurrent edit" conflict resolution
// but this is where we would apply that sort of logic
const modelToUpdate = mapper.entityToStore(initiative, model);
// update the backing item
const updatedModel = await updateModel(modelToUpdate, requestOptions);
// now map back into an initiative and return that
let updatedInitiative = mapper.storeToEntity(updatedModel, initiative);
updatedInitiative = computeProps(model, updatedInitiative, requestOptions);
// the casting is needed because modelToObject returns a `Partial<T>`
// where as this function returns a `T`
return updatedInitiative as IHubInitiative;
}
/**
* @private
* Get a Hub Initiative by id or slug
* @param identifier item id or slug
* @param requestOptions
*/
export function fetchInitiative(
identifier: string,
requestOptions: IRequestOptions
): Promise<IHubInitiative> {
let getPrms;
if (isGuid(identifier)) {
// get item by id
getPrms = getItem(identifier, requestOptions);
} else {
getPrms = getItemBySlug(identifier, requestOptions);
}
return getPrms.then((item) => {
if (!item) return null;
return convertItemToInitiative(item, requestOptions);
});
}
/**
* @private
* Remove a Hub Initiative
* @param id
* @param requestOptions
*/
export async function deleteInitiative(
id: string,
requestOptions: IUserRequestOptions
): Promise<void> {
const ro = { ...requestOptions, ...{ id } } as IUserItemOptions;
await removeItem(ro);
return;
}
/**
* @private
* Convert an Hub Initiative Item into a Hub Initiative, fetching any additional
* information that may be required
* @param item
* @param auth
* @returns
*/
export async function convertItemToInitiative(
item: IItem,
requestOptions: IRequestOptions
): Promise<IHubInitiative> {
let model = await fetchModelFromItem(item, requestOptions);
// apply migrations
model = await applyInitiativeMigrations(model, requestOptions);
const mapper = new PropertyMapper<Partial<IHubInitiative>, IModel>(
getPropertyMap()
);
const prj = mapper.storeToEntity(model, {}) as IHubInitiative;
return computeProps(model, prj, requestOptions);
}
/**
* @private
* Fetch Initiative specific enrichments
* @param item
* @param include
* @param requestOptions
* @returns
*/
export async function enrichInitiativeSearchResult(
item: IItem,
include: string[],
requestOptions: IHubRequestOptions
): Promise<IHubSearchResult> {
// Create the basic structure
const result: IHubSearchResult = {
access: item.access,
id: item.id,
type: item.type,
name: item.title,
owner: item.owner,
summary: item.snippet || item.description,
createdDate: new Date(item.created),
createdDateSource: "item.created",
updatedDate: new Date(item.modified),
updatedDateSource: "item.modified",
family: getFamily(item.type),
links: {
self: "not-implemented",
siteRelative: "not-implemented",
thumbnail: "not-implemented",
workspaceRelative: "not-implemented",
},
location: deriveLocationFromItem(item),
rawResult: item,
};
// default includes
const DEFAULTS: string[] = [];
// merge includes
include = [...DEFAULTS, ...include].filter(unique);
// Parse the includes into a valid set of enrichments
const specs = include.map(parseInclude);
// Extract out the low-level enrichments needed
const enrichments = mapBy("enrichment", specs).filter(unique);
// fetch the enrichments
let enriched = {};
if (enrichments.length) {
// TODO: Look into caching for the requests in fetchItemEnrichments
enriched = await fetchItemEnrichments(item, enrichments, requestOptions);
}
// map the enriched props onto the result
specs.forEach((spec) => {
result[spec.prop] = getProp(enriched, spec.path);
});
// Handle links
// TODO: Link handling should be an enrichment
result.links = computeLinks(item, requestOptions);
return result;
}
/**
* ** DEPRECATED: Please use the association methods directly.
* This will be removed in the next breaking version **
*
* Fetch the Projects that are "Accepted" with an Initiative.
* This is a subset of the "Associated" projects, limited
* to those included in the Initiative's Catalog.
* @param initiative
* @param requestOptions
* @param query: Optional `IQuery` to further filter the results
* @returns
*/
export async function fetchAcceptedProjects(
initiative: IHubInitiative,
requestOptions: IHubRequestOptions,
query?: IQuery
): Promise<IEntityInfo[]> {
let projectQuery = getAcceptedProjectsQuery(initiative);
// combineQueries will purge undefined/null entries
projectQuery = combineQueries([projectQuery, query]);
return queryAsEntityInfo(projectQuery, requestOptions);
}
/**
* ** DEPRECATED: Please use the association methods directly.
* This will be removed in the next breaking version **
*
* Fetch the Projects that are "Associated" to the Initiative but are not
* "Accepted", meaning they have the keyword but are not included in the Initiative's Catalog.
* This is how we can get the list of Projects awaiting Acceptance
* @param initiative
* @param requestOptions
* @param query
* @returns
*/
export async function fetchPendingProjects(
initiative: IHubInitiative,
requestOptions: IHubRequestOptions,
query?: IQuery
): Promise<IEntityInfo[]> {
let projectQuery = getPendingProjectsQuery(initiative);
// combineQueries will purge undefined/null entries
projectQuery = combineQueries([projectQuery, query]);
return queryAsEntityInfo(projectQuery, requestOptions);
}
/**
* ** DEPRECATED: This will be removed in the next
* breaking version **
*
* Execute the query and convert into EntityInfo objects
* @param query
* @param requestOptions
* @returns
*/
async function queryAsEntityInfo(
query: IQuery,
requestOptions: IHubRequestOptions
) {
const response = await portalSearchItemsAsItems(query, {
requestOptions,
num: 100,
});
return response.results.map((item) => {
return {
id: item.id,
name: item.title,
type: item.type,
} as IEntityInfo;
});
}
/**
* ** DEPRECATED: Please use the association methods directly.
* This will be removed in the next breaking version **
*
* Associated projects are those with the Initiative id in the typekeywords
* and is included in the Initiative's catalog.
* This is passed into the Gallery showing "Approved Projects"
* @param initiative
* @returns
*/
export function getAcceptedProjectsQuery(initiative: IHubInitiative): IQuery {
// get query that returns Hub Projects with the initiative keyword
let query = getTypeWithKeywordQuery(
"Hub Project",
`initiative|${initiative.id}`
);
// Get the item scope from the catalog
const qry = getProp(initiative, "catalog.scopes.item");
// combineQueries will remove null/undefined entries
query = combineQueries([query, qry]);
return query;
}
/**
* ** DEPRECATED: Please use the association methods directly.
* This will be removed in the next breaking version **
*
* Related Projects are those that have the Initiative id in the
* typekeywords but NOT in the catalog. We use this query to show
* Projects which want to be associated but are not yet included in
* the catalog
* This is passed into the Gallery showing "Pending Projects"
* @param initiative
* @returns
*/
export function getPendingProjectsQuery(initiative: IHubInitiative): IQuery {
// get query that returns Hub Projects with the initiative keyword
let query = getTypeWithKeywordQuery(
"Hub Project",
`initiative|${initiative.id}`
);
// The the item scope from the catalog...
const qry = getProp(initiative, "catalog.scopes.item");
// negate the scope, combine that with the base query
query = combineQueries([query, negateGroupPredicates(qry)]);
return query;
}