Skip to content

Commit 422505e

Browse files
authored
Replaced moment.js with Temporal polyfill (#2549)
## Summary: This update changes birthday-picker.tsx to use Temporal for working with dates and removes the moment.js library. Issue: None ## Test plan: - updated unit tests - manual testing via storybook Author: dmcalpin Reviewers: beaesguerra, marcysutton, dmcalpin, jandrade Required Reviewers: Approved By: jandrade, beaesguerra Checks: ✅ 12 checks were successful, ⏭️ 2 checks have been skipped Pull Request URL: #2549
1 parent fab761d commit 422505e

File tree

8 files changed

+121
-54
lines changed

8 files changed

+121
-54
lines changed

.changeset/tricky-lizards-lie.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-birthday-picker": major
3+
---
4+
5+
Removed moment.js and replaced it with Temporal polyfill

.github/workflows/chromatic-build.yml

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
runs-on: ${{ matrix.os }}
4747
env:
4848
CI: true
49+
LANG: "en-US"
4950
strategy:
5051
fail-fast: false
5152
matrix:

package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@
134134
"@phosphor-icons/core": "catalog:",
135135
"@popperjs/core": "catalog:",
136136
"aphrodite": "catalog:",
137-
"moment": "catalog:",
138137
"node-fetch": "catalog:",
139138
"react": "catalog:",
140139
"react-dom": "catalog:",
@@ -149,4 +148,4 @@
149148
"@types/react-dom": "18"
150149
},
151150
"packageManager": "[email protected]+sha512.c89847b0667ddab50396bbbd008a2a43cf3b581efd59cf5d9aa8923ea1fb4b8106c041d540d08acb095037594d73ebc51e1ec89ee40c88b30b8a66c0fae0ac1b"
152-
}
151+
}

