Skip to content

Commit

Permalink
feat: add logic/schema to allow follower group creation (#1333)
Browse files Browse the repository at this point in the history
- add a new uiSchema to create followers groups
- hoist getUniqueGroupTitle util from hub-teams into hub-common/groups and leverage in group creation workflow
- gate followers pane on sites to premium licenses
- add permission for creating followers group
  • Loading branch information
juliannemarik authored Nov 21, 2023
1 parent 9ff78e4 commit 9515f8c
Show file tree
Hide file tree
Showing 17 changed files with 504 additions and 34 deletions.
2 changes: 2 additions & 0 deletions packages/common/src/core/schemas/internal/getEditorSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ export async function getEditorSchemas(
schema = cloneObject(GroupSchema);

const groupModule = await {
"hub:group:create:followers": () =>
import("../../../groups/_internal/GroupUiSchemaCreateFollowers"),
"hub:group:edit": () =>
import("../../../groups/_internal/GroupUiSchemaEdit"),
"hub:group:settings": () =>
Expand Down
13 changes: 3 additions & 10 deletions packages/common/src/groups/HubGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,6 @@ export class HubGroup
* @returns
*/
async fromEditor(editor: IHubGroupEditor): Promise<IHubGroup> {
const isCreate = !editor.id;

// Setting the thumbnailCache will ensure that
// the thumbnail is updated on next save
if (editor._thumbnail) {
Expand Down Expand Up @@ -339,14 +337,9 @@ export class HubGroup
// of the toEditor method
const entity = cloneObject(editor) as IHubGroup;

// create it if it does not yet exist...
if (isCreate) {
throw new Error("Cannot create group using the Editor.");
} else {
// ...otherwise, update the in-memory entity and save it
this.entity = entity;
await this.save();
}
// save or create group
this.entity = entity;
await this.save();

return this.entity;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/common/src/groups/HubGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { convertGroupToHubGroup } from "./_internal/convertGroupToHubGroup";
import { setDiscussableKeyword } from "../discussions";
import { IHubSearchResult } from "../search/types/IHubSearchResult";
import { computeLinks } from "./_internal/computeLinks";
import { getUniqueGroupTitle } from "./_internal/getUniqueGroupTitle";

/**
* Enrich a generic search result
Expand Down Expand Up @@ -102,6 +103,11 @@ export async function createHubGroup(
): Promise<IHubGroup> {
// merge the incoming and default groups
const hubGroup = { ...DEFAULT_GROUP, ...partialGroup } as IHubGroup;

// ensure the group has a unique title
const uniqueTitle = await getUniqueGroupTitle(hubGroup.name, requestOptions);
hubGroup.name = uniqueTitle;

hubGroup.typeKeywords = setDiscussableKeyword(
hubGroup.typeKeywords,
hubGroup.isDiscussable
Expand Down
9 changes: 9 additions & 0 deletions packages/common/src/groups/_internal/GroupBusinessRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export const GroupPermissionPolicies: IPermissionPolicy[] = [
dependencies: ["hub:group"],
authenticated: true,
privileges: ["portal:user:createGroup"],
assertions: [
{
property: "context:currentUser.groups",
type: "length-lt",
// TODO: 512 is the default, but there are exceptions
// this should be based on the org's specified limit
value: 512,
},
],
},
{
permission: "hub:group:view",
Expand Down
2 changes: 2 additions & 0 deletions packages/common/src/groups/_internal/GroupSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const GroupEditorTypes = [
"hub:group:edit",
"hub:group:settings",
"hub:group:discussions",
// editor to create a followers group
"hub:group:create:followers",
] as const;

/**
Expand Down
111 changes: 111 additions & 0 deletions packages/common/src/groups/_internal/GroupUiSchemaCreateFollowers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { IUiSchema, UiSchemaRuleEffects } from "../../core/schemas/types";
import { IArcGISContext } from "../../ArcGISContext";
import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions";

/**
* @private
* constructs the complete uiSchema for creating a followers
* group. This defines how the schema properties should be
* rendered in the follower group creation experience
*/
export const buildUiSchema = async (
i18nScope: string,
options: EntityEditorOptions,
context: IArcGISContext
): Promise<IUiSchema> => {
return {
type: "Layout",
elements: [
{
type: "Section",
options: { section: "stepper", scale: "l" },
elements: [
{
type: "Step",
labelKey: `${i18nScope}.sections.details.label`,
elements: [
{
labelKey: `${i18nScope}.fields.name.label`,
scope: "/properties/name",
type: "Control",
options: {
messages: [
{
type: "ERROR",
keyword: "required",
icon: true,
labelKey: `${i18nScope}.fields.name.requiredError`,
},
{
type: "ERROR",
keyword: "maxLength",
icon: true,
labelKey: `${i18nScope}.fields.name.maxLengthError`,
},
],
},
},
{
labelKey: `${i18nScope}.fields.summary.label`,
scope: "/properties/summary",
type: "Control",
options: {
control: "hub-field-input-input",
type: "textarea",
rows: 4,
messages: [
{
type: "ERROR",
keyword: "maxLength",
icon: true,
labelKey: `${i18nScope}.fields.summary.maxLengthError`,
},
],
},
},
],
},
{
type: "Step",
labelKey: `${i18nScope}.sections.membershipAccess.label`,
rule: {
effect: UiSchemaRuleEffects.DISABLE,
condition: {
scope: "/properties/name",
schema: { const: "" },
},
},
elements: [
{
labelKey: `${i18nScope}.fields.membershipAccess.label`,
scope: "/properties/membershipAccess",
type: "Control",
options: {
control: "hub-field-input-radio",
labels: [
`{{${i18nScope}.fields.membershipAccess.org:translate}}`,
`{{${i18nScope}.fields.membershipAccess.collab:translate}}`,
`{{${i18nScope}.fields.membershipAccess.any:translate}}`,
],
disabled: [false, false, options.isSharedUpdate],
},
},
{
labelKey: `${i18nScope}.fields.contributeContent.label`,
scope: "/properties/isViewOnly",
type: "Control",
options: {
control: "hub-field-input-radio",
labels: [
`{{${i18nScope}.fields.contributeContent.all:translate}}`,
`{{${i18nScope}.fields.contributeContent.admins:translate}}`,
],
},
},
],
},
],
},
],
};
};
65 changes: 65 additions & 0 deletions packages/common/src/groups/_internal/getUniqueGroupTitle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { IQuery } from "../../search/types";
import { hubSearch } from "../../search/hubSearch";
import { IUserRequestOptions } from "@esri/arcgis-rest-auth";

/**
* @private
* Given a title, construct a group title that is unique
* in the user's org.
*
* Ex: Given a title of "Medical Team", if a group with that
* title exists, this fn will add a number on the end, and
* increment until an available group title is found - i.e.
* "Medical Team 3"
*
* @param {String} title Group Title to ensure is unique
* @param {IUserRequestOptions} requestOptions
* @param {Number} step Number to increment. Defaults to 0
*/
export async function getUniqueGroupTitle(
title: string,
requestOptions: IUserRequestOptions,
step = 0
): Promise<string> {
let combinedName = title;

if (step) {
combinedName = `${title} ${step}`;
}

return doesGroupExist(combinedName, requestOptions)
.then((result) => {
if (result) {
step++;
return getUniqueGroupTitle(title, requestOptions, step);
} else {
return combinedName;
}
})
.catch((err) => {
throw Error(`Error in getUniqueGroupTitle: ${err}`);
});
}

/**
* checks whether a group with the specified title
* exists in the user's org
*
* @param {String} title Group Title
* @param {IUserRequestOptions} requestOptions
*/
async function doesGroupExist(
title: string,
requestOptions: IUserRequestOptions
) {
const query: IQuery = {
targetEntity: "group",
filters: [{ predicates: [{ title }] }],
};
try {
const { results } = await hubSearch(query, { requestOptions });
return results.length > 0;
} catch (error) {
throw Error(`Error in getUniqueGroupTitle > doesGroupExist: ${error}`);
}
}
31 changes: 31 additions & 0 deletions packages/common/src/permissions/_internal/checkAssertion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export function checkAssertion(
case "lt":
response = rangeAssertions(assertion, propValue, val);
break;
case "length-gt":
case "length-lt":
response = lengthAssertions(assertion, propValue, val);
break;
case "is-group-admin":
case "is-group-member":
case "is-group-owner":
Expand Down Expand Up @@ -229,6 +233,33 @@ function rangeAssertions(
return response;
}

/**
* Is the propValue length "gt" or "lt" to the val?
* @param assertion
* @param propValue
* @param val
* @returns
*/
function lengthAssertions(
assertion: IPolicyAssertion,
propValue: any,
val: any
): PolicyResponse {
let response: PolicyResponse = "granted";
if (typeof propValue !== "string" && !Array.isArray(propValue)) {
response = "property-has-no-length";
} else if (typeof val !== "number") {
response = "assertion-requires-numeric-values";
} else {
if (assertion.type === "length-gt" && propValue.length < val) {
response = "assertion-failed";
} else if (assertion.type === "length-lt" && propValue.length > val) {
response = "assertion-failed";
}
}
return response;
}

/**
* Does the propValue array "contain" or "without" the val?
* @param assertion
Expand Down
2 changes: 2 additions & 0 deletions packages/common/src/permissions/types/IPermissionPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export type AssertionType =
| "neq"
| "gt"
| "lt"
| "length-gt"
| "length-lt"
| "contains"
| "contains-all"
| "without"
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/permissions/types/PolicyResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type PolicyResponse =
| "not-beta-org" // user is not in a beta org
| "property-missing" // assertion requires property but is missing from entity
| "property-not-array" // assertion requires array property
| "property-has-no-length" // assertion requires string or array property
| "array-contains-invalid-value" // assertion specifies a value not be included
| "array-missing-required-value" // assertion specifies a value not be included
| "property-mismatch"
Expand Down
10 changes: 10 additions & 0 deletions packages/common/src/sites/_internal/SiteBusinessRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const SitePermissions = [
"hub:site:workspace:followers",
"hub:site:workspace:followers:member",
"hub:site:workspace:followers:manager",
"hub:site:workspace:followers:create",
"hub:site:workspace:discussion",
"hub:site:manage",
] as const;
Expand Down Expand Up @@ -137,6 +138,9 @@ export const SitesPermissionPolicies: IPermissionPolicy[] = [
},
{
permission: "hub:site:workspace:followers",
// TODO: refactor once we have an "upsell" UI to try to
// get basic users to switch to premium for this feature
licenses: ["hub-premium"],
dependencies: ["hub:site:workspace", "hub:site:edit"],
},
{
Expand All @@ -161,6 +165,12 @@ export const SitesPermissionPolicies: IPermissionPolicy[] = [
},
],
},
// permission to create a followers group
{
permission: "hub:site:workspace:followers:create",
dependencies: ["hub:site:workspace:followers", "hub:group:create"],
privileges: ["portal:user:addExternalMembersToGroup"],
},
{
permission: "hub:site:workspace:discussion",
dependencies: ["hub:site:workspace", "hub:site:edit"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { GroupEditorTypes } from "../../../../src/groups/_internal/GroupSchema";
import * as GroupBuildEditUiSchema from "../../../../src/groups/_internal/GroupUiSchemaEdit";
import * as GroupBuildSettingsUiSchema from "../../../../src/groups/_internal/GroupUiSchemaSettings";
import * as GroupBuildDiscussionsUiSchema from "../../../../src/groups/_internal/GroupUiSchemaDiscussions";
import * as GroupBuildCreateFollowersUiSchema from "../../../../src/groups/_internal/GroupUiSchemaCreateFollowers";

import { InitiativeTemplateEditorTypes } from "../../../../src/initiative-templates/_internal/InitiativeTemplateSchema";
import * as InitiativeTemplateBuildEditUiSchema from "../../../../src/initiative-templates/_internal/InitiativeTemplateUiSchemaEdit";
Expand Down Expand Up @@ -73,11 +74,12 @@ describe("getEditorSchemas: ", () => {
{ type: TemplateEditorTypes[0], buildFn: TemplateBuildEditUiSchema },
{ type: GroupEditorTypes[0], buildFn: GroupBuildEditUiSchema },
{ type: GroupEditorTypes[1], buildFn: GroupBuildSettingsUiSchema },
{ type: GroupEditorTypes[2], buildFn: GroupBuildDiscussionsUiSchema },
{ type: GroupEditorTypes[3], buildFn: GroupBuildCreateFollowersUiSchema },
{
type: InitiativeTemplateEditorTypes[0],
buildFn: InitiativeTemplateBuildEditUiSchema,
},
{ type: GroupEditorTypes[2], buildFn: GroupBuildDiscussionsUiSchema },
{ type: validCardEditorTypes[0], buildFn: statUiSchemaModule },
].forEach(async ({ type, buildFn }) => {
it("returns a schema & uiSchema for a given entity and editor type", async () => {
Expand Down
Loading

0 comments on commit 9515f8c

Please sign in to comment.