Harden date input instrumentation; remove it from front-end boot

Test plan:
  - build passes

flag=none

Change-Id: I2130999fc5e762aff704095ea7446bdfc4313c0b
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/327520
Reviewed-by: Charley Kline <ckline@instructure.com>
QA-Review: Charley Kline <ckline@instructure.com>
Product-Review: Charley Kline <ckline@instructure.com>
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
This commit is contained in:
Aaron Shafovaloff 2023-09-12 18:19:22 -06:00
parent 75d5900d20
commit f96b5e683d
6 changed files with 91 additions and 92 deletions

View File

@ -26,7 +26,6 @@ import './initializers/fakeRequireJSFallback'
import './initializers/ujsLinks'
import {up as configureDateTimeMomentParser} from './initializers/configureDateTimeMomentParser'
import {up as configureDateTime} from './initializers/configureDateTime'
import {up as enableDTNPI} from './initializers/enableDTNPI'
import {initSentry} from './initializers/initSentry'
import {up as renderRailsFlashNotifications} from './initializers/renderRailsFlashNotifications'
import {up as activateCourseMenuToggler} from './initializers/activateCourseMenuToggler'
@ -67,10 +66,6 @@ window.addEventListener('canvasReadyStateChange', function ({detail}) {
}
})
isolate(enableDTNPI)({
endpoint: window.ENV.DATA_COLLECTION_ENDPOINT,
})
// In non-prod environments only, arrange for filtering of "useless" console
// messages, and if deprecation reporting is enabled, arrange to inject and
// set up Sentry for it.

View File