packages/wonder-blocks-birthday-picker/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"peerDependencies": {
2626
"@phosphor-icons/core": "catalog:",
2727
"aphrodite": "catalog:",
28-
"moment": "catalog:",
28+
"temporal-polyfill": "catalog:",
2929
"react": "catalog:"
3030
},
3131
"devDependencies": {

packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx

+11-8
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as React from "react";
2-
import moment from "moment";
32
import {render, act, screen, waitFor} from "@testing-library/react";
43
import * as DateMock from "jest-date-mock";
54
import {userEvent, PointerEventsCheckLevel} from "@testing-library/user-event";
5+
import {Temporal} from "temporal-polyfill";
66

77
import BirthdayPicker, {defaultLabels} from "../birthday-picker";
88

@@ -63,8 +63,7 @@ describe("BirthdayPicker", () => {
6363

6464
it("renders with a valid default value", async () => {
6565
// Arrange
66-
const date = moment(today);
67-
const defaultValue = date.format("YYYY-MM-DD");
66+
const defaultValue = Temporal.Now.plainDateISO().toString();
6867

6968
// Act
7069
render(
@@ -92,8 +91,9 @@ describe("BirthdayPicker", () => {
9291
it("renders with a invalid default future value", async () => {
9392
// Arrange
9493
DateMock.advanceTo(today);
95-
const date = moment(today).add(1, "day");
96-
const defaultValue = date.format("YYYY-MM-DD");
94+
const defaultValue = Temporal.Now.plainDateISO()
95+
.add({days: 1})
96+
.toString();
9797

9898
// Act
9999
render(
@@ -119,8 +119,11 @@ describe("BirthdayPicker", () => {
119119

120120
it("renders an error with a invalid default future value", async () => {
121121
// Arrange
122-
const date = moment(today).add(1, "day");
123-
const defaultValue = date.format("YYYY-MM-DD");
122+
const defaultValue = Temporal.PlainDate.from({
123+
year: today.getFullYear(),
124+
month: today.getMonth() + 1, // Temporal is 1-based
125+
day: today.getDate() + 1, // +1 for future date
126+
}).toString();
124127

125128
// Act
126129
render(
@@ -424,7 +427,7 @@ describe("BirthdayPicker", () => {
424427
// This test was written by calling methods on the instance because
425428
// react-window (used by SingleSelect) doesn't show all of the items
426429
// in the dropdown.
427-
await act(() => instance.handleMonthChange("1"));
430+
await act(() => instance.handleMonthChange("2"));
428431
await act(() => instance.handleDayChange("31"));
429432
await act(() => instance.handleYearChange("2021"));
430433

packages/wonder-blocks-birthday-picker/src/components/birthday-picker.tsx

+83-28
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import moment from "moment"; // NOTE: DO NOT use named imports; 'moment' does not support named imports
1+
import {Temporal} from "temporal-polyfill";
22
import * as React from "react";
33
import {StyleSheet} from "aphrodite";
44
import {StyleType, View} from "@khanacademy/wonder-blocks-core";
@@ -186,31 +186,50 @@ export default class BirthdayPicker extends React.Component<Props, State> {
186186
// merge custom labels with the default ones
187187
this.labels = {...defaultLabels, ...this.props.labels};
188188

189-
// If a default value was provided then we use moment to convert it
189+
// If a default value was provided then we use Temporal to convert it
190190
// into a date that we can use to populate the
191191
if (defaultValue) {
192-
let date = moment(defaultValue);
193-
194-
if (date.isValid()) {
195-
if (monthYearOnly) {
196-
date = date.endOf("month");
197-
}
192+
let date: Temporal.PlainDate | null = null;
193+
try {
194+
date = Temporal.PlainDate.from(defaultValue);
195+
} catch (err) {
196+
initialState.error = this.labels.errorMessage;
197+
return initialState;
198+
}
198199

199-
initialState.month = String(date.month());
200-
initialState.day = String(date.date());
201-
initialState.year = String(date.year());
200+
if (monthYearOnly) {
201+
date = date.with({day: date.daysInMonth});
202202
}
203203

204-
// If the date is in the future or is invalid then we want to show
205-
// an error to the user.
206-
if (date.isAfter() || !date.isValid()) {
204+
initialState.month = String(date.month);
205+
initialState.day = String(date.day);
206+
initialState.year = String(date.year);
207+
208+
// If the date is in the future then we want to show an error to
209+
// the user.
210+
if (this.isFutureDate(date)) {
207211
initialState.error = this.labels.errorMessage;
208212
}
209213
}
210214

211215
return initialState;
212216
}
213217

218+
/**
219+
* Determines whether a given date is in the future.
220+
*
221+
* @param date - The Temporal.PlainDate to check.
222+
* @returns True if the provided date comes after today's date, false otherwise.
223+
*/
224+
isFutureDate(date: Temporal.PlainDate): boolean {
225+
// The Temporal.PlainDate.compare() static method returns a number
226+
// (-1, 0, or 1) indicating whether the first date comes before, is the
227+
// same as, or comes after the second date.
228+
return (
229+
Temporal.PlainDate.compare(date, Temporal.Now.plainDateISO()) === 1
230+
);
231+
}
232+
214233
lastChangeValue: string | null | undefined = null;
215234

216235
/**
@@ -248,25 +267,45 @@ export default class BirthdayPicker extends React.Component<Props, State> {
248267
return;
249268
}
250269

251-
// If the month/year only mode is enabled, we set the day to the
252-
// last day of the selected month.
253-
// NOTE: at this point dateFields is guaranteed to have non-null values
254-
// because of the .some() check above.
255-
let date = moment(dateFields as Array<string>);
256-
if (monthYearOnly) {
257-
date = date.endOf("month");
270+
let date: Temporal.PlainDate;
271+
try {
272+
// If the month/year only mode is enabled, we set the day to the
273+
// last day of the selected month.
274+
// NOTE: at this point dateFields is guaranteed to have non-null values
275+
// because of the .some() check above.
276+
if (monthYearOnly) {
277+
date = Temporal.PlainDate.from({
278+
year: Number(year),
279+
month: Number(month),
280+
// Temporal will constrain the date to the last day of the month
281+
day: 31,
282+
});
283+
} else {
284+
date = Temporal.PlainDate.from(
285+
{
286+
year: Number(year),
287+
month: Number(month),
288+
day: Number(day),
289+
},
290+
{overflow: "reject"},
291+
);
292+
}
293+
} catch (err) {
294+
this.setState({error: this.labels.errorMessage});
295+
this.reportChange(null);
296+
return;
258297
}
259298

260299
// If the date is in the future or is invalid then we want to show
261300
// an error to the user and return a null value.
262-
if (date.isAfter() || !date.isValid()) {
301+
if (this.isFutureDate(date)) {
263302
this.setState({error: this.labels.errorMessage});
264303
this.reportChange(null);
265304
} else {
266305
this.setState({error: null});
267-
// If the date picker is in a non-English language, we still
268-
// want to generate an English date.
269-
this.reportChange(date.locale("en").format("YYYY-MM-DD"));
306+
// Regardless of locale, we want to format the date as YYYY-MM-DD
307+
// toString() returns an ISO 8601 date string, which is YYYY-MM-DD.
308+
this.reportChange(date.toString());
270309
}
271310
};
272311

@@ -315,6 +354,18 @@ export default class BirthdayPicker extends React.Component<Props, State> {
315354
);
316355
}
317356

357+
monthsShort(): string[] {
358+
const format = new Intl.DateTimeFormat(navigator.language, {
359+
month: "short",
360+
}).format;
361+
return [...Array(12).keys()].map((m) =>
362+
// TODO: use Temporal.PlainDate.from() once the linter lets
363+
// format() accept a Temporal object
364+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format#parameters
365+
format(new Date(2021, m, 15)),
366+
);
367+
}
368+
318369
renderMonth(): React.ReactNode {
319370
const {disabled, monthYearOnly, dropdownStyle} = this.props;
320371
const {month} = this.state;
@@ -331,9 +382,13 @@ export default class BirthdayPicker extends React.Component<Props, State> {
331382
style={[{minWidth}, defaultStyles.input, dropdownStyle]}
332383
testId="birthday-picker-month"
333384
>
334-
{/* eslint-disable-next-line import/no-named-as-default-member */}
335-
{moment.monthsShort().map((month, i) => (
336-
<OptionItem key={month} label={month} value={String(i)} />
385+
{this.monthsShort().map((monthShort, i) => (
386+
<OptionItem
387+
key={monthShort}
388+
label={monthShort}
389+
// +1 because Temporal months are 1-indexed
390+
value={String(i + 1)}
391+
/>
337392
))}
338393
</SingleSelect>
339394
);

pnpm-lock.yaml

+18-14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ catalog:
1818
# Styling
1919
aphrodite: ^1.2.5
2020
# Dates
21-
moment: 2.29.4
21+
temporal-polyfill: "^0.3.0"
2222
# Testing/data
2323
node-fetch: ^2.6.7
2424
# React

0 commit comments

Comments
 (0)