Skip to content

Commit

Permalink
187864576 v3 DI Collaborative Plugin (#1326)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
tealefristoe authored Jul 9, 2024
1 parent 5c686d0 commit 202077a
Show file tree
Hide file tree
Showing 19 changed files with 217 additions and 38 deletions.
6 changes: 6 additions & 0 deletions v3/src/components/web-view/collaborator-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions v3/src/components/web-view/collaborator-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions v3/src/data-interactive/data-interactive-type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions v3/src/data-interactive/data-interactive-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -176,7 +180,7 @@ export interface DISuccessResult {
success: true
values?: DIResultValues
caseIDs?: number[]
itemIDs?: string[]
itemIDs?: number[]
}

export interface DIErrorResult {
Expand Down
6 changes: 3 additions & 3 deletions v3/src/data-interactive/handlers/collection-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion v3/src/data-interactive/handlers/collection-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
})
Expand Down
8 changes: 4 additions & 4 deletions v3/src/data-interactive/handlers/data-context-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions v3/src/data-interactive/handlers/di-handler-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions v3/src/data-interactive/handlers/item-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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, [
Expand All @@ -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", () => {
Expand Down
70 changes: 64 additions & 6 deletions v3/src/data-interactive/handlers/item-handler.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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<string, number[]> = {}
let itemIDs: string[] = []
dataContext.applyModelChange(() => {
// Get case ids from before new items are added
const oldCaseIds: Record<string, Set<number>> = {}
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))
}
},

Expand Down
22 changes: 22 additions & 0 deletions v3/src/data-interactive/handlers/item-search-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
})
})
Loading

0 comments on commit 202077a

Please sign in to comment.