@ -1,4 +1,3 @@
// @ts-nocheck
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
@ -17,12 +16,13 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import createPersistentArray from './enableDTNPI.utils'
import {createPersistentArray, normalizeEvent, postToBackend} from './enableDTNPI.utils'
import type {PersistentArray, NormalizedDTNPIEvent} from './enableDTNPI.utils'
import {configure} from '@canvas/datetime-natural-parsing-instrument'
const localStorageKey = 'dtnpi'
const LOCAL_STORAGE_KEY = 'dtnpi'
let events
let events: PersistentArray<NormalizedDTNPIEvent> | null
export async function up(options = {endpoint: null, throttle: 1000, size: 50}) {
const throttle = Math.max(1, Math.min(options.throttle, 1000))
@ -30,17 +30,16 @@ export async function up(options = {endpoint: null, throttle: 1000, size: 50}) {
const {endpoint} = options
events = createPersistentArray({
key: localStorageKey,
key: LOCAL_STORAGE_KEY,
throttle,
size,
// @ts-expect-error
transform: value => value.map(normalizeEvent),
})
configure({events})
// submit events that have been collected so far:
const collected = [].concat(events)
const collected = [...events]
if (endpoint && collected.length) {
await postToBackend({endpoint, events: collected})
@ -55,32 +54,5 @@ export function down() {
events = null
}
localStorage.removeItem(localStorageKey)
}
function normalizeEvent(event) {
return {
id: event.id,
type: 'datepicker_usage',
locale: (window.ENV && window.ENV.LOCALE) || null,
method: event.method,
parsed: event.parsed,
// don't store values that may be too long, 32 feels plenty for what people
// may actually type
value: event.value ? event.value.slice(0, 32) : null,
}
}
// TODO: submit to an actual backend
function postToBackend({endpoint, events}): Promise<void> {
// @ts-expect-error
return fetch(endpoint, {
method: 'PUT',
mode: 'cors',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(events),
})
localStorage.removeItem(LOCAL_STORAGE_KEY)
}

View File

@ -1,4 +1,3 @@
// @ts-nocheck
/*
* Copyright (C) 2023 - present Instructure, Inc.
*
@ -41,19 +40,39 @@ export type PersistentArrayParameters = {
// transform parameter. This only applies to the saved value and not for the
// one stored in memory.
//
// Don't mutate it!!!
transform: <T>(array: Array<T>) => Array<T>
// Don't mutate it
transform: (array: NormalizedDTNPIEvent[]) => NormalizedDTNPIEvent[]
}
export type DateTimeInputMethod = 'pick' | 'type' | 'paste'
export type DTNPIEvent = {
id: string
method: DateTimeInputMethod
parsed: string | null
value: string | null
}
export type NormalizedDTNPIEvent = {
id: string
type: string
locale: string | null
method: DateTimeInputMethod
parsed: string | null
value: string | null
}
// A special Array that persists its value to localStorage whenever you push to,
// pop from, or splice it.
export default function createPersistentArray({
export function createPersistentArray({
key,
throttle = 1000,
size = Infinity,
transform = x => x,
}: PersistentArrayParameters) {
const value = JSON.parse(localStorage.getItem(key) || '[]')
transform = x => x as NormalizedDTNPIEvent[],
}: PersistentArrayParameters): PersistentArray<NormalizedDTNPIEvent> {
const value = JSON.parse(localStorage.getItem(key) || '[]') as NormalizedDTNPIEvent[]
if (!Array.isArray(value)) {
throw new Error(`Expected ${key} to be an array`)
}
const save = () => localStorage.setItem(key, JSON.stringify(transform(value)))
const resize = () => {
if (value.length >= size) {
@ -76,16 +95,19 @@ export default function createPersistentArray({
// nb: we only intend to cover the APIs we're using
}
for (const [method, saveImpl] of Object.entries(saveBehaviors)) {
function entries<T extends Record<string, unknown>>(obj: T) {
return Object.entries(obj) as Array<[keyof T, T[keyof T]]>
}
for (const [method, saveImpl] of entries(saveBehaviors)) {
// define them as properties so that they are not enumerable; we want this
// to behave as much like a regular Array as possible
Object.defineProperty(value, method, {
enumerable: false,
configurable: false,
value() {
// @ts-expect-error
value(...args: any[]) {
saveImpl()
return Array.prototype[method].apply(this, arguments)
return Array.prototype[method].apply(this, args)
},
})
}
@ -101,6 +123,41 @@ export default function createPersistentArray({
}
const pipe =
(...f) =>
x =>
(...f: Array<(x: unknown) => void>) =>
(x?: unknown) =>
f.reduce((acc, fx) => fx(acc), x)
export function normalizeEvent(event: DTNPIEvent): NormalizedDTNPIEvent {
return {
id: event.id,
type: 'datepicker_usage',
locale: window.ENV?.LOCALE || null,
method: event.method,
parsed: event.parsed,
// don't store values that may be too long, 32 feels plenty for what people
// may actually type
value: event.value ? event.value.slice(0, 32) : null,
}
}
export function postToBackend({
endpoint,
events,
}: {
endpoint: string
events: unknown
}): Promise<Response> {
const url = endpoint || window.ENV.DATA_COLLECTION_ENDPOINT
if (!url) {
throw new Error('No endpoint provided')
}
return fetch(url, {
method: 'PUT',
mode: 'cors',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(events),
})
}

View File

@ -16,25 +16,28 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type InputEvent = {
export type DTNPIEvent = {
// A unique ID for the widget that generated this event, which can be used
// to group related events to get an idea of the whole interaction.
id: string
locale?: string
type: string
locale?: string | null
method: 'pick' | 'type' | 'paste'
// Will be null if the value did not parse into a date.
parsed?: string
value: string
parsed?: string | null
value: string | null
}
let state = {events: [] as InputEvent[]}
let state: {
events: DTNPIEvent[]
} = {events: []}
export function configure({events}: {events: Array<InputEvent>}) {
export function configure({events}: {events: Array<DTNPIEvent>}) {
const previousState = {...state}
state = {events}
return previousState
}
export const log = (event: InputEvent): void => {
export const log = (event: DTNPIEvent): void => {
state.events.push(event)
}

View File

@ -22,7 +22,7 @@ import tz from '@canvas/timezone'
import htmlEscape from 'html-escape'
import * as dateFunctions from '../date-functions'
import {changeTimezone} from '../changeTimezone'
import DatetimeField from './InstrumentedDatetimeField'
import DatetimeField from './DatetimeField'
import renderDatepickerTime from '../react/components/render-datepicker-time'
import '@canvas/keycodes'
import 'jqueryui/datepicker'

View File

@ -27,8 +27,6 @@ import {DateInput} from '@instructure/ui-date-input'
import {IconButton} from '@instructure/ui-buttons'
// @ts-ignore
import {IconArrowOpenEndSolid, IconArrowOpenStartSolid} from '@instructure/ui-icons'
import {nanoid} from 'nanoid'
import {log} from '@canvas/datetime-natural-parsing-instrument'
import {
DateInputInteraction,
DateInputLayout,
@ -170,7 +168,6 @@ export default function CanvasDateInput({
const [isShowingCalendar, setIsShowingCalendar] = useState(false)
const [renderedMoment, setRenderedMoment] = useState(selectedMoment || todayMoment)
const [internalMessages, setInternalMessages] = useState<typeof messages>([])
const [widgetId] = useState(nanoid())
const [inputDetails, setInputDetails] = useState<{
method: 'paste' | 'pick'
value: string
@ -269,9 +266,9 @@ export default function CanvasDateInput({
const parsedMoment = moment.tz(date, timezone)
let input = parsedMoment
if (selectedDate) {
const selectedMoment = moment.tz(selectedDate, timezone)
if (selectedMoment.isSame(parsedMoment, 'day')) {
input = selectedMoment
const selectedMoment_ = moment.tz(selectedDate, timezone)
if (selectedMoment_.isSame(parsedMoment, 'day')) {
input = selectedMoment_
}
}
syncInput(input)
@ -288,34 +285,9 @@ export default function CanvasDateInput({
onSelectedDateChange(newDate, 'other')
if (inputDetails?.method === 'pick') {
const date = inputDetails.value
setInputDetails(null)
log({
id: widgetId,
method: 'pick',
parsed: (newDate && newDate.toISOString()) || undefined,
value: date,
})
} else if (inputDetails?.method === 'paste') {
const pastedValue = inputDetails.value
setInputDetails(null)
log({
id: widgetId,
method: 'paste',
parsed: (newDate && newDate.toISOString()) || undefined,
value: pastedValue,
})
} else if (!inputEmpty) {
log({
id: widgetId,
method: 'type',
parsed: (newDate && newDate.toISOString()) || undefined,
value: inputValue.trim(),
})
}
onBlur?.(event)
}