DEV: Convert select-kit base classes to native class syntax (#28467)

This lays the groundwork for converting SelectKit subclasses to native class syntax. This commit is designed to be entirely backwards-compatible, so it should not affect any existing subclasses.

Of interest:

- Any properties which are designed to be overridden by subclasses are implemented using a local `@protoProp` decorator. That means they are applied to the prototype, so that they can be overridden in subclasses by both legacy `.extend()` prototype extensions, and by modern native-class fields.

- New class decorators are introduced: `@selectKitOptions` and `@pluginApiIdentifiers`. These are native class versions of the legacy `concatenatedProperties` system. This follows the pattern Ember has introduced for `@className`, `@classNameBindings`, etc.
This commit is contained in:
David Taylor 2024-08-22 09:39:03 +01:00 committed by GitHub
parent ebbe23e4d2
commit 3e3c051164
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1276 additions and 1255 deletions

View File

@ -1,15 +1,15 @@
import { computed } from "@ember/object";
import { next } from "@ember/runloop";
import { isPresent } from "@ember/utils";
import { classNames } from "@ember-decorators/component";
import { makeArray } from "discourse-common/lib/helpers";
import SelectKitComponent from "select-kit/components/select-kit";
import SelectKitComponent, {
pluginApiIdentifiers,
selectKitOptions,
} from "select-kit/components/select-kit";
export default SelectKitComponent.extend({
pluginApiIdentifiers: ["multi-select"],
classNames: ["multi-select"],
multiSelect: true,
selectKitOptions: {
@classNames("multi-select")
@selectKitOptions({
none: "select_kit.default_header_text",
clearable: true,
filterable: true,
@ -22,20 +22,24 @@ export default SelectKitComponent.extend({
caretDownIcon: "caretIcon",
caretUpIcon: "caretIcon",
useHeaderFilter: false,
},
})
@pluginApiIdentifiers(["multi-select"])
export default class MultiSelect extends SelectKitComponent {
multiSelect = true;
caretIcon: computed("value.[]", function () {
@computed("value.[]")
get caretIcon() {
const maximum = this.selectKit.options.maximum;
return maximum && makeArray(this.value).length >= parseInt(maximum, 10)
? null
: "plus";
}),
}
search(filter) {
return this._super(filter).filter(
(content) => !makeArray(this.selectedContent).includes(content)
);
},
return super
.search(filter)
.filter((content) => !makeArray(this.selectedContent).includes(content));
}
append(values) {
const existingItems = values
@ -60,7 +64,7 @@ export default SelectKitComponent.extend({
);
this.selectKit.change(newValues, newContent);
},
}
deselect(item) {
this.clearErrors();
@ -73,7 +77,7 @@ export default SelectKitComponent.extend({
this.valueProperty ? newContent.mapBy(this.valueProperty) : newContent,
newContent
);
},
}
select(value, item) {
if (this.selectKit.hasSelection && this.selectKit.options.maximum === 1) {
@ -122,13 +126,10 @@ export default SelectKitComponent.extend({
: makeArray(this.defaultItem(value, value))
);
}
},
}
selectedContent: computed(
"value.[]",
"content.[]",
"selectKit.noneItem",
function () {
@computed("value.[]", "content.[]", "selectKit.noneItem")
get selectedContent() {
const value = makeArray(this.value).map((v) =>
this.selectKit.options.castInteger && this._isNumeric(v) ? Number(v) : v
);
@ -157,7 +158,6 @@ export default SelectKitComponent.extend({
return null;
}
),
_onKeydown(event) {
if (
@ -192,5 +192,5 @@ export default SelectKitComponent.extend({
}
return true;
},
});
}
}

View File

@ -1,10 +1,14 @@
import Component from "@ember/component";
import EmberObject, { computed, get } from "@ember/object";
import { guidFor } from "@ember/object/internals";
import Mixin from "@ember/object/mixin";
import { bind, cancel, next, schedule, throttle } from "@ember/runloop";
import { service } from "@ember/service";
import { isEmpty, isNone, isPresent } from "@ember/utils";
import {
classNameBindings,
classNames,
tagName,
} from "@ember-decorators/component";
import { createPopper } from "@popperjs/core";
import { Promise } from "rsvp";
import { INPUT_DELAY } from "discourse-common/config/environment";
@ -13,7 +17,7 @@ import deprecated from "discourse-common/lib/deprecated";
import { makeArray } from "discourse-common/lib/helpers";
import { bind as bindDecorator } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import PluginApiMixin, {
import {
applyContentPluginApiCallbacks,
applyOnChangePluginApiCallbacks,
} from "select-kit/mixins/plugin-api";
@ -22,49 +26,124 @@ import UtilsMixin from "select-kit/mixins/utils";
export const MAIN_COLLECTION = "MAIN_COLLECTION";
export const ERRORS_COLLECTION = "ERRORS_COLLECTION";
const EMPTY_OBJECT = Object.freeze({});
const SELECT_KIT_OPTIONS = Mixin.create({
concatenatedProperties: ["selectKitOptions"],
selectKitOptions: EMPTY_OBJECT,
});
function isDocumentRTL() {
return document.documentElement.classList.contains("rtl");
}
export default Component.extend(
SELECT_KIT_OPTIONS,
PluginApiMixin,
UtilsMixin,
{
tagName: "details",
pluginApiIdentifiers: ["select-kit"],
classNames: ["select-kit"],
classNameBindings: [
/**
* Simulates the behavior of Ember's concatenatedProperties under native class syntax
*/
function concatProtoProperty(target, key, value) {
target.proto();
target.prototype[key] = [
...makeArray(target.prototype[key]),
...makeArray(value),
];
}
/**
* @decorator
*
* Apply select-kit options to a component class
*
*/
export function selectKitOptions(options) {
return function (target) {
concatProtoProperty(target, "selectKitOptions", options);
};
}
/**
* @decorator
*
* Register one or more plugin API identifiers for a component class
*
*/
export function pluginApiIdentifiers(identifiers) {
return function (target) {
concatProtoProperty(target, "pluginIdIdentifiers", identifiers);
};
}
// Decorator which converts a field into a prototype property.
// This allows the value to be overridden in subclasses, even if they're still
// using the legacy Ember `.extend()` syntax.
function protoProp(prototype, key, descriptor) {
return {
value: descriptor.initializer?.(),
writable: true,
enumerable: true,
configurable: true,
};
}
@tagName("details")
@classNames("select-kit")
@classNameBindings(
"selectKit.isLoading:is-loading",
"selectKit.isExpanded:is-expanded",
"selectKit.options.disabled:is-disabled",
"selectKit.isHidden:is-hidden",
"selectKit.hasSelection:has-selection",
],
tabindex: 0,
content: null,
value: null,
selectKit: null,
mainCollection: null,
errorsCollection: null,
options: null,
valueProperty: "id",
nameProperty: "name",
singleSelect: false,
multiSelect: false,
labelProperty: null,
titleProperty: null,
langProperty: null,
appEvents: service(),
"selectKit.hasSelection:has-selection"
)
@selectKitOptions({
allowAny: false,
showFullTitle: true,
none: null,
translatedNone: null,
filterable: false,
autoFilterable: "autoFilterable",
filterIcon: "search",
filterPlaceholder: null,
translatedFilterPlaceholder: null,
icon: null,
icons: null,
maximum: null,
maximumLabel: null,
minimum: null,
autoInsertNoneItem: true,
closeOnChange: true,
useHeaderFilter: false,
limitMatches: null,
placement: isDocumentRTL() ? "bottom-end" : "bottom-start",
verticalOffset: 3,
filterComponent: "select-kit/select-kit-filter",
selectedNameComponent: "selected-name",
selectedChoiceComponent: "selected-choice",
castInteger: false,
focusAfterOnChange: true,
triggerOnChangeOnTab: true,
autofocus: false,
placementStrategy: null,
mobilePlacementStrategy: null,
desktopPlacementStrategy: null,
hiddenValues: null,
disabled: false,
expandedOnInsert: false,
formName: null,
})
@pluginApiIdentifiers(["select-kit"])
export default class SelectKit extends Component.extend(UtilsMixin) {
@service appEvents;
singleSelect = false;
multiSelect = false;
@protoProp tabindex = 0;
@protoProp content = null;
@protoProp value = null;
@protoProp selectKit = null;
@protoProp mainCollection = null;
@protoProp errorsCollection = null;
@protoProp options = null;
@protoProp valueProperty = "id";
@protoProp nameProperty = "name";
@protoProp labelProperty = null;
@protoProp titleProperty = null;
@protoProp langProperty = null;
init() {
this._super(...arguments);
super.init(...arguments);
this._searchPromise = null;
@ -139,14 +218,14 @@ export default Component.extend(
bodyElement: bind(this, this._bodyElement),
})
);
},
}
_modifyComponentForRowWrapper(collection, item) {
let component = this.modifyComponentForRow(collection, item);
return component || "select-kit/select-kit-row";
},
}
modifyComponentForRow() {},
modifyComponentForRow() {}
_modifyContentForCollectionWrapper(identifier) {
let collection = this.modifyContentForCollection(identifier);
@ -163,9 +242,9 @@ export default Component.extend(
}
return collection;
},
}
modifyContentForCollection() {},
modifyContentForCollection() {}
_modifyComponentForCollectionWrapper(identifier) {
let component = this.modifyComponentForCollection(identifier);
@ -182,18 +261,18 @@ export default Component.extend(
}
return component;
},
}
modifyComponentForCollection() {},
modifyComponentForCollection() {}
didUpdateAttrs() {
this._super(...arguments);
super.didUpdateAttrs(...arguments);
this.handleDeprecations();
},
}
didInsertElement() {
this._super(...arguments);
super.didInsertElement(...arguments);
this.appEvents.on("keyboard-visibility-change", this, this._updatePopper);
@ -202,32 +281,28 @@ export default Component.extend(
this._open();
});
}
},
}
click(event) {
event.preventDefault();
event.stopPropagation();
},
}
willDestroyElement() {
this._super(...arguments);
super.willDestroyElement(...arguments);
this._cancelSearch();
this.appEvents.off(
"keyboard-visibility-change",
this,
this._updatePopper
);
this.appEvents.off("keyboard-visibility-change", this, this._updatePopper);
if (this.popper) {
this.popper.destroy();
this.popper = null;
}
},
}
didReceiveAttrs() {
this._super(...arguments);
super.didReceiveAttrs(...arguments);
const deprecatedOptions = this._resolveDeprecatedOptions();
const mergedOptions = Object.assign({}, ...this.selectKitOptions);
@ -258,11 +333,7 @@ export default Component.extend(
return;
}
if (
typeof value === "string" &&
!value.includes(".") &&
value in this
) {
if (typeof value === "string" && !value.includes(".") && value in this) {
const computedValue = get(this, value);
if (typeof computedValue !== "function") {
this.selectKit.options.set(key, computedValue);
@ -290,58 +361,19 @@ export default Component.extend(
this.set("content", this.computeContent());
}
},
}
selectKitOptions: {
allowAny: false,
showFullTitle: true,
none: null,
translatedNone: null,
filterable: false,
autoFilterable: "autoFilterable",
filterIcon: "search",
filterPlaceholder: null,
translatedFilterPlaceholder: null,
icon: null,
icons: null,
maximum: null,
maximumLabel: null,
minimum: null,
autoInsertNoneItem: true,
closeOnChange: true,
useHeaderFilter: false,
limitMatches: null,
placement: isDocumentRTL() ? "bottom-end" : "bottom-start",
verticalOffset: 3,
filterComponent: "select-kit/select-kit-filter",
selectedNameComponent: "selected-name",
selectedChoiceComponent: "selected-choice",
castInteger: false,
focusAfterOnChange: true,
triggerOnChangeOnTab: true,
autofocus: false,
placementStrategy: null,
mobilePlacementStrategy: null,
desktopPlacementStrategy: null,
hiddenValues: null,
disabled: false,
expandedOnInsert: false,
formName: null,
},
autoFilterable: computed("content.[]", "selectKit.filter", function () {
@computed("content.[]", "selectKit.filter")
get autoFilterable() {
return (
this.selectKit.filter &&
this.options.autoFilterable &&
this.content.length > 15
);
}),
}
collections: computed(
"selectedContent.[]",
"mainCollection.[]",
"errorsCollection.[]",
function () {
@computed("selectedContent.[]", "mainCollection.[]", "errorsCollection.[]")
get collections() {
return this._collections.map((identifier) => {
return {
identifier,
@ -349,11 +381,10 @@ export default Component.extend(
};
});
}
),
createContentFromInput(input) {
return input;
},
}
validateCreate(filter, content) {
this.clearErrors();
@ -364,7 +395,7 @@ export default Component.extend(
!content.map((c) => this.getValue(c)).includes(filter) &&
!makeArray(this.value).includes(filter)
);
},
}
validateSelect() {
this.clearErrors();
@ -374,14 +405,13 @@ export default Component.extend(
const maximum = this.selectKit.options.maximum;
if (maximum && selection.length >= maximum) {
const key =
this.selectKit.options.maximumLabel ||
"select_kit.max_content_reached";
this.selectKit.options.maximumLabel || "select_kit.max_content_reached";
this.addError(I18n.t(key, { count: maximum }));
return false;
}
return true;
},
}
addError(error) {
if (!this.errorsCollection.includes(error)) {
@ -389,7 +419,7 @@ export default Component.extend(
}
this._safeAfterRender(() => this._updatePopper());
},
}
clearErrors() {
if (!this.element || this.isDestroyed || this.isDestroying) {
@ -397,29 +427,29 @@ export default Component.extend(
}
this.set("errorsCollection", []);
},
}
prependCollection(identifier) {
this._collections.unshift(identifier);
},
}
appendCollection(identifier) {
this._collections.push(identifier);
},
}
insertCollectionAtIndex(identifier, index) {
this._collections.insertAt(index, identifier);
},
}
insertBeforeCollection(identifier, insertedIdentifier) {
const index = this._collections.indexOf(identifier);
this.insertCollectionAtIndex(insertedIdentifier, index - 1);
},
}
insertAfterCollection(identifier, insertedIdentifier) {
const index = this._collections.indexOf(identifier);
this.insertCollectionAtIndex(insertedIdentifier, index + 1);
},
}
_onInput(event) {
this._updatePopper();
@ -436,21 +466,18 @@ export default Component.extend(
event.target.value,
INPUT_DELAY
);
},
}
_debouncedInput(filter) {
this.selectKit.set("filter", filter);
this.triggerSearch(filter);
},
}
_onChangeWrapper(value, items) {
this.selectKit.set("filter", null);
return new Promise((resolve) => {
if (
!this.selectKit.valueProperty &&
this.selectKit.noneItem === value
) {
if (!this.selectKit.valueProperty && this.selectKit.noneItem === value) {
value = null;
items = [];
}
@ -499,21 +526,21 @@ export default Component.extend(
}
}
});
},
}
_modifyContentWrapper(content) {
content = this.modifyContent(content);
return applyContentPluginApiCallbacks(content, this);
},
}
modifyContent(content) {
return content;
},
}
_modifyNoSelectionWrapper() {
return this.modifyNoSelection();
},
}
modifyNoSelection() {
if (this.selectKit.options.translatedNone) {
@ -543,39 +570,39 @@ export default Component.extend(
}
return item;
},
}
_modifySelectionWrapper(item) {
return this.modifySelection(item);
},
}
modifySelection(item) {
return item;
},
}
_onKeydownWrapper(event) {
return this._boundaryActionHandler("onKeydown", event);
},
}
_mainElement() {
return document.querySelector(`#${this.selectKit.uniqueID}`);
},
}
_headerElement() {
return this.selectKit.mainElement().querySelector("summary");
},
}
_bodyElement() {
return this.selectKit.mainElement().querySelector(".select-kit-body");
},
}
_onHover(value, item) {
throttle(this, this._highlight, item, 25, true);
},
}
_highlight(item) {
this.selectKit.set("highlighted", item);
},
}
_boundaryActionHandler(actionName, ...params) {
if (!this.element || this.isDestroying || this.isDestroyed) {
@ -603,12 +630,12 @@ export default Component.extend(
}
return boundaryAction;
},
}
deselect() {
this.clearErrors();
this.selectKit.change(null, null);
},
}
deselectByValue(value) {
if (!value) {
@ -617,11 +644,11 @@ export default Component.extend(
const item = this.itemForValue(value, this.selectedContent);
this.deselect(item);
},
}
append() {
// do nothing on general case
},
}
search(filter) {
let content = this.content || [];
@ -633,15 +660,13 @@ export default Component.extend(
});
}
return content;
},
}
triggerSearch(filter) {
this._searchPromise && cancel(this._searchPromise);
this._searchPromise = this._searchWrapper(
filter || this.selectKit.filter
);
},
this._searchPromise = this._searchWrapper(filter || this.selectKit.filter);
}
_searchWrapper(filter) {
if (this.isDestroyed || this.isDestroying) {
@ -731,7 +756,7 @@ export default Component.extend(
}
this.set("selectKit.enterDisabled", false);
});
},
}
_safeAfterRender(fn) {
next(() => {
@ -743,7 +768,7 @@ export default Component.extend(
fn();
});
});
},
}
_scrollToRow(rowItem, preventScroll = true) {
const value = this.getValue(rowItem);
@ -758,7 +783,7 @@ export default Component.extend(
}
rowContainer?.focus({ preventScroll });
},
}
_highlightLast() {
const highlighted = this.mainCollection.objectAt(
@ -768,7 +793,7 @@ export default Component.extend(
this._scrollToRow(highlighted, false);
this.set("selectKit.highlighted", highlighted);
}
},
}
_highlightFirst() {
const highlighted = this.mainCollection.objectAt(0);
@ -776,7 +801,7 @@ export default Component.extend(
this._scrollToRow(highlighted, false);
this.set("selectKit.highlighted", highlighted);
}
},
}
_highlightNext() {
let highlightedIndex = this.mainCollection.indexOf(
@ -801,7 +826,7 @@ export default Component.extend(
this._scrollToRow(highlighted, false);
this.set("selectKit.highlighted", highlighted);
}
},
}
_highlightPrevious() {
let highlightedIndex = this.mainCollection.indexOf(
@ -826,13 +851,13 @@ export default Component.extend(
this._scrollToRow(highlighted, false);
this.set("selectKit.highlighted", highlighted);
}
},
}
_deselectLast() {
if (this.selectKit.hasSelection) {
this.deselectByValue(this.value[this.value.length - 1]);
}
},
}
select(value, item) {
if (!isPresent(value)) {
@ -847,26 +872,26 @@ export default Component.extend(
this.selectKit.change(value, item || this.defaultItem(value, value));
}
},
}
_onClearSelection() {
this.selectKit.change(null, null);
},
}
_onOpenWrapper() {
return this._boundaryActionHandler("onOpen");
},
}
_cancelSearch() {
this._searchPromise && cancel(this._searchPromise);
},
}
_onCloseWrapper() {
this._cancelSearch();
this.set("selectKit.highlighted", null);
return this._boundaryActionHandler("onClose");
},
}
_toggle(event) {
if (this.selectKit.isExpanded) {
@ -874,7 +899,7 @@ export default Component.extend(
} else {
this._open(event);
}
},
}
_close(event) {
if (!this.selectKit.isExpanded) {
@ -897,7 +922,7 @@ export default Component.extend(
isExpanded: false,
filter: null,
});
},
}
_open(event) {
if (this.selectKit.isExpanded) {
@ -913,9 +938,7 @@ export default Component.extend(
const anchor = document.querySelector(
`#${this.selectKit.uniqueID}-header`
);
const popper = document.querySelector(
`#${this.selectKit.uniqueID}-body`
);
const popper = document.querySelector(`#${this.selectKit.uniqueID}-body`);
const strategy = this._computePlacementStrategy();
let bottomOffset = 0;
@ -1060,7 +1083,7 @@ export default Component.extend(
this._scrollToCurrent();
this._updatePopper();
});
},
}
_scrollToCurrent() {
if (this.value && this.mainCollection) {
@ -1080,7 +1103,7 @@ export default Component.extend(
this.set("selectKit.highlighted", highlighted);
}
}
},
}
_focusFilter(forceHeader = false) {
if (!this.selectKit.mainElement()) {
@ -1108,7 +1131,7 @@ export default Component.extend(
headerContainer && headerContainer.focus({ preventScroll: true });
}
});
},
}
_focusFilterInput() {
const input = this.getFilterInput();
@ -1120,26 +1143,26 @@ export default Component.extend(
input.selectionStart = input.selectionEnd = input.value.length;
}
}
},
}
getFilterInput() {
return document.querySelector(`#${this.selectKit.uniqueID}-filter input`);
},
}
getHeader() {
return document.querySelector(`#${this.selectKit.uniqueID}-header`);
},
}
handleDeprecations() {
this._deprecateValueAttribute();
this._deprecateMutations();
this._handleDeprecatedArgs();
},
}
@bindDecorator
_updatePopper() {
this.popper?.update?.();
},
}
_computePlacementStrategy() {
let placementStrategy = this.selectKit.options.placementStrategy;
@ -1157,7 +1180,7 @@ export default Component.extend(
}
return placementStrategy;
},
}
_deprecated(text) {
deprecated(text, {
@ -1165,7 +1188,7 @@ export default Component.extend(
dropFrom: "2.9.0.beta1",
id: "discourse.select-kit",
});
},
}
_deprecateValueAttribute() {
if (this.valueAttribute || this.valueAttribute === null) {
@ -1175,7 +1198,7 @@ export default Component.extend(
this.set("valueProperty", this.valueAttribute);
}
},
}
_deprecateMutations() {
this.actions ??= {};
@ -1190,7 +1213,7 @@ export default Component.extend(
this.actions.onSelect ||
((value) => this.set("value", value));
}
},
}
_resolveDeprecatedOptions() {
const migrations = {
@ -1229,7 +1252,7 @@ export default Component.extend(
});
return resolvedDeprecations;
},
}
_handleDeprecatedArgs() {
const migrations = {
@ -1248,6 +1271,12 @@ export default Component.extend(
this.set(to, this.get(from));
}
});
},
}
);
}
// Keep the concatenatedProperties behavior for legacy `.extend()` subclasses
SelectKit.prototype.concatenatedProperties = [
...SelectKit.prototype.concatenatedProperties,
"selectKitOptions",
"pluginApiIdentifiers",
];

