From 202077aa0adf155dcd0ce8ed7a6402da38cab2d2 Mon Sep 17 00:00:00 2001 From: Teale Fristoe Date: Tue, 9 Jul 2024 09:32:52 -0700 Subject: [PATCH] 187864576 v3 DI Collaborative Plugin (#1326) * Handle wacky collaborative create item requests. * Add deleteable field to attribute model. * Prevent editing when __editable__ value is 'false'. * Connect API created datasets to the formula manager. * Remove default collection when empty dataset is created and then collections are added via API requests. * Default to creating new collections as the child-most. * Handle notify itemSearch itemOrder requests. * Use cid for attribute and collection id if provided. * Allow blank strings when item or case searching. * Handle ids specified in create item requests. * Broadcast createCases notifications in response to create item API requests. --- .../web-view/collaborator-utils.test.ts | 6 ++ .../components/web-view/collaborator-utils.ts | 6 +- .../data-interactive-type-utils.ts | 10 +-- .../data-interactive-types.ts | 10 ++- .../handlers/collection-handler.test.ts | 6 +- .../handlers/collection-handler.ts | 13 +++- .../handlers/data-context-handler.ts | 8 +-- .../handlers/di-handler-utils.ts | 4 +- .../handlers/item-handler.test.ts | 17 +++-- .../data-interactive/handlers/item-handler.ts | 70 +++++++++++++++++-- .../handlers/item-search-handler.test.ts | 22 ++++++ .../handlers/item-search-handler.ts | 45 +++++++++++- .../resource-parser-utils.test.ts | 6 ++ .../data-interactive/resource-parser-utils.ts | 5 +- .../data-interactive/resource-parser.test.ts | 7 +- .../codap/create-codap-document.test.ts | 1 + v3/src/models/data/attribute.ts | 4 ++ v3/src/models/data/data-set-notifications.ts | 10 +++ v3/src/v2/codap-v2-types.ts | 5 +- 19 files changed, 217 insertions(+), 38 deletions(-) diff --git a/v3/src/components/web-view/collaborator-utils.test.ts b/v3/src/components/web-view/collaborator-utils.test.ts index ffccdcd1ca..8e3eb5a1b8 100644 --- a/v3/src/components/web-view/collaborator-utils.test.ts +++ b/v3/src/components/web-view/collaborator-utils.test.ts @@ -52,9 +52,12 @@ describe('Collaborator Utils', () => { // item1 and item5 are in case2 const item1Id = dataSet.itemIds[1] const item5Id = dataSet.itemIds[5] + // item3 is used to test a blank value in __editable__ + const item3Id = dataSet.itemIds[3] dataSet.setCaseValues([ { __id__: item0Id, [editableAttribute.id]: "true" }, { __id__: item1Id, [editableAttribute.id]: "true" }, + { __id__: item4Id, [editableAttribute.id]: "false" }, { __id__: item5Id, [editableAttribute.id]: "true" } ]) dataSet.validateCaseGroups() @@ -72,6 +75,7 @@ describe('Collaborator Utils', () => { checkItem(item4Id, true) checkItem(item1Id, true) checkItem(item5Id, true) + checkItem(item3Id, true) expect(isCaseEditable(dataSet, case0Id)).toBe(true) expect(isCaseEditable(dataSet, case2Id)).toBe(true) @@ -85,6 +89,7 @@ describe('Collaborator Utils', () => { checkItem(item4Id, true) checkItem(item1Id, true) checkItem(item5Id, true) + checkItem(item3Id, true) expect(isCaseEditable(dataSet, case0Id)).toBe(true) expect(isCaseEditable(dataSet, case2Id)).toBe(true) @@ -95,6 +100,7 @@ describe('Collaborator Utils', () => { checkItem(item4Id, false) checkItem(item1Id, true) checkItem(item5Id, true) + checkItem(item3Id, false) // A case is only editable when all of its items are editable expect(isCaseEditable(dataSet, case0Id)).toBe(false) expect(isCaseEditable(dataSet, case2Id)).toBe(true) diff --git a/v3/src/components/web-view/collaborator-utils.ts b/v3/src/components/web-view/collaborator-utils.ts index f1dd387f74..b80ccadbde 100644 --- a/v3/src/components/web-view/collaborator-utils.ts +++ b/v3/src/components/web-view/collaborator-utils.ts @@ -25,7 +25,7 @@ export function getPreventAttributeDeletion(dataset: IDataSet) { } // respectEditableItemAttribute affects a dataset's items in several ways, based on a special attribute named -// "__editable__". If an item's value in "__editable__" is falsy, the following hold true: +// "__editable__". If an item's value in "__editable__" is falsy or "false", the following hold true: // - Its cells cannot be edited. // - It cannot be deleted using mass delete options from the trash menu. export function getRespectEditableItemAttribute(dataset: IDataSet) { @@ -36,8 +36,8 @@ export function isItemEditable(dataset: IDataSet, itemId: string) { if (!getRespectEditableItemAttribute(dataset)) return true const editableAttribute = dataset.getAttributeByName("__editable__") if (!editableAttribute) return true - // TODO Handle editable attribute with formula? - return !!dataset.getStrValue(itemId, editableAttribute.id) + const strValue = dataset.getStrValue(itemId, editableAttribute.id) + return !!strValue && strValue.toLowerCase() !== "false" } // caseId can be a case or item id diff --git a/v3/src/data-interactive/data-interactive-type-utils.ts b/v3/src/data-interactive/data-interactive-type-utils.ts index d257ac067d..82e75aa58b 100644 --- a/v3/src/data-interactive/data-interactive-type-utils.ts +++ b/v3/src/data-interactive/data-interactive-type-utils.ts @@ -6,7 +6,7 @@ import { ICase } from "../models/data/data-set-types" import { v2ModelSnapshotFromV2ModelStorage } from "../models/data/v2-model" import { IGlobalValue } from "../models/global/global-value" import { getSharedCaseMetadataFromDataset } from "../models/shared/shared-data-utils" -import { kAttrIdPrefix, maybeToV2Id, toV2Id } from "../utilities/codap-utils" +import { kAttrIdPrefix, maybeToV2Id, toV2Id, toV3AttrId } from "../utilities/codap-utils" import { ICodapV2AttributeV3, ICodapV2CollectionV3, ICodapV2DataContextV3, v3TypeFromV2TypeString } from "../v2/codap-v2-types" @@ -16,8 +16,10 @@ import { getCaseValues } from "./data-interactive-utils" export function convertValuesToAttributeSnapshot(_values: DISingleValues): IAttributeSnapshot | undefined { const values = _values as DIAttribute if (values.name) { + const id = values.id != null ? toV3AttrId(values.id) : values.cid return { ...v2ModelSnapshotFromV2ModelStorage(kAttrIdPrefix, values), + id, userType: v3TypeFromV2TypeString(values.type), // defaultMin: values.defaultMin, // TODO defaultMin not a part of IAttribute yet // defaultMax: values.defaultMax, // TODO defaultMax not a part of IAttribute yet @@ -27,7 +29,7 @@ export function convertValuesToAttributeSnapshot(_values: DISingleValues): IAttr editable: values.editable == null || !!values.editable, // hidden is part of metadata, not the attribute model // renameable: values.renameable, // TODO renameable not part of IAttribute yet - // deleteable: values.deleteable, // TODO deleteable not part of IAttribute yet + deleteable: values.deleteable, formula: values.formula ? { display: values.formula } : undefined, // deletedFormula: values.deletedFormula, // TODO deletedFormula not part of IAttribute. Should it be? precision: values.precision == null || values.precision === "" ? undefined : +values.precision, @@ -104,7 +106,7 @@ export function getCaseRequestResultValues(c: ICase, dataContext: IDataSet): DIG export function convertAttributeToV2(attribute: IAttribute, dataContext?: IDataSet): ICodapV2AttributeV3 { const metadata = dataContext && getSharedCaseMetadataFromDataset(dataContext) - const { name, type, title, description, editable, id, precision } = attribute + const { name, type, title, description, deleteable, editable, id, precision } = attribute const v2Id = toV2Id(id) return { name, @@ -120,7 +122,7 @@ export function convertAttributeToV2(attribute: IAttribute, dataContext?: IDataS editable, hidden: (attribute && metadata?.hidden.get(attribute.id)) ?? false, renameable: true, // TODO What should this be? - deleteable: true, // TODO What should this be? + deleteable, formula: attribute.formula?.display, // deletedFormula: self.deletedFormula, // TODO What should this be? guid: v2Id, diff --git a/v3/src/data-interactive/data-interactive-types.ts b/v3/src/data-interactive/data-interactive-types.ts index f37d8dc0e9..980dcad2d7 100644 --- a/v3/src/data-interactive/data-interactive-types.ts +++ b/v3/src/data-interactive/data-interactive-types.ts @@ -96,6 +96,7 @@ export interface DIInteractiveFrame { version?: string } export type DIItem = DICaseValues +export type DIItemValues = DIItem | { id?: string | number, values: DIItem } export interface DICreateCollection { name?: string title?: string @@ -132,6 +133,9 @@ export interface DIUpdateDataContext extends DIDataContext { export interface DINotification { request?: string } +export interface DIItemSearchNotify { + itemOrder?: "first" | "last" | number[] +} export interface DIResources { attribute?: IAttribute @@ -158,8 +162,8 @@ export interface DIResources { // types for values accepted as inputs by the API export type DISingleValues = DIAttribute | DIAttributeLocationValues | DICase | DIDataContext | - DIGlobal | DIInteractiveFrame | DICreateCollection | DINewCase | DIUpdateCase | DINotification | - V2SpecificComponent + DIGlobal | DIInteractiveFrame | DIItemValues | DICreateCollection | DINewCase | DIUpdateCase | + DINotification | DIItemSearchNotify | V2SpecificComponent export type DIValues = DISingleValues | DISingleValues[] | number | string[] // types returned as outputs by the API @@ -176,7 +180,7 @@ export interface DISuccessResult { success: true values?: DIResultValues caseIDs?: number[] - itemIDs?: string[] + itemIDs?: number[] } export interface DIErrorResult { diff --git a/v3/src/data-interactive/handlers/collection-handler.test.ts b/v3/src/data-interactive/handlers/collection-handler.test.ts index f9a0fbb1ed..934a2b1438 100644 --- a/v3/src/data-interactive/handlers/collection-handler.test.ts +++ b/v3/src/data-interactive/handlers/collection-handler.test.ts @@ -32,9 +32,9 @@ describe("DataInteractive CollectionHandler", () => { const rightResult = handler.create?.({ dataContext }, { name: "right", attributes: [{ name: "a4" }] }) expect(rightResult?.success).toBe(true) expect(dataset.collections.length).toBe(4) - expect(dataset.collections[2].name).toBe("right") - expect(dataset.collections[2].attributes.length).toBe(1) - expect(dataset.collections[2].attributes[0]?.name).toBe("a4") + expect(dataset.collections[3].name).toBe("right") + expect(dataset.collections[3].attributes.length).toBe(1) + expect(dataset.collections[3].attributes[0]?.name).toBe("a4") // Add a left-most collection // Add attributes with attrs field diff --git a/v3/src/data-interactive/handlers/collection-handler.ts b/v3/src/data-interactive/handlers/collection-handler.ts index e0449f0bbd..f77d2e7d02 100644 --- a/v3/src/data-interactive/handlers/collection-handler.ts +++ b/v3/src/data-interactive/handlers/collection-handler.ts @@ -23,6 +23,10 @@ export const diCollectionHandler: DIHandler = { const newCollections: ICollectionModel[] = [] dataContext.applyModelChange(() => { + // Find the empty default collection if it exists. It will be removed if any collections are added. + const oldCollection = dataContext.collections.length === 1 && dataContext.collections[0] + const emptyCollection = oldCollection && oldCollection.attributes.length === 0 ? oldCollection : undefined + collections.forEach(collection => { const { name, title, parent, attributes, attrs } = collection as DICreateCollection // Collections require a name, so bail if one isn't included @@ -54,7 +58,11 @@ export const diCollectionHandler: DIHandler = { } } - const newCollection = dataContext.addCollection({ name, _title: title }, { before: beforeCollectionId }) + // If no before collection is specified, we have to explicitly put the new collection after the + // child-most collection + const options = beforeCollectionId ? { before: beforeCollectionId } + : { after: dataContext.collectionIds[dataContext.collectionIds.length - 1] } + const newCollection = dataContext.addCollection({ name, _title: title }, options) newCollections.push(newCollection) // Attributes can be specified in both attributes and attrs @@ -70,6 +78,9 @@ export const diCollectionHandler: DIHandler = { returnValues.push({ id: toV2Id(newCollection.id), name: newCollection.name }) }) + + // Remove the empty default collection if any collections were added + if (emptyCollection && dataContext.collections.length > 1) dataContext.removeCollection(emptyCollection) }, { notifications: () => newCollections.map(newCollection => createCollectionNotification(newCollection, dataContext)) }) diff --git a/v3/src/data-interactive/handlers/data-context-handler.ts b/v3/src/data-interactive/handlers/data-context-handler.ts index 80901b4076..ec9cb3d673 100644 --- a/v3/src/data-interactive/handlers/data-context-handler.ts +++ b/v3/src/data-interactive/handlers/data-context-handler.ts @@ -4,7 +4,7 @@ import { DataSet } from "../../models/data/data-set" import { dataContextCountChangedNotification, dataContextDeletedNotification } from "../../models/data/data-set-notifications" -import { getSharedCaseMetadataFromDataset } from "../../models/shared/shared-data-utils" +import { getFormulaManager } from "../../models/tiles/tile-environment" import { hasOwnProperty } from "../../utilities/js-utils" import { registerDIHandler } from "../data-interactive-handler" import { @@ -30,15 +30,15 @@ export const diDataContextHandler: DIHandler = { return document.applyModelChange(() => { // Create dataset const dataSet = DataSet.create({ description, name, _title: title }) - gDataBroker.addDataSet(dataSet) - const metadata = getSharedCaseMetadataFromDataset(dataSet) + const { caseMetadata } = gDataBroker.addDataSet(dataSet) + getFormulaManager(document)?.addDataSet(dataSet) if (collections?.length) { // remove the default collection dataSet.removeCollection(dataSet.collections[0]) // Create and add collections and attributes - collections.forEach(v2collection => createCollection(v2collection, dataSet, metadata)) + collections.forEach(v2collection => createCollection(v2collection, dataSet, caseMetadata)) } return { diff --git a/v3/src/data-interactive/handlers/di-handler-utils.ts b/v3/src/data-interactive/handlers/di-handler-utils.ts index bcab388ea2..52f954b3bf 100644 --- a/v3/src/data-interactive/handlers/di-handler-utils.ts +++ b/v3/src/data-interactive/handlers/di-handler-utils.ts @@ -24,10 +24,10 @@ export function createCollection(v2collection: DICollection, dataContext: IDataS // TODO How should we handle duplicate names? // TODO How should we handle missing names? // TODO Handle labels - const { attrs, name: collectionName, title: collectionTitle } = v2collection + const { attrs, cid, name: collectionName, title: collectionTitle } = v2collection const _title = v2NameTitleToV3Title(collectionName ?? "", collectionTitle) const options: IAddCollectionOptions = { after: dataContext.childCollection?.id } - const collection = dataContext.addCollection({ name: collectionName, _title }, options) + const collection = dataContext.addCollection({ id: cid, name: collectionName, _title }, options) attrs?.forEach(attr => { createAttribute(attr, dataContext, collection, metadata) diff --git a/v3/src/data-interactive/handlers/item-handler.test.ts b/v3/src/data-interactive/handlers/item-handler.test.ts index 6bc8acd616..3fafaf9e52 100644 --- a/v3/src/data-interactive/handlers/item-handler.test.ts +++ b/v3/src/data-interactive/handlers/item-handler.test.ts @@ -8,7 +8,7 @@ describe("DataInteractive ItemHandler", () => { const handler = diItemHandler it("create works", () => { - const { dataset } = setupTestDataset() + const { dataset, a1 } = setupTestDataset() expect(handler.create?.({}).success).toBe(false) @@ -19,7 +19,7 @@ describe("DataInteractive ItemHandler", () => { const result1 = handler.create?.(resources, { a1: "d", a2: "w", a3: 7 } as DIItem) as DISuccessResult expect(result1.success).toBe(true) expect(result1.itemIDs?.length).toBe(1) - expect(result1.itemIDs?.[0]).toBe(dataset.items[6].__id__) + expect(result1.itemIDs?.[0]).toBe(toV2Id(dataset.items[6].__id__)) // Create multiple items const result2 = handler.create?.(resources, [ @@ -28,8 +28,17 @@ describe("DataInteractive ItemHandler", () => { ] as DIItem[]) as DISuccessResult expect(result2.success).toBe(true) expect(result2.itemIDs?.length).toBe(2) - expect(result2.itemIDs?.[0]).toBe(dataset.items[7].__id__) - expect(result2.itemIDs?.[1]).toBe(dataset.items[8].__id__) + expect(result2.itemIDs?.[0]).toBe(toV2Id(dataset.items[7].__id__)) + expect(result2.itemIDs?.[1]).toBe(toV2Id(dataset.items[8].__id__)) + + // Create item in Collaborative format + const id = "testId123" + const result3 = handler.create?.(resources, { id, values: { a1: "g", a2: "t", a3: 10 } }) as DISuccessResult + expect(result3?.success).toBe(true) + expect(result3.itemIDs?.length).toBe(1) + expect(dataset.items[9].__id__).toBe(id) + expect(result3.itemIDs?.[0]).toBe(toV2Id(id)) + expect(a1.value(9)).toBe("g") }) it("delete works", () => { diff --git a/v3/src/data-interactive/handlers/item-handler.ts b/v3/src/data-interactive/handlers/item-handler.ts index 229695d5c4..e2f49e4bdd 100644 --- a/v3/src/data-interactive/handlers/item-handler.ts +++ b/v3/src/data-interactive/handlers/item-handler.ts @@ -1,6 +1,7 @@ +import { createCasesNotification } from "../../models/data/data-set-notifications" +import { toV2Id, toV3ItemId } from "../../utilities/codap-utils" import { registerDIHandler } from "../data-interactive-handler" -import { DICaseValues, DIHandler, DIResources, DIValues } from "../data-interactive-types" -import { attrNamesToIds } from "../data-interactive-utils" +import { DIHandler, DIItem, DIItemValues, DIResources, DIValues } from "../data-interactive-types" import { deleteItem, getItem, updateCaseBy, updateCasesBy } from "./handler-functions" import { dataContextNotFoundResult, valuesRequiredResult } from "./di-results" @@ -10,12 +11,69 @@ export const diItemHandler: DIHandler = { if (!dataContext) return dataContextNotFoundResult if (!values) return valuesRequiredResult - const items = (Array.isArray(values) ? values : [values]) as DICaseValues[] - const itemIDs = dataContext.addCases(items.map(item => attrNamesToIds(item, dataContext))) + const _items = (Array.isArray(values) ? values : [values]) as DIItemValues[] + const items: DIItem[] = [] + // Some plugins (Collaborative) create items with values like [{ values: { ... } }] instead of + // like [{ ... }], so we accommodate that extra layer of indirection here. + _items.forEach(item => { + let newItem: DIItem + if (typeof item.values === "object") { + newItem = item.values + } else { + newItem = item as DIItem + } + + // If an id is specified, we need to put it in the right format + // The Collaborative plugin makes use of this feature + const { id } = item + if (!newItem.__id__ && (typeof id === "string" || typeof id === "number")) { + newItem.__id__ = toV3ItemId(id) + } + items.push(newItem) + }) + + const newCaseIds: Record = {} + let itemIDs: string[] = [] + dataContext.applyModelChange(() => { + // Get case ids from before new items are added + const oldCaseIds: Record> = {} + dataContext.collections.forEach(collection => { + oldCaseIds[collection.id] = new Set(collection.caseIds.map(caseId => toV2Id(caseId))) + }) + + // Add items and update cases + itemIDs = dataContext.addCases(items, { canonicalize: true }) + dataContext.validateCaseGroups() + + // Find newly added cases by comparing current cases to previous cases + dataContext.collections.forEach(collection => { + newCaseIds[collection.id] = [] + collection.caseIds.forEach(caseId => { + const v2CaseId = toV2Id(caseId) + if (!oldCaseIds[collection.id].has(v2CaseId)) newCaseIds[collection.id].push(v2CaseId) + }) + }) + }, { + notifications: () => { + const notifications = [] + for (const collectionId in newCaseIds) { + const caseIds = newCaseIds[collectionId] + if (caseIds.length > 0) { + notifications.push(createCasesNotification(caseIds, dataContext)) + } + } + return notifications + } + }) + + const caseIDs: number[] = [] + for (const collectionId in newCaseIds) { + caseIDs.concat(newCaseIds[collectionId]) + } return { success: true, - // caseIDs, // TODO This should include all cases created, both grouped and ungrouped - itemIDs + caseIDs, + itemIDs: itemIDs.map(itemID => toV2Id(itemID)) } }, diff --git a/v3/src/data-interactive/handlers/item-search-handler.test.ts b/v3/src/data-interactive/handlers/item-search-handler.test.ts index 80dbf882c0..ce1ca3922c 100644 --- a/v3/src/data-interactive/handlers/item-search-handler.test.ts +++ b/v3/src/data-interactive/handlers/item-search-handler.test.ts @@ -43,4 +43,26 @@ describe("DataInteractive ItemSearchHandler", () => { ) }) }) + + it("notify works", () => { + const { dataset: dataContext } = setupTestDataset() + const item = dataContext.items[1] + const itemSearch = [item] + const last = { itemOrder: "last" } + const notify = handler.notify! + + expect(notify({ itemSearch }, last).success).toBe(false) + expect(notify({ dataContext }, last).success).toBe(false) + expect(notify({ dataContext, itemSearch }).success).toBe(false) + expect(notify({ dataContext, itemSearch }, {}).success).toBe(false) + + expect(notify({ dataContext, itemSearch }, last).success).toBe(true) + expect(dataContext.itemIds[dataContext.itemIds.length - 1]).toBe(item.__id__) + + expect(notify({ dataContext, itemSearch }, { itemOrder: "first" }).success).toBe(true) + expect(dataContext.itemIds[0]).toBe(item.__id__) + + expect(notify({ dataContext, itemSearch }, { itemOrder: [1] }).success).toBe(true) + expect(dataContext.itemIds[1]).toBe(item.__id__) + }) }) diff --git a/v3/src/data-interactive/handlers/item-search-handler.ts b/v3/src/data-interactive/handlers/item-search-handler.ts index b9e3897c06..50a4838083 100644 --- a/v3/src/data-interactive/handlers/item-search-handler.ts +++ b/v3/src/data-interactive/handlers/item-search-handler.ts @@ -1,8 +1,10 @@ import { toV2Id } from "../../utilities/codap-utils" +import { t } from "../../utilities/translation/translate" import { registerDIHandler } from "../data-interactive-handler" import { getV2ItemResult } from "../data-interactive-type-utils" -import { DIHandler, DIResources } from "../data-interactive-types" -import { couldNotParseQueryResult, dataContextNotFoundResult } from "./di-results" +import { ICase } from "../../models/data/data-set-types" +import { DIHandler, DIItemSearchNotify, DIResources, DIValues } from "../data-interactive-types" +import { couldNotParseQueryResult, dataContextNotFoundResult, errorResult, valuesRequiredResult } from "./di-results" export const diItemSearchHandler: DIHandler = { delete(resources: DIResources) { @@ -25,6 +27,45 @@ export const diItemSearchHandler: DIHandler = { const values = itemSearch.map(aCase => getV2ItemResult(dataContext, aCase.__id__)) return { success: true, values } + }, + + notify(resources: DIResources, values?: DIValues) { + const { dataContext, itemSearch } = resources + if (!dataContext) return dataContextNotFoundResult + if (!itemSearch) return couldNotParseQueryResult + + if (!values) return valuesRequiredResult + const { itemOrder } = values as DIItemSearchNotify + if (!itemOrder) return errorResult(t("V3.DI.Error.fieldRequired", { vars: ["Notify", "itemSearch", "itemOrder"] })) + + dataContext.applyModelChange(() => { + const itemIds = itemSearch.map(({ __id__ }) => __id__) + if (Array.isArray(itemOrder)) { + // When itemOrder is an array, move each item to the specified index in order + // Note that this means later items can impact the position of earlier items + itemIds.forEach((id, index) => { + const itemIndex = itemOrder[index] + const item = dataContext.getItem(id) as ICase + if (item && itemIndex && isFinite(itemIndex)) { + dataContext.removeCases([id]) + const before = dataContext.itemIds[itemIndex] + dataContext.addCases([item], { before }) + dataContext.validateCaseGroups() + } + }) + } else { + // Otherwise, move all the items to the beginning or end + const items = itemIds.map(id => dataContext.getItem(id)) as ICase[] + dataContext.removeCases(itemIds) + const options = itemOrder === "first" && dataContext.itemIds.length > 0 + ? { before: dataContext.itemIds[0] } + : undefined + dataContext.addCases(items, options) + } + dataContext.validateCaseGroups() + }) + + return { success: true } } } diff --git a/v3/src/data-interactive/resource-parser-utils.test.ts b/v3/src/data-interactive/resource-parser-utils.test.ts index 3b81af4d2e..7a1cf61488 100644 --- a/v3/src/data-interactive/resource-parser-utils.test.ts +++ b/v3/src/data-interactive/resource-parser-utils.test.ts @@ -24,5 +24,11 @@ describe("DataInteractive ResourceParser Utilities", () => { expect(legalResult2.valid).toBe(true) expect(legalResult2.left?.value).toBe(false) expect(legalResult2.right?.attr?.id).toBe(a2.id) + + // The right operand can be blank + const emptyResult = parseSearchQuery("a1==", dataset) + expect(emptyResult.valid).toBe(true) + expect(emptyResult.left?.attr?.id).toBe(a1.id) + expect(emptyResult.right?.value).toBe("") }) }) diff --git a/v3/src/data-interactive/resource-parser-utils.ts b/v3/src/data-interactive/resource-parser-utils.ts index 6cf5a1f9ab..8641cad714 100644 --- a/v3/src/data-interactive/resource-parser-utils.ts +++ b/v3/src/data-interactive/resource-parser-utils.ts @@ -11,7 +11,7 @@ export function parseSearchQuery(query: string, dataContextOrCollection?: IDataS } // RegExs here and below taken from CODAP v2 - const matches = query.match(/([^=!<>]+)(==|!=|<=|<|>=|>)([^=!<>]+)/) + const matches = query.match(/([^=!<>]+)(==|!=|<=|<|>=|>)([^=!<>]*)/) if (!matches) return { valid: false, func: () => false } const parseOperand = (_rawValue: string) => { @@ -19,7 +19,8 @@ export function parseSearchQuery(query: string, dataContextOrCollection?: IDataS const rawValue = _rawValue.replace(/^\s+|\s+$/g, '') const numberValue = Number(rawValue) - const value = rawValue === "true" ? true + const value = rawValue === "" ? "" + : rawValue === "true" ? true : rawValue === "false" ? false : isNaN(numberValue) ? rawValue : numberValue diff --git a/v3/src/data-interactive/resource-parser.test.ts b/v3/src/data-interactive/resource-parser.test.ts index c16e25d890..af6e7666df 100644 --- a/v3/src/data-interactive/resource-parser.test.ts +++ b/v3/src/data-interactive/resource-parser.test.ts @@ -108,7 +108,7 @@ describe("DataInteractive ResourceParser", () => { expect(resolve(`dataContext[data].collection[collection2].caseSearch`).caseSearch).toBeUndefined() expect(resolve(`dataContext[data].collection[collection2].caseSearch[]`).caseSearch).toBeUndefined() expect(resolve(`dataContext[data].collection[collection2].caseSearch[bad search]`).caseSearch).toBeUndefined() - expect(resolve(`dataContext[data].collection[collection2].caseSearch[a2>]`).caseSearch).toBeUndefined() + expect(resolve(`dataContext[data].collection[collection2].caseSearch[>a2]`).caseSearch).toBeUndefined() expect(resolve(`dataContext[data].collection[collection2].caseSearch[1!=2]`).caseSearch).toBeUndefined() expect(resolve(`dataContext[data].collection[collection2].caseSearch[a1==a]`).caseSearch).toBeUndefined() @@ -146,7 +146,7 @@ describe("DataInteractive ResourceParser", () => { expect(resolve(`dataContext[data].itemSearch`).itemSearch).toBeUndefined() expect(resolve(`dataContext[data].itemSearch[]`).itemSearch).toBeUndefined() expect(resolve(`dataContext[data].itemSearch[bad search]`).itemSearch).toBeUndefined() - expect(resolve(`dataContext[data].itemSearch[a1>]`).itemSearch).toBeUndefined() + expect(resolve(`dataContext[data].itemSearch[>a1]`).itemSearch).toBeUndefined() expect(resolve(`dataContext[data].itemSearch[!=2]`).itemSearch).toBeUndefined() const allResult = resolve(`dataContext[data].itemSearch[*]`) @@ -157,6 +157,9 @@ describe("DataInteractive ResourceParser", () => { const a2Result = resolve(`dataContext[data].itemSearch[ x < a2 ]`) expect(a2Result.itemSearch?.length).toBe(4) + + const emptyResult = resolve(`dataContext[data].itemSearch[a3==]`) + expect(emptyResult.itemSearch?.length).toBe(0) }) it("finds itemByCaseID", () => { diff --git a/v3/src/models/codap/create-codap-document.test.ts b/v3/src/models/codap/create-codap-document.test.ts index 0afbb65d01..cca5c97e4f 100644 --- a/v3/src/models/codap/create-codap-document.test.ts +++ b/v3/src/models/codap/create-codap-document.test.ts @@ -83,6 +83,7 @@ describe("createCodapDocument", () => { clientKey: "", id: "test-8", name: "a", + deleteable: true, editable: true, values: ["1", "2", "3"] } diff --git a/v3/src/models/data/attribute.ts b/v3/src/models/data/attribute.ts index c3368ee608..4795261f4e 100644 --- a/v3/src/models/data/attribute.ts +++ b/v3/src/models/data/attribute.ts @@ -66,6 +66,7 @@ export const Attribute = V2Model.named("Attribute").props({ // userFormat: types.maybe(types.string), units: types.maybe(types.string), precision: types.maybe(types.number), + deleteable: true, editable: true, formula: types.maybe(Formula), // simple array -- _not_ MST all the way down to the array elements @@ -258,6 +259,9 @@ export const Attribute = V2Model.named("Attribute").props({ setPrecision(precision?: number) { self.precision = precision }, + setDeleteable(deleteable: boolean) { + self.deleteable = deleteable + }, setEditable(editable: boolean) { self.editable = editable }, diff --git a/v3/src/models/data/data-set-notifications.ts b/v3/src/models/data/data-set-notifications.ts index 04ba2d5b5c..09fe1030da 100644 --- a/v3/src/models/data/data-set-notifications.ts +++ b/v3/src/models/data/data-set-notifications.ts @@ -93,6 +93,16 @@ export function updateAttributesNotification(attrs: IAttribute[], data?: IDataSe return attributeNotification("updateAttributes", data, attrs.map(attr => attr.id), attrs) } +export function createCasesNotification(caseIDs: number[], data?: IDataSet) { + const caseID = caseIDs.length > 0 ? caseIDs[0] : undefined + const result = { + success: true, + caseIDs, + caseID + } + return notification("createCases", result, data) +} + export function updateCasesNotification(data: IDataSet, cases?: ICase[]) { const caseIDs = cases?.map(c => toV2Id(c.__id__)) const result = { diff --git a/v3/src/v2/codap-v2-types.ts b/v3/src/v2/codap-v2-types.ts index 34b350ce06..7d7f568f37 100644 --- a/v3/src/v2/codap-v2-types.ts +++ b/v3/src/v2/codap-v2-types.ts @@ -62,8 +62,9 @@ export interface ICodapV2Collection { attrs: ICodapV2Attribute[] cases: ICodapV2Case[] caseName: string | null - childAttrName: string | null, - collapseChildren: boolean | null, + childAttrName: string | null + cid?: string + collapseChildren: boolean | null defaults?: { xAttr: string yAttr: string