diff --git a/packages/common/src/associations/index.ts b/packages/common/src/associations/index.ts index 45a11aafa89..84aafb75246 100644 --- a/packages/common/src/associations/index.ts +++ b/packages/common/src/associations/index.ts @@ -10,6 +10,7 @@ export * from "./getPendingEntitiesQuery"; export * from "./getRequestingEntitiesQuery"; export * from "./getReferencedEntityIds"; export * from "./wellKnownAssociationCatalogs"; +export * from "./setEntityAssociationGroup"; // Note: we expose "requestAssociation" under 2 names. // These actions are functionally equivalent, but we want // to make the intent more clear to the consumer. diff --git a/packages/common/src/associations/setEntityAssociationGroup.ts b/packages/common/src/associations/setEntityAssociationGroup.ts new file mode 100644 index 00000000000..47b3092e2fb --- /dev/null +++ b/packages/common/src/associations/setEntityAssociationGroup.ts @@ -0,0 +1,47 @@ +import { IArcGISContext } from "../ArcGISContext"; +import { IHubGroup, getTypeFromEntity } from "../core"; +import { HubEntity } from "../core/types/HubEntity"; +import { updateHubEntity } from "../core/updateHubEntity"; +import { setProp } from "../objects"; + +/** + * Utility to create a relationship between an entity and a group, setting the group as + * the entity's association group. This adds the entity-specific association keyword to the association group, + * and it adds the association definition to the original entity. + * @param entity + * @param group + * @param context + * @returns + */ +export async function setEntityAssociationGroup( + entity: HubEntity, + group: IHubGroup, + context: IArcGISContext +): Promise { + const type = getTypeFromEntity(entity); + + // 1. Add the association marker to the group entity + // if we don't already have it + const associationKeyword = `${type}|${entity.id}`; + if (!group.typeKeywords.includes(associationKeyword)) { + group.typeKeywords = [...group.typeKeywords, `${type}|${entity.id}`]; + } + + await updateHubEntity("group", group, context); + + // 2. construct and persist the initiative's association definition + const associations = { + groupId: group.id, + rules: { + schemaVersion: 1, + query: { + targetEntity: "item", + filters: [{ predicates: [{ group: group.id }] }], + }, + }, + }; + setProp("associations", associations, entity); + const updatedEntity = await updateHubEntity(type, entity, context); + + return updatedEntity; +} diff --git a/packages/common/src/core/schemas/internal/getCardEditorSchemas.ts b/packages/common/src/core/schemas/internal/getCardEditorSchemas.ts index 3282146c534..09fb4e9f4c9 100644 --- a/packages/common/src/core/schemas/internal/getCardEditorSchemas.ts +++ b/packages/common/src/core/schemas/internal/getCardEditorSchemas.ts @@ -9,6 +9,7 @@ import { filterSchemaToUiSchema } from "./filterSchemaToUiSchema"; import { CardEditorOptions } from "./EditorOptions"; import { cloneObject } from "../../../util"; import { IArcGISContext } from "../../../ArcGISContext"; +import { ICardEditorModuleType } from "../types"; /** * get the editor schema and uiSchema defined for a layout card. @@ -36,6 +37,7 @@ export async function getCardEditorSchemas( let uiSchema; let schemaPromise; let uiSchemaPromise; + let defaults; switch (cardType) { case "stat": @@ -55,8 +57,19 @@ export async function getCardEditorSchemas( options, context ); + + // if we have buildDefaults, build the defaults + // TODO: when first implementing buildDefaults for initiative templates, remove the ignore line + + /* istanbul ignore next */ + if ((uiSchemaModuleResolved as ICardEditorModuleType).buildDefaults) { + defaults = ( + uiSchemaModuleResolved as ICardEditorModuleType + ).buildDefaults(i18nScope, options, context); + } } ); + break; case "follow": // get correct module diff --git a/packages/common/src/core/schemas/internal/getEditorSchemas.ts b/packages/common/src/core/schemas/internal/getEditorSchemas.ts index 8c52f93a583..d6933313830 100644 --- a/packages/common/src/core/schemas/internal/getEditorSchemas.ts +++ b/packages/common/src/core/schemas/internal/getEditorSchemas.ts @@ -5,6 +5,8 @@ import { IConfigurationSchema, IUiSchema, IEditorConfig, + IConfigurationValues, + IEntityEditorModuleType, } from "../types"; import { filterSchemaToUiSchema } from "./filterSchemaToUiSchema"; import { SiteEditorType } from "../../../sites/_internal/SiteSchema"; @@ -46,6 +48,7 @@ export async function getEditorSchemas( // the entity type and the provided editor type let schema: IConfigurationSchema; let uiSchema: IUiSchema; + let defaults: IConfigurationValues; switch (editorType) { case "site": const { SiteSchema } = await import( @@ -53,7 +56,7 @@ export async function getEditorSchemas( ); schema = cloneObject(SiteSchema); - const siteModule = await { + const siteModule: IEntityEditorModuleType = await { "hub:site:edit": () => import("../../../sites/_internal/SiteUiSchemaEdit"), "hub:site:create": () => @@ -71,6 +74,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when implementing buildDefaults for sites, remove the ignore line + + /* istanbul ignore next */ + if (siteModule.buildDefaults) { + defaults = await siteModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "discussion": @@ -79,7 +94,7 @@ export async function getEditorSchemas( ); schema = cloneObject(DiscussionSchema); - const discussionModule = await { + const discussionModule: IEntityEditorModuleType = await { "hub:discussion:edit": () => import("../../../discussions/_internal/DiscussionUiSchemaEdit"), "hub:discussion:create": () => @@ -93,6 +108,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for discussions, remove the ignore line + + /* istanbul ignore next */ + if (discussionModule.buildDefaults) { + defaults = await discussionModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "project": @@ -101,7 +128,7 @@ export async function getEditorSchemas( ); schema = cloneObject(ProjectSchema); - const projectModule = await { + const projectModule: IEntityEditorModuleType = await { "hub:project:edit": () => import("../../../projects/_internal/ProjectUiSchemaEdit"), "hub:project:create": () => @@ -114,6 +141,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for projects, remove the ignore line + + /* istanbul ignore next */ + if (projectModule.buildDefaults) { + defaults = await projectModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "initiative": @@ -122,7 +161,7 @@ export async function getEditorSchemas( ); schema = cloneObject(InitiativeSchema); - const initiativeModule = await { + const initiativeModule: IEntityEditorModuleType = await { "hub:initiative:edit": () => import("../../../initiatives/_internal/InitiativeUiSchemaEdit"), "hub:initiative:create": () => @@ -134,6 +173,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for initiatives, remove the ignore line + + /* istanbul ignore next */ + if (initiativeModule.buildDefaults) { + defaults = await initiativeModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "page": @@ -142,7 +193,7 @@ export async function getEditorSchemas( ); schema = cloneObject(PageSchema); - const pageModule = await { + const pageModule: IEntityEditorModuleType = await { "hub:page:edit": () => import("../../../pages/_internal/PageUiSchemaEdit"), }[type as PageEditorType](); @@ -152,6 +203,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for pages, remove the ignore line + + /* istanbul ignore next */ + if (pageModule.buildDefaults) { + defaults = await pageModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "content": @@ -160,7 +223,7 @@ export async function getEditorSchemas( ); schema = cloneObject(ContentSchema); - const contentModule = await { + const contentModule: IEntityEditorModuleType = await { "hub:content:edit": () => import("../../../content/_internal/ContentUiSchemaEdit"), "hub:content:discussions": () => @@ -174,6 +237,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for content, remove the ignore line + + /* istanbul ignore next */ + if (contentModule.buildDefaults) { + defaults = await contentModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "template": @@ -182,7 +257,7 @@ export async function getEditorSchemas( ); schema = cloneObject(TemplateSchema); - const templateModule = await { + const templateModule: IEntityEditorModuleType = await { "hub:template:edit": () => import("../../../templates/_internal/TemplateUiSchemaEdit"), }[type as TemplateEditorType](); @@ -192,6 +267,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for templates, remove the ignore line + + /* istanbul ignore next */ + if (templateModule.buildDefaults) { + defaults = await templateModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; // ---------------------------------------------------- case "group": @@ -200,9 +287,11 @@ export async function getEditorSchemas( ); schema = cloneObject(GroupSchema); - const groupModule = await { + const groupModule: IEntityEditorModuleType = await { "hub:group:create:followers": () => import("../../../groups/_internal/GroupUiSchemaCreateFollowers"), + "hub:group:create:association": () => + import("../../../groups/_internal/GroupUiSchemaCreateAssociation"), "hub:group:create:view": () => import("../../../groups/_internal/GroupUiSchemaCreateView"), "hub:group:create:edit": () => @@ -220,6 +309,15 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + if (groupModule.buildDefaults) { + defaults = await groupModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; case "initiativeTemplate": @@ -227,7 +325,7 @@ export async function getEditorSchemas( "../../../initiative-templates/_internal/InitiativeTemplateSchema" ); schema = cloneObject(InitiativeTemplateSchema); - const initiativeTemplateModule = await { + const initiativeTemplateModule: IEntityEditorModuleType = await { "hub:initiativeTemplate:edit": () => import( "../../../initiative-templates/_internal/InitiativeTemplateUiSchemaEdit" @@ -240,6 +338,18 @@ export async function getEditorSchemas( context ); + // if we have the buildDefaults fn, then construct the defaults + // TODO: when first implementing buildDefaults for initiative templates, remove the ignore line + + /* istanbul ignore next */ + if (initiativeTemplateModule.buildDefaults) { + defaults = await initiativeTemplateModule.buildDefaults( + i18nScope, + options as EntityEditorOptions, + context + ); + } + break; case "card": @@ -251,10 +361,11 @@ export async function getEditorSchemas( ); schema = result.schema; uiSchema = result.uiSchema; + defaults = result.defaults; } // filter out properties not used in the UI schema schema = filterSchemaToUiSchema(schema, uiSchema); - return Promise.resolve({ schema, uiSchema }); + return Promise.resolve({ schema, uiSchema, defaults }); } diff --git a/packages/common/src/core/schemas/internal/metrics/ProjectUiSchemaMetrics.ts b/packages/common/src/core/schemas/internal/metrics/ProjectUiSchemaMetrics.ts index 69cb80c596e..6121a29cada 100644 --- a/packages/common/src/core/schemas/internal/metrics/ProjectUiSchemaMetrics.ts +++ b/packages/common/src/core/schemas/internal/metrics/ProjectUiSchemaMetrics.ts @@ -18,11 +18,11 @@ import { * @param context * @returns */ -export const buildUiSchema = ( +export const buildUiSchema = async ( i18nScope: string, config: EntityEditorOptions, context: IArcGISContext -): IUiSchema => { +): Promise => { return { type: "Layout", elements: [ diff --git a/packages/common/src/core/schemas/internal/metrics/StatCardUiSchema.ts b/packages/common/src/core/schemas/internal/metrics/StatCardUiSchema.ts index 70841e2ec7b..a8f220a3c16 100644 --- a/packages/common/src/core/schemas/internal/metrics/StatCardUiSchema.ts +++ b/packages/common/src/core/schemas/internal/metrics/StatCardUiSchema.ts @@ -7,11 +7,11 @@ import { UiSchemaRuleEffects, IUiSchema } from "../../types"; * Exports the uiSchema of the stat card * @returns */ -export const buildUiSchema = ( +export const buildUiSchema = async ( i18nScope: string, config: IStatCardEditorOptions, context: IArcGISContext -): IUiSchema => { +): Promise => { const { themeColors } = config; return { type: "Layout", diff --git a/packages/common/src/core/schemas/types.ts b/packages/common/src/core/schemas/types.ts index 844b21b338c..e200216be22 100644 --- a/packages/common/src/core/schemas/types.ts +++ b/packages/common/src/core/schemas/types.ts @@ -10,10 +10,16 @@ import { ContentEditorTypes } from "../../content/_internal/ContentSchema"; import { TemplateEditorTypes } from "../../templates/_internal/TemplateSchema"; import { GroupEditorTypes } from "../../groups/_internal/GroupSchema"; import { InitiativeTemplateEditorTypes } from "../../initiative-templates/_internal/InitiativeTemplateSchema"; +import { + CardEditorOptions, + EntityEditorOptions, +} from "./internal/EditorOptions"; +import { IArcGISContext } from "../../ArcGISContext"; export interface IEditorConfig { schema: IConfigurationSchema; uiSchema: IUiSchema; + defaults?: IConfigurationValues; } /** @@ -67,6 +73,50 @@ export const validEditorTypes = [ ...validCardEditorTypes, ] as const; +/** + * An editor's module when dynamically imported depending on the EditorType. This + * will always have a buildUiSchema function, and sometimes it will have a + * buildDefaults function to override default values in the editor. + */ +export type IEditorModuleType = IEntityEditorModuleType | ICardEditorModuleType; + +/** + * An entity editor's module when dynamically imported depending on the EditorType. This + * will always have a buildUiSchema function, and sometimes it will have a + * buildDefaults function to override default values in the editor. + */ +export interface IEntityEditorModuleType { + buildUiSchema: ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext + ) => Promise; + buildDefaults?: ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext + ) => Promise; +} + +/** + * A card editor's module when dynamically imported depending on the EditorType. This + * will always have a buildUiSchema function, and sometimes it will have a + * buildDefaults function to override default values in the editor. + */ +export interface ICardEditorModuleType { + buildUiSchema: ( + i18nScope: string, + config: CardEditorOptions, + context: IArcGISContext + ) => Promise; + + buildDefaults?: ( + i18nScope: string, + options: CardEditorOptions, + context: IArcGISContext + ) => Promise; +} + export enum UiSchemaRuleEffects { SHOW = "SHOW", HIDE = "HIDE", diff --git a/packages/common/src/groups/HubGroups.ts b/packages/common/src/groups/HubGroups.ts index dec6555138a..37faf6451d7 100644 --- a/packages/common/src/groups/HubGroups.ts +++ b/packages/common/src/groups/HubGroups.ts @@ -120,6 +120,17 @@ export async function createHubGroup( authentication: requestOptions.authentication, }; const result = await createGroup(opts); + // createGroup does not set a protection value based on the value of 'protected' + // so we have to make an additional call to protectGroup to set protection + if (group.protected) { + result.group.protected = ( + await protectGroup({ + id: result.group.id, + authentication: requestOptions.authentication, + }) + ).success; + } + return convertGroupToHubGroup(result.group, requestOptions); } diff --git a/packages/common/src/groups/_internal/GroupSchema.ts b/packages/common/src/groups/_internal/GroupSchema.ts index 9b069b83835..b3dea6b1139 100644 --- a/packages/common/src/groups/_internal/GroupSchema.ts +++ b/packages/common/src/groups/_internal/GroupSchema.ts @@ -13,6 +13,8 @@ export const GroupEditorTypes = [ "hub:group:discussions", // editor to create a followers group "hub:group:create:followers", + // editor to create an association group + "hub:group:create:association", "hub:group:create:view", "hub:group:create:edit", ] as const; diff --git a/packages/common/src/groups/_internal/GroupUiSchemaCreateAssociation.ts b/packages/common/src/groups/_internal/GroupUiSchemaCreateAssociation.ts new file mode 100644 index 00000000000..2dc212ad444 --- /dev/null +++ b/packages/common/src/groups/_internal/GroupUiSchemaCreateAssociation.ts @@ -0,0 +1,153 @@ +import { + IConfigurationValues, + IUiSchema, + UiSchemaRuleEffects, +} from "../../core/schemas/types"; +import { IArcGISContext } from "../../ArcGISContext"; +import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions"; +import { getWellKnownGroup } from "../getWellKnownGroup"; +import { checkPermission } from "../../permissions/checkPermission"; + +/** + * @private + * constructs the complete uiSchema for creating an association + * group. This defines how the schema properties should be + * rendered in the association group creation experience + */ +export const buildUiSchema = async ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext +): Promise => { + return { + type: "Layout", + elements: [ + { + type: "Section", + options: { section: "stepper", scale: "l" }, + elements: [ + { + type: "Section", + labelKey: `${i18nScope}.sections.details.label`, + options: { + section: "step", + }, + 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: "Section", + labelKey: `${i18nScope}.sections.membershipAccess.label`, + options: { + section: "step", + }, + 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.createAssociation.any:translate}}`, + ], + disabled: [ + false, + !checkPermission( + "platform:portal:user:addExternalMembersToGroup", + context + ).access, + !checkPermission( + "platform:portal:user:addExternalMembersToGroup", + context + ).access, + ], + }, + }, + { + 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.createAssociation.admins:translate}}`, + ], + }, + }, + ], + }, + ], + }, + ], + }; +}; + +/** + * @private + * constructs the default values for creating an associations group. + * This is used to pre-populate the form with specific default values + * that are different from the normal Group Schema defaults. + * @param i18nScope + * @param options + * @param context + * @returns + */ +export const buildDefaults = async ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext +): Promise => { + return { + ...getWellKnownGroup("hubAssociationsGroup", context), + }; +}; diff --git a/packages/common/src/groups/_internal/GroupUiSchemaCreateEdit.ts b/packages/common/src/groups/_internal/GroupUiSchemaCreateEdit.ts index 28ceff6bb38..24250fc5808 100644 --- a/packages/common/src/groups/_internal/GroupUiSchemaCreateEdit.ts +++ b/packages/common/src/groups/_internal/GroupUiSchemaCreateEdit.ts @@ -1,7 +1,12 @@ -import { IUiSchema, UiSchemaRuleEffects } from "../../core/schemas/types"; +import { + IConfigurationValues, + IUiSchema, + UiSchemaRuleEffects, +} from "../../core/schemas/types"; import { IArcGISContext } from "../../ArcGISContext"; import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions"; import { checkPermission } from "../../permissions"; +import { getWellKnownGroup } from "../getWellKnownGroup"; /** * @private @@ -123,3 +128,23 @@ export const buildUiSchema = async ( ], }; }; + +/** + * @private + * constructs the default values for creating an edit group. + * This is used to pre-populate the form with specific default values + * that are different from the normal Group Schema defaults. + * @param i18nScope + * @param options + * @param context + * @returns + */ +export const buildDefaults = async ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext +): Promise => { + return { + ...getWellKnownGroup("hubEditGroup", context), + }; +}; diff --git a/packages/common/src/groups/_internal/GroupUiSchemaCreateFollowers.ts b/packages/common/src/groups/_internal/GroupUiSchemaCreateFollowers.ts index b918a99922e..653256092b5 100644 --- a/packages/common/src/groups/_internal/GroupUiSchemaCreateFollowers.ts +++ b/packages/common/src/groups/_internal/GroupUiSchemaCreateFollowers.ts @@ -1,6 +1,11 @@ -import { IUiSchema, UiSchemaRuleEffects } from "../../core/schemas/types"; +import { + IConfigurationValues, + IUiSchema, + UiSchemaRuleEffects, +} from "../../core/schemas/types"; import { IArcGISContext } from "../../ArcGISContext"; import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions"; +import { getWellKnownGroup } from "../getWellKnownGroup"; /** * @private @@ -115,3 +120,24 @@ export const buildUiSchema = async ( ], }; }; + +/** + * @private + * constructs the default values for creating a followers group. + * This is used to pre-populate the form with specific default values + * that are different from the normal Group Schema defaults. + * @param i18nScope + * @param options + * @param context + * @returns + */ +export const buildDefaults = async ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext +): Promise => { + const { name } = options; + return { + ...getWellKnownGroup("hubFollowersGroup", context), + }; +}; diff --git a/packages/common/src/groups/_internal/GroupUiSchemaCreateView.ts b/packages/common/src/groups/_internal/GroupUiSchemaCreateView.ts index 711beb0c573..802a96f33ad 100644 --- a/packages/common/src/groups/_internal/GroupUiSchemaCreateView.ts +++ b/packages/common/src/groups/_internal/GroupUiSchemaCreateView.ts @@ -1,7 +1,12 @@ -import { IUiSchema, UiSchemaRuleEffects } from "../../core/schemas/types"; +import { + IConfigurationValues, + IUiSchema, + UiSchemaRuleEffects, +} from "../../core/schemas/types"; import { IArcGISContext } from "../../ArcGISContext"; import { EntityEditorOptions } from "../../core/schemas/internal/EditorOptions"; import { checkPermission } from "../../permissions"; +import { getWellKnownGroup } from "../getWellKnownGroup"; /** * @private @@ -126,3 +131,23 @@ export const buildUiSchema = async ( ], }; }; + +/** + * @private + * constructs the default values for creating a view group. + * This is used to pre-populate the form with specific default values + * that are different from the normal Group Schema defaults. + * @param i18nScope + * @param options + * @param context + * @returns + */ +export const buildDefaults = async ( + i18nScope: string, + options: EntityEditorOptions, + context: IArcGISContext +): Promise => { + return { + ...getWellKnownGroup("hubViewGroup", context), + }; +}; diff --git a/packages/common/src/groups/getWellKnownGroup.ts b/packages/common/src/groups/getWellKnownGroup.ts index a716cd13f76..74b1b38dc2f 100644 --- a/packages/common/src/groups/getWellKnownGroup.ts +++ b/packages/common/src/groups/getWellKnownGroup.ts @@ -2,7 +2,11 @@ import { IHubGroup } from "../core/types/IHubGroup"; import { checkPermission } from "../permissions"; import { IArcGISContext } from "../ArcGISContext"; -type WellKnownGroup = "hubViewGroup" | "hubEditGroup" | "hubFollowersGroup"; +type WellKnownGroup = + | "hubViewGroup" + | "hubEditGroup" + | "hubFollowersGroup" + | "hubAssociationsGroup"; /** * Fetches a well known group template based on a name * @@ -49,6 +53,19 @@ export function getWellKnownGroup( isInvitationOnly: false, isViewOnly: true, }, + hubAssociationsGroup: { + access: "public", + autoJoin: false, + isInvitationOnly: false, + isViewOnly: true, + membershipAccess: checkPermission( + "platform:portal:user:addExternalMembersToGroup", + context + ).access + ? "anyone" + : "organization", + protected: true, + }, }; return configs[groupType]; diff --git a/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts b/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts index 7f201974e63..b58a5ca05c7 100644 --- a/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts +++ b/packages/common/src/initiatives/_internal/InitiativeBusinessRules.ts @@ -34,6 +34,7 @@ export const InitiativePermissions = [ "hub:initiative:workspace:collaborators", "hub:initiative:workspace:content", "hub:initiative:workspace:metrics", + "hub:initiative:workspace:associationGroup:create", "hub:initiative:manage", ] as const; @@ -148,4 +149,9 @@ export const InitiativePermissionPolicies: IPermissionPolicy[] = [ permission: "hub:initiative:manage", dependencies: ["hub:initiative:edit"], }, + // permission to create an association group + { + permission: "hub:initiative:workspace:associationGroup:create", + dependencies: ["hub:initiative:workspace:projects", "hub:group:create"], + }, ]; diff --git a/packages/common/test/associations/fixtures.ts b/packages/common/test/associations/fixtures.ts index b4f6cf42b24..fb4e2092354 100644 --- a/packages/common/test/associations/fixtures.ts +++ b/packages/common/test/associations/fixtures.ts @@ -1,5 +1,10 @@ import { HubEntity } from "../../src/core/types"; +export const MOCK_INITIAL_PARENT_ENTITY = { + id: "parent-00a", + type: "Hub Initiative", +} as unknown as HubEntity; + export const MOCK_PARENT_ENTITY = { id: "parent-00a", type: "Hub Initiative", diff --git a/packages/common/test/associations/setEntityAssociationGroup.test.ts b/packages/common/test/associations/setEntityAssociationGroup.test.ts new file mode 100644 index 00000000000..86511aa9e3c --- /dev/null +++ b/packages/common/test/associations/setEntityAssociationGroup.test.ts @@ -0,0 +1,130 @@ +import { IHubGroup } from "../../src/core/types/IHubGroup"; +import { setEntityAssociationGroup } from "../../src/associations/setEntityAssociationGroup"; +import { MOCK_INITIAL_PARENT_ENTITY } from "./fixtures"; +import * as UpdateHubEntityModule from "../../src/core/updateHubEntity"; +import { ArcGISContext } from "../../src/ArcGISContext"; + +describe("setEntityAssociationGroup", () => { + let updateHubEntitySpy: jasmine.Spy; + + beforeEach(() => { + updateHubEntitySpy = spyOn( + UpdateHubEntityModule, + "updateHubEntity" + ).and.returnValue(Promise.resolve()); + }); + + it("sets an entity association group", async () => { + const group = { + typeKeywords: [], + id: "g123", + } as unknown as IHubGroup; + + await setEntityAssociationGroup( + MOCK_INITIAL_PARENT_ENTITY, + group, + {} as ArcGISContext + ); + expect(updateHubEntitySpy).toHaveBeenCalledTimes(2); + expect(updateHubEntitySpy.calls.argsFor(0)).toEqual([ + "group", + { typeKeywords: ["initiative|parent-00a"], id: "g123" }, + {}, + ]); + expect(updateHubEntitySpy.calls.argsFor(1)).toEqual([ + "initiative", + { + id: "parent-00a", + type: "Hub Initiative", + associations: { + groupId: "g123", + rules: { + schemaVersion: 1, + query: { + targetEntity: "item", + filters: [{ predicates: [{ group: "g123" }] }], + }, + }, + }, + }, + {}, + ]); + }); + + it("set an entity association group with existing typeKeywords", async () => { + const group = { + typeKeywords: ["existing-keyword"], + id: "g123", + } as unknown as IHubGroup; + + await setEntityAssociationGroup( + MOCK_INITIAL_PARENT_ENTITY, + group, + {} as ArcGISContext + ); + expect(updateHubEntitySpy).toHaveBeenCalledTimes(2); + expect(updateHubEntitySpy.calls.argsFor(0)).toEqual([ + "group", + { + typeKeywords: ["existing-keyword", "initiative|parent-00a"], + id: "g123", + }, + {}, + ]); + expect(updateHubEntitySpy.calls.argsFor(1)).toEqual([ + "initiative", + { + id: "parent-00a", + type: "Hub Initiative", + associations: { + groupId: "g123", + rules: { + schemaVersion: 1, + query: { + targetEntity: "item", + filters: [{ predicates: [{ group: "g123" }] }], + }, + }, + }, + }, + {}, + ]); + }); + + it("sets an association group that already has the entity typeKeyword", async () => { + const group = { + typeKeywords: ["initiative|parent-00a"], + id: "g123", + } as unknown as IHubGroup; + + await setEntityAssociationGroup( + MOCK_INITIAL_PARENT_ENTITY, + group, + {} as ArcGISContext + ); + expect(updateHubEntitySpy).toHaveBeenCalledTimes(2); + expect(updateHubEntitySpy.calls.argsFor(0)).toEqual([ + "group", + { typeKeywords: ["initiative|parent-00a"], id: "g123" }, + {}, + ]); + expect(updateHubEntitySpy.calls.argsFor(1)).toEqual([ + "initiative", + { + id: "parent-00a", + type: "Hub Initiative", + associations: { + groupId: "g123", + rules: { + schemaVersion: 1, + query: { + targetEntity: "item", + filters: [{ predicates: [{ group: "g123" }] }], + }, + }, + }, + }, + {}, + ]); + }); +}); diff --git a/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts b/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts index e9377113577..00818dcd4b9 100644 --- a/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts +++ b/packages/common/test/core/schemas/internal/getEditorSchemas.test.ts @@ -38,59 +38,78 @@ import * as GroupBuildEditUiSchema from "../../../../src/groups/_internal/GroupU 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 * as GroupBuildCreateAssociationUiSchema from "../../../../src/groups/_internal/GroupUiSchemaCreateAssociation"; import * as GroupBuildCreateViewUiSchema from "../../../../src/groups/_internal/GroupUiSchemaCreateView"; import * as GroupBuildCreateEditUiSchema from "../../../../src/groups/_internal/GroupUiSchemaCreateEdit"; import { InitiativeTemplateEditorTypes } from "../../../../src/initiative-templates/_internal/InitiativeTemplateSchema"; import * as InitiativeTemplateBuildEditUiSchema from "../../../../src/initiative-templates/_internal/InitiativeTemplateUiSchemaEdit"; -import { validCardEditorTypes } from "../../../../src/core/schemas/types"; +import { + EditorType, + IEditorModuleType, + validCardEditorTypes, +} from "../../../../src/core/schemas/types"; import * as statUiSchemaModule from "../../../../src/core/schemas/internal/metrics/StatCardUiSchema"; describe("getEditorSchemas: ", () => { let uiSchemaBuildFnSpy: any; + let defaultsFnSpy: any; afterEach(() => { uiSchemaBuildFnSpy.calls.reset(); + + if (defaultsFnSpy) { + defaultsFnSpy.calls.reset(); + } }); - [ - { type: ProjectEditorTypes[0], buildFn: ProjectBuildCreateUiSchema }, - { type: ProjectEditorTypes[1], buildFn: ProjectBuildEditUiSchema }, - { type: ProjectEditorTypes[2], buildFn: ProjectBuildMetricUiSchema }, - { type: InitiativeEditorTypes[0], buildFn: InitiativeBuildEditUiSchema }, - { type: InitiativeEditorTypes[1], buildFn: InitiativeBuildCreateUiSchema }, - { type: SiteEditorTypes[0], buildFn: SiteBuildEditUiSchema }, - { type: SiteEditorTypes[1], buildFn: SiteBuildCreateUiSchema }, - { type: SiteEditorTypes[2], buildFn: SiteBuildFollowersUiSchema }, - { type: SiteEditorTypes[3], buildFn: SiteBuildDiscussionsUiSchema }, - { type: SiteEditorTypes[4], buildFn: SiteBuildTelemetryUiSchema }, - { type: DiscussionEditorTypes[0], buildFn: DiscussionBuildEditUiSchema }, - { type: DiscussionEditorTypes[1], buildFn: DiscussionBuildCreateUiSchema }, + const modules: Array<{ type: EditorType; module: IEditorModuleType }> = [ + { type: ProjectEditorTypes[0], module: ProjectBuildCreateUiSchema }, + { type: ProjectEditorTypes[1], module: ProjectBuildEditUiSchema }, + { type: ProjectEditorTypes[2], module: ProjectBuildMetricUiSchema }, + { type: InitiativeEditorTypes[0], module: InitiativeBuildEditUiSchema }, + { type: InitiativeEditorTypes[1], module: InitiativeBuildCreateUiSchema }, + { type: SiteEditorTypes[0], module: SiteBuildEditUiSchema }, + { type: SiteEditorTypes[1], module: SiteBuildCreateUiSchema }, + { type: SiteEditorTypes[2], module: SiteBuildFollowersUiSchema }, + { type: SiteEditorTypes[3], module: SiteBuildDiscussionsUiSchema }, + { type: SiteEditorTypes[4], module: SiteBuildTelemetryUiSchema }, + { type: DiscussionEditorTypes[0], module: DiscussionBuildEditUiSchema }, + { type: DiscussionEditorTypes[1], module: DiscussionBuildCreateUiSchema }, { type: DiscussionEditorTypes[2], - buildFn: DiscussionBuildSettingsUiSchema, + module: DiscussionBuildSettingsUiSchema, }, - { type: ContentEditorTypes[0], buildFn: ContentBuildEditUiSchema }, - { type: ContentEditorTypes[1], buildFn: ContentBuildSettingsUiSchema }, - { type: ContentEditorTypes[2], buildFn: ContentBuildDiscussionsUiSchema }, - { type: PageEditorTypes[0], buildFn: PageBuildEditUiSchema }, - { 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: GroupEditorTypes[4], buildFn: GroupBuildCreateViewUiSchema }, - { type: GroupEditorTypes[5], buildFn: GroupBuildCreateEditUiSchema }, + { type: ContentEditorTypes[0], module: ContentBuildEditUiSchema }, + { type: ContentEditorTypes[1], module: ContentBuildSettingsUiSchema }, + { type: ContentEditorTypes[2], module: ContentBuildDiscussionsUiSchema }, + { type: PageEditorTypes[0], module: PageBuildEditUiSchema }, + { type: TemplateEditorTypes[0], module: TemplateBuildEditUiSchema }, + { type: GroupEditorTypes[0], module: GroupBuildEditUiSchema }, + { type: GroupEditorTypes[1], module: GroupBuildSettingsUiSchema }, + { type: GroupEditorTypes[2], module: GroupBuildDiscussionsUiSchema }, + { type: GroupEditorTypes[3], module: GroupBuildCreateFollowersUiSchema }, + { type: GroupEditorTypes[4], module: GroupBuildCreateAssociationUiSchema }, + { type: GroupEditorTypes[5], module: GroupBuildCreateViewUiSchema }, + { type: GroupEditorTypes[6], module: GroupBuildCreateEditUiSchema }, { type: InitiativeTemplateEditorTypes[0], - buildFn: InitiativeTemplateBuildEditUiSchema, + module: InitiativeTemplateBuildEditUiSchema, }, - { type: validCardEditorTypes[0], buildFn: statUiSchemaModule }, - ].forEach(async ({ type, buildFn }) => { + { type: validCardEditorTypes[0], module: statUiSchemaModule }, + ]; + + modules.forEach(async ({ type, module }) => { it("returns a schema & uiSchema for a given entity and editor type", async () => { - uiSchemaBuildFnSpy = spyOn(buildFn, "buildUiSchema").and.returnValue( + uiSchemaBuildFnSpy = spyOn(module, "buildUiSchema").and.returnValue( Promise.resolve({}) ); - const { schema, uiSchema } = await getEditorSchemas( + if (module.buildDefaults) { + defaultsFnSpy = spyOn(module, "buildDefaults").and.returnValue( + Promise.resolve({}) + ); + } + + const { schema, uiSchema, defaults } = await getEditorSchemas( "some.scope", type, {} as any, @@ -99,6 +118,11 @@ describe("getEditorSchemas: ", () => { expect(uiSchemaBuildFnSpy).toHaveBeenCalledTimes(1); expect(schema).toBeDefined(); expect(uiSchema).toBeDefined(); + + if (module.buildDefaults) { + expect(defaultsFnSpy).toHaveBeenCalledTimes(1); + expect(defaults).toBeDefined(); + } }); }); it("filters the schemas to the uiSchema elements before returning", async () => { diff --git a/packages/common/test/core/schemas/internal/metrics/ProjectUiSchemaMetrics.test.ts b/packages/common/test/core/schemas/internal/metrics/ProjectUiSchemaMetrics.test.ts index 81bf07f43b2..6fb80254340 100644 --- a/packages/common/test/core/schemas/internal/metrics/ProjectUiSchemaMetrics.test.ts +++ b/packages/common/test/core/schemas/internal/metrics/ProjectUiSchemaMetrics.test.ts @@ -4,8 +4,12 @@ import { MOCK_CONTEXT } from "../../../../mocks/mock-auth"; import { UiSchemaRuleEffects } from "../../../../../src/core/schemas/types"; describe("buildUiSchema: metric", () => { - it("returns the full metric uiSchema", () => { - const uiSchema = buildUiSchema("some.scope", {} as HubEntity, MOCK_CONTEXT); + it("returns the full metric uiSchema", async () => { + const uiSchema = await buildUiSchema( + "some.scope", + {} as HubEntity, + MOCK_CONTEXT + ); expect(uiSchema).toEqual({ type: "Layout", diff --git a/packages/common/test/core/schemas/internal/metrics/StatCardUiSchema.test.ts b/packages/common/test/core/schemas/internal/metrics/StatCardUiSchema.test.ts index 009fb7720b1..925d8bef909 100644 --- a/packages/common/test/core/schemas/internal/metrics/StatCardUiSchema.test.ts +++ b/packages/common/test/core/schemas/internal/metrics/StatCardUiSchema.test.ts @@ -4,7 +4,7 @@ import { MOCK_CONTEXT } from "../../../../mocks/mock-auth"; describe("buildUiSchema: stat", () => { it("returns the full stat card uiSchema", async () => { - const uiSchema = buildUiSchema( + const uiSchema = await buildUiSchema( "", { themeColors: ["#ffffff"] }, MOCK_CONTEXT diff --git a/packages/common/test/groups/HubGroups.test.ts b/packages/common/test/groups/HubGroups.test.ts index fcc895b5a99..d91d6b8c3fd 100644 --- a/packages/common/test/groups/HubGroups.test.ts +++ b/packages/common/test/groups/HubGroups.test.ts @@ -156,6 +156,74 @@ describe("HubGroups Module:", () => { GetUniqueGroupTitleModule, "getUniqueGroupTitle" ).and.returnValue(Promise.resolve(TEST_GROUP.title)); + const portalProtectGroupSpy = spyOn( + PortalModule, + "protectGroup" + ).and.returnValue(Promise.resolve({ success: true })); + const portalCreateGroupSpy = spyOn( + PortalModule, + "createGroup" + ).and.callFake((group: IGroup) => { + group.id = TEST_GROUP.id; + group.description = TEST_GROUP.description; + group.group.userMembership = { + memberType: TEST_GROUP.userMembership?.memberType, + }; + group.protected = false; + return Promise.resolve(group); + }); + const chk = await HubGroupsModule.createHubGroup( + { name: TEST_GROUP.title, protected: TEST_GROUP.protected }, + { authentication: MOCK_AUTH } + ); + expect(chk.name).toBe("dev followers Content"); + expect(getUniqueGroupTitleSpy).toHaveBeenCalledTimes(1); + expect(portalCreateGroupSpy).toHaveBeenCalledTimes(1); + expect(portalProtectGroupSpy).toHaveBeenCalledTimes(1); + expect(chk.protected).toBe(true); + }); + + it("creates a HubGroup without the protected flag", async () => { + const getUniqueGroupTitleSpy = spyOn( + GetUniqueGroupTitleModule, + "getUniqueGroupTitle" + ).and.returnValue(Promise.resolve(TEST_GROUP.title)); + const portalProtectGroupSpy = spyOn( + PortalModule, + "protectGroup" + ).and.returnValue(Promise.resolve({ success: true })); + const portalCreateGroupSpy = spyOn( + PortalModule, + "createGroup" + ).and.callFake((group: IGroup) => { + group.id = TEST_GROUP.id; + group.description = TEST_GROUP.description; + group.group.userMembership = { + memberType: TEST_GROUP.userMembership?.memberType, + }; + group.protected = false; + return Promise.resolve(group); + }); + const chk = await HubGroupsModule.createHubGroup( + { name: TEST_GROUP.title, protected: false }, + { authentication: MOCK_AUTH } + ); + expect(chk.name).toBe("dev followers Content"); + expect(getUniqueGroupTitleSpy).toHaveBeenCalledTimes(1); + expect(portalCreateGroupSpy).toHaveBeenCalledTimes(1); + expect(portalProtectGroupSpy).toHaveBeenCalledTimes(0); + expect(chk.protected).toBe(false); + }); + + it("does not set the protected flag if the protect call fails", async () => { + const getUniqueGroupTitleSpy = spyOn( + GetUniqueGroupTitleModule, + "getUniqueGroupTitle" + ).and.returnValue(Promise.resolve(TEST_GROUP.title)); + const portalProtectGroupSpy = spyOn( + PortalModule, + "protectGroup" + ).and.returnValue(Promise.resolve({ success: false })); const portalCreateGroupSpy = spyOn( PortalModule, "createGroup" @@ -165,15 +233,18 @@ describe("HubGroups Module:", () => { group.group.userMembership = { memberType: TEST_GROUP.userMembership?.memberType, }; + group.protected = false; return Promise.resolve(group); }); const chk = await HubGroupsModule.createHubGroup( - { name: TEST_GROUP.title }, + { name: TEST_GROUP.title, protected: TEST_GROUP.protected }, { authentication: MOCK_AUTH } ); expect(chk.name).toBe("dev followers Content"); expect(getUniqueGroupTitleSpy).toHaveBeenCalledTimes(1); expect(portalCreateGroupSpy).toHaveBeenCalledTimes(1); + expect(portalProtectGroupSpy).toHaveBeenCalledTimes(1); + expect(chk.protected).toBe(false); }); }); diff --git a/packages/common/test/groups/_internal/GroupUiSchemaCreateAssociation.test.ts b/packages/common/test/groups/_internal/GroupUiSchemaCreateAssociation.test.ts new file mode 100644 index 00000000000..8be7e501133 --- /dev/null +++ b/packages/common/test/groups/_internal/GroupUiSchemaCreateAssociation.test.ts @@ -0,0 +1,158 @@ +import { IHubGroup, UiSchemaRuleEffects } from "../../../src"; +import { + buildUiSchema, + buildDefaults, +} from "../../../src/groups/_internal/GroupUiSchemaCreateAssociation"; +import { + MOCK_CONTEXT, + getMockContextWithPrivilenges, +} from "../../mocks/mock-auth"; + +describe("GroupUiSchemaCreateAssociation", () => { + describe("buildUiSchema: create association group", () => { + it("returns the uiSchema to create a association group", async () => { + const uiSchema = await buildUiSchema( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + MOCK_CONTEXT + ); + expect(uiSchema).toEqual({ + type: "Layout", + elements: [ + { + type: "Section", + options: { section: "stepper", scale: "l" }, + elements: [ + { + type: "Section", + labelKey: "some.scope.sections.details.label", + options: { + section: "step", + }, + elements: [ + { + labelKey: "some.scope.fields.name.label", + scope: "/properties/name", + type: "Control", + options: { + messages: [ + { + type: "ERROR", + keyword: "required", + icon: true, + labelKey: "some.scope.fields.name.requiredError", + }, + { + type: "ERROR", + keyword: "maxLength", + icon: true, + labelKey: "some.scope.fields.name.maxLengthError", + }, + ], + }, + }, + { + labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", + }, + ], + }, + }, + ], + }, + { + type: "Section", + labelKey: "some.scope.sections.membershipAccess.label", + options: { + section: "step", + }, + rule: { + effect: UiSchemaRuleEffects.DISABLE, + condition: { + scope: "/properties/name", + schema: { const: "" }, + }, + }, + elements: [ + { + labelKey: "some.scope.fields.membershipAccess.label", + scope: "/properties/membershipAccess", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.membershipAccess.org:translate}}", + "{{some.scope.fields.membershipAccess.collab:translate}}", + "{{some.scope.fields.membershipAccess.createAssociation.any:translate}}", + ], + disabled: [false, true, true], + }, + }, + { + labelKey: "some.scope.fields.contributeContent.label", + scope: "/properties/isViewOnly", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.contributeContent.all:translate}}", + "{{some.scope.fields.contributeContent.createAssociation.admins:translate}}", + ], + }, + }, + ], + }, + ], + }, + ], + }); + }); + }); + + describe("buildDefaults: create association group", () => { + it("returns the defaults to create a association group", async () => { + const defaults = await buildDefaults( + "some.scope", + { name: "Groupname" } as IHubGroup, + MOCK_CONTEXT + ); + expect(defaults).toEqual({ + access: "public", + autoJoin: false, + isInvitationOnly: false, + isViewOnly: true, + membershipAccess: "organization", + protected: true, + }); + }); + it("returns the defaults to create an association group with privs", async () => { + const mockContext = getMockContextWithPrivilenges([ + "portal:user:addExternalMembersToGroup", + ]); + const defaults = await buildDefaults( + "some.scope", + { name: "Groupname" } as IHubGroup, + mockContext + ); + expect(defaults).toEqual({ + access: "public", + autoJoin: false, + isInvitationOnly: false, + isViewOnly: true, + membershipAccess: "anyone", + protected: true, + }); + }); + }); +}); diff --git a/packages/common/test/groups/_internal/GroupUiSchemaCreateEdit.test.ts b/packages/common/test/groups/_internal/GroupUiSchemaCreateEdit.test.ts index b4f9d00d51c..0d83c95a5cd 100644 --- a/packages/common/test/groups/_internal/GroupUiSchemaCreateEdit.test.ts +++ b/packages/common/test/groups/_internal/GroupUiSchemaCreateEdit.test.ts @@ -1,114 +1,159 @@ import { IHubGroup, UiSchemaRuleEffects } from "../../../src"; -import { buildUiSchema } from "../../../src/groups/_internal/GroupUiSchemaCreateEdit"; -import { MOCK_CONTEXT } from "../../mocks/mock-auth"; +import { + buildUiSchema, + buildDefaults, +} from "../../../src/groups/_internal/GroupUiSchemaCreateEdit"; +import { + MOCK_CONTEXT, + getMockContextWithPrivilenges, +} from "../../mocks/mock-auth"; -describe("buildUiSchema: create edit group", () => { - it("returns the uiSchema to create a edit group", async () => { - const uiSchema = await buildUiSchema( - "some.scope", - { isSharedUpdate: true } as IHubGroup, - MOCK_CONTEXT - ); - expect(uiSchema).toEqual({ - type: "Layout", - elements: [ - { - type: "Section", - options: { section: "stepper", scale: "l" }, - elements: [ - { - type: "Section", - labelKey: "some.scope.sections.details.label", - options: { - section: "step", - }, - elements: [ - { - labelKey: "some.scope.fields.name.label", - scope: "/properties/name", - type: "Control", - options: { - messages: [ - { - type: "ERROR", - keyword: "required", - icon: true, - labelKey: "some.scope.fields.name.requiredError", - }, - { - type: "ERROR", - keyword: "maxLength", - icon: true, - labelKey: "some.scope.fields.name.maxLengthError", - }, - ], - }, +describe("GroupUiSchemaCreateEdit", () => { + describe("buildUiSchema: create edit group", () => { + it("returns the uiSchema to create a edit group", async () => { + const uiSchema = await buildUiSchema( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + MOCK_CONTEXT + ); + expect(uiSchema).toEqual({ + type: "Layout", + elements: [ + { + type: "Section", + options: { section: "stepper", scale: "l" }, + elements: [ + { + type: "Section", + labelKey: "some.scope.sections.details.label", + options: { + section: "step", }, - { - labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", - }, - ], + elements: [ + { + labelKey: "some.scope.fields.name.label", + scope: "/properties/name", + type: "Control", + options: { + messages: [ + { + type: "ERROR", + keyword: "required", + icon: true, + labelKey: "some.scope.fields.name.requiredError", + }, + { + type: "ERROR", + keyword: "maxLength", + icon: true, + labelKey: "some.scope.fields.name.maxLengthError", + }, + ], + }, }, - }, - ], - }, - { - type: "Section", - labelKey: "some.scope.sections.membershipAccess.label", - options: { - section: "step", + { + labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", + }, + ], + }, + }, + ], }, - rule: { - effect: UiSchemaRuleEffects.DISABLE, - condition: { - scope: "/properties/name", - schema: { const: "" }, + { + type: "Section", + labelKey: "some.scope.sections.membershipAccess.label", + options: { + section: "step", }, - }, - elements: [ - { - labelKey: "some.scope.fields.membershipAccess.label", - scope: "/properties/membershipAccess", - type: "Control", - options: { - control: "hub-field-input-radio", - labels: [ - "{{some.scope.fields.membershipAccess.org:translate}}", - "{{some.scope.fields.membershipAccess.collab:translate}}", - "{{some.scope.fields.membershipAccess.any:translate}}", - ], - disabled: [false, true, true], + rule: { + effect: UiSchemaRuleEffects.DISABLE, + condition: { + scope: "/properties/name", + schema: { const: "" }, }, }, - { - labelKey: "some.scope.fields.contributeContent.label", - scope: "/properties/isViewOnly", - type: "Control", - options: { - control: "hub-field-input-radio", - labels: [ - "{{some.scope.fields.contributeContent.all:translate}}", - "{{some.scope.fields.contributeContent.admins:translate}}", - ], + elements: [ + { + labelKey: "some.scope.fields.membershipAccess.label", + scope: "/properties/membershipAccess", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.membershipAccess.org:translate}}", + "{{some.scope.fields.membershipAccess.collab:translate}}", + "{{some.scope.fields.membershipAccess.any:translate}}", + ], + disabled: [false, true, true], + }, }, - }, - ], - }, - ], - }, - ], + { + labelKey: "some.scope.fields.contributeContent.label", + scope: "/properties/isViewOnly", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.contributeContent.all:translate}}", + "{{some.scope.fields.contributeContent.admins:translate}}", + ], + }, + }, + ], + }, + ], + }, + ], + }); + }); + }); + + describe("buildDefaults", () => { + it("builds defaults for create edit group when permission is false", async () => { + const defaults = await buildDefaults( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + MOCK_CONTEXT + ); + expect(defaults).toEqual({ + access: "org", + autoJoin: false, + isSharedUpdate: true, + isInvitationOnly: false, + hiddenMembers: false, + isViewOnly: false, + tags: ["Hub Group"], + membershipAccess: "organization", + }); + }); + it("builds defaults for create edit group when permission is true", async () => { + const defaults = await buildDefaults( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + getMockContextWithPrivilenges(["portal:user:addExternalMembersToGroup"]) + ); + expect(defaults).toEqual({ + access: "org", + autoJoin: false, + isSharedUpdate: true, + isInvitationOnly: false, + hiddenMembers: false, + isViewOnly: false, + tags: ["Hub Group"], + membershipAccess: "collaborators", + }); }); }); }); diff --git a/packages/common/test/groups/_internal/GroupUiSchemaCreateFollowers.test.ts b/packages/common/test/groups/_internal/GroupUiSchemaCreateFollowers.test.ts index 84bd00ec680..a6f57e5e995 100644 --- a/packages/common/test/groups/_internal/GroupUiSchemaCreateFollowers.test.ts +++ b/packages/common/test/groups/_internal/GroupUiSchemaCreateFollowers.test.ts @@ -1,114 +1,134 @@ import { IHubGroup, UiSchemaRuleEffects } from "../../../src"; -import { buildUiSchema } from "../../../src/groups/_internal/GroupUiSchemaCreateFollowers"; +import { + buildUiSchema, + buildDefaults, +} from "../../../src/groups/_internal/GroupUiSchemaCreateFollowers"; import { MOCK_CONTEXT } from "../../mocks/mock-auth"; -describe("buildUiSchema: create followers group", () => { - it("returns the uiSchema to create a followers group", async () => { - const uiSchema = await buildUiSchema( - "some.scope", - { isSharedUpdate: true } as IHubGroup, - MOCK_CONTEXT - ); - expect(uiSchema).toEqual({ - type: "Layout", - elements: [ - { - type: "Section", - options: { section: "stepper", scale: "l" }, - elements: [ - { - type: "Section", - labelKey: "some.scope.sections.details.label", - options: { - section: "step", - }, - elements: [ - { - labelKey: "some.scope.fields.name.label", - scope: "/properties/name", - type: "Control", - options: { - messages: [ - { - type: "ERROR", - keyword: "required", - icon: true, - labelKey: "some.scope.fields.name.requiredError", - }, - { - type: "ERROR", - keyword: "maxLength", - icon: true, - labelKey: "some.scope.fields.name.maxLengthError", - }, - ], - }, +describe("GroupUiSchemaCreateFollowers", () => { + describe("buildUiSchema: create followers group", () => { + it("returns the uiSchema to create a followers group", async () => { + const uiSchema = await buildUiSchema( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + MOCK_CONTEXT + ); + expect(uiSchema).toEqual({ + type: "Layout", + elements: [ + { + type: "Section", + options: { section: "stepper", scale: "l" }, + elements: [ + { + type: "Section", + labelKey: "some.scope.sections.details.label", + options: { + section: "step", }, - { - labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", - }, - ], + elements: [ + { + labelKey: "some.scope.fields.name.label", + scope: "/properties/name", + type: "Control", + options: { + messages: [ + { + type: "ERROR", + keyword: "required", + icon: true, + labelKey: "some.scope.fields.name.requiredError", + }, + { + type: "ERROR", + keyword: "maxLength", + icon: true, + labelKey: "some.scope.fields.name.maxLengthError", + }, + ], + }, }, - }, - ], - }, - { - type: "Section", - labelKey: "some.scope.sections.membershipAccess.label", - options: { - section: "step", + { + labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", + }, + ], + }, + }, + ], }, - rule: { - effect: UiSchemaRuleEffects.DISABLE, - condition: { - scope: "/properties/name", - schema: { const: "" }, + { + type: "Section", + labelKey: "some.scope.sections.membershipAccess.label", + options: { + section: "step", }, - }, - elements: [ - { - labelKey: "some.scope.fields.membershipAccess.label", - scope: "/properties/membershipAccess", - type: "Control", - options: { - control: "hub-field-input-radio", - labels: [ - "{{some.scope.fields.membershipAccess.org:translate}}", - "{{some.scope.fields.membershipAccess.collab:translate}}", - "{{some.scope.fields.membershipAccess.createFollowers.any:translate}}", - ], - disabled: [false, false, true], + rule: { + effect: UiSchemaRuleEffects.DISABLE, + condition: { + scope: "/properties/name", + schema: { const: "" }, }, }, - { - labelKey: "some.scope.fields.contributeContent.label", - scope: "/properties/isViewOnly", - type: "Control", - options: { - control: "hub-field-input-radio", - labels: [ - "{{some.scope.fields.contributeContent.all:translate}}", - "{{some.scope.fields.contributeContent.createFollowers.admins:translate}}", - ], + elements: [ + { + labelKey: "some.scope.fields.membershipAccess.label", + scope: "/properties/membershipAccess", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.membershipAccess.org:translate}}", + "{{some.scope.fields.membershipAccess.collab:translate}}", + "{{some.scope.fields.membershipAccess.createFollowers.any:translate}}", + ], + disabled: [false, false, true], + }, }, - }, - ], - }, - ], - }, - ], + { + labelKey: "some.scope.fields.contributeContent.label", + scope: "/properties/isViewOnly", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.contributeContent.all:translate}}", + "{{some.scope.fields.contributeContent.createFollowers.admins:translate}}", + ], + }, + }, + ], + }, + ], + }, + ], + }); + }); + }); + describe("buildDefaults: create followers group", () => { + it("returns the default values for a followers group", async () => { + const defaults = await buildDefaults( + "some.scope", + { name: "Groupname" } as IHubGroup, + MOCK_CONTEXT + ); + expect(defaults).toEqual({ + access: "public", + autoJoin: true, + isInvitationOnly: false, + isViewOnly: true, + }); }); }); }); diff --git a/packages/common/test/groups/_internal/GroupUiSchemaCreateView.test.ts b/packages/common/test/groups/_internal/GroupUiSchemaCreateView.test.ts index 850c0e1db93..7a27f846807 100644 --- a/packages/common/test/groups/_internal/GroupUiSchemaCreateView.test.ts +++ b/packages/common/test/groups/_internal/GroupUiSchemaCreateView.test.ts @@ -1,114 +1,159 @@ import { IHubGroup, UiSchemaRuleEffects } from "../../../src"; -import { buildUiSchema } from "../../../src/groups/_internal/GroupUiSchemaCreateView"; -import { MOCK_CONTEXT } from "../../mocks/mock-auth"; +import { + buildUiSchema, + buildDefaults, +} from "../../../src/groups/_internal/GroupUiSchemaCreateView"; +import { + MOCK_CONTEXT, + getMockContextWithPrivilenges, +} from "../../mocks/mock-auth"; -describe("buildUiSchema: create view group", () => { - it("returns the uiSchema to create a view group", async () => { - const uiSchema = await buildUiSchema( - "some.scope", - { isSharedUpdate: true } as IHubGroup, - MOCK_CONTEXT - ); - expect(uiSchema).toEqual({ - type: "Layout", - elements: [ - { - type: "Section", - options: { section: "stepper", scale: "l" }, - elements: [ - { - type: "Section", - labelKey: "some.scope.sections.details.label", - options: { - section: "step", - }, - elements: [ - { - labelKey: "some.scope.fields.name.label", - scope: "/properties/name", - type: "Control", - options: { - messages: [ - { - type: "ERROR", - keyword: "required", - icon: true, - labelKey: "some.scope.fields.name.requiredError", - }, - { - type: "ERROR", - keyword: "maxLength", - icon: true, - labelKey: "some.scope.fields.name.maxLengthError", - }, - ], - }, +describe("GroupUiSchemaCreateView", () => { + describe("buildUiSchema: create view group", () => { + it("returns the uiSchema to create a view group", async () => { + const uiSchema = await buildUiSchema( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + MOCK_CONTEXT + ); + expect(uiSchema).toEqual({ + type: "Layout", + elements: [ + { + type: "Section", + options: { section: "stepper", scale: "l" }, + elements: [ + { + type: "Section", + labelKey: "some.scope.sections.details.label", + options: { + section: "step", }, - { - labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", - }, - ], + elements: [ + { + labelKey: "some.scope.fields.name.label", + scope: "/properties/name", + type: "Control", + options: { + messages: [ + { + type: "ERROR", + keyword: "required", + icon: true, + labelKey: "some.scope.fields.name.requiredError", + }, + { + type: "ERROR", + keyword: "maxLength", + icon: true, + labelKey: "some.scope.fields.name.maxLengthError", + }, + ], + }, }, - }, - ], - }, - { - type: "Section", - labelKey: "some.scope.sections.membershipAccess.label", - options: { - section: "step", + { + labelKey: "some.scope.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: "some.scope.fields.summary.maxLengthError", + }, + ], + }, + }, + ], }, - rule: { - effect: UiSchemaRuleEffects.DISABLE, - condition: { - scope: "/properties/name", - schema: { const: "" }, + { + type: "Section", + labelKey: "some.scope.sections.membershipAccess.label", + options: { + section: "step", }, - }, - elements: [ - { - labelKey: "some.scope.fields.membershipAccess.label", - scope: "/properties/membershipAccess", - type: "Control", - options: { - control: "hub-field-input-radio", - labels: [ - "{{some.scope.fields.membershipAccess.org:translate}}", - "{{some.scope.fields.membershipAccess.collab:translate}}", - "{{some.scope.fields.membershipAccess.any:translate}}", - ], - disabled: [false, true, true], + rule: { + effect: UiSchemaRuleEffects.DISABLE, + condition: { + scope: "/properties/name", + schema: { const: "" }, }, }, - { - labelKey: "some.scope.fields.contributeContent.label", - scope: "/properties/isViewOnly", - type: "Control", - options: { - control: "hub-field-input-radio", - labels: [ - "{{some.scope.fields.contributeContent.all:translate}}", - "{{some.scope.fields.contributeContent.admins:translate}}", - ], + elements: [ + { + labelKey: "some.scope.fields.membershipAccess.label", + scope: "/properties/membershipAccess", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.membershipAccess.org:translate}}", + "{{some.scope.fields.membershipAccess.collab:translate}}", + "{{some.scope.fields.membershipAccess.any:translate}}", + ], + disabled: [false, true, true], + }, }, - }, - ], - }, - ], - }, - ], + { + labelKey: "some.scope.fields.contributeContent.label", + scope: "/properties/isViewOnly", + type: "Control", + options: { + control: "hub-field-input-radio", + labels: [ + "{{some.scope.fields.contributeContent.all:translate}}", + "{{some.scope.fields.contributeContent.admins:translate}}", + ], + }, + }, + ], + }, + ], + }, + ], + }); + }); + }); + + describe("buildDefaults", () => { + it("returns the defaults to create a view group when platform:portal:user:addExternalMember is false", async () => { + const defaults = await buildDefaults( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + MOCK_CONTEXT + ); + + expect(defaults).toEqual({ + access: "org", + autoJoin: false, + isInvitationOnly: false, + hiddenMembers: false, + isViewOnly: false, + tags: ["Hub Group"], + membershipAccess: "organization", + }); + }); + + it("returns the defaults to create a view group when platform:portal:user:addExternalMember is true", async () => { + const defaults = await buildDefaults( + "some.scope", + { isSharedUpdate: true } as IHubGroup, + getMockContextWithPrivilenges(["portal:user:addExternalMembersToGroup"]) + ); + expect(defaults).toEqual({ + access: "org", + autoJoin: false, + isInvitationOnly: false, + hiddenMembers: false, + isViewOnly: false, + tags: ["Hub Group"], + membershipAccess: "anyone", + }); }); }); }); diff --git a/packages/common/test/groups/getWellKnownGroup.test.ts b/packages/common/test/groups/getWellKnownGroup.test.ts index 4c25d4042e1..d4cbe69df13 100644 --- a/packages/common/test/groups/getWellKnownGroup.test.ts +++ b/packages/common/test/groups/getWellKnownGroup.test.ts @@ -98,4 +98,31 @@ describe("getWellKnownGroup: ", () => { membershipAccess: "collaborators", }); }); + + it("returns an associations group", () => { + const resp = getWellKnownGroup("hubAssociationsGroup", MOCK_CONTEXT); + expect(resp).toEqual({ + access: "public", + autoJoin: false, + isInvitationOnly: false, + isViewOnly: true, + membershipAccess: "organization", + protected: true, + }); + }); + + it("returns an associations group with membershipAccess set to anyone", () => { + const resp = getWellKnownGroup( + "hubAssociationsGroup", + MOCK_CONTEXT_WITH_PRIVILEGES + ); + expect(resp).toEqual({ + access: "public", + autoJoin: false, + isInvitationOnly: false, + isViewOnly: true, + membershipAccess: "anyone", + protected: true, + }); + }); }); diff --git a/packages/common/test/mocks/mock-auth.ts b/packages/common/test/mocks/mock-auth.ts index 4e98117be16..7d814d4b764 100644 --- a/packages/common/test/mocks/mock-auth.ts +++ b/packages/common/test/mocks/mock-auth.ts @@ -69,6 +69,41 @@ export const MOCK_ENTERPRISE_REQOPTS = { isPortal: true, } as unknown as IHubRequestOptions; +export function getMockContextWithPrivilenges( + privileges: string[] +): IArcGISContext { + return new ArcGISContext({ + id: 123, + currentUser: { + username: "mock_user", + favGroupId: "456abc", + orgId: "789def", + privileges, + }, + portalUrl: "https://qaext.arcgis.com", + hubUrl: "https://hubqa.arcgis.com", + authentication: MOCK_AUTH, + portalSelf: { + id: "123", + name: "My org", + isPortal: false, + urlKey: "www", + }, + serviceStatus: { + portal: "online", + discussions: "online", + events: "online", + metrics: "online", + notifications: "online", + "hub-search": "online", + domains: "online", + }, + userHubSettings: { + schemaVersion: 1, + }, + }) as IArcGISContext; +} + export const MOCK_CONTEXT = new ArcGISContext({ id: 123, currentUser: {