From 7ea0597a169201d5b0e61abe93ddb07bc4641adc Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Thu, 4 Apr 2024 16:30:25 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(entity=20selector)=20sort=20by?= =?UTF-8?q?=20name=20or=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/src/Checkbox.scss | 5 +- .../components/src/Checkbox.tsx | 2 +- .../core-table/src/CoreTableColumns.ts | 4 + .../core-table/src/OwidTable.ts | 2 +- .../@ourworldindata/core-table/src/index.ts | 1 + .../grapher/src/controls/Dropdown.scss | 86 +++ .../grapher/src/controls/Dropdown.tsx | 26 + .../src/controls/MapProjectionMenu.scss | 94 +--- .../src/controls/MapProjectionMenu.tsx | 24 +- .../grapher/src/controls/RadioButton.tsx | 2 +- .../grapher/src/core/grapher.scss | 1 + .../grapher/src/core/typography.scss | 9 + .../src/entitySelector/EntitySelector.scss | 88 ++- .../src/entitySelector/EntitySelector.tsx | 510 +++++++++++++++--- packages/@ourworldindata/utils/src/Util.ts | 4 + packages/@ourworldindata/utils/src/index.ts | 1 + 16 files changed, 657 insertions(+), 202 deletions(-) create mode 100644 packages/@ourworldindata/grapher/src/controls/Dropdown.scss create mode 100644 packages/@ourworldindata/grapher/src/controls/Dropdown.tsx diff --git a/packages/@ourworldindata/components/src/Checkbox.scss b/packages/@ourworldindata/components/src/Checkbox.scss index f7523480202..eb83ac05d3e 100644 --- a/packages/@ourworldindata/components/src/Checkbox.scss +++ b/packages/@ourworldindata/components/src/Checkbox.scss @@ -3,7 +3,7 @@ $light-stroke: #dadada; $hover-stroke: #a4b6ca; - $active-fill: #dbe5f0; + $active-fill: #a4b6ca; position: relative; @@ -26,7 +26,7 @@ pointer-events: none; border-radius: 2px; border: 1px solid $light-stroke; - color: $dark-text; + color: #fff; display: flex; align-items: center; @@ -35,7 +35,6 @@ svg { font-size: 10px; padding-left: 0.75px; - color: $active-text; } } diff --git a/packages/@ourworldindata/components/src/Checkbox.tsx b/packages/@ourworldindata/components/src/Checkbox.tsx index 207a9439003..022bc2426f1 100644 --- a/packages/@ourworldindata/components/src/Checkbox.tsx +++ b/packages/@ourworldindata/components/src/Checkbox.tsx @@ -4,7 +4,7 @@ import { faCheck } from "@fortawesome/free-solid-svg-icons" export class Checkbox extends React.Component<{ checked: boolean - onChange: React.ChangeEventHandler + onChange?: React.ChangeEventHandler label: React.ReactNode }> { render(): JSX.Element { diff --git a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts index 8e50270822e..079b964c1a8 100644 --- a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts +++ b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts @@ -915,3 +915,7 @@ export const ColumnTypeMap = { const _ColumnTypeMap: { [key in ColumnTypeNames]: unknown } = ColumnTypeMap + +export function isColumnWithNumberFormatting(column: CoreColumn): boolean { + return column instanceof AbstractColumnWithNumberFormatting +} diff --git a/packages/@ourworldindata/core-table/src/OwidTable.ts b/packages/@ourworldindata/core-table/src/OwidTable.ts index 8a65d486b0f..5a93b99859d 100644 --- a/packages/@ourworldindata/core-table/src/OwidTable.ts +++ b/packages/@ourworldindata/core-table/src/OwidTable.ts @@ -189,7 +189,7 @@ export class OwidTable extends CoreTable { } // Does a stable sort by time. You can refer to this table for fast time filtering. - @imemo private get sortedByTime(): this { + @imemo get sortedByTime(): this { if (this.timeColumn.isMissing) return this return this.sortBy([this.timeColumn.slug]) } diff --git a/packages/@ourworldindata/core-table/src/index.ts b/packages/@ourworldindata/core-table/src/index.ts index b2bd9e9c71e..bd1f4888afd 100644 --- a/packages/@ourworldindata/core-table/src/index.ts +++ b/packages/@ourworldindata/core-table/src/index.ts @@ -14,6 +14,7 @@ export { ColumnTypeMap, AbstractCoreColumn, TimeColumn, + isColumnWithNumberFormatting, } from "./CoreTableColumns.js" export { OwidTable, BlankOwidTable } from "./OwidTable.js" diff --git a/packages/@ourworldindata/grapher/src/controls/Dropdown.scss b/packages/@ourworldindata/grapher/src/controls/Dropdown.scss new file mode 100644 index 00000000000..0873f9f73bd --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/Dropdown.scss @@ -0,0 +1,86 @@ +.grapher-dropdown { + $option-checkmark: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0xMS4wMTU2IDAuOTg0Mzc1QzExLjMyMDMgMS4yNjU2MiAxMS4zMjAzIDEuNzU3ODEgMTEuMDE1NiAyLjAzOTA2TDUuMDE1NjIgOC4wMzkwNkM0LjczNDM4IDguMzQzNzUgNC4yNDIxOSA4LjM0Mzc1IDMuOTYwOTQgOC4wMzkwNkwwLjk2MDkzOCA1LjAzOTA2QzAuNjU2MjUgNC43NTc4MSAwLjY1NjI1IDQuMjY1NjIgMC45NjA5MzggMy45ODQzOEMxLjI0MjE5IDMuNjc5NjkgMS43MzQzOCAzLjY3OTY5IDIuMDE1NjIgMy45ODQzOEw0LjQ3NjU2IDYuNDQ1MzFMOS45NjA5NCAwLjk4NDM3NUMxMC4yNDIyIDAuNjc5Njg4IDEwLjczNDQgMC42Nzk2ODggMTEuMDE1NiAwLjk4NDM3NVoiIGZpbGw9IiMxRDNENjMiLz4KPC9zdmc+"; + $menu-caret-up: "data:image/svg+xml;base64, PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI1IiB2aWV3Qm94PSIwIDAgOCA1IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMC40NjA5MzggMy43MzQzOEwzLjQzNzUgMC43MzQzNzVDMy42MDE1NiAwLjU5Mzc1IDMuNzg5MDYgMC41IDQgMC41QzQuMTg3NSAwLjUgNC4zNzUgMC41OTM3NSA0LjUxNTYyIDAuNzM0Mzc1TDcuNDkyMTkgMy43MzQzOEM3LjcwMzEyIDMuOTQ1MzEgNy43NzM0NCA0LjI3MzQ0IDcuNjU2MjUgNC41NTQ2OUM3LjUzOTA2IDQuODM1OTQgNy4yODEyNSA1IDYuOTc2NTYgNUgxQzAuNjk1MzEyIDUgMC40MTQwNjIgNC44MzU5NCAwLjI5Njg3NSA0LjU1NDY5QzAuMTc5Njg4IDQuMjczNDQgMC4yNSAzLjk0NTMxIDAuNDYwOTM4IDMuNzM0MzhaIiBmaWxsPSIjNUI1QjVCIi8+Cjwvc3ZnPg=="; + $menu-caret-down: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI1IiB2aWV3Qm94PSIwIDAgOCA1IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNy41MTU2MiAxLjI4OTA2TDQuNTM5MDYgNC4yODkwNkM0LjM3NSA0LjQyOTY5IDQuMTg3NSA0LjUgNCA0LjVDMy43ODkwNiA0LjUgMy42MDE1NiA0LjQyOTY5IDMuNDYwOTQgNC4yODkwNkwwLjQ4NDM3NSAxLjI4OTA2QzAuMjUgMS4wNzgxMiAwLjE3OTY4OCAwLjc1IDAuMjk2ODc1IDAuNDY4NzVDMC40MTQwNjIgMC4xODc1IDAuNjk1MzEyIDAgMSAwSDYuOTc2NTZDNy4yODEyNSAwIDcuNTM5MDYgMC4xODc1IDcuNjU2MjUgMC40Njg3NUM3Ljc3MzQ0IDAuNzUgNy43MjY1NiAxLjA3ODEyIDcuNTE1NjIgMS4yODkwNloiIGZpbGw9IiM1QjVCNUIiLz4KPC9zdmc+Cg=="; + + $medium: 400; + $lato: $sans-serif-font-stack; + + $light-stroke: #e7e7e7; + $active-stroke: #a4b6ca; + + $active-fill: #dbe5f0; + $hover-fill: #f2f2f2; + $selected-fill: #c7ced7; + + $light-text: #858585; + + font: $medium 13px/16px $lato; + + .control { + min-height: auto; + font: $medium 13px/16px $lato; + letter-spacing: 0.01em; + display: flex; + align-items: center; + border: 1px solid $light-stroke; + border-radius: 4px; + padding: 7px; + color: $dark-text; + + &:hover { + background: $hover-fill; + cursor: pointer; + } + + &:after { + content: " "; + background: url($menu-caret-down) no-repeat center; + width: 16px; + height: 16px; + } + + &.active { + border-color: $active-stroke; + &:after { + background: url($menu-caret-up) no-repeat center; + } + } + } + + .menu { + margin-top: 3px; + border-radius: 4px; + background: white; + box-shadow: 0px 4px 40px 0px rgba(0, 0, 0, 0.15); + z-index: $zindex-controls-popup; + + .option { + padding: 8px 18px; + &:hover { + cursor: pointer; + background: $hover-fill; + } + &:active, + &.active { + color: $active-text; + background: $active-fill; + } + &.active { + position: relative; + &:hover { + background: $selected-fill; + } + &:after { + content: " "; + background: url($option-checkmark) no-repeat; + width: 12px; + height: 9px; + position: absolute; + right: 18px; + bottom: 11px; + } + } + } + } +} diff --git a/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx b/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx new file mode 100644 index 00000000000..ec3b019f3b9 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx @@ -0,0 +1,26 @@ +import React from "react" +import Select, { Props } from "react-select" + +export function Dropdown(props: Props): React.JSX.Element { + return ( + - state.menuIsOpen ? "active control" : "control", - option: (state) => - state.isSelected ? "active option" : "option", - menu: () => "menu", - }} /> ) : null diff --git a/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx b/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx index 10117e869e1..93b789d6108 100644 --- a/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx +++ b/packages/@ourworldindata/grapher/src/controls/RadioButton.tsx @@ -2,7 +2,7 @@ import React from "react" export class RadioButton extends React.Component<{ checked: boolean - onChange: React.ChangeEventHandler + onChange?: React.ChangeEventHandler label: React.ReactNode group?: string }> { diff --git a/packages/@ourworldindata/grapher/src/core/grapher.scss b/packages/@ourworldindata/grapher/src/core/grapher.scss index b810dad4c96..f7d757d42b4 100644 --- a/packages/@ourworldindata/grapher/src/core/grapher.scss +++ b/packages/@ourworldindata/grapher/src/core/grapher.scss @@ -89,6 +89,7 @@ $zindex-controls-drawer: 150; @import "../../../components/src/Checkbox.scss"; @import "../closeButton/CloseButton.scss"; @import "../controls/RadioButton.scss"; +@import "../controls/Dropdown.scss"; .grapher_dark { color: $dark-text; diff --git a/packages/@ourworldindata/grapher/src/core/typography.scss b/packages/@ourworldindata/grapher/src/core/typography.scss index 149e3b11d66..0ba748d14c4 100644 --- a/packages/@ourworldindata/grapher/src/core/typography.scss +++ b/packages/@ourworldindata/grapher/src/core/typography.scss @@ -134,3 +134,12 @@ .grapher_label-2-regular { @include grapher_label-2-regular; } + +@mixin grapher_label-2-medium { + @include grapher_label-2-regular; + font-weight: 500; +} + +.grapher_label-2-medium { + @include grapher_label-2-medium; +} diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss index 29ccc421b2a..988c3c86598 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss @@ -19,7 +19,64 @@ background-color: #fff; z-index: $zindex-header; padding: 0 var(--padding) 8px var(--padding); + margin-bottom: 8px; + } + + .entity-selector__sort-bar { + padding: 0 var(--padding); + display: flex; + align-items: center; margin-bottom: 16px; + + .grapher-dropdown { + flex-grow: 1; + } + + .label { + flex-shrink: 0; + margin-right: 8px; + } + + button.sort { + flex-shrink: 0; + margin-left: 16px; + + $size: 32px; + + display: flex; + align-items: center; + justify-content: center; + + position: relative; + height: $size; + width: $size; + padding: 7px; + + color: $dark-text; + background: #f2f2f2; + border-radius: 4px; + + svg { + height: 14px; + width: 14px; + } + + &:hover:not(:disabled) { + background: #e7e7e7; + cursor: pointer; + } + + &:active:not(:disabled) { + color: #1d3d63; + background: #dbe5f0; + border: 1px solid #dbe5f0; + } + + &:disabled { + background: #f2f2f2; + color: #a1a1a1; + } + } } .entity-selector__footer { @@ -70,7 +127,7 @@ } .entity-selector__content { - margin: -8px var(--padding) ($footer-height + 8px) var(--padding); + margin: 0 var(--padding) ($footer-height + 8px) var(--padding); } &.entity-selector--single { @@ -83,18 +140,41 @@ margin-top: 16px; } - .section-title { - letter-spacing: 0.01em; + .entity-section + .entity-section { margin-top: 16px; + } + + .entity-section__title { + letter-spacing: 0.01em; margin-bottom: 8px; } .selectable-entity { padding: 9px 8px 9px 16px; + display: flex; + justify-content: space-between; + position: relative; cursor: pointer; mark { - background: #dbe5f0; + color: inherit; + background: transparent; + font-weight: 600; + } + + .value { + color: #a1a1a1; + white-space: nowrap; + margin-left: 8px; + } + + .bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: #ebeef2; + z-index: -1; } } diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index 94d87a48131..5d0dc28ff71 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -5,10 +5,14 @@ import cx from "classnames" import a from "indefinite" import { isTouchDevice, - sortBy, partition, capitalize, last, + SortOrder, + orderBy, + keyBy, + isFiniteWithGuard, + CoreValueType, } from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FuzzySearch } from "../controls/FuzzySearch" @@ -26,24 +30,49 @@ import { DEFAULT_GRAPHER_ENTITY_TYPE_PLURAL, GRAPHER_SCROLLABLE_CONTAINER_CLASS, } from "../core/GrapherConstants" +import { + CoreColumn, + OwidTable, + isColumnWithNumberFormatting, +} from "@ourworldindata/core-table" +import { SortIcon } from "../controls/SortIcon" +import { Dropdown } from "../controls/Dropdown" export interface EntitySelectorManager { + tableForSelection: OwidTable selection: SelectionArray canChangeEntity: boolean entitiesAreCountryLike?: boolean entityType?: string entityTypePlural?: string + activeColumnSlugs?: string[] } -interface SearchableEntity { - name: string +type Slug = string + +interface SortConfig { + slug: Slug + order: SortOrder } +type SearchableEntity = { name: string } & Record< + Slug, + CoreValueType | undefined +> + interface PartitionedEntities { - selected: string[] - unselected: string[] + selected: SearchableEntity[] + unselected: SearchableEntity[] +} + +interface DropdownOption { + value: string + label: string } +const RELEVANCE_OPTION = { value: "relevance", label: "Relevance" } +const RELEVANCE = RELEVANCE_OPTION.value + @observer export class EntitySelector extends React.Component<{ manager: EntitySelectorManager @@ -53,8 +82,12 @@ export class EntitySelector extends React.Component<{ container: React.RefObject = React.createRef() searchField: React.RefObject = React.createRef() - @observable searchInput: string = "" - + @observable _searchInput: string = "" + @observable private _sortConfig: SortConfig = { + slug: this.table.entityNameSlug, + order: SortOrder.asc, + } + @observable private previousSortConfig: SortConfig = this.sortConfig @observable private mostRecentlySelectedEntityName: string | null = null componentDidMount(): void { @@ -74,10 +107,151 @@ export class EntitySelector extends React.Component<{ ) } + @computed private get searchInput(): string { + return this._searchInput + } + + private set searchInput(newSearchInput: string) { + this._searchInput = newSearchInput + + // switch to relevance sorting when searching + if (newSearchInput) { + this.sortConfig = { + slug: RELEVANCE, + order: SortOrder.asc, + } + } else if (this.sortConfig.slug === RELEVANCE) { + this.sortConfig = this.previousSortConfig + } + } + + @computed private get sortConfig(): SortConfig { + return this._sortConfig + } + + private set sortConfig(newSortConfig: SortConfig) { + // remember the previous sort config that was not relevance + if (this.sortConfig.slug !== RELEVANCE) { + this.previousSortConfig = this.sortConfig + } + + // sort names in ascending order by default + if ( + this.isSortedByName(newSortConfig) && + !this.isCurrentlySortedByName + ) { + newSortConfig.order = SortOrder.asc + } + + // sort relevance in ascending order by default + if ( + this.isSortedByRelevance(newSortConfig) && + !this.isCurrentlySortedByRelevance + ) { + newSortConfig.order = SortOrder.asc + } + + // sort values in descending order by default + if ( + !this.isSortedByName(newSortConfig) && + !this.isSortedByRelevance(newSortConfig) && + (this.isCurrentlySortedByName || this.isCurrentlySortedByRelevance) + ) { + newSortConfig.order = SortOrder.desc + } + + this._sortConfig = newSortConfig + } + + private updateSortSlug(newSlug: Slug) { + this.sortConfig = { + slug: newSlug, + order: this.sortConfig.order, + } + } + + private toggleSortOrder() { + const newOrder = + this.sortConfig.order === SortOrder.asc + ? SortOrder.desc + : SortOrder.asc + this.sortConfig = { + slug: this.sortConfig.slug, + order: newOrder, + } + } + @computed private get manager(): EntitySelectorManager { return this.props.manager } + @computed private get table(): OwidTable { + return this.manager.tableForSelection + } + + @computed private get sortColumns(): CoreColumn[] { + const activeSlugs = this.manager.activeColumnSlugs ?? [] + + const sortColumns = activeSlugs + .map((slug) => this.table.get(slug)) + .filter((column) => isColumnWithNumberFormatting(column)) + + return [this.table.entityNameColumn, ...sortColumns] + } + + @computed private get sortColumnsBySlug(): Record { + return keyBy(this.sortColumns, (column: CoreColumn) => column.slug) + } + + @computed get sortOptions(): DropdownOption[] { + const options: DropdownOption[] = [] + + // use relevance sorting when searching + if (this.searchInput) { + options.push(RELEVANCE_OPTION) + } + + const getDropdownLabel = (column: CoreColumn): string => { + if (column.slug === this.table.entityNameSlug) return "Name" + return column.displayName || column.nonEmptyName + } + + options.push( + ...this.sortColumns.map((column) => { + return { + value: column.slug, + label: getDropdownLabel(column), + } + }) + ) + + return options + } + + @computed get sortValue(): DropdownOption | null { + return ( + this.sortOptions.find( + (option) => option.value === this.sortConfig.slug + ) ?? null + ) + } + + private isSortedByName(sortConfig: SortConfig): boolean { + return sortConfig.slug === this.table.entityNameSlug + } + + private isSortedByRelevance(sortConfig: SortConfig): boolean { + return sortConfig.slug === RELEVANCE + } + + @computed private get isCurrentlySortedByName(): boolean { + return this.isSortedByName(this.sortConfig) + } + + @computed private get isCurrentlySortedByRelevance(): boolean { + return this.isSortedByRelevance(this.sortConfig) + } + @computed private get entityType(): string { return this.manager.entityType || DEFAULT_GRAPHER_ENTITY_TYPE } @@ -92,12 +266,75 @@ export class EntitySelector extends React.Component<{ return makeSelectionArray(this.manager.selection) } - @computed get sortedAvailableEntities(): string[] { - return sortBy(this.selectionArray.availableEntityNames) + @computed private get availableEntityNames(): string[] { + return this.table.availableEntityNames + } + + @computed private get availableEntities(): SearchableEntity[] { + return this.availableEntityNames.map((entityName) => { + const searchableEntity: SearchableEntity = { name: entityName } + + for (const column of this.sortColumns) { + searchableEntity[column.slug] = + this.table.getLatestValueForEntity(entityName, column.slug) + } + + return searchableEntity + }) + } + + private sortEntities(entities: SearchableEntity[]): SearchableEntity[] { + const { sortConfig } = this + + const shouldBeSortedByName = + sortConfig.slug === this.table.entityNameSlug + const shouldBeSortedByRelevance = sortConfig.slug === RELEVANCE + + // search results are already sorted by relevance, but if + // the user toggles the sort order, we reverse the results + if (shouldBeSortedByRelevance) { + if (sortConfig.order === SortOrder.desc) { + const entitiesCopy = [...entities] + entitiesCopy.reverse() + return entitiesCopy + } + return entities + } + + // sort by name + if (shouldBeSortedByName) { + return orderBy( + entities, + (entity: SearchableEntity) => entity.name, + sortConfig.order + ) + } + + // sort by number column, with missing values at the end + + const [withValues, withoutValues] = partition( + entities, + (entity: SearchableEntity) => + isFiniteWithGuard(entity[sortConfig.slug]) + ) + + const sortedEntitiesWithValues = orderBy( + withValues, + (entity: SearchableEntity) => entity[sortConfig.slug], + sortConfig.order + ) + + const sortedEntitiesWithoutValues = orderBy( + withoutValues, + (entity: SearchableEntity) => entity.name, + SortOrder.asc + ) + + return [...sortedEntitiesWithValues, ...sortedEntitiesWithoutValues] } - @computed private get searchableEntities(): SearchableEntity[] { - return this.sortedAvailableEntities.map((name) => ({ name })) + @computed private get sortedAvailableEntities(): SearchableEntity[] { + return this.sortEntities(this.availableEntities) } @computed get isMultiMode(): boolean { @@ -105,13 +342,13 @@ export class EntitySelector extends React.Component<{ } @computed get fuzzy(): FuzzySearch { - return new FuzzySearch(this.searchableEntities, "name") + return new FuzzySearch(this.sortedAvailableEntities, "name") } @computed get searchResults(): SearchableEntity[] | undefined { - return this.searchInput - ? this.fuzzy.search(this.searchInput) - : undefined + if (!this.searchInput) return undefined + const searchResults = this.fuzzy.search(this.searchInput) + return this.sortEntities(searchResults) } @computed get partitionedSearchResults(): PartitionedEntities | undefined { @@ -120,20 +357,26 @@ export class EntitySelector extends React.Component<{ if (!searchResults) return undefined const [selected, unselected] = partition( - searchResults.map((entity) => entity.name), - (name) => this.isEntitySelected(name) + searchResults, + (entity: SearchableEntity) => this.isEntitySelected(entity) ) - return { selected, unselected } + return { + selected, + unselected, + } } @computed get partitionedEntities(): PartitionedEntities { const [selected, unselected] = partition( this.sortedAvailableEntities, - (name) => this.isEntitySelected(name) + (entity: SearchableEntity) => this.isEntitySelected(entity) ) - return { selected, unselected } + return { + selected: this.sortEntities(selected), + unselected: this.sortEntities(unselected), + } } @computed get partitionedVisibleEntities(): PartitionedEntities { @@ -161,7 +404,20 @@ export class EntitySelector extends React.Component<{ @action.bound onClear(): void { const { partitionedVisibleEntities: visibleEntities } = this - this.selectionArray.deselectEntities(visibleEntities.selected) + this.selectionArray.deselectEntities( + visibleEntities.selected.map((entity) => entity.name) + ) + } + + @action.bound onChangeSortSlug(selected: unknown): void { + if (selected) { + const selectedOption = selected as DropdownOption + this.updateSortSlug(selectedOption.value) + } + } + + @action.bound onChangeSortOrder(): void { + this.toggleSortOrder() } private highlightMatchedTokens(label: string): string | JSX.Element { @@ -232,6 +488,34 @@ export class EntitySelector extends React.Component<{ ) } + private renderSortBar(): JSX.Element { + return ( +
+ Sort by + + +
+ ) + } + private renderSearchResults(): JSX.Element { if (!this.searchResults || this.searchResults.length === 0) { return ( @@ -245,13 +529,14 @@ export class EntitySelector extends React.Component<{ return (
    - {this.searchResults.map(({ name }) => ( -
  • + {this.searchResults.map((entity) => ( +
  • this.onChange(name)} + checked={this.isEntitySelected(entity)} + bar={this.getBarConfigForEntity(entity)} + onChange={() => this.onChange(entity.name)} />
  • ))} @@ -262,13 +547,14 @@ export class EntitySelector extends React.Component<{ private renderAllEntitiesInSingleMode(): JSX.Element { return (
      - {this.sortedAvailableEntities.map((name) => ( -
    • + {this.sortedAvailableEntities.map((entity) => ( +
    • this.onChange(name)} + checked={this.isEntitySelected(entity)} + bar={this.getBarConfigForEntity(entity)} + onChange={() => this.onChange(entity.name)} />
    • ))} @@ -276,8 +562,26 @@ export class EntitySelector extends React.Component<{ ) } - private isEntitySelected(name: string): boolean { - return this.selectionArray.selectedSet.has(name) + private getBarConfigForEntity( + entity: SearchableEntity + ): BarConfig | undefined { + if (this.isCurrentlySortedByName || this.isCurrentlySortedByRelevance) + return undefined + + const value = entity[this.sortConfig.slug] + + if (!isFiniteWithGuard(value)) return { formattedValue: "No data" } + + const column = this.sortColumnsBySlug[this.sortConfig.slug] + + return { + formattedValue: column.formatValueShort(value), + width: value / column.maxValue, + } + } + + private isEntitySelected(entity: SearchableEntity): boolean { + return this.selectionArray.selectedSet.has(entity.name) } private renderAllEntitiesInMultiMode(): JSX.Element { @@ -291,62 +595,84 @@ export class EntitySelector extends React.Component<{ }} flipKey={this.selectionArray.selectedEntityNames.join(",")} > - {selected.length > 0 && ( - -
      - Selection -
      -
      - )} -
        - {selected.map((name) => ( - -
      • + {selected.length > 0 && ( + +
        + Selection +
        +
        + )} +
          + {selected.map((entity) => ( + - this.onChange(name)} - /> - +
        • + + this.onChange(entity.name) + } + /> +
        • +
          + ))} +
        + + +
        + {selected.length > 0 && unselected.length > 0 && ( + +
        + {capitalize(this.entityTypePlural)} +
        - ))} -
      - - {selected.length > 0 && unselected.length > 0 && ( - -
      - {capitalize(this.entityTypePlural)} -
      -
      - )} + )} -
        - {unselected.map((name) => ( - -
      • + {unselected.map((entity) => ( + - this.onChange(name)} - /> -
      • -
        - ))} -
      +
    • + + this.onChange(entity.name) + } + /> +
    • + + ))} +
    + ) } @@ -383,6 +709,8 @@ export class EntitySelector extends React.Component<{ > {this.renderSearchBar()} + {this.sortOptions.length > 1 && this.renderSortBar()} +
    {this.searchInput ? this.renderSearchResults() @@ -397,15 +725,19 @@ export class EntitySelector extends React.Component<{ } } +type BarConfig = { formattedValue: string; width?: number } + function SelectableEntity({ name, checked, type, + bar, onChange, }: { name: React.ReactNode checked: boolean type: "checkbox" | "radio" + bar?: BarConfig onChange: () => void }) { const Input = { @@ -422,7 +754,15 @@ function SelectableEntity({ onChange() }} > + {bar && bar.width !== undefined && ( +
    + )} + {bar && ( + + {bar.formattedValue} + + )}
    ) } diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index 0fcea199c28..3e35c175b3a 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1831,3 +1831,7 @@ export function cartesian(matrix: T[][]): T[][] { [[]] ) } + +export function isFiniteWithGuard(value: unknown): value is number { + return isFinite(value as any) +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index f14c7459a2d..87f8b984e22 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -118,6 +118,7 @@ export { checkIsDataInsight, checkIsAuthor, cartesian, + isFiniteWithGuard, } from "./Util.js" export {