Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Labs] Timezone Picker #1568

Merged
merged 83 commits into from
Sep 22, 2017
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
356743c
Remove non-existent tsconfig include dir
Sep 12, 2017
66c6edc
Install moment and moment-timezone
Sep 12, 2017
4a4aef2
Remove unused import
Sep 12, 2017
b6d8302
Shorten imports
Sep 12, 2017
456303a
Timezone Input V0
Sep 12, 2017
7de36a1
Use Select component
Sep 12, 2017
67ca676
Seed list with representative timezones
Sep 13, 2017
dad05d1
Additional timezone metadata
Sep 13, 2017
75dd866
Specify general tab size and scss-specific tab size in workspace sett…
Sep 13, 2017
a1ee5cb
Left-aligned time zone offset label
Sep 13, 2017
5739478
Labels look better on right
Sep 13, 2017
f61607c
Enforce a min-width, so the menu doesn't jump around while filtering
Sep 13, 2017
185a671
Move abbreviation after name, so offsets align
Sep 13, 2017
4bb8e12
Additional props: disabled, defaultTimezone, selectedTimezoneFormat
Sep 13, 2017
50f8e78
Clean up naming and initial timezone getter
Sep 13, 2017
0b0237c
showUserTimezoneGuess prop
Sep 13, 2017
25176ab
Pass through popoverProps
Sep 13, 2017
c08169f
placeholder prop
Sep 13, 2017
3e4a482
targetClassName prop
Sep 13, 2017
8823063
Add disabled switch to example; Use handler helpers
Sep 13, 2017
4c2def9
Add interface to docs page
Sep 13, 2017
e5dc667
Add example switches for default timezone and use guess
Sep 13, 2017
ba10a57
Timezone query candidates
Sep 13, 2017
86ade6b
Change empty state text
Sep 13, 2017
cbf7ee3
Only show user timezone if query is empty
Sep 13, 2017
72c143a
Controlled mode for selected timezone
Sep 13, 2017
cd0f2ad
Don't exclude popular guess
Sep 14, 2017
8671cc0
Add onQueryChange to account for the query being changed through reset
Sep 14, 2017
d0f1e4f
Use onQueryChange
Sep 14, 2017
f6f8b93
Add defaultToUserTimezoneGuess prop
Sep 14, 2017
c7b76d2
Make timezone input look more like an input
Sep 14, 2017
1946977
Make placeholder consistent with display format
Sep 14, 2017
b359c1a
Show the timezone input example in the context of date and time pickers
Sep 14, 2017
020d02f
More documentation
Sep 14, 2017
0cc560c
Clean up props; Add documentation
Sep 14, 2017
44e70d1
timezone input -> timezone select
Sep 14, 2017
4d55db2
Custom target renderer
Sep 14, 2017
f237bdc
Handle empty date
Sep 14, 2017
1054cea
Merge remote-tracking branch 'origin/master' into bb/timezone
Sep 14, 2017
f6858a7
Only use text cursor if not disabled
Sep 14, 2017
9400904
Basic and extended examples
Sep 14, 2017
d2f7f78
Placeholder style
Sep 14, 2017
e10b3d3
Fix lint
Sep 14, 2017
2114c6a
Improve documentation
Sep 15, 2017
d9d00bc
Improve props docs
Sep 15, 2017
f652ffe
Optional icon name
Sep 15, 2017
dc85ca6
Don't use the `||` pattern
Sep 15, 2017
b586639
Use custom class name last
Sep 15, 2017
add9e49
Fix nits
Sep 15, 2017
900c07e
Reorder documentation
Sep 15, 2017
bbf0ff1
Rename TimezoneSelect to TimezonePicker
Sep 15, 2017
7fc4720
Remove the display tag from the example
Sep 15, 2017
8782648
Remove confusing default value
Sep 15, 2017
15947e6
Add buttonProps; Better placeholder
Sep 15, 2017
5afc0d7
Remove pt-timezone-picker-target-placeholder
Sep 15, 2017
ef932e9
Remove defaultToLocalTimezone prop
Sep 15, 2017
cd20736
Split timezone utilities into separate files
Sep 15, 2017
b23c37c
Expose input props; More descriptive input placeholder
Sep 15, 2017
4c8cb85
Don't make example button primary
Sep 15, 2017
4ca31e4
Use radio group for formats in example
Sep 15, 2017
7b96c8a
Composite display format
Sep 15, 2017
bf15ba5
Clean up timezone querying
Sep 15, 2017
11631b8
Basic query match ranking
Sep 16, 2017
76cad64
Prioritize exact matches
Sep 16, 2017
bd9ef19
Add fuzzaldrin-plus
Sep 19, 2017
b2b4788
Use fuzzaldrin-plus for timezone item sorting and filtering
Sep 19, 2017
ff49db9
Remove unneeded query candidates because fuzzaldrin is that good
Sep 19, 2017
d427502
Use filter key constant for clarity
Sep 19, 2017
ab8d5d5
Docs nits
Sep 19, 2017
b8e6c32
Merge remote-tracking branch 'origin/master' into bb/timezone
Sep 19, 2017
68ce37c
Make timezone code "prettier"
Sep 19, 2017
7f5df58
Fix lints
Sep 19, 2017
b0be6b6
[Labs/Select] Sync input props value with query value
Sep 19, 2017
ffbb9d3
[Labs/TimezonePicker] Sync input props value with query value
Sep 19, 2017
956b612
Fix misnamed test
Sep 19, 2017
76fccc2
Timezone picker tests
Sep 19, 2017
c208968
Fix nits
Sep 21, 2017
56bf58e
Use module augmentation instead of casting for zone.population
Sep 21, 2017
d5ab205
Don't import `from ".."`
Sep 21, 2017
3453ce0
Fix doc nits
Sep 21, 2017
b14a7c2
Make test style more consistent
Sep 21, 2017
248f670
Fix tests
Sep 21, 2017
2b37613
Concatenate item query candidates for better ranking
Sep 21, 2017
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/core/src/components/menu/menuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,9 @@ export class MenuItem extends AbstractComponent<IMenuItemProps, IMenuItemState>
if (hasSubmenu) {
const measureSubmenu = this.props.useSmartPositioning ? this.measureSubmenu : null;
const submenuElement = <Menu ref={measureSubmenu}>{this.renderChildren()}</Menu>;
const popoverClasses = classNames(
Classes.MINIMAL,
Classes.MENU_SUBMENU,
popoverProps.popoverClassName,
{ [Classes.ALIGN_LEFT]: this.state.alignLeft },
);
const popoverClasses = classNames(Classes.MINIMAL, Classes.MENU_SUBMENU, popoverProps.popoverClassName, {
[Classes.ALIGN_LEFT]: this.state.alignLeft,
});

content = (
<Popover
Expand Down
8 changes: 7 additions & 1 deletion packages/core/test/menu/menuTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ describe("MenuItem", () => {
);
assert.strictEqual(wrapper.find(Popover).prop("inline"), popoverProps.inline);
assert.strictEqual(wrapper.find(Popover).prop("interactionKind"), popoverProps.interactionKind);
assert.notStrictEqual(wrapper.find(Popover).prop("popoverClassName").indexOf(popoverProps.popoverClassName), 0);
assert.notStrictEqual(
wrapper
.find(Popover)
.prop("popoverClassName")
.indexOf(popoverProps.popoverClassName),
0,
);
assert.notStrictEqual(wrapper.find(Popover).prop("content"), popoverProps.content);
});

Expand Down
24 changes: 9 additions & 15 deletions packages/labs/examples/timezonePickerExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,16 @@ export class TimezonePickerExample extends BaseExample<ITimezonePickerExampleSta
timezone: "",
};