View File

@ -1,21 +1,21 @@
import { computed } from "@ember/object";
import { isEmpty } from "@ember/utils";
import SelectKitComponent from "select-kit/components/select-kit";
import { classNames } from "@ember-decorators/component";
import SelectKitComponent, {
pluginApiIdentifiers,
selectKitOptions,
} from "select-kit/components/select-kit";
export default SelectKitComponent.extend({
pluginApiIdentifiers: ["single-select"],
classNames: ["single-select"],
singleSelect: true,
selectKitOptions: {
@classNames("single-select")
@selectKitOptions({
headerComponent: "select-kit/single-select-header",
},
})
@pluginApiIdentifiers(["single-select"])
export default class SingleSelect extends SelectKitComponent {
singleSelect = true;
selectedContent: computed(
"value",
"content.[]",
"selectKit.noneItem",
function () {
@computed("value", "content.[]", "selectKit.noneItem")
get selectedContent() {
if (!isEmpty(this.value)) {
let content;
@ -42,5 +42,4 @@ export default SelectKitComponent.extend({
return this.selectKit.noneItem;
}
}
),
});
}

View File

@ -1,4 +1,3 @@
import Mixin from "@ember/object/mixin";
import { isNone } from "@ember/utils";
import { makeArray } from "discourse-common/lib/helpers";
@ -97,9 +96,3 @@ export function clearCallbacks() {
_onChangeCallbacks = {};
_replaceContentCallbacks = {};
}
const EMPTY_ARRAY = Object.freeze([]);
export default Mixin.create({
concatenatedProperties: ["pluginApiIdentifiers"],
pluginApiIdentifiers: EMPTY_ARRAY,
});