Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(): handle item type combobox with nested selections #1726

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion packages/common/src/search/_internal/portalSearchItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { enrichProjectSearchResult } from "../../projects";
import { enrichSiteSearchResult } from "../../sites";
import { enrichInitiativeSearchResult } from "../../initiatives/HubInitiatives";
import { enrichTemplateSearchResult } from "../../templates/fetch";
import { IHubRequestOptions } from "../../types";
import { HubFamilies, HubFamily, IHubRequestOptions } from "../../types";

import {
IFilter,
Expand All @@ -31,6 +31,7 @@ import { enrichContentSearchResult } from "../../content/search";
import { cloneObject } from "../../util";
import { getWellknownCollection } from "../wellKnownCatalog";
import { getProp } from "../../objects";
import { getFamilyTypes } from "../../content/get-family";

/**
* @internal
Expand Down Expand Up @@ -485,6 +486,51 @@ export function applyWellKnownItemPredicates(query: IQuery): IQuery {
);
acc = [...acc, ...replacements];
replacedPredicates = true;
} else if (
/**
* NOTE: as of Nov. 26 2024, we have elected to start using the family types
* for a type replacement rather than the entire replacement itself. This updates
* a well-known predicate to only have type values, rather than types, typekeywords, etc etc.
* We also use the family types to replace the type values. Almost all of our current type
* replacements include typekeywords only to also retrieve old items -- i.e. having -- we need to be aware
* that by using family types, we are not including these old items in results in these cases.
*
* This clause is primarily used by custom-build catalogs using the new catalog editor.
*
* We specifically do not say that we have replaced filters here either as we want to leave the
* operator as is.
*/
predicate.type &&
typeof predicate.type !== "string" &&
!Array.isArray(predicate.type)
) {
// we have an IMatchOptions object, so we have to iterate over the all/any/not
Object.keys(predicate.type).forEach((key) => {
const types = predicate.type[key];

// try to reduce the array if it is an array
if (Array.isArray(types)) {
// for each type, try to replace it with the family types if it is an expansion
predicate.type[key] = types.reduce(
(typesAcc: string[], type: string) => {
if (isFamilyExpansionType(type)) {
// we need the type keyword without the dollar sign
const family = type.slice(1);
// get the family types from the given expansion
const familyTypes = getFamilyTypes(family as HubFamily);
typesAcc = [...typesAcc, ...familyTypes];
} else {
typesAcc.push(type);
}
return typesAcc;
},
[]
);
}
});

// keep the updated predicate
acc.push(predicate);
} else {
// this predicate does not have a well-known type
// so we just keep it
Expand Down Expand Up @@ -525,6 +571,27 @@ export function isWellKnownTypeFilter(
return result;
}

/**
* Checks to see if our type is a family expansion,
* i.e. our type is a key in HubFamilies and it begins with a dollar sign
*
* $content, $site, etc.
* @param key
* @returns
*/
export function isFamilyExpansionType(key: string): boolean {
let result = false;
// if we have a key, the first character of the key is a $, and the key without the $ is in Hub Families
if (
key &&
key.charAt(0) === "$" &&
HubFamilies.includes(key.slice(1) as HubFamily)
) {
result = true;
}
return result;
}

/**
* Return the predicates for a well-known type
* @param key
Expand Down
43 changes: 26 additions & 17 deletions packages/common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,23 +177,32 @@ export type IRevertableTaskResult =
| IRevertableTaskSuccess
| IRevertableTaskFailed;

export type HubFamily =
| "app"
| "content"
| "dataset"
| "document"
| "event"
| "feedback"
| "initiative"
| "map"
| "people"
| "site"
| "team"
| "template"
| "project"
| "channel"
| "discussion"
| "eventAttendee";
/**
* All Hub families
*/
export const HubFamilies = [
"app",
"content",
"dataset",
"document",
"event",
"feedback",
"initiative",
"map",
"people",
"site",
"team",
"template",
"project",
"channel",
"discussion",
"eventAttendee",
] as const;

/**
* All Hub families
*/
export type HubFamily = (typeof HubFamilies)[number];

/**
* Visibility levels of a Hub resource
Expand Down
25 changes: 17 additions & 8 deletions packages/common/src/utils/isComboboxItemSelected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@ import { IUiSchemaComboboxItem } from "../core/schemas/types";
* To be used with combobox items; determines if a given combobox item is selected
* @param node the combo box item (to possible check)
* @param values the selected values in the greater combobox options
* @param selectionMode the selection mode of the combobox (this determines if children's selection status determines the parent's selection status)
* @returns whether or not the combo box item is selected
*/
export function isComboboxItemSelected(
node: IUiSchemaComboboxItem,
values: string[]
values: string[],
selectionMode: string
): boolean {
return (
values.includes(node.value) || // if this node is selected
(!!node.children?.length &&
node.children.some((child: IUiSchemaComboboxItem) =>
isComboboxItemSelected(child, values)
))
); // or any of its children are selected
let isSelected = values.includes(node.value);

// we only check if children are selected if the selectionMode is "ancestors"
if (selectionMode === "ancestors") {
isSelected =
isSelected || // if this node is selected
(!!node.children?.length &&
// or any of its children are selected
node.children.some((child: IUiSchemaComboboxItem) =>
isComboboxItemSelected(child, values, selectionMode)
));
}

return isSelected;
}
104 changes: 104 additions & 0 deletions packages/common/test/search/_internal/portalSearchItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
IQuery,
WellKnownCollection,
} from "../../../src";
import { getFamilyTypes } from "../../../src/content/get-family";

