Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: EntityEditor class #1127

Merged
merged 9 commits into from
Jul 24, 2023
51 changes: 51 additions & 0 deletions packages/common/src/core/EntityEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { IArcGISContext } from "../ArcGISContext";
import { HubInitiative } from "../initiatives/HubInitiative";
import { HubProject } from "../projects/HubProject";
import { HubSite } from "../sites/HubSite";
import { IEditorConfig, IWithEditorBehavior } from "./behaviors";
import { getTypeFromEntity } from "./getTypeFromEntity";
import { EditorType, UiSchemaElementOptions } from "./schemas";
import { HubEntity } from "./types/HubEntity";
import { HubEntityEditor, IEntityEditorContext } from "./types/HubEntityEditor";

export class EntityEditor {
instance: IWithEditorBehavior;

private constructor(instance: IWithEditorBehavior) {
this.instance = instance;
}

static fromEntity(entity: HubEntity, context: IArcGISContext): EntityEditor {
const entityType = getTypeFromEntity(entity);
// Create the instance and cast to EntityEditor
let editor: IWithEditorBehavior;
if (entityType === "project") {
editor = HubProject.fromJson(entity, context) as IWithEditorBehavior;
}
// TODO: Uncomment as we support more entity types
// if (entityType === "initiative") {
// editor = HubInitiative.fromJson(entity, context) as EntityEditor;
// }
// if (entity.type === "site") {
// editor = HubSite.fromJson(entity, context) as EntityEditor;
// }
if (editor) {
return new EntityEditor(editor);
} else {
throw new Error(`Unsupported entity type: ${entity.type}`);
}
}

async getConfig(i18nScope: string, type: EditorType): Promise<IEditorConfig> {
return this.instance.getEditorConfig(i18nScope, type);
}

toEditor(editorContext: IEntityEditorContext = {}): HubEntityEditor {
// This is ugly but it's the only way to get the type to be correct
return this.instance.toEditor(editorContext) as unknown as HubEntityEditor;
}

async save(editor: HubEntityEditor): Promise<HubEntity> {
return this.instance.fromEditor(editor);
}
}
22 changes: 22 additions & 0 deletions packages/common/src/core/HubItemEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,31 @@ export abstract class HubItemEntity<T extends IHubItemEntity>
* @param groupId
*/
async shareWithGroup(groupId: string): Promise<void> {
if (!this.context.currentUser) {
throw new HubError(
"Share Item With Group",
"Cannot share item with group when no user is logged in."
);
}
// Group should be in current user's group collection,
// but we do not enforce that because they could have
// joined a group since the context was creatd.
// Get the group from user to check if it's an update group
// NOTE: If this becomes a problem, we can simply fetch the group
// and check user membership as well as capabilities.
const group = this.context.currentUser.groups?.find(
(g) => g.id === groupId
);

// and see if it's an edit group b/c we need to send an additional param
const isEditGroup = (group?.capabilities || []).includes(
"updateitemcontrol"
);

await shareItemWithGroup({
id: this.entity.id,
groupId,
confirmItemControl: isEditGroup,
owner: this.entity.owner,
authentication: this.context.session,
});
Expand Down
37 changes: 24 additions & 13 deletions packages/common/src/core/behaviors/IWithEditorBehavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,37 @@ import {
UiSchemaElementOptions,
EditorType,
} from "../schemas";

/**
* DEPRECATED: the following will be removed at next breaking version
* we will be shifting towards the EditorType defined in the schemas
* directory
*/
export type EditorConfigType = "create" | "edit";
import { HubEntity, HubEntityEditor, IEntityEditorContext } from "../types";

export interface IEditorConfig {
schema: IConfigurationSchema;
uiSchema: IUiSchema;
}

/**
*
* Functions that are used by the arcgis-hub-entity-editor component
*/
export interface IWithEditorBehavior {
getEditorConfig(
i18nScope: string,
type: EditorType,
options: UiSchemaElementOptions[]
): Promise<IEditorConfig>;
/**
* Get the Entity's ui and schema for the editor
* @param i18nScope
* @param type
* @param options
*/
getEditorConfig(i18nScope: string, type: EditorType): Promise<IEditorConfig>;

/**
* Convert the entity into it's "Editor" structure.
* This should only be used by the arcgis-hub-entity-editor component.
* For general use, see the `toJson():<T>` method
*/
toEditor(editorContext: IEntityEditorContext): HubEntityEditor;

/**
* Update the internal Entity from the "Editor" structure.
* This should only be used by the arcgis-hub-entity-editor component.
* For general use, see the `update(Partial<T>)` method
* @param values
*/
fromEditor(editor: HubEntityEditor): Promise<HubEntity>;
}
1 change: 1 addition & 0 deletions packages/common/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./fetchHubEntity";
export * from "./getTypeFromEntity";
export * from "./getRelativeWorkspaceUrl";
export * from "./isValidEntityType";

// For sme reason, if updateHubEntity is exported here,
// it is not actually exported in the final package.
// export * from "./updateHubEntity";
11 changes: 10 additions & 1 deletion packages/common/src/core/schemas/getEntityEditorSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,16 @@ export const getEntityEditorSchemas = async (
// apply the options
uiSchema = applyUiSchemaElementOptions(uiSchema, options);
// interpolate the i18n scope into the uiSchema
uiSchema = interpolate(uiSchema, { i18nScope });
uiSchema = interpolate(
uiSchema,
{ i18nScope },
// We don't have a real i18n object here, so just return the key
{
translate(i18nKey: string) {
return `{{${i18nKey}:translate}}`;
},
}
);

return Promise.resolve({ schema, uiSchema });
};
41 changes: 41 additions & 0 deletions packages/common/src/core/schemas/internal/getCategoryItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { request } from "@esri/arcgis-rest-request";
import { IHubRequestOptions } from "../../../types";
import { HubEntity } from "../../types/HubEntity";
import { IUiSchemaComboboxItem } from "../types";

/**
* Fetch the categorySchema for all the categories, including
* the ones with 0 count, then recursively collet all the child
* categories on the deepest level. Parse into a format that
* can be consumed by the combobox field
*
* TODO: render nested categories in combobox
*/
export async function getCategoryItems(
orgId: string,
hubRequestOptions: IHubRequestOptions
): Promise<IUiSchemaComboboxItem[]> {
const url = `${hubRequestOptions.portal}/portals/${orgId}/categorySchema`;
try {
const { categorySchema } = await request(url, hubRequestOptions);
return parseCategories(categorySchema[0].categories, []);
} catch (e) {
return [];
}
}

function parseCategories(
categories: any[],
allCategories: any[]
): IUiSchemaComboboxItem[] {
categories.forEach((c) => {
if (!c.categories?.length) {
allCategories.push({
value: c.title,
});
} else {
parseCategories(c.categories, allCategories);
}
});
return allCategories;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { IUser } from "@esri/arcgis-rest-portal";
import {
WellKnownCatalog,
getWellKnownCatalog,
} from "../../../search/wellKnownCatalog";

/**
* Return a catalog structured for picking featured content.
* @param user
* @returns
*/
export function getFeaturedContentCatalogs(user: IUser) {
const catalogNames: WellKnownCatalog[] = [
"myContent",
"favorites",
"organization",
"world",
];
const catalogs = catalogNames.map((name: WellKnownCatalog) => {
const opts = { user };
const catalog = getWellKnownCatalog(
"shared.fields.featuredContent",
name,
"item",
opts
);
return catalog;
});

return { catalogs };
}
14 changes: 14 additions & 0 deletions packages/common/src/core/schemas/internal/getFeaturedImageUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { IArcGISContext } from "../../../ArcGISContext";
import { cacheBustUrl } from "../../../urls/cacheBustUrl";
import { HubEntity } from "../../types/HubEntity";

export function getFeaturedImageUrl(
entity: HubEntity,
context: IArcGISContext
) {
const queryParams = context.isAuthenticated
? `?token=${context.session.token}`
: "";
// TODO: Decide if the url should be passed in or plucked out of this deep path here
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good question, and generally speaking, I feel like it would be nice to make this util more generic/less featured-image-specific. I just ran into needing similar functionality with Aaron who's trying to hook up editing an item's thumbnail. The differences there are:

  1. the url lives at entity.thumbnailUrl
  2. entity.thumbnailUrl already has the token appended to it

So not entirely sure how to make this generic to work in both cases, but you could do something like:

getImageUrl (
  url: string,
  context?: IArcGISContext // make this optional if you want to append the token
) {
  const queryParams = context?.isAuthenticated
      ? `?token=${context.session.token}`
      : "";

  return cacheBustUrl(`${url}${queryParams}`);
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree - I'll make an issue to try to resolve this - I don't want to add additional concerns / refactors to this PR

return cacheBustUrl(`${entity.view.featuredImageUrl}${queryParams}`);
}
16 changes: 16 additions & 0 deletions packages/common/src/core/schemas/internal/getLocationExtent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { bBoxToExtent, getGeographicOrgExtent } from "../../../extent";
import { IHubRequestOptions } from "../../../types";
import { HubEntity } from "../../types/HubEntity";

/**
* Get the extent from the entity's location, if it has one.
* Otherwise, fall back to using the org extent.
*/
export async function getLocationExtent(
entity: HubEntity,
hubRequestOptions: IHubRequestOptions
) {
return entity.location?.extent?.length
? bBoxToExtent(entity.location.extent)
: await getGeographicOrgExtent(hubRequestOptions);
}
66 changes: 66 additions & 0 deletions packages/common/src/core/schemas/internal/getLocationOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { extentToBBox, getGeographicOrgExtent } from "../../../extent";
import { IHubRequestOptions } from "../../../types";
import { getTypeFromEntity } from "../../getTypeFromEntity";
import { HubEntity } from "../../types/HubEntity";
import { HubEntityType } from "../../types/HubEntityType";
import { IHubLocation, IHubLocationOption } from "../../types/IHubLocation";

/**
* Construct the dynamic location picker options with the entity's
* configured location using the following logic:
*
* 1. If there is no entity (e.g. we're creating an entity),
* select the "custom" option by default
* 2. Else if there is no location set on the entity, select the
* "none" option
* 3. Else if the entity has a location and it's the same as the
* current option, select it and update the option with the entity's
* location
*/
export async function getLocationOptions(
entity: HubEntity,
portalName: string,
hubRequestOptions: IHubRequestOptions
): Promise<IHubLocationOption[]> {
const defaultExtent = await getGeographicOrgExtent(hubRequestOptions);
const location: IHubLocation = entity.location;

return (
[
{
label: "{{shared.fields.location.none:translate}}",
location: { type: "none" },
},
{
label: "{{shared.fields.location.org:translate}}",
description: portalName,
location: {
type: "org",
extent: extentToBBox(defaultExtent),
spatialReference: defaultExtent.spatialReference,
},
},
{
label: "{{shared.fields.location.custom:translate}}",
description: "{{shared.fields.location.customDescription:translate}}",
entityType: getTypeFromEntity(entity),
location: {
type: "custom",
spatialReference: defaultExtent.spatialReference,
},
},
] as IHubLocationOption[]
).map((option) => {
// If this is a new entity, select the custom option by default
if (!entity.id && option.location.type === "custom") {
option.selected = true;
} else if (entity.id && !location && option.location.type === "none") {
option.selected = true;
} else if (location?.type === option.location.type) {
option.location = location;
option.selected = true;
}

return option;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { IGroup } from "@esri/arcgis-rest-portal";
import { IUiSchemaComboboxItem } from "../types";

/**
* Filter an array of groups into the set the user has the rights to share content
* into, and then convert them to UiSchemaComboboxItem array for use in the Entity Editor component
*/
export function getSharableGroupsComboBoxItems(
groups: IGroup[]
): IUiSchemaComboboxItem[] {
return groups.reduce((groupItems: IUiSchemaComboboxItem[], group: IGroup) => {
const isEditGroup = group.capabilities.includes("updateitemcontrol");
const memberType = group.userMembership?.memberType;

/**
* a user can only share to a group if:
* 1. the group is NOT view only
* 2. their membership type in a view only group is "owner" or "admin"
*/
const canShareToGroup =
!group.isViewOnly ||
(group.isViewOnly && ["owner", "admin"].includes(memberType));
if (canShareToGroup) {
groupItems.push({
value: group.id,
label: group.title,
icon: isEditGroup ? "unlock" : "view-mixed",
});
}

return groupItems;
}, []);
}
Loading