Skip to content

Commit

Permalink
feat: user workspace pane permissions & add content config (#1667)
Browse files Browse the repository at this point in the history
  • Loading branch information
dbouwman authored Sep 25, 2024
1 parent 796ccb2 commit 82bcd8b
Show file tree
Hide file tree
Showing 11 changed files with 318 additions and 41 deletions.
5 changes: 5 additions & 0 deletions packages/common/src/content/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
deriveLocationFromItem,
getHubRelativeUrl,
} from "./_internal/internalContentUtils";
import { getRelativeWorkspaceUrl } from "../core/getRelativeWorkspaceUrl";

/**
* Enrich a generic search result
Expand Down Expand Up @@ -89,6 +90,10 @@ export async function enrichContentSearchResult(
result.id,
item.typeKeywords
);
result.links.workspaceRelative = getRelativeWorkspaceUrl(
result.type,
result.id
);

return result;
}
44 changes: 44 additions & 0 deletions packages/common/src/search/_internal/getUserGroupsByMembership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { IUser } from "@esri/arcgis-rest-types";
import { IGroupsByMembership } from "../types/IGroupsByMembership";

/**
* Retrieves the user's groups categorized by their membership type.
*
* @param user - The user object containing group information.
* @returns An object categorizing the user's groups into `owner`, `admin`, and `member`.
*
* The function processes the user's groups and classifies them based on the membership type:
* - `owner`: Groups where the user is an owner.
* - `admin`: Groups where the user is an admin.
* - `member`: Groups where the user is a member and the group is not view-only.
*
* Note: The `none` membership type is not considered as it is not expected to be present in the user's groups.
*/

export function getUserGroupsByMembership(user: IUser): IGroupsByMembership {
const response: IGroupsByMembership = {
owner: [],
member: [],
admin: [],
};
// get the user's groups
const userGroups = user.groups || [];
// loop through the groups and determine if the user is an admin or normal member
// and add into the response
userGroups.forEach((group) => {
if (group.userMembership?.memberType === "owner") {
response.owner.push(group.id);
}
if (group.userMembership?.memberType === "admin") {
response.admin.push(group.id);
}
// If user is just a member and the group is not view only
if (group.userMembership?.memberType === "member" && !group.isViewOnly) {
response.member.push(group.id);
}
// there is a `none` option in the userMembership but
// that would never be returned in the user's groups
// so we don't need to check for it
});
return response;
}
47 changes: 23 additions & 24 deletions packages/common/src/search/_internal/getUserGroupsFromQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IUser } from "@esri/arcgis-rest-types";
import { getPredicateValues } from "../getPredicateValues";
import { IGroupsByMembership } from "../types/IGroupsByMembership";
import { IQuery } from "../types/IHubCatalog";
import { getUserGroupsByMembership } from "./getUserGroupsByMembership";
/**
* Given a query and a user, return an object with the set of groups
* that are in the Query, and which the user is a member of, split by
Expand All @@ -16,37 +17,35 @@ export function getUserGroupsFromQuery(
query: IQuery,
user: IUser
): IGroupsByMembership {
const response: IGroupsByMembership = {
let response: IGroupsByMembership = {
owner: [],
member: [],
admin: [],
};
// collect up all the group predicates from the query's filters
// NOTE: this only pulls the all and any predicates
const groups: string[] = getPredicateValues("group", query);
// get the user's groups by membership
const allUserGroups = getUserGroupsByMembership(user);
// if there are groups in the query, we subset the user's groups
// based on the groups in the query
if (groups.length) {
const props: Array<keyof IGroupsByMembership> = [
"owner",
"admin",
"member",
];
groups.forEach((groupId) => {
// check each group type and add the group to the response if the user is a member
props.forEach((prop) => {
if (allUserGroups[prop].includes(groupId)) {
response[prop].push(groupId);
}
});
});
} else {
response = allUserGroups;
}

// get the user's groups
const userGroups = user.groups || [];
// loop through the groups and determine if the user is an admin or normal member
// and add into the response
groups.forEach((groupId) => {
// get the group from the user's groups array
const group = userGroups.find((g) => g.id === groupId);
if (group) {
if (group.userMembership?.memberType === "owner") {
response.owner.push(groupId);
}
if (group.userMembership?.memberType === "admin") {
response.admin.push(groupId);
}
// If user is just a member and the group is not view only
if (group.userMembership?.memberType === "member" && !group.isViewOnly) {
response.member.push(groupId);
}
// there is a `none` option in the userMembership but
// that would never be returned in the user's groups
// so we don't need to check for it
}
});
return response;
}
1 change: 1 addition & 0 deletions packages/common/src/search/_internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from "./hubSearchEvents";
export * from "./hubSearchEventAttendees";
export * from "./getWorkflowForType";
export * from "./getCatalogGroups";
export * from "./getUserGroupsByMembership";
67 changes: 57 additions & 10 deletions packages/common/src/search/getAddContentConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
} from "./_internal/getWorkflowForType";
import { getProp } from "../objects/get-prop";
import { getUserGroupsFromQuery } from "./_internal/getUserGroupsFromQuery";
import { getUserGroupsByMembership } from "./_internal/getUserGroupsByMembership";
import { IAddContentWorkflowConfig } from "./types/AddContentWorkflowTypes";
import { getCatalogGroups } from "./_internal";
import { IGroupsByMembership } from "./types/IGroupsByMembership";

const EmptyAddContentWorkflowConfig: IAddContentWorkflowConfig = {
create: null,
Expand Down Expand Up @@ -144,6 +146,8 @@ function getAddContentConfigForQuery(
return getAddContentConfigForItemQuery(query, context);
} else if (query.targetEntity === "event") {
return getAddContentConfigForEventQuery(query, context);
} else if (query.targetEntity === "group") {
return getAddContentConfigForGroupQuery(query, context);
} else {
const response = cloneObject(EmptyAddContentWorkflowConfig);
response.state = "disabled";
Expand All @@ -152,6 +156,31 @@ function getAddContentConfigForQuery(
}
}

function getAddContentConfigForGroupQuery(
_query: IQuery,
context: IArcGISContext
): IAddContentWorkflowConfig {
const response = cloneObject(EmptyAddContentWorkflowConfig);
// groups can be created or added but the user needs permission
const chk = checkPermission("hub:group:create", context);
if (chk.access) {
response.create = {
targetEntity: "group",
workflow: "create",
types: ["Group"],
};
response.state = "enabled";
} else {
response.state = "disabled";
response.reason = "no-permission";
if (chk.response === "assertion-failed") {
response.reason = "too-many-groups";
}
}

return response;
}

/**
* Specific logic for targetEntity="event"
* @param query
Expand Down Expand Up @@ -210,16 +239,31 @@ function getAddContentConfigForItemQuery(
context: IArcGISContext
): IAddContentWorkflowConfig {
const response = cloneObject(EmptyAddContentWorkflowConfig);
const userGroups = getUserGroupsFromQuery(query, context.currentUser);
if (
!userGroups.owner.length &&
!userGroups.admin.length &&
!userGroups.member.length
) {
response.state = "disabled";
response.reason = "not-in-groups";
return response;
// We need to return groups by membership so we can show the group sharing ux
let userGroups: IGroupsByMembership = {
owner: [],
admin: [],
member: [],
};

// If the query has groups, then we need to check that the user is a member of those groups
const groups: string[] = getPredicateValues("group", query);
if (groups.length) {
userGroups = getUserGroupsFromQuery(query, context.currentUser);
if (
!userGroups.owner.length &&
!userGroups.admin.length &&
!userGroups.member.length
) {
response.state = "disabled";
response.reason = "not-in-groups";
return response;
}
} else {
// we just need all the user's groups, as IGroupsByMembership object
userGroups = getUserGroupsByMembership(context.currentUser);
}

// Get all the types from all the the predicates in all of the filters
let queryTypes = getPredicateValues("type", query);

Expand Down Expand Up @@ -248,7 +292,10 @@ function getAddContentConfigForItemQuery(
}
response.create.types.push(wft.type);
}
if (wft.workflows.includes("existing")) {
// Only show the add existing workflows if the query includes groups
// otherwise the query is defined by other criteria that we likely can't
// handle at this time (e.g. items owned by current user)
if (wft.workflows.includes("existing") && groups.length) {
if (!response.existing) {
response.existing = {
targetEntity: wft.targetEntity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface IAddContentWorkflowConfig {
| "no-permission"
| "not-in-groups"
| "invalid-object"
| "too-many-groups"
| "unsupported-target-entity";
// FUTURE we can add the checks
}
Expand Down
25 changes: 20 additions & 5 deletions packages/common/src/users/_internal/UserBusinessRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const UserPermissions = [
"hub:user:workspace",
"hub:user:workspace:overview",
"hub:user:workspace:settings",
"hub:user:workspace:content",
"hub:user:workspace:groups",
"hub:user:workspace:shared-with-me",
"hub:user:manage",
] as const;

Expand Down Expand Up @@ -51,14 +54,26 @@ export const UserPermissionPolicies: IPermissionPolicy[] = [
},
{
permission: "hub:user:workspace:overview",
// NOTE: other entities like site, group, and content gate this to alpha
// but at least for now this is the only pane for users, so no gating
// availability: ["alpha"],
dependencies: ["hub:user:workspace", "hub:user:view"],
dependencies: ["hub:user:workspace"],
},
{
permission: "hub:user:workspace:content",
availability: ["alpha"],
dependencies: ["hub:user:workspace", "hub:user:owner"],
},
{
permission: "hub:user:workspace:groups",
availability: ["alpha"],
dependencies: ["hub:user:workspace", "hub:user:owner"],
},
{
permission: "hub:user:workspace:shared-with-me",
availability: ["alpha"],
dependencies: ["hub:user:workspace", "hub:user:owner"],
},
{
permission: "hub:user:workspace:settings",
dependencies: ["hub:user:workspace", "hub:user:edit"],
dependencies: ["hub:user:workspace", "hub:user:owner"],
},
{
permission: "hub:user:manage",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { IQuery } from "../../../src";
import { IUser } from "../../../src/events/api";
import { getUserGroupsFromQuery } from "../../../src/search/_internal/getUserGroupsFromQuery";
import * as GetUserGroupsByMembershipModule from "../../../src/search/_internal/getUserGroupsByMembership";

describe("getUserGroupsFromQuery:", () => {
it("returns all user groups by membership if query lacks group predicate", async () => {
const query: IQuery = {
targetEntity: "item",
filters: [],
};
const user: IUser = {
username: "user1",
} as unknown as IUser;

const getUserGroupsByMembershipSpy = spyOn(
GetUserGroupsByMembershipModule,
"getUserGroupsByMembership"
).and.callFake(() => {
return {
owner: ["o00"],
member: ["m00"],
admin: ["a00"],
};
});

const result = await getUserGroupsFromQuery(query, user);
expect(result).toEqual({
owner: ["o00"],
member: ["m00"],
admin: ["a00"],
});
expect(getUserGroupsByMembershipSpy).toHaveBeenCalled();
});

it("returns only groups user is a member of, from groups in query", async () => {
const query: IQuery = {
targetEntity: "item",
filters: [
{
predicates: [
{
group: "m00",
},
],
},
],
};
const user: IUser = {
username: "user1",
} as unknown as IUser;

const getUserGroupsByMembershipSpy = spyOn(
GetUserGroupsByMembershipModule,
"getUserGroupsByMembership"
).and.callFake(() => {
return {
owner: ["o00"],
member: ["m00"],
admin: ["a00"],
};
});
const result = await getUserGroupsFromQuery(query, user);
expect(result).toEqual({
owner: [],
member: ["m00"],
admin: [],
});
expect(getUserGroupsByMembershipSpy).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ describe("hubSearchItems Module |", () => {
links: {
self: "https://www.arcgis.com/home/item.html?id=f4bcc",
siteRelative: "/maps/f4bcc",
workspaceRelative: "/workspace/content/f4bcc",
thumbnail:
"https://www.arcgis.com/sharing/rest/content/items/f4bcc/info/thumbnail/hub_thumbnail_1658341016537.png",
},
Expand Down
Loading

0 comments on commit 82bcd8b

Please sign in to comment.