Repository: concord-consortium/codap
Pull request #1914: CODAP-605 Date Slider API
Author: login: scytacki
Labels: name: v3
description: CODAP v3
name: run regression
Created: 2025-04-18T11:27:27Z | Merged: 2025-04-19T13:58:18Z

This also adds support for updating sliders, and adds validation of many of the properties sent during create or update.

A question about the new fields is the use of strings for values. The existing fields of animationDirection and animationMode use indexes. The strings are consistent with the underlying modeling and in other parts of the CODAP api string are used.

CODAP-605


Diff:
diff --git a/v3/src/components/slider/component-handler-slider.test.ts b/v3/src/components/slider/component-handler-slider.test.ts
deleted file mode 100644
index a5bc5271c0..0000000000
--- a/v3/src/components/slider/component-handler-slider.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { V2GetSlider } from "../../data-interactive/data-interactive-component-types"
-import { DIComponentInfo } from "../../data-interactive/data-interactive-types"
-import { diComponentHandler } from "../../data-interactive/handlers/component-handler"
-import { testGetComponent } from "../../data-interactive/handlers/component-handler-test-utils"
-import { appState } from "../../models/app-state"
-import { getSharedModelManager } from "../../models/tiles/tile-environment"
-import { getGlobalValueManager } from "../../models/global/global-value-manager"
-import { toV3Id } from "../../utilities/codap-utils"
-import { ISliderModel, isSliderModel } from "./slider-model"
-import { kSliderIdPrefix } from "./slider-registration"
-import { AnimationDirections, AnimationModes } from "./slider-types"
-
-
-describe("DataInteractive ComponentHandler Slider", () => {
-  const documentContent = appState.document.content!
-  const globalManager = getGlobalValueManager(getSharedModelManager(documentContent))
-  globalManager?.addValueSnapshot({ name: "global", value: 10 })
-  const handler = diComponentHandler
-
-  it("create and get slider work", async () => {
-    // Create slider with no value specified
-    expect(documentContent.tileMap.size).toBe(0)
-    const newValueResult = handler.create!({}, { type: "slider" })
-    expect(newValueResult.success).toBe(true)
-    expect(documentContent.tileMap.size).toBe(1)
-    const newValueResultValues = newValueResult.values as DIComponentInfo
-    const newValueTile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, newValueResultValues.id!))!
-    expect(newValueTile).toBeDefined()
-    expect(isSliderModel(newValueTile.content)).toBe(true)
-
-    // Delete slider tile
-    const deleteResult = handler.delete!({ component: newValueTile })
-    expect(deleteResult.success).toBe(true)
-    expect(documentContent.tileMap.size).toBe(0)
-
-    // Create slider with value and other options specified
-    const oldValueResult = handler.create!({}, {
-      type: "slider", globalValueName: "global", lowerBound: -100, upperBound: 100,
-      animationDirection: 0, animationMode: 1
-    })
-    expect(oldValueResult.success).toBe(true)
-    expect(documentContent.tileMap.size).toBe(1)
-    const oldValueResultValues = oldValueResult.values as DIComponentInfo
-    const oldValueTile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, oldValueResultValues.id!))!
-    expect(oldValueTile).toBeDefined()
-    expect(isSliderModel(oldValueTile.content)).toBe(true)
-    const oldValueContent = oldValueTile.content as ISliderModel
-    expect(oldValueContent.globalValue.name).toBe("global")
-    expect(oldValueContent.axis.min).toBe(-100)
-    expect(oldValueContent.axis.max).toBe(100)
-    expect(oldValueContent.animationDirection).toBe(AnimationDirections[0])
-    expect(oldValueContent.animationMode).toBe(AnimationModes[1])
-
-    // Get slider
-    testGetComponent(oldValueTile, handler, (sliderTile, values) => {
-      const {
-        animationDirection, animationMode, globalValueName, lowerBound, upperBound, value
-      } = values as V2GetSlider
-      const sliderContent = sliderTile.content as ISliderModel
-      expect(animationDirection).toBe(AnimationDirections.findIndex(v => v === sliderContent.animationDirection))
-      expect(animationMode).toBe(AnimationModes.findIndex(v => v === sliderContent.animationMode))
-      expect(globalValueName).toBe(sliderContent.globalValue.name)
-      expect(lowerBound).toBe(sliderContent.axis.min)
-      expect(upperBound).toBe(sliderContent.axis.max)
-      expect(value).toBe(sliderContent.globalValue.value)
-    })
-
-    // Cannot create slider with duplicate global value
-    const duplicateResult = handler.create!({}, { type: "slider", globalValueName: "global" })
-    expect(duplicateResult.success).toBe(false)
-    expect(documentContent.tileMap.size).toBe(1)
-
-    // Cannot create slider with global value that hasn't been defined
-    const noValueResult = handler.create!({}, { type: "slider", globalValueName: "noGlobal" })
-    expect(noValueResult.success).toBe(false)
-    expect(documentContent.tileMap.size).toBe(1)
-  })
-})
diff --git a/v3/src/components/slider/slider-component-handler.test.ts b/v3/src/components/slider/slider-component-handler.test.ts
new file mode 100644
index 0000000000..22ceb00b29
--- /dev/null
+++ b/v3/src/components/slider/slider-component-handler.test.ts
@@ -0,0 +1,435 @@
+import { DIComponentInfo } from "../../data-interactive/data-interactive-types"
+import { diComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { diGlobalHandler } from "../../data-interactive/handlers/global-handler"
+import { appState } from "../../models/app-state"
+import { toV3Id } from "../../utilities/codap-utils"
+import { isSliderModel } from "./slider-model"
+import { kSliderIdPrefix } from "./slider-registration"
+import { IDocumentContentModel } from "../../models/document/document-content"
+import { Logger } from "../../lib/logger"
+import { testGetComponent } from "../../data-interactive/handlers/component-handler-test-utils"
+import { getSnapshot } from "mobx-state-tree"
+import { kSliderTileType } from "./slider-defs"
+
+// The logger has to be initialized with a document before a new document
+// can be created.
+Logger.initializeLogger(appState.document)
+
+function createGlobalValue(name: string, value: number) {
+  const globalResult = diGlobalHandler.create!({}, {
+    type: "global",
+    name,
+    value,
+  })
+  expect(globalResult.success).toBe(true)
+  return name
+}
+
+describe("Slider ComponentHandler", () => {
+  const handler = diComponentHandler
+  let documentContent: IDocumentContentModel
+
+  beforeEach(async () => {
+    // Create a new document
+    await appState.setDocument({type: "CODAP"})
+    documentContent = appState.document.content!
+  })
+
+  test("create slider without a global", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, { type: "slider" })
+    // TODO: is this correct? Without a global value, should the slider be created?
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    expect(isSliderModel(tile.content)).toBe(true)
+  })
+
+  test("cannot create slider with a globalValue that doesn't exist", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName: "nonexistentGlobalValue",
+    })
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(result.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(0)
+  })
+
+  test("create slider and check defaults", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    if (!isSliderModel(tile.content)) {
+      throw new Error("Expected tile content to be a slider model")
+    }
+    const sliderModel = tile.content
+    expect(sliderModel.globalValue.name).toBe(globalValueName)
+    expect(sliderModel.globalValue.value).toBe(1)
+
+    // Check default values
+    const snapshot = getSnapshot(sliderModel)
+    expect(snapshot).toMatchObject({
+      type: kSliderTileType,
+      globalValue: expect.stringMatching(/GLOB.*/),
+      axis: {
+        min: -0.5,
+        max: 11.5,
+        place: "bottom",
+      },
+      animationDirection: "lowToHigh",
+      animationMode: "onceOnly",
+      scaleType: "numeric",
+      dateMultipleOfUnit: "day",
+      multipleOf: undefined,
+    })
+  })
+
+  test("created slider roundtrips with non-default values", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+
+    const sliderCreationObject = {
+      type: "slider",
+      globalValueName,
+      lowerBound: -100,
+      upperBound: 100,
+      animationDirection: 0,
+      animationMode: 0,
+      animationRate: 1,
+      scaleType: "date",
+      dateMultipleOfUnit: "month",
+      multipleOf: 1,
+    }
+    const result = handler.create!({}, sliderCreationObject)
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    const sliderModel = tile.content
+    if (!isSliderModel(sliderModel)) {
+      throw new Error("Expected tile content to be a slider model")
+    }
+    expect(sliderModel.globalValue.name).toBe(globalValueName)
+    expect(sliderModel.globalValue.value).toBe(1)
+    expect(sliderModel.axis.type).toBe("date")
+    expect(sliderModel.axis.min).toBe(-100)
+    expect(sliderModel.axis.max).toBe(100)
+    expect(sliderModel.animationDirection).toBe("backAndForth")
+    expect(sliderModel.animationMode).toBe("nonStop")
+    expect(sliderModel.animationRate).toBe(1)
+    expect(sliderModel.dateMultipleOfUnit).toBe("month")
+    expect(sliderModel.multipleOf).toBe(1)
+    expect(sliderModel.scaleType).toBe("date")
+
+    testGetComponent(tile, handler, (_ignored, values) => {
+      // Check the values are round tripped correctly
+      expect(values).toMatchObject(sliderCreationObject)
+    })
+  })
+
+  test("create slider with invalid values", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+      dateMultipleOfUnit: "invalidUnit",
+    })
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(result.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result2 = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+      scaleType: "invalidScaleType",
+    })
+    expect(result2.success).toBe(false)
+    if (result2.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(result2.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result3 = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+      animationDirection: -1,
+    })
+    expect(result3.success).toBe(false)
+    if (result3.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(result3.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result4 = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+      animationMode: -1,
+    })
+    expect(result4.success).toBe(false)
+    if (result4.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(result4.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(0)
+  })
+
+  test("update slider with most values", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    const sliderModel = tile.content
+    if (!isSliderModel(sliderModel)) {
+      throw new Error("Expected tile content to be a slider model")
+    }
+
+    const updateResult = handler.update!({ component: tile }, {
+      lowerBound: -100,
+      upperBound: 100,
+      animationDirection: 0,
+      animationMode: 0,
+      animationRate: 1,
+      scaleType: "date",
+      dateMultipleOfUnit: "month",
+      multipleOf: 1,
+    })
+    expect(updateResult.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    // TODO: check these values
+    // const updatedResultValues = updateResult.values as DIComponentInfo
+
+    expect(getSnapshot(sliderModel)).toMatchObject({
+      type: kSliderTileType,
+      globalValue: expect.stringMatching(/GLOB.*/),
+      axis: {
+        type: "date",
+        min: -100,
+        max: 100,
+        place: "bottom",
+      },
+      animationDirection: "backAndForth",
+      animationMode: "nonStop",
+      scaleType: "date",
+      dateMultipleOfUnit: "month",
+      multipleOf: 1,
+    })
+  })
+
+  test("update slider with a global value", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    const sliderModel = tile.content
+    if (!isSliderModel(sliderModel)) {
+      throw new Error("Expected tile content to be a slider model")
+    }
+    expect(sliderModel.globalValue.name).toBe(globalValueName)
+    expect(sliderModel.globalValue.value).toBe(1)
+
+    const newGlobalValueName = createGlobalValue("newTestGlobalValue", 2)
+    const updateResult = handler.update!({ component: tile }, {
+      globalValueName: newGlobalValueName,
+    })
+    expect(updateResult.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    expect(sliderModel.globalValue.name).toBe(newGlobalValueName)
+    expect(sliderModel.globalValue.value).toBe(2)
+  })
+  test("update slider without values", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+    const sliderCreationObject = {
+      type: "slider",
+      globalValueName,
+      lowerBound: -100,
+      upperBound: 100,
+      animationDirection: 0,
+      animationMode: 0,
+      animationRate: 1,
+      scaleType: "date",
+      dateMultipleOfUnit: "month",
+      multipleOf: 1,
+    }
+    const result = handler.create!({}, sliderCreationObject)
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    const sliderModel = tile.content
+    if (!isSliderModel(sliderModel)) {
+      throw new Error("Expected tile content to be a slider model")
+    }
+
+    const updateResult = handler.update!({ component: tile }, {
+      title: "newTitle",
+    })
+    expect(updateResult.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    expect(tile.title).toBe("newTitle")
+
+    const snapshot = getSnapshot(sliderModel)
+    expect(snapshot).toMatchObject({
+      type: kSliderTileType,
+      globalValue: expect.stringMatching(/GLOB.*/),
+      axis: {
+        type: "date",
+        min: -100,
+        max: 100,
+        place: "bottom",
+      },
+      animationDirection: "backAndForth",
+      animationMode: "nonStop",
+      scaleType: "date",
+      dateMultipleOfUnit: "month",
+      multipleOf: 1,
+    })
+  })
+
+  test("update slider with a non-existent global value", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    const sliderModel = tile.content
+    if (!isSliderModel(sliderModel)) {
+      throw new Error("Expected tile content to be a slider model")
+    }
+    expect(sliderModel.globalValue.name).toBe(globalValueName)
+    expect(sliderModel.globalValue.value).toBe(1)
+    const updateResult = handler.update!({ component: tile }, {
+      globalValueName: "nonexistentGlobalValue",
+    })
+    expect(updateResult.success).toBe(false)
+    if (updateResult.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(updateResult.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(1)
+    expect(sliderModel.globalValue.name).toBe(globalValueName)
+    expect(sliderModel.globalValue.value).toBe(1)
+  })
+
+  test("cannot create multiple sliders with the same globalValue", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const duplicateResult = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(duplicateResult.success).toBe(false)
+    if (duplicateResult.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(duplicateResult.values!.error).toBeDefined()
+    expect(documentContent.tileMap.size).toBe(1)
+  })
+
+  test("cannot update a slider to have the same globalValue as another slider", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const globalValueName2 = createGlobalValue("testGlobalValue2", 1)
+    const result2 = handler.create!({}, {
+      type: "slider",
+      globalValueName: globalValueName2,
+    })
+    expect(result2.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(2)
+
+    const resultValues2 = result2.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues2.id!))!
+    const result3 = handler.update!({ component: tile }, {
+      globalValueName,
+    })
+    expect(result3.success).toBe(false)
+    if (result3.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
+    expect(result3.values!.error).toBeDefined()
+  })
+
+
+  test("delete slider", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+    const globalValueName = createGlobalValue("testGlobalValue", 1)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName,
+    })
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+
+    const deleteResult = handler.delete!({ component: tile })
+    expect(deleteResult.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(0)
+  })
+})
diff --git a/v3/src/components/slider/slider-component-handler.ts b/v3/src/components/slider/slider-component-handler.ts
new file mode 100644
index 0000000000..a8ef4e0bbc
--- /dev/null
+++ b/v3/src/components/slider/slider-component-handler.ts
@@ -0,0 +1,202 @@
+import { MergeExclusive, SetRequired } from "type-fest"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { isDateUnit } from "../../utilities/date-utils"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, isSliderScaleType } from "./slider-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel):
+  { error: ReturnType<typeof errorResult>, validatedValues?: never } |
+  { error?: never, validatedValues: Partial<ISliderSnapshot> }
+{
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!isDateUnit(dateMultipleOfUnit)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit
+  }
+
+  if (scaleType != null) {
+    if (!isSliderScaleType(scaleType)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedScaleType", { vars: [scaleType] }))}
+    }
+    snapshot.scaleType = scaleType
+  }
+
+  if (_animationDirection != null) {
+    const directionNumber = Number(_animationDirection)
+    if (isNaN(directionNumber) || directionNumber < 0 || directionNumber >= AnimationDirections.length) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedAnimationDirection", { vars: [_animationDirection] }))}
+    }
+    snapshot.animationDirection = AnimationDirections[directionNumber]
+  }
+
+  if (_animationMode != null) {
+    const modeNumber = Number(_animationMode)
+    if (isNaN(modeNumber) || modeNumber < 0 || modeNumber >= AnimationModes.length) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedAnimationMode", { vars: [_animationMode] }))}
+    }
+    snapshot.animationMode = AnimationModes[modeNumber]
+  }
+
+  return { validatedValues: snapshot }
+}
+
+export const sliderComponentHandler: DIComponentHandler = {
+  create({ values }) {
+    const {
+      animationRate,
+      multipleOf,
+      lowerBound,
+      upperBound,
+    } = values as V2Slider
+    const { error, validatedValues } = validateInput(values)
+    if (error) {
+      return error
+    }
+    const {
+      animationDirection,
+      animationMode,
+      dateMultipleOfUnit,
+      globalValue,
+      scaleType,
+    } = validatedValues
+
+    if (!globalValue) return {}
+
+    function getAxis(): ISliderSnapshot["axis"] {
+      if (lowerBound == null || upperBound == null) {
+        return undefined
+      }
+
+      const baseAxis: ISliderSnapshot["axis"] =
+        { min: lowerBound, max: upperBound, place: "bottom" }
+      baseAxis.type = (scaleType || "numeric")
+      return baseAxis
+    }
+
+    const content: SetRequired<ISliderSnapshot, "type"> = {
+      type: kSliderTileType,
+      animationDirection,
+      animationMode,
+      _animationRate: animationRate,
+      multipleOf,
+      scaleType,
+      dateMultipleOfUnit,
+      axis: getAxis(),
+      globalValue
+    }
+
+    return { content }
+  },
+  get(content) {
+    if (isSliderModel(content)) {
+      const animationDirection = AnimationDirections.findIndex(value => value === content.animationDirection)
+      const animationMode = AnimationModes.findIndex(value => value === content.animationMode)
+      return {
+        type: kV2SliderType,
+        animationDirection,
+        animationMode,
+        animationRate: content.animationRate,
+        multipleOf: content.multipleOf,
+        dateMultipleOfUnit: content.dateMultipleOfUnit,
+        scaleType: content.scaleType,
+        globalValueName: content.globalValue.name,
+        lowerBound: content.axis.min,
+        upperBound: content.axis.max,
+        value: content.globalValue.value
+      } as V2Slider
+    }
+  },
+
+  update(content, values) {
+    if (!isSliderModel(content)) return { success: false }
+
+    const {
+      animationRate,
+      multipleOf,
+      lowerBound,
+      upperBound,
+    } = values as V2Slider
+
+    const { error, validatedValues } = validateInput(values, content)
+    if (error) {
+      return error
+    }
+
+    const {
+      scaleType,
+    } = validatedValues
+
+    const snapshot = {
+      ...cloneDeep(getSnapshot(content)),
+      ...validatedValues,
+    }
+
+    if (animationRate != null) {
+      snapshot._animationRate = animationRate
+    }
+    if (multipleOf != null) {
+      snapshot.multipleOf = multipleOf
+    }
+    if (scaleType != null) {
+      // The scaleType has already been validated, so we can safely use it
+      snapshot.axis.type = scaleType
+    }
+    if (lowerBound != null) {
+      snapshot.axis.min = lowerBound
+    }
+    if (upperBound != null) {
+      snapshot.axis.max = upperBound
+    }
+
+    applySnapshot(content, snapshot)
+
+    return { success: true }
+  }
+}
diff --git a/v3/src/components/slider/slider-registration.ts b/v3/src/components/slider/slider-registration.ts
index e031689f24..2daccaa106 100644
--- a/v3/src/components/slider/slider-registration.ts
+++ b/v3/src/components/slider/slider-registration.ts
@@ -1,10 +1,7 @@
 import { SetOptional, SetRequired } from "type-fest"
 import SliderIcon from "../../assets/icons/icon-slider.svg"
-import { V2Slider } from "../../data-interactive/data-interactive-component-types"
 import { registerComponentHandler } from "../../data-interactive/handlers/component-handler"
-import { errorResult } from "../../data-interactive/handlers/di-results"
-import { appState } from "../../models/app-state"
-import { GlobalValueManager, getGlobalValueManager } from "../../models/global/global-value-manager"
+import { getGlobalValueManager } from "../../models/global/global-value-manager"
 import { registerTileComponentInfo } from "../../models/tiles/tile-component-info"
 import { ITileLikeModel, registerTileContentInfo } from "../../models/tiles/tile-content-info"
 import { ITileModelSnapshotIn } from "../../models/tiles/tile-model"
@@ -28,6 +25,7 @@ import {
   kDefaultAnimationDirection, kDefaultAnimationMode, kDefaultSliderAxisMax, kDefaultSliderAxisMin
 } from "./slider-types"
 import { kDefaultSliderName, kDefaultSliderValue } from "./slider-utils"
+import { sliderComponentHandler } from "./slider-component-handler"
 
 export const kSliderIdPrefix = "SLID"
 
@@ -178,60 +176,4 @@ registerV2TileImporter("DG.SliderView", ({ v2Component, v2Document, getGlobalVal
   return sliderTile
 })
 
-registerComponentHandler(kV2SliderType, {
-  create({ values }) {
-    const {
-      animationDirection: _animationDirection, animationMode: _animationMode, globalValueName,
-      lowerBound, upperBound
-    } = values as V2Slider
-
-    let content: SetRequired<ISliderSnapshot, "type"> | undefined
-    if (globalValueName) {
-      const { document } = appState
-      const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
-      const global = globalManager?.getValueByName(globalValueName)
-      if (!global) {
-        return errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))
-      }
-
-      // Multiple sliders for one global value are not allowed
-      let existingTile = false
-      document.content?.tileMap.forEach(sliderTile => {
-        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
-          existingTile = true
-        }
-      })
-      if (existingTile) {
-        return errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))
-      }
-
-      const animationDirection = _animationDirection != null
-        ? AnimationDirections[Number(_animationDirection)] : undefined
-      const animationMode = _animationMode != null ? AnimationModes[_animationMode] : undefined
-      content = {
-        type: kSliderTileType,
-        animationDirection,
-        animationMode,
-        axis: { min: lowerBound, max: upperBound, place: "bottom" },
-        globalValue: global.id
-      } as SetRequired<ISliderSnapshot, "type">
-    }
-
-    return { content }
-  },
-  get(content) {
-    if (isSliderModel(content)) {
-      const animationDirection = AnimationDirections.findIndex(value => value === content.animationDirection)
-      const animationMode = AnimationModes.findIndex(value => value === content.animationMode)
-      return {
-        type: kV2SliderType,
-        animationDirection,
-        animationMode,
-        globalValueName: content.globalValue.name,
-        lowerBound: content.axis.min,
-        upperBound: content.axis.max,
-        value: content.globalValue.value
-      } as V2Slider
-    }
-  }
-})
+registerComponentHandler(kV2SliderType, sliderComponentHandler)
diff --git a/v3/src/components/slider/slider-types.ts b/v3/src/components/slider/slider-types.ts
index b07d1c99a2..5ec98971ca 100644
--- a/v3/src/components/slider/slider-types.ts
+++ b/v3/src/components/slider/slider-types.ts
@@ -34,5 +34,8 @@ export const kAnimationDefaults = {
 export const SliderScaleTypes = ["numeric", "date"] as const
 export type ISliderScaleType = typeof SliderScaleTypes[number]
 export const kDefaultSliderScaleType = "numeric"
+export function isSliderScaleType(value: any): value is ISliderScaleType {
+  return SliderScaleTypes.includes(value as ISliderScaleType)
+}
 
 export type FixValueFn = (value: number) => number
diff --git a/v3/src/data-interactive/data-interactive-component-types.ts b/v3/src/data-interactive/data-interactive-component-types.ts
index 84611625c5..313391d989 100644
--- a/v3/src/data-interactive/data-interactive-component-types.ts
+++ b/v3/src/data-interactive/data-interactive-component-types.ts
@@ -132,13 +132,21 @@ export interface V2Map extends V2Component {
   type: "map"
   zoom?: number
 }
+
+// This really isn't the v2 form of the Slider component, that is
+// defined by ICodapV2SliderStorage. This the format of data that
+// can be sent and received via API.
 export interface V2Slider extends V2Component {
+  type: "slider"
   animationDirection?: number
   animationMode?: number
+  animationRate?: number
   globalValueName?: string
-  lowerBound?: number
-  type: "slider"
+  multipleOf?: number
+  dateMultipleOfUnit?: string
+  scaleType?: string
   upperBound?: number
+  lowerBound?: number
 }
 export interface V2GetSlider extends V2Slider {
   value?: number
diff --git a/v3/src/utilities/date-utils.ts b/v3/src/utilities/date-utils.ts
index caaaa4de73..fba25e25d3 100644
--- a/v3/src/utilities/date-utils.ts
+++ b/v3/src/utilities/date-utils.ts
@@ -16,6 +16,10 @@ export enum EDateTimeLevel {
 export const dateUnits = ["year", "month", "day", "hour", "minute", "second", "millisecond"] as const
 export type DateUnit = typeof dateUnits[number]
 
+export function isDateUnit(value: any): value is DateUnit {
+  return dateUnits.includes(value as DateUnit)
+}
+
 export enum DatePrecision {
   None = '',
   Millisecond = 'millisecond',
diff --git a/v3/src/utilities/translation/lang/en-US.json5 b/v3/src/utilities/translation/lang/en-US.json5
index 8d108cef3a..9c8cd796ba 100644
--- a/v3/src/utilities/translation/lang/en-US.json5
+++ b/v3/src/utilities/translation/lang/en-US.json5
@@ -136,6 +136,10 @@
     "V3.DI.Error.globalIllegalValue": "global values must be numbers",
     "V3.DI.Error.globalNotFound": "Global not found: %@",
     "V3.DI.Error.noMultipleSliders": "Cannot create multiple sliders for %@",
+    "V3.DI.Error.unsupportedScaleType": "Unsupported scaleType %@",
+    "V3.DI.Error.unsupportedDateUnit": "Unsupported dateUnit %@",
+    "V3.DI.Error.unsupportedAnimationDirection": "Unsupported animationDirection %@",
+    "V3.DI.Error.unsupportedAnimationMode": "Unsupported animationMode %@",
     "V3.DI.Error.missingGlobalOrValue": "missing global or value",
     "V3.DI.Error.interactiveFrameNotFound": "Interactive Frame not found",
     "V3.DI.Error.itemNotFound": "Item not found",


Reviews:
user: login: tealefristoe
body: Looks good 👍 

I left a few suggestions, mostly related to typing, but I don't know if it's actually worth the time it would take to address them.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T21:42:17Z
user: login: kswenson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T23:09:28Z
user: login: scytacki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T01:11:52Z
user: login: scytacki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T01:20:26Z
user: login: kswenson
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T01:24:00Z
user: login: scytacki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T12:33:05Z
user: login: scytacki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T12:53:41Z
user: login: scytacki
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-19T18:12:57Z

Review comments:
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
path: v3/src/components/slider/slider-component-handler.ts
user: login: tealefristoe
body: Import order
created_at: 2025-04-18T20:52:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
path: v3/src/components/slider/slider-component-handler.ts
user: login: tealefristoe
body: I feel like there must be a way to do this where it's not necessary to cast variables like this and in the conditional below. But maybe it's more work to set that up than it's worth.
created_at: 2025-04-18T21:20:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
+  }
+
+  if (scaleType != null) {
+    if (!SliderScaleTypes.includes(scaleType as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedScaleType", { vars: [scaleType] }))}
+    }
+    snapshot.scaleType = scaleType as ISliderSnapshot["scaleType"]
+  }
+
+  if (_animationDirection != null) {
+    const directionNumber = Number(_animationDirection)
+    if (isNaN(directionNumber) || directionNumber < 0 || directionNumber >= AnimationDirections.length) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedAnimationDirection", { vars: [_animationDirection] }))}
+    }
+    snapshot.animationDirection = AnimationDirections[directionNumber]
+  }
+
+  if (_animationMode != null) {
+    const modeNumber = Number(_animationMode)
+    if (isNaN(modeNumber) || modeNumber < 0 || modeNumber >= AnimationModes.length) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedAnimationMode", { vars: [_animationMode] }))}
+    }
+    snapshot.animationMode = AnimationModes[modeNumber]
+  }
+
+  return { validatedValues: snapshot }
+}
+
+export const sliderComponentHandler: DIComponentHandler = {
+  create({ values }) {
+    const {
+      animationRate,
+      multipleOf,
+      lowerBound,
+      upperBound,
+    } = values as V2Slider
+    const { error, validatedValues } = validateInput(values)
+    if (error) {
+      return error
+    }
+    const {
+      animationDirection,
+      animationMode,
+      dateMultipleOfUnit,
+      globalValue,
+      scaleType,
+    } = validatedValues as Partial<ISliderSnapshot>
path: v3/src/components/slider/slider-component-handler.ts
user: login: tealefristoe
body: I think the only reason to cast `validatedValues` here is if it's `undefined`. So I think it would be better to throw an error if `validateValues` doesn't exist (which will probably never happen if there's no error but who knows what the future will bring). Then you wouldn't have to cast here. I think this will cause an error if `validatedValues` is ever `undefined`.
created_at: 2025-04-18T21:23:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,435 @@
+import { DIComponentInfo } from "../../data-interactive/data-interactive-types"
+import { diComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { diGlobalHandler } from "../../data-interactive/handlers/global-handler"
+import { appState } from "../../models/app-state"
+import { toV3Id } from "../../utilities/codap-utils"
+import { isSliderModel } from "./slider-model"
+import { kSliderIdPrefix } from "./slider-registration"
+import { IDocumentContentModel } from "../../models/document/document-content"
+import { Logger } from "../../lib/logger"
+import { testGetComponent } from "../../data-interactive/handlers/component-handler-test-utils"
+import { getSnapshot } from "mobx-state-tree"
+import { kSliderTileType } from "./slider-defs"
+
+// The logger has to be initialized with a document before a new document
+// can be created.
+Logger.initializeLogger(appState.document)
+
+function createGlobalValue(name: string, value: number) {
+  const globalResult = diGlobalHandler.create!({}, {
+    type: "global",
+    name,
+    value,
+  })
+  expect(globalResult.success).toBe(true)
+  return name
+}
+
+describe("Slider ComponentHandler", () => {
+  const handler = diComponentHandler
+  let documentContent: IDocumentContentModel
+
+  beforeEach(async () => {
+    // Create a new document
+    await appState.setDocument({type: "CODAP"})
+    documentContent = appState.document.content!
+  })
+
+  test("create slider without a global", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, { type: "slider" })
+    // TODO: is this correct? Without a global value, should the slider be created?
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    expect(isSliderModel(tile.content)).toBe(true)
+  })
+
+  test("cannot create slider with a globalValue that doesn't exist", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName: "nonexistentGlobalValue",
+    })
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
path: v3/src/components/slider/slider-component-handler.test.ts
user: login: tealefristoe
body: Are these extra checks necessary? Seems like if the result is anything other than `false`, the above check will fail and we'll bail on the test anyway.
created_at: 2025-04-18T21:37:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
path: v3/src/components/slider/slider-component-handler.ts
user: login: kswenson
body: I think the way to clean this up, including the `any` a few lines above, is to implement an `isDateUnit()` type guard function which takes a string and returns `value is DateUnit`.
created_at: 2025-04-18T23:09:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,435 @@
+import { DIComponentInfo } from "../../data-interactive/data-interactive-types"
+import { diComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { diGlobalHandler } from "../../data-interactive/handlers/global-handler"
+import { appState } from "../../models/app-state"
+import { toV3Id } from "../../utilities/codap-utils"
+import { isSliderModel } from "./slider-model"
+import { kSliderIdPrefix } from "./slider-registration"
+import { IDocumentContentModel } from "../../models/document/document-content"
+import { Logger } from "../../lib/logger"
+import { testGetComponent } from "../../data-interactive/handlers/component-handler-test-utils"
+import { getSnapshot } from "mobx-state-tree"
+import { kSliderTileType } from "./slider-defs"
+
+// The logger has to be initialized with a document before a new document
+// can be created.
+Logger.initializeLogger(appState.document)
+
+function createGlobalValue(name: string, value: number) {
+  const globalResult = diGlobalHandler.create!({}, {
+    type: "global",
+    name,
+    value,
+  })
+  expect(globalResult.success).toBe(true)
+  return name
+}
+
+describe("Slider ComponentHandler", () => {
+  const handler = diComponentHandler
+  let documentContent: IDocumentContentModel
+
+  beforeEach(async () => {
+    // Create a new document
+    await appState.setDocument({type: "CODAP"})
+    documentContent = appState.document.content!
+  })
+
+  test("create slider without a global", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, { type: "slider" })
+    // TODO: is this correct? Without a global value, should the slider be created?
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    expect(isSliderModel(tile.content)).toBe(true)
+  })
+
+  test("cannot create slider with a globalValue that doesn't exist", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName: "nonexistentGlobalValue",
+    })
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
path: v3/src/components/slider/slider-component-handler.test.ts
user: login: scytacki
body: They narrow the type of the result so the code below that calls `result.values!.error` is allowed by typescript. I could get rid of the `expect(result.success).toBe(false)` so at least it isn't redundant. However in that case if the code changes and the test fails, what gets printed out by Jest isn't as easy to understand.
created_at: 2025-04-19T01:11:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
path: v3/src/components/slider/slider-component-handler.ts
user: login: scytacki
body: Yeah I can do that. It adds more lines of code and still requires a cast to do the `includes` check inside of `isDateUnit()`, but it does look nicer. And perhaps the isDateUnit will come in handy some other time.
created_at: 2025-04-19T01:20:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
path: v3/src/components/slider/slider-component-handler.ts
user: login: kswenson
body: True, but you can cast the other way: `(dateUnits as string[]).includes(value)` which seems nicer because it's a widening cast instead of a narrowing one, i.e. it assumes less.
created_at: 2025-04-19T01:24:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
+  }
+
+  if (scaleType != null) {
+    if (!SliderScaleTypes.includes(scaleType as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedScaleType", { vars: [scaleType] }))}
+    }
+    snapshot.scaleType = scaleType as ISliderSnapshot["scaleType"]
+  }
+
+  if (_animationDirection != null) {
+    const directionNumber = Number(_animationDirection)
+    if (isNaN(directionNumber) || directionNumber < 0 || directionNumber >= AnimationDirections.length) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedAnimationDirection", { vars: [_animationDirection] }))}
+    }
+    snapshot.animationDirection = AnimationDirections[directionNumber]
+  }
+
+  if (_animationMode != null) {
+    const modeNumber = Number(_animationMode)
+    if (isNaN(modeNumber) || modeNumber < 0 || modeNumber >= AnimationModes.length) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedAnimationMode", { vars: [_animationMode] }))}
+    }
+    snapshot.animationMode = AnimationModes[modeNumber]
+  }
+
+  return { validatedValues: snapshot }
+}
+
+export const sliderComponentHandler: DIComponentHandler = {
+  create({ values }) {
+    const {
+      animationRate,
+      multipleOf,
+      lowerBound,
+      upperBound,
+    } = values as V2Slider
+    const { error, validatedValues } = validateInput(values)
+    if (error) {
+      return error
+    }
+    const {
+      animationDirection,
+      animationMode,
+      dateMultipleOfUnit,
+      globalValue,
+      scaleType,
+    } = validatedValues as Partial<ISliderSnapshot>
path: v3/src/components/slider/slider-component-handler.ts
user: login: scytacki
body: I solved this a different way. I had `validateInput` return a type that either has an error or has validateValues but not both. So the check above of `if (error)` lets TS know validatedValues is never undefined.
created_at: 2025-04-19T12:33:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,435 @@
+import { DIComponentInfo } from "../../data-interactive/data-interactive-types"
+import { diComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { diGlobalHandler } from "../../data-interactive/handlers/global-handler"
+import { appState } from "../../models/app-state"
+import { toV3Id } from "../../utilities/codap-utils"
+import { isSliderModel } from "./slider-model"
+import { kSliderIdPrefix } from "./slider-registration"
+import { IDocumentContentModel } from "../../models/document/document-content"
+import { Logger } from "../../lib/logger"
+import { testGetComponent } from "../../data-interactive/handlers/component-handler-test-utils"
+import { getSnapshot } from "mobx-state-tree"
+import { kSliderTileType } from "./slider-defs"
+
+// The logger has to be initialized with a document before a new document
+// can be created.
+Logger.initializeLogger(appState.document)
+
+function createGlobalValue(name: string, value: number) {
+  const globalResult = diGlobalHandler.create!({}, {
+    type: "global",
+    name,
+    value,
+  })
+  expect(globalResult.success).toBe(true)
+  return name
+}
+
+describe("Slider ComponentHandler", () => {
+  const handler = diComponentHandler
+  let documentContent: IDocumentContentModel
+
+  beforeEach(async () => {
+    // Create a new document
+    await appState.setDocument({type: "CODAP"})
+    documentContent = appState.document.content!
+  })
+
+  test("create slider without a global", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, { type: "slider" })
+    // TODO: is this correct? Without a global value, should the slider be created?
+    expect(result.success).toBe(true)
+    expect(documentContent.tileMap.size).toBe(1)
+
+    const resultValues = result.values as DIComponentInfo
+    const tile = documentContent.tileMap.get(toV3Id(kSliderIdPrefix, resultValues.id!))!
+    expect(tile).toBeDefined()
+    expect(isSliderModel(tile.content)).toBe(true)
+  })
+
+  test("cannot create slider with a globalValue that doesn't exist", () => {
+    expect(documentContent.tileMap.size).toBe(0)
+
+    const result = handler.create!({}, {
+      type: "slider",
+      globalValueName: "nonexistentGlobalValue",
+    })
+
+    expect(result.success).toBe(false)
+    if (result.success) {
+      throw new Error("Expected result to be unsuccessful")
+    }
path: v3/src/components/slider/slider-component-handler.test.ts
user: login: scytacki
body: This issue has more info on this: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/41179
But what isn't mentioned is the impact using the helper functions have on the Jest output when the tests fails.
created_at: 2025-04-19T12:53:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,202 @@
+import { SetRequired } from "type-fest"
+import { V2Slider } from "../../data-interactive/data-interactive-component-types"
+import { DIComponentHandler } from "../../data-interactive/handlers/component-handler"
+import { errorResult } from "../../data-interactive/handlers/di-results"
+import { appState } from "../../models/app-state"
+import { GlobalValueManager } from "../../models/global/global-value-manager"
+import { t } from "../../utilities/translation/translate"
+import { kSliderTileType, kV2SliderType } from "./slider-defs"
+import { ISliderModel, ISliderSnapshot, isSliderModel } from "./slider-model"
+import { AnimationDirections, AnimationModes, SliderScaleTypes } from "./slider-types"
+import { applySnapshot, getSnapshot } from "mobx-state-tree"
+import { cloneDeep } from "lodash"
+import { dateUnits } from "../../utilities/date-utils"
+import { DIValues } from "../../data-interactive/data-interactive-types"
+
+function validateInput(values: DIValues | undefined, sliderModel?: ISliderModel): {
+  error?: ReturnType<typeof errorResult>
+  validatedValues?: Partial<ISliderSnapshot>
+} {
+  if (!values) {
+    return {validatedValues: {}}
+  }
+  const {
+    animationDirection: _animationDirection,
+    animationMode: _animationMode,
+    globalValueName,
+    scaleType,
+    dateMultipleOfUnit,
+  } = values as V2Slider
+
+  const snapshot: Partial<ISliderSnapshot> = {}
+
+  if (globalValueName) {
+    const { document } = appState
+    const globalManager = document.content?.getFirstSharedModelByType(GlobalValueManager)
+    const global = globalManager?.getValueByName(globalValueName)
+    if (!global) {
+      return {error: errorResult(t("V3.DI.Error.globalNotFound", { vars: [globalValueName] }))}
+    }
+    snapshot.globalValue = global.id
+
+    // If this is a new slider, or the global is being changed, check for multiple sliders
+    if (!sliderModel || sliderModel.globalValue.id !== global.id) {
+      // Multiple sliders for one global value are not allowed
+      let existingTile = false
+      document.content?.tileMap.forEach(sliderTile => {
+        if (isSliderModel(sliderTile.content) && sliderTile.content.globalValue.id === global.id) {
+          existingTile = true
+        }
+      })
+      if (existingTile) {
+        return {error: errorResult(t("V3.DI.Error.noMultipleSliders", { vars: [globalValueName] }))}
+      }
+    }
+  }
+
+  if (dateMultipleOfUnit != null) {
+    if (!dateUnits.includes(dateMultipleOfUnit as any)) {
+      return {error: errorResult(t("V3.DI.Error.unsupportedDateUnit", { vars: [dateMultipleOfUnit] }))}
+    }
+    snapshot.dateMultipleOfUnit = dateMultipleOfUnit as ISliderSnapshot["dateMultipleOfUnit"]
path: v3/src/components/slider/slider-component-handler.ts
user: login: scytacki
body: I found a better way to do it, but I already merged the PR.

The `(dateUnits as string[]).includes(value)` isn't better because the user could pass almost anything as value since it is JSON object from a plugin in this case.

So the best approach seems to be:
```
export function isDateUnit(value: any): value is DateUnit {
  return dateUnits.includes(value)
}
```

When I ended up merging was:
```
export function isDateUnit(value: any): value is DateUnit {
  return dateUnits.includes(value as DateUnit) 
```
That `as DateUnit` is unnecessary.
created_at: 2025-04-19T18:12:56Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-18T11:29:57Z
body: ## [Codecov](https://app.codecov.io/gh/concord-consortium/codap/pull/1914?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) Report
Attention: Patch coverage is `98.95833%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 87.83%. Comparing base [(`78835e0`)](https://app.codecov.io/gh/concord-consortium/codap/commit/78835e087a8a56757fd32fd718e621a72fa804e1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) to head [(`5572c5d`)](https://app.codecov.io/gh/concord-consortium/codap/commit/5572c5d600a720c882ef62a960014440bf847414?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium).
> Report is 51 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/concord-consortium/codap/pull/1914?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | Patch % | Lines |
|---|---|---|
| [.../src/components/slider/slider-component-handler.ts](https://app.codecov.io/gh/concord-consortium/codap/pull/1914?src=pr&el=tree&filepath=v3%2Fsrc%2Fcomponents%2Fslider%2Fslider-component-handler.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#diff-djMvc3JjL2NvbXBvbmVudHMvc2xpZGVyL3NsaWRlci1jb21wb25lbnQtaGFuZGxlci50cw==) | 98.87% | [1 Missing :warning: ](https://app.codecov.io/gh/concord-consortium/codap/pull/1914?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1914      +/-   ##
==========================================
- Coverage   87.88%   87.83%   -0.05%     
==========================================
  Files         658      659       +1     
  Lines       33710    33778      +68     
  Branches     8973     8991      +18     
==========================================
+ Hits        29625    29670      +45     
- Misses       4079     4102      +23     
  Partials        6        6              
```

| [Flag](https://app.codecov.io/gh/concord-consortium/codap/pull/1914/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | Coverage Δ | |
|---|---|---|
| [cypress](https://app.codecov.io/gh/concord-consortium/codap/pull/1914/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | `72.25% <2.38%> (-0.23%)` | :arrow_down: |
| [jest](https://app.codecov.io/gh/concord-consortium/codap/pull/1914/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | `56.47% <98.95%> (+0.09%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/concord-consortium/codap/pull/1914?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: cypress[bot]
created_at: 2025-04-18T11:31:07Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/msrfxa?utm_source=github&utm_medium=PASSED&utm_campaign=project">codap-v3</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #7099</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#7099</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/concord-consortium/codap/commit/73276f0821953ea1cd41c577a9fbf37dc8ae0010">73276f0821</a>: Merge pull request #1914 from concord-consortium/CODAP-605-date-slider-api</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/msrfxa?utm_source=github&utm_medium=PASSED&utm_campaign=project">codap-v3</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/msrfxa/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1914">main</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#7099</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>06m 26s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/concord-consortium/codap/commit/73276f0821953ea1cd41c577a9fbf37dc8ae0010">73276f0821</a>: Merge pull request #1914 from concord-consortium/CODAP-605-date-slider-api</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Scott Cytacki</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>50</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/7099/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>252</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/msrfxa/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1914">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE
user: login: scytacki
created_at: 2025-04-19T13:58:07Z
body: Thank you both for the reviews. 
author_association: MEMBER