private handleDisabledChange = handleBooleanChange((disabled) => this.setState({ disabled }));
private handleShowLocalTimezoneChange = handleBooleanChange((showLocalTimezone) =>
this.setState({ showLocalTimezone }));
private handleDisabledChange = handleBooleanChange(disabled => this.setState({ disabled }));
private handleShowLocalTimezoneChange = handleBooleanChange(showLocalTimezone =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please always wrap params in parens. this should be a lint failure...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, this is the prettier standard

this.setState({ showLocalTimezone }),
);
private handleFormatChange = handleStringChange((targetDisplayFormat: TimezoneDisplayFormat) =>
this.setState({ targetDisplayFormat }));
this.setState({ targetDisplayFormat }),
);

protected renderExample() {
const {
date,
timezone,
targetDisplayFormat,
disabled,
showLocalTimezone,
} = this.state;
const { date, timezone, targetDisplayFormat, disabled, showLocalTimezone } = this.state;

return (
<TimezonePicker
Expand Down Expand Up @@ -72,9 +68,7 @@ export class TimezonePickerExample extends BaseExample<ITimezonePickerExampleSta
onChange={this.handleDisabledChange}
/>,
],
[
this.renderDisplayFormatOption(),
],
[this.renderDisplayFormatOption()],
];
}