import * as SimpleResponse from "../../mocks/portal-search/simple-response.json";
import * as AllTypesResponse from "../../mocks/portal-search/response-with-key-types.json";
import { MOCK_AUTH } from "../../mocks/mock-auth";
import {
applyWellKnownCollectionFilters,
applyWellKnownItemPredicates,
isFamilyExpansionType,
portalSearchItems,
portalSearchItemsAsItems,
WellKnownItemPredicates,
Expand Down Expand Up @@ -484,6 +486,86 @@ describe("portalSearchItems Module:", () => {
expect(chk.filters[0].predicates[0].owner).not.toBeDefined();
expect(chk.filters[0].predicates[1].group).toEqual("00c");
});

it("handles when type is an array", () => {
const qry: IQuery = {
targetEntity: "item",
filters: [
{
predicates: [
{
type: ["Hub Initiative"],
},
],
operation: "AND",
},
],
};

const chk = applyWellKnownItemPredicates(qry);
expect(chk.filters.length).toBe(1);
expect(chk.filters[0].operation).toBe("AND");
expect(chk.filters[0].predicates.length).toBe(1);
expect(chk.filters[0].predicates[0].type).toEqual(["Hub Initiative"]);
});

it("handles a match options object", () => {
const qry: IQuery = {
targetEntity: "item",
filters: [
{
predicates: [
{
type: {
not: "Code attachment",
},
},
],
operation: "AND",
},
],
};

const chk = applyWellKnownItemPredicates(qry);
expect(chk.filters.length).toBe(1);
expect(chk.filters[0].operation).toBe("AND");
expect(chk.filters[0].predicates.length).toBe(1);
expect(chk.filters[0].predicates[0].type.not).toEqual(
"Code attachment"
);
});

it("handles a match options object with expansions", () => {
const qry: IQuery = {
targetEntity: "item",
filters: [
{
predicates: [
{
type: {
any: ["$content", "$app"],
not: ["$site", "Hub Initiative"],
},
},
],
operation: "AND",
},
],
};

const chk = applyWellKnownItemPredicates(qry);
expect(chk.filters.length).toBe(1);
expect(chk.filters[0].operation).toBe("AND");
expect(chk.filters[0].predicates.length).toBe(1);
expect(chk.filters[0].predicates[0].type.any).toEqual([
...getFamilyTypes("content"),
...getFamilyTypes("app"),
]);
expect(chk.filters[0].predicates[0].type.not).toEqual([
...getFamilyTypes("site"),
"Hub Initiative",
]);
});
});
describe("applyWellKnownCollectionFilters", () => {
const baseQuery: IQuery = {
Expand Down Expand Up @@ -525,6 +607,28 @@ describe("portalSearchItems Module:", () => {

expect(result).toEqual(expected);
});

describe("isFamilyExpansionType", () => {
it("returns true for a family type", () => {
expect(isFamilyExpansionType("$content")).toBe(true);
});

it("returns false for a non-family type", () => {
expect(isFamilyExpansionType("$webmap")).toBe(false);
});

it("returns false for a dollar sign type that is not a family", () => {
expect(isFamilyExpansionType("$storymap")).toBe(false);
});

it("returns false for a family type that does not have a dollar sign in front", () => {
expect(isFamilyExpansionType("content")).toBe(false);
});

it("returns false for an empty key", () => {
expect(isFamilyExpansionType("")).toBe(false);
});
});
});
});

Expand Down
72 changes: 53 additions & 19 deletions packages/common/test/utils/isComboboxItemSelected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,65 @@ const nodes = [
describe("isComboboxItemSelected:", () => {
it("will select top level node", async () => {
const selected = ["/Categories/Thing A"];
expect(isComboboxItemSelected(nodes[0], selected)).toBe(true);
expect(isComboboxItemSelected(nodes[1], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[1].children![0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2].children![0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2].children![1], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[0], selected, "single")).toBe(true);
expect(isComboboxItemSelected(nodes[1], selected, "single")).toBe(false);
expect(
isComboboxItemSelected(nodes[1].children![0], selected, "single")
).toBe(false);
expect(isComboboxItemSelected(nodes[2], selected, "single")).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![0], selected, "single")
).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![1], selected, "single")
).toBe(false);
});

it("will select child node and subsequently its parent node", async () => {
it("will select child node and subsequently its parent node with ancestors selection mode", async () => {
const selected = ["/Categories/Thing B/Child of B"];
expect(isComboboxItemSelected(nodes[0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[1], selected)).toBe(true);
expect(isComboboxItemSelected(nodes[1].children![0], selected)).toBe(true);
expect(isComboboxItemSelected(nodes[2], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2].children![0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2].children![1], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[0], selected, "ancestors")).toBe(false);
expect(isComboboxItemSelected(nodes[1], selected, "ancestors")).toBe(true);
expect(
isComboboxItemSelected(nodes[1].children![0], selected, "ancestors")
).toBe(true);
expect(isComboboxItemSelected(nodes[2], selected, "ancestors")).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![0], selected, "ancestors")
).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![1], selected, "ancestors")
).toBe(false);
});

it("will check correct nodes even if there are two nodes with equal labels (but differing values)", async () => {
const selected = ["/Categories/Thing C/Thing A"];
expect(isComboboxItemSelected(nodes[0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[1], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[1].children![0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2], selected)).toBe(true);
expect(isComboboxItemSelected(nodes[2].children![0], selected)).toBe(false);
expect(isComboboxItemSelected(nodes[2].children![1], selected)).toBe(true);
expect(isComboboxItemSelected(nodes[0], selected, "ancestors")).toBe(false);
expect(isComboboxItemSelected(nodes[1], selected, "ancestors")).toBe(false);
expect(
isComboboxItemSelected(nodes[1].children![0], selected, "ancestors")
).toBe(false);
expect(isComboboxItemSelected(nodes[2], selected, "ancestors")).toBe(true);
expect(
isComboboxItemSelected(nodes[2].children![0], selected, "ancestors")
).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![1], selected, "ancestors")
).toBe(true);
});

it("will select child node but will not select parent node with multiple selection mode", async () => {
const selected = ["/Categories/Thing B/Child of B"];
expect(isComboboxItemSelected(nodes[0], selected, "multiple")).toBe(false);
expect(isComboboxItemSelected(nodes[1], selected, "multiple")).toBe(false);
expect(
isComboboxItemSelected(nodes[1].children![0], selected, "multiple")
).toBe(true);
expect(isComboboxItemSelected(nodes[2], selected, "multiple")).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![0], selected, "multiple")
).toBe(false);
expect(
isComboboxItemSelected(nodes[2].children![1], selected, "multiple")
).toBe(false);
});
});