Skip to content

Commit

Permalink
Merge pull request #1351 from concord-consortium/187965959-case-table…
Browse files Browse the repository at this point in the history
…-dates

Add case table dates support
  • Loading branch information
pjanik authored Jul 19, 2024
2 parents c14aacd + 45afcf5 commit 140a89b
Show file tree
Hide file tree
Showing 8 changed files with 619 additions and 373 deletions.
10 changes: 9 additions & 1 deletion v3/cypress/e2e/attribute-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ context("attribute types", () => {
table.getCell("3", "2").should("contain", "48")
})
it("verify date", () => {
table.getCell("4", "2").should("contain", "8/7/2017 12:01 PM")
table.getCell("4", "2").should("contain", "8/7/2017, 12:01 PM")
table.getCell("4", "3").should("contain", "6/15/1966, 12:00 AM")
table.getCell("4", "4").should("contain", "12/7/1787, 12:00 AM")
table.getCell("4", "5").should("contain", "1/2/2003, 12:00 AM")
table.getCell("4", "6").should("contain", "12/3/1818, 12:00 AM")
table.getCell("4", "7").should("contain", "6/1/1992, 12:00 AM")
table.getCell("4", "8").should("contain", "11/2/1989, 12:00 AM")
table.getCell("4", "9").should("contain", "11/16/2007, 12:00 AM")
table.getCell("4", "10").should("contain", "11/30/2000, 12:00 AM")
})
it.skip("verify boolean", () => {
table.getCell("5", "2").should("contain", "false")
Expand Down
20 changes: 20 additions & 0 deletions v3/src/components/case-table/use-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { kDefaultColumnWidth, symDom, TColumn, TRenderCellProps } from "./case-t
import CellTextEditor from "./cell-text-editor"
import ColorCellTextEditor from "./color-cell-text-editor"
import { ColumnHeader } from "./column-header"
import { parseDate } from "../../utilities/date-parser"
import { DatePrecision, formatDate } from "../../utilities/date-utils"

// cache d3 number formatters so we don't have to generate them on every render
type TNumberFormatter = (n: number) => string
Expand Down Expand Up @@ -52,6 +54,24 @@ export function renderValue(str = "", num = NaN, attr?: IAttribute, key?: number
if (formatter) str = formatter(num)
}

// dates
// Note that CODAP v2 formats dates in the case table ONLY if the user explicitly specified type as "date".
// Dates are not interpreted as dates and formatted by default.
if (userType === "date" && str !== "") {
const date = parseDate(str, true)
if (date) {
// TODO: add precision support for date formatting
const formattedDate = formatDate(date, DatePrecision.None)
return {
value: str,
content: <span className="cell-span" key={key}>{formattedDate || `"${str}"`}</span>
}
} else {
// If the date is not valid, wrap it in quotes (CODAP V2 behavior).
str = `"${str}"`
}
}

return {
value: str,
content: <span className="cell-span" key={key}>{str}</span>
Expand Down
12 changes: 12 additions & 0 deletions v3/src/models/data/attribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Formula, IFormula } from "../formula/formula"
import { applyModelChange } from "../history/apply-model-change"
import { withoutUndo } from "../history/without-undo"
import { V2Model } from "./v2-model"
import { isDateString } from "../../utilities/date-parser"

export const kDefaultFormatStr = ".3~f"

Expand Down Expand Up @@ -129,6 +130,13 @@ export const Attribute = V2Model.named("Attribute").props({
self.changeCount // eslint-disable-line no-unused-expressions
return self.strValues.reduce((prev, current) => parseColor(current) ? ++prev : prev, 0)
}),
getDateCount: cachedFnFactory<number>(() => {
// Note that `self.changeCount` is absolutely not necessary here. However, historically, this function used to be
// a MobX computed property, and `self.changeCount` was used to invalidate the cache. Also, there are tests
// (and possibly some features?) that depend on MobX reactivity. Hence, this is left here for now.
self.changeCount // eslint-disable-line no-unused-expressions
return self.strValues.reduce((prev, current) => isDateString(current) ? ++prev : prev, 0)
}),
get hasFormula() {
return !!self.formula && !self.formula.empty
},
Expand Down Expand Up @@ -215,6 +223,10 @@ export const Attribute = V2Model.named("Attribute").props({
const numCount = self.getNumericCount()
if (numCount > 0 && numCount === this.length - self.getEmptyCount()) return "numeric"

// only infer date if all non-empty values are dates
const dateCount = self.getDateCount()
if (dateCount > 0 && dateCount === this.length - self.getEmptyCount()) return "date"

return "categorical"
},
get format() {
Expand Down
187 changes: 187 additions & 0 deletions v3/src/utilities/date-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { isDateString, isValidDateSpec, parseDate } from './date-parser'

describe('Date Parser tests - V2 compatibility', () => {
// These tests are ported from V2 and should always pass unchanged as long as we want to maintain compatibility.
test('local date format', () => {
expect(parseDate('11/9/2016', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString())
expect(parseDate('11/09/2016', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString())
expect(parseDate('4/1/1928', true)?.toISOString()).toBe(new Date(1928, 3, 1).toISOString())
expect(parseDate('11/9/16', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString())
expect(parseDate('11/09/16', true)?.toISOString()).toBe(new Date(2016, 10, 9).toISOString())
expect(parseDate('04/1/28', true)?.toISOString()).toBe(new Date(2028, 3, 1).toISOString())
})
test('ISO dates', () => {
expect(parseDate('2016-01', true)?.toISOString()).toBe(new Date(2016, 0, 1).toISOString())
expect(parseDate('2016-02-02', true)?.toISOString()).toBe(new Date(2016, 1, 2).toISOString())
})
test('day, month name, year dates', () => {
expect(parseDate('03 Mar 2016', true)?.toISOString()).toBe(new Date(2016, 2, 3).toISOString())
})
test('traditional US dates', () => {
expect(parseDate('April 4, 2016', true)?.toISOString()).toBe(new Date(2016, 3, 4).toISOString())
expect(parseDate('Apr 5, 2016', true)?.toISOString()).toBe(new Date(2016, 3, 5).toISOString())
expect(parseDate('Monday, May 5, 2016', true)?.toISOString()).toBe(new Date(2016, 4, 5).toISOString())
})
test('year.month.day dates', () => {
expect(parseDate('2016.6.6', true)?.toISOString()).toBe(new Date(2016, 5, 6).toISOString())
})
test('unix dates', () => {
expect(parseDate('Thu Jul 11 09:12:47 PDT 2019', true)?.toISOString())
.toBe(new Date(2019, 6, 11, 9, 12, 47).toISOString())
})
test('UTC dates', () => {
expect(parseDate('Thu, 11 Jul 2019 16:17:01 GMT', true)?.toISOString())
.toBe(new Date(2019, 6, 11, 16, 17, 1).toISOString())
})
test('ISO Date/time', () => {
expect(parseDate('2019-07-11T16:20:38.575Z', true)?.toISOString())
.toBe(new Date(2019, 6, 11, 16, 20, 38, 575).toISOString())
})
test('dates with times', () => {
expect(parseDate('11/9/2016 7:18', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString())
expect(parseDate('11/9/2016 7:18am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString())
expect(parseDate('11/9/2016 7:18 am', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString())
expect(parseDate('11/9/2016 7:18AM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18).toISOString())
expect(parseDate('11/9/2016 7:18:02', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2).toISOString())
expect(parseDate('11/9/2016 7:18:02 AM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 7, 18, 2).toISOString())
expect(parseDate('11/9/2016 7:18:02PM', true)?.toISOString()).toBe(new Date(2016, 10, 9, 19, 18, 2).toISOString())
expect(parseDate('11/9/2016 7:18:02.123', true)?.toISOString())
.toBe(new Date(2016, 10, 9, 7, 18, 2, 123).toISOString())
expect(parseDate('11/9/2016 7:18:02.234 pm', true)?.toISOString())
.toBe(new Date(2016, 10, 9, 19, 18, 2, 234).toISOString())
expect(parseDate('11/9/2016 7:18:02.234am', true)?.toISOString())
.toBe(new Date(2016, 10, 9, 7, 18, 2, 234).toISOString())
expect(parseDate('11/9/2016 17:18:02', true)?.toISOString())
.toBe(new Date(2016, 10, 9, 17, 18, 2).toISOString())
expect(parseDate('11/9/2016 17:18:02.123', true)?.toISOString())
.toBe(new Date(2016, 10, 9, 17, 18, 2, 123).toISOString())
})
test('ISO 8601', () => {
expect(isDateString('2016-11-10')).toBe(true)
expect(isDateString('2016-11-09T07:18:02')).toBe(true)
expect(isDateString('2016-11-09T07:18:02-07:00')).toBe(true)
expect(isDateString('2016-11-09T07:18:02+0700')).toBe(true)
expect(isDateString('2016-11-10T21:27:42Z')).toBe(true)
expect(isDateString('2016-11-09T07:18:02.123')).toBe(true)
expect(isDateString('2016-11-09T07:18:02.123+07:00')).toBe(true)
expect(isDateString('2016-11-09T07:18:02.123-0700')).toBe(true)
expect(isDateString('2016-11-10T21:27:42.123Z')).toBe(true)
expect(isDateString('September 1, 2016')).toBe(true)
expect(isDateString('2016-11-10T21:27:42.12Z')).toBe(true)
})
test('invalid strings', () => {
expect(isDateString('')).toBe(false)
expect(isDateString('a')).toBe(false)
expect(isDateString('123')).toBe(false)
expect(isDateString('123%')).toBe(false)
expect(isDateString('//')).toBe(false)
expect(isDateString(':')).toBe(false)
expect(isDateString('::')).toBe(false)
// Likely meant to be dates, but not recognized, yet.
expect(isDateString('11/ 9/2016')).toBe(false)
expect(isDateString('12/31')).toBe(false)
expect(isDateString('1/2')).toBe(false)
expect(isDateString('2016-1-10T21:27:42.123Z')).toBe(false)
expect(isDateString('2016-1-10T21:7:42.123Z')).toBe(false)
expect(isDateString('2016-1-10T1:07:42.123Z')).toBe(false)
})
})

describe('isValidDateSpec', () => {
test('returns dateSpec when all values are valid', () => {
const validDateSpec = {
year: 2023,
month: 7,
day: 17,
hour: 15,
min: 30,
sec: 45,
subsec: 123
}
expect(isValidDateSpec(validDateSpec)).toEqual(validDateSpec)
})
test('returns null when year is NaN', () => {
const invalidDateSpec = {
year: NaN,
month: 7,
day: 17,
hour: 15,
min: 30,
sec: 45,
subsec: 123
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
test('returns null when month is out of range', () => {
const invalidDateSpec = {
year: 2023,
month: 13,
day: 17,
hour: 15,
min: 30,
sec: 45,
subsec: 123
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
test('returns null when day is out of range', () => {
const invalidDateSpec = {
year: 2023,
month: 7,
day: 32,
hour: 15,
min: 30,
sec: 45,
subsec: 123
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
test('returns null when hour is out of range', () => {
const invalidDateSpec = {
year: 2023,
month: 7,
day: 17,
hour: 24,
min: 30,
sec: 45,
subsec: 123
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
test('returns null when minute is out of range', () => {
const invalidDateSpec = {
year: 2023,
month: 7,
day: 17,
hour: 15,
min: 60,
sec: 45,
subsec: 123
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
test('returns null when second is out of range', () => {
const invalidDateSpec = {
year: 2023,
month: 7,
day: 17,
hour: 15,
min: 30,
sec: 60,
subsec: 123
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
test('returns null when subsecond is NaN', () => {
const invalidDateSpec = {
year: 2023,
month: 7,
day: 17,
hour: 15,
min: 30,
sec: 45,
subsec: NaN
}
expect(isValidDateSpec(invalidDateSpec)).toBeFalsy()
})
})
Loading

0 comments on commit 140a89b

Please sign in to comment.