Expand All @@ -96,5 +90,5 @@ export class TimezonePickerExample extends BaseExample<ITimezonePickerExampleSta

private handleTimezoneChange = (timezone: string) => {
this.setState({ timezone });
}
};
}
2 changes: 2 additions & 0 deletions packages/labs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dependencies": {
"@blueprintjs/core": "^1.26.0",
"classnames": "^2.2",
"fuzzaldrin-plus": "^0.5.0",
"moment": "^2.14.1",
"moment-timezone": "^0.5.13",
"popper.js": "1.11.0",
Expand All @@ -17,6 +18,7 @@
"tslib": "^1.5.0"
},
"devDependencies": {
"@types/fuzzaldrin-plus": "^0.0.1",
"@types/moment-timezone": "^0.2.35",
"bourbon": "^4.3.4",
"react": "^15.6.1",
Expand Down
20 changes: 16 additions & 4 deletions packages/labs/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,20 @@ export class Select<T> extends React.Component<ISelectProps<T>, ISelectState<T>>
return Select as new () => Select<T>;
}

public state: ISelectState<T> = { isOpen: false, query: "" };

private TypedQueryList = QueryList.ofType<T>();
private list: QueryList<T>;
private refHandlers = {
queryList: (ref: QueryList<T>) => (this.list = ref),
};
private previousFocusedElement: HTMLElement;

constructor(props?: ISelectProps<T>, context?: any) {
super(props, context);

const query = props && props.inputProps && props.inputProps.value !== undefined ? props.inputProps.value : "";
this.state = { isOpen: false, query };
}

public render() {
// omit props specific to this component, spread the rest.
const {
Expand All @@ -155,6 +160,13 @@ export class Select<T> extends React.Component<ISelectProps<T>, ISelectState<T>>
);
}

public componentWillReceiveProps(nextProps: ISelectProps<T>) {
const { inputProps: nextInputProps = {} } = nextProps;
if (nextInputProps.value !== undefined && this.state.query !== nextInputProps.value) {
this.setState({ query: nextInputProps.value });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! this seems like a valuable fix and could use a unit test (since Select has actual coverage).

in fact you could go so far as to pull this change to a separate PR focused solely on supporting controlled input value.

though in truth we probably want an obvious query prop instead of inputProps.value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to start on this in a separate PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}

public componentDidUpdate(_prevProps: ISelectProps<T>, prevState: ISelectState<T>) {
if (this.state.isOpen && !prevState.isOpen && this.list != null) {
this.list.scrollActiveItemIntoView();
Expand Down Expand Up @@ -302,12 +314,12 @@ export class Select<T> extends React.Component<ISelectProps<T>, ISelectState<T>>
this.setState({ query });
Utils.safeInvoke(inputProps.onChange, event);
Utils.safeInvoke(onQueryChange, query);
}
};

private resetQuery = () => {
const { items, onQueryChange } = this.props;
const query = "";
this.setState({ activeItem: items[0], query });
Utils.safeInvoke(onQueryChange, query);
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { getTimezoneMetadata } from "./timezoneMetadata";

export type TimezoneDisplayFormat = "offset" | "abbreviation" | "name" | "composite";
export const TimezoneDisplayFormat = {
/** Abbreviation format i.e. "GMT" */
/** Abbreviation format i.e. "HST" */
ABBREVIATION: "abbreviation" as "abbreviation",
/** Composite format i.e. "Pacific/Honolulu (HST) -10:00" */
COMPOSITE: "composite" as "composite",
/** Name format i.e. "Pacific/Honolulu" */
NAME: "name" as "name",
/** Offset format i.e. "-07:00" */
/** Offset format i.e. "-10:00" */
OFFSET: "offset" as "offset",
};

Expand All @@ -32,7 +32,7 @@ export function formatTimezone(
const { abbreviation, offsetAsString } = getTimezoneMetadata(timezone, date);
switch (displayFormat) {
case TimezoneDisplayFormat.ABBREVIATION:
// Fall back to the offset in regions where there is no abbreviation.
// Fall back to the offset when there is no abbreviation.
return abbreviation ? abbreviation : offsetAsString;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer to avoid implicit boolean coercion. abbreviation === undefined ? default : actual

case TimezoneDisplayFormat.NAME:
return timezone;
Expand Down
65 changes: 42 additions & 23 deletions packages/labs/src/components/timezone-picker/timezoneItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,61 @@ import * as moment from "moment-timezone";
import { getTimezoneMetadata, ITimezoneMetadata } from "./timezoneMetadata";
import { getLocalTimezone } from "./timezoneUtils";

/** Timezone-specific QueryList item */
export interface ITimezoneItem {
/** Key to be used as the rendered react key. */
key: string;
/** Text for the timezone. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blank line after each property, before the next doc comment.

text: string;
/** Label for the timezone. */
label: string;
/** Optional icon for the timezone. */
iconName?: IconName;
/** The actual timezone. */
timezone: string;
}

/**
* Get a list of all timezone items.
* @param date the date to use when determining timezone offsets
*/
export function getTimezoneItems(date: Date): ITimezoneItem[] {
return moment.tz.names().map((timezone) => toTimezoneItem(timezone, date));
return moment.tz.names().map(timezone => toTimezoneItem(timezone, date));
}

/**
* Get a list of timezone items where there is one timezone per offset
* and optionally the local timezone as the first item.
* The most populous timezone for each offset is chosen.
* @param date the date to use when determining timezone offsets
* @param includeLocalTimezone whether to include the local timezone
*/
export function getInitialTimezoneItems(date: Date, includeLocalTimezone: boolean): ITimezoneItem[] {
const populous = getPopulousTimezoneItems(date);
const local = getLocalTimezoneItem(date);
return includeLocalTimezone && local !== undefined
? [local, ...populous]
: populous;
return includeLocalTimezone && local !== undefined ? [local, ...populous] : populous;
}

/**
* Get the timezone item for the user's local timezone.
* @param date the date to use when determining timezone offsets
*/
export function getLocalTimezoneItem(date: Date): ITimezoneItem | undefined {
const timezone = getLocalTimezone();
if (timezone !== undefined) {
const timestamp = date.getTime();
const zonedDate = moment.tz(timestamp, timezone);
const offsetAsString = zonedDate.format("Z");
return {
iconName: "locate",
key: `${timezone}-local`,
label: offsetAsString,
text: "Current timezone",
timezone,
};
} else {
return undefined;
}
}

/**
Expand All @@ -37,7 +74,7 @@ export function getInitialTimezoneItems(date: Date, includeLocalTimezone: boolea
*/
function getPopulousTimezoneItems(date: Date): ITimezoneItem[] {
// Filter out noisy timezones. See https://github.com/moment/moment-timezone/issues/227
const timezones = moment.tz.names().filter((timezone) => /\//.test(timezone) && !/Etc\//.test(timezone));
const timezones = moment.tz.names().filter(timezone => /\//.test(timezone) && !/Etc\//.test(timezone));

const timezoneToMetadata: { [timezone: string]: ITimezoneMetadata } = {};
for (const timezone of timezones) {
Expand Down Expand Up @@ -73,24 +110,6 @@ function getPopulousTimezoneItems(date: Date): ITimezoneItem[] {
return initialTimezones;
}

function getLocalTimezoneItem(date: Date): ITimezoneItem | undefined {
const timezone = getLocalTimezone();
if (timezone !== undefined) {
const timestamp = date.getTime();
const zonedDate = moment.tz(timestamp, timezone);
const offsetAsString = zonedDate.format("Z");
return {
iconName: "locate",
key: `${timezone}-local`,
label: offsetAsString,
text: "Current timezone",
timezone,
};
} else {
return undefined;
}
}

function toTimezoneItem(timezone: string, date: Date): ITimezoneItem {
const { abbreviation, offsetAsString } = getTimezoneMetadata(timezone, date);
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ export function getTimezoneMetadata(timezone: string, date: Date): ITimezoneMeta
const offsetAsString = zonedDate.format("Z");
const abbreviation = getAbbreviation(timezone, timestamp);
// TODO: amend moment-timezone typings to include the population field
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue to track this? in moment's repo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const population = zone && (zone as any).population !== undefined
? (zone as any).population
: undefined;
const population = zone && (zone as any).population !== undefined ? (zone as any).population : undefined;

return {
timezone,
Expand Down
48 changes: 26 additions & 22 deletions packages/labs/src/components/timezone-picker/timezonePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,11 @@ import {
MenuItem,
Utils,
} from "@blueprintjs/core";
import {
ISelectItemRendererProps,
Select,
} from "..";
import { ISelectItemRendererProps, Select } from "..";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ import from actual component source file, not the index.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import * as Classes from "../../common/classes";
import { formatTimezone, TimezoneDisplayFormat } from "./timezoneDisplayFormat";
import { getInitialTimezoneItems, getTimezoneItems, ITimezoneItem } from "./timezoneItems";
import { createTimezoneQueryEngine, IItemQueryEngine } from "./timezoneQueryEngine";
import { filterWithQueryCandidates, getTimezoneQueryCandidates } from "./timezoneUtils";

export { TimezoneDisplayFormat };

Expand Down Expand Up @@ -118,16 +115,16 @@ export class TimezonePicker extends AbstractComponent<ITimezonePickerProps, ITim
};

private timezoneItems: ITimezoneItem[];
private timezoneQueryEngine: IItemQueryEngine<ITimezoneItem>;
private initialTimezoneItems: ITimezoneItem[];

constructor(props: ITimezonePickerProps, context?: any) {
super(props, context);

const { value, date = new Date(), showLocalTimezone } = props;
this.state = { date, value };
const { value, date = new Date(), showLocalTimezone, inputProps = {} } = props;
const query = inputProps.value !== undefined ? inputProps.value : "";
this.state = { date, value, query };

this.updateTimezones(date);
this.timezoneItems = getTimezoneItems(date);
this.initialTimezoneItems = getInitialTimezoneItems(date, showLocalTimezone);
}

Expand Down Expand Up @@ -165,11 +162,11 @@ export class TimezonePicker extends AbstractComponent<ITimezonePickerProps, ITim
}

public componentWillReceiveProps(nextProps: ITimezonePickerProps) {
const { date: nextDate = new Date() } = nextProps;
const { date: nextDate = new Date(), inputProps: nextInputProps = {} } = nextProps;
const dateChanged = this.state.date.getTime() !== nextDate.getTime();

if (dateChanged) {
this.updateTimezones(nextDate);
this.timezoneItems = getTimezoneItems(nextDate);
}
if (dateChanged || this.props.showLocalTimezone !== nextProps.showLocalTimezone) {
this.initialTimezoneItems = getInitialTimezoneItems(nextDate, nextProps.showLocalTimezone);
Expand All @@ -182,6 +179,9 @@ export class TimezonePicker extends AbstractComponent<ITimezonePickerProps, ITim
if (this.state.value !== nextProps.value) {
nextState.value = nextProps.value;
}
if (nextInputProps.value !== undefined && this.state.query !== nextInputProps.value) {
nextState.query = nextInputProps.value;
}
this.setState(nextState);
}

Expand All @@ -208,15 +208,19 @@ export class TimezonePicker extends AbstractComponent<ITimezonePickerProps, ITim
);
}

private updateTimezones(date: Date): void {
this.timezoneItems = getTimezoneItems(date);
this.timezoneQueryEngine = createTimezoneQueryEngine(date);
}

private filterItems = (query: string, items: ITimezoneItem[]): ITimezoneItem[] => {
// Only filter and rank the non-initial items
return items === this.initialTimezoneItems ? items : this.timezoneQueryEngine.filterAndRankItems(query, items);
}
if (query === "") {
return items;
}

const { date } = this.state;
return filterWithQueryCandidates(
items,
query,
item => item.timezone,
item => getTimezoneQueryCandidates(item.timezone, date),
);
};

private renderItem = (itemProps: ISelectItemRendererProps<ITimezoneItem>) => {
const { item, isActive, handleClick } = itemProps;
Expand All @@ -236,16 +240,16 @@ export class TimezonePicker extends AbstractComponent<ITimezonePickerProps, ITim
shouldDismissPopover={false}
/>
);
}
};

private handleItemSelect = (timezone: ITimezoneItem) => {
if (this.props.value === undefined) {
this.setState({ value: timezone.timezone });
}
Utils.safeInvoke(this.props.onChange, timezone.timezone);
}
};

private handleQueryChange = (query: string) => {
this.setState({ query });
}
};
}
Loading