DEV: Replace deprecated Ember's array uniq and uniqBy (#35227)

This commit replaces several instances of `.uniq`, `.uniqBy`, and related
array deduplication methods with a new utility function
`uniqueItemsFromArray`. This change ensures proper deduplication logic
across the codebase while addressing the deprecation issues.

**Main Changes:**

* Created new utility function `uniqueItemsFromArray`: Located in a new
file, `array-tools.js`, this function provides a reusable and
configurable alternative for deduplication.
* Replaced old methods: Updated multiple files to use the new utility
function instead of deprecated or custom implementations for
deduplication.
* Replaced manual deduplication: Replace uses of `[...new Set(array)]`
to standardize the deduplication logic across the codebase.
* Added unit tests: Introduced thorough test coverage
(`array-tools-test.js`) to validate functionality and edge cases for the
utility function.
* Updated deprecation workflow: Added logging for deprecated uniq and
uniqBy methods to the `deprecation-workflow.js`.

This change is primarily focused on code quality improvements, ensuring
future-proof deduplication, and maintaining alignment with deprecation
guidelines.
This commit is contained in:
Sérgio Saquetim
2025-10-13 16:47:36 -03:00
committed by GitHub
parent a225da4fd3
commit 0c4f285618
40 changed files with 457 additions and 111 deletions
@@ -15,6 +15,7 @@ import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import number from "discourse/helpers/number";
import { reportModeComponent } from "discourse/lib/admin-report-additional-modes";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { bind } from "discourse/lib/decorators";
import { isTesting } from "discourse/lib/environment";
import { exportEntity } from "discourse/lib/export-csv";
@@ -381,7 +382,7 @@ export default class AdminReport extends Component {
// T and T+x, and all the ajax responses would occur after T+(x+y)
// to avoid any inconsistencies we filter by period and make sure
// the array contains only unique values
let filteredReports = this._reports.uniqBy("report_key");
let filteredReports = uniqueItemsFromArray(this._reports, "report_key");
let foundReport;
const sort = (report) => {
@@ -9,6 +9,7 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { isEmpty } from "@ember/utils";
import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseComputed from "discourse/lib/decorators";
import UppyUpload from "discourse/lib/uppy/uppy-upload";
import { i18n } from "discourse-i18n";
@@ -66,7 +67,7 @@ export default class EmojiUploader extends Component {
createEmojiGroup(group) {
let newEmojiGroups = this.newEmojiGroups;
if (group !== DEFAULT_GROUP) {
newEmojiGroups = this.emojiGroups.concat([group]).uniq();
newEmojiGroups = uniqueItemsFromArray(this.emojiGroups.concat([group]));
}
this.setProperties({
newEmojiGroups,
@@ -14,6 +14,7 @@ import DButton from "discourse/components/d-button";
import JsonSchemaEditorModal from "discourse/components/modal/json-schema-editor";
import basePath from "discourse/helpers/base-path";
import icon from "discourse/helpers/d-icon";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { bind } from "discourse/lib/decorators";
import { deepEqual } from "discourse/lib/object";
import { sanitize } from "discourse/lib/text";
@@ -383,7 +384,9 @@ export default class SiteSettingComponent extends Component {
setDefaultValues() {
this.buffered.set(
"value",
this.bufferedValues.concat(this.defaultValues).uniq().join("|")
uniqueItemsFromArray(this.bufferedValues.concat(this.defaultValues)).join(
"|"
)
);
this.setting.validationMessage = null;
}
@@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { makeArray } from "discourse/lib/helpers";
import ListSetting from "select-kit/components/list-setting";
@@ -17,13 +18,11 @@ export default class CompactList extends Component {
}
get settingChoices() {
return [
...new Set([
...makeArray(this.settingValue),
...makeArray(this.args.setting.choices),
...makeArray(this.createdChoices),
]),
];
return uniqueItemsFromArray([
...makeArray(this.settingValue),
...makeArray(this.args.setting.choices),
...makeArray(this.createdChoices),
]);
}
@action
@@ -33,9 +32,10 @@ export default class CompactList extends Component {
@action
onChangeChoices(choices) {
this.createdChoices = [
...new Set([...makeArray(this.createdChoices), ...makeArray(choices)]),
];
this.createdChoices = uniqueItemsFromArray([
...makeArray(this.createdChoices),
...makeArray(choices),
]);
}
<template>
@@ -5,6 +5,7 @@ import { action } from "@ember/object";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import DButton from "discourse/components/d-button";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { makeArray } from "discourse/lib/helpers";
import { i18n } from "discourse-i18n";
import ListSetting from "select-kit/components/list-setting";
@@ -39,13 +40,11 @@ export default class FileTypesList extends Component {
}
get settingChoices() {
return [
...new Set([
...makeArray(this.settingValue),
...makeArray(this.args.setting.choices),
...makeArray(this.createdChoices),
]),
];
return uniqueItemsFromArray([
...makeArray(this.settingValue),
...makeArray(this.args.setting.choices),
...makeArray(this.createdChoices),
]);
}
@action
@@ -55,9 +54,10 @@ export default class FileTypesList extends Component {
@action
onChangeChoices(choices) {
this.createdChoices = [
...new Set([...makeArray(this.createdChoices), ...makeArray(choices)]),
];
this.createdChoices = uniqueItemsFromArray([
...makeArray(this.createdChoices),
...makeArray(choices),
]);
}
@action
@@ -79,7 +79,7 @@ export default class FileTypesList extends Component {
}
const oldTypes = this.args.value.split(TOKEN_SEPARATOR);
const newTypes = [...new Set([...oldTypes, ...types])];
const newTypes = uniqueItemsFromArray([...oldTypes, ...types]);
const diffTypes = newTypes.filter((type) => !oldTypes.includes(type));
if (isEmpty(diffTypes)) {
@@ -7,6 +7,7 @@ import { empty, reads } from "@ember/object/computed";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { classNames } from "@ember-decorators/component";
import DButton from "discourse/components/d-button";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseComputed from "discourse/lib/decorators";
import { makeArray } from "discourse/lib/helpers";
import ComboBox from "select-kit/components/combo-box";
@@ -113,7 +114,7 @@ export default class ValueList extends Component {
this.collection.removeObject(value);
if (this.choices) {
this.set("choices", this.choices.concat([value]).uniq());
this.set("choices", uniqueItemsFromArray(this.choices.concat([value])));
} else {
this.set("choices", makeArray(value));
}
@@ -2,6 +2,7 @@ import { tracked } from "@glimmer/tracking";
import { computed } from "@ember/object";
import { isEmpty } from "@ember/utils";
import { observes } from "@ember-decorators/object";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseComputed from "discourse/lib/decorators";
import Group from "discourse/models/group";
import RestModel from "discourse/models/rest";
@@ -19,7 +20,10 @@ class WebHookExtras {
}
addCategories(categories) {
this.categories = this.categories.concat(categories).uniqBy((c) => c.id);
this.categories = uniqueItemsFromArray(
this.categories.concat(categories),
"id"
);
}
get categoriesById() {
@@ -3,6 +3,7 @@ import EmberObject, { action } from "@ember/object";
import Service, { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { i18n } from "discourse-i18n";
const ALL_FILTER = "all";
@@ -35,7 +36,9 @@ export default class AdminEmojis extends Service {
}
get emojiGroups() {
return [DEFAULT_GROUP].concat(this.emojis.map((e) => e.group)).uniq();
return uniqueItemsFromArray(
[DEFAULT_GROUP].concat(this.emojis.map((e) => e.group))
);
}
get filteringGroups() {
@@ -1,4 +1,5 @@
import Service, { service } from "@ember/service";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import scrollLock from "discourse/lib/scroll-lock";
import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels";
import AdminSearchModal from "admin/components/modal/admin-search";
@@ -17,13 +18,11 @@ export default class AdminSidebarStateManager extends Service {
return;
}
this.keywords[link_name].navigation = [
...new Set(
this.keywords[link_name].navigation.concat(
keywords.map((keyword) => keyword.toLowerCase())
)
),
];
this.keywords[link_name].navigation = uniqueItemsFromArray(
this.keywords[link_name].navigation.concat(
keywords.map((keyword) => keyword.toLowerCase())
)
);
}
maybeForceAdminSidebar(opts = {}) {
@@ -9,6 +9,7 @@ import PluginOutlet from "discourse/components/plugin-outlet";
import categoryBadge from "discourse/helpers/category-badge";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import lazyHash from "discourse/helpers/lazy-hash";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import {
CATEGORY_STYLE_TYPES,
CATEGORY_TEXT_COLORS,
@@ -47,12 +48,13 @@ export default class EditCategoryGeneral extends Component {
@cached
get backgroundColors() {
const categories = this.site.get("categoriesList");
return this.siteSettings.category_colors
.split("|")
.filter(Boolean)
.map((i) => i.toUpperCase())
.concat(categories.map((c) => c.color.toUpperCase()))
.uniq();
return uniqueItemsFromArray(
this.siteSettings.category_colors
.split("|")
.filter(Boolean)
.map((i) => i.toUpperCase())
.concat(categories.map((c) => c.color.toUpperCase()))
);
}
@cached
@@ -2,6 +2,7 @@ import { fn, hash } from "@ember/helper";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import { buildCategoryPanel } from "discourse/components/edit-category-panel";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { i18n } from "discourse-i18n";
export default class EditCategoryLocalizations extends buildCategoryPanel(
@@ -16,7 +17,7 @@ export default class EditCategoryLocalizations extends buildCategoryPanel(
(obj) => obj.value
);
const committed = this.transientData.localizations.map((obj) => obj.locale);
const allLocales = [...supported, ...committed].uniq();
const allLocales = uniqueItemsFromArray([...supported, ...committed]);
return allLocales.map((value) => ({
name: this.languageNameLookup.getLanguageName(value),
@@ -19,6 +19,7 @@ import replaceEmoji from "discourse/helpers/replace-emoji";
import withEventValue from "discourse/helpers/with-event-value";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import {
disableBodyScroll,
enableBodyScroll,
@@ -90,9 +91,9 @@ export default class EmojiPicker extends Component {
});
addVisibleSections(sections) {
this.visibleSections = makeArray(this.visibleSections)
.concat(makeArray(sections))
.uniq();
this.visibleSections = uniqueItemsFromArray(
makeArray(this.visibleSections).concat(makeArray(sections))
);
}
get sections() {
@@ -8,6 +8,7 @@ import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { not } from "truth-helpers";
import icon from "discourse/helpers/d-icon";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { i18n } from "discourse-i18n";
export default class TagChooserField extends Component {
@@ -111,10 +112,10 @@ export default class TagChooserField extends Component {
set(
this.composer.model,
"tags",
[
uniqueItemsFromArray([
...this.tags.filter((tag) => !this.selectedTags.includes(tag)),
...selectedTags,
].uniq()
])
);
}
@@ -1,6 +1,7 @@
import Component from "@glimmer/component";
import PluginOutlet from "discourse/components/plugin-outlet";
import lazyHash from "discourse/helpers/lazy-hash";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
export default class GroupInfo extends Component {
<template>
@@ -24,7 +25,9 @@ export default class GroupInfo extends Component {
get names() {
const { full_name, display_name, name } = this.args.group;
return [...new Set([full_name, display_name, name].filter(Boolean))];
return uniqueItemsFromArray(
[full_name, display_name, name].filter(Boolean)
);
}
get name() {
@@ -1,5 +1,6 @@
import Controller, { inject as controller } from "@ember/controller";
import EmberObject, { action } from "@ember/object";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseComputed from "discourse/lib/decorators";
import Badge from "discourse/models/badge";
import UserBadge from "discourse/models/user-badge";
@@ -25,7 +26,7 @@ export default class ShowController extends Controller {
id: 0,
badge: Badge.create({ name: i18n("badges.none") }),
}),
...filteredList.uniqBy("badge.name"),
...uniqueItemsFromArray(filteredList, "badge.name"),
];
}
@@ -2,6 +2,7 @@ import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import { and } from "@ember/object/computed";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseComputed from "discourse/lib/decorators";
import { makeArray } from "discourse/lib/helpers";
import { i18n } from "discourse-i18n";
@@ -34,7 +35,7 @@ export default class UsersController extends Controller {
usernames = usernames.split(",").filter(Boolean);
}
return makeArray(usernames).uniq();
return uniqueItemsFromArray(makeArray(usernames));
}
@computed("model.allowed_pm_usernames")
@@ -45,17 +46,23 @@ export default class UsersController extends Controller {
usernames = usernames.split(",").filter(Boolean);
}
return makeArray(usernames).uniq();
return uniqueItemsFromArray(makeArray(usernames));
}
@action
onChangeMutedUsernames(usernames) {
this.model.set("muted_usernames", usernames.uniq().join(","));
this.model.set(
"muted_usernames",
uniqueItemsFromArray(usernames).join(",")
);
}
@action
onChangeAllowedPmUsernames(usernames) {
this.model.set("allowed_pm_usernames", usernames.uniq().join(","));
this.model.set(
"allowed_pm_usernames",
uniqueItemsFromArray(usernames).join(",")
);
}
@discourseComputed("model.user_option.allow_private_messages")
@@ -23,6 +23,7 @@ import { MIN_POSTS_COUNT } from "discourse/components/topic-map/topic-map-summar
import { spinnerHTML } from "discourse/helpers/loading-spinner";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { BookmarkFormData } from "discourse/lib/bookmark-form-data";
import { resetCachedTopicList } from "discourse/lib/cached-topic-list";
import discourseComputed, { bind } from "discourse/lib/decorators";
@@ -1071,9 +1072,11 @@ export default class TopicController extends Controller {
selectReplies(post) {
ajax(`/posts/${post.id}/reply-ids.json`).then((replies) => {
const replyIds = replies.map((r) => r.id);
this.selectedPostIds = [
...new Set([...this.selectedPostIds, post.id, ...replyIds]),
];
this.selectedPostIds = uniqueItemsFromArray([
...this.selectedPostIds,
post.id,
...replyIds,
]);
this._forceRefreshPostStream();
});
}
@@ -313,6 +313,14 @@ const DeprecationWorkflow = new DiscourseDeprecationWorkflow([
handler: "log",
matchId: "discourse.native-array-extensions.toArray",
},
{
handler: "log",
matchId: "discourse.native-array-extensions.uniq",
},
{
handler: "log",
matchId: "discourse.native-array-extensions.uniqBy",
},
{
handler: "log",
matchId: "discourse.native-array-extensions.without",
@@ -1,3 +1,6 @@
import { get } from "@ember/object";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
/**
* Removes all occurrences of a specified value from an array.
*
@@ -30,3 +33,74 @@ export function removeValuesFromArray(array, values) {
return array;
}
/**
* Normalizes the selector argument into a callable function.
* @param {(string|number|Function)} selector
* @returns {Function}
* @throws {Error} when selector type is invalid
*/
function buildSelector(selector) {
switch (typeof selector) {
case "number":
return (item) => item[selector];
case "string":
return selector.includes(".")
? (item) => get(item, selector)
: (item) => item[selector];
case "function":
return selector;
default:
throw new Error(
"uniqueItemsFromArray: the `selector` argument must be a string/number key " +
`or a function, got ${typeof selector} instead`
);
}
}
/**
* Returns a new array with unique items based on the provided selector function.
* @param {Array} array
* @param {Function} selectorFn
* @returns {Array}
*/
function dedupeBy(array, selectorFn) {
const uniqueKeys = new Set();
const result = [];
for (const item of array) {
const key = selectorFn(item);
if (!uniqueKeys.has(key)) {
uniqueKeys.add(key);
result.push(item);
}
}
return result;
}
/**
* Returns a new array containing the unique elements from the input array.
* Elements are determined to be unique based on either strict equality or a specified condition.
*
* @param {Array} array - The input array from which unique elements are to be extracted.
* Must be a valid array. An error is thrown if the input is not an array.
* @param {(string|number|Function)} [selector] - Optional selector to determine uniqueness:
* - If undefined: uses strict equality comparison
* - If string/number: uses the value at the specified object property path
* - If function: uses the return value of the function called with each item
* @throws {Error} If array is not an Array or if selector is an invalid type
* @return {Array|TrackedArray} A new array with only unique elements (TrackedArray if input was TrackedArray)
*/
export function uniqueItemsFromArray(array, selector) {
if (!Array.isArray(array)) {
throw new Error(
`uniqueItemsFromArray expects an array as first argument, got ${typeof array} instead`
);
}
const items =
selector === undefined
? [...new Set(array)]
: dedupeBy(array, buildSelector(selector));
return array instanceof TrackedArray ? new TrackedArray(items) : items;
}
@@ -3,6 +3,7 @@ import { action } from "@ember/object";
import { getOwner, setOwner } from "@ember/owner";
import { service } from "@ember/service";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { NotificationLevels } from "discourse/lib/notification-levels";
import Topic from "discourse/models/topic";
@@ -33,7 +34,7 @@ export default class BulkSelectHelper {
}
get selectedCategoryIds() {
return this.selected.map((item) => item.category_id).uniq();
return uniqueItemsFromArray(this.selected.map((item) => item.category_id));
}
@action
@@ -1,4 +1,5 @@
import { ajax } from "discourse/lib/ajax";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import domFromString from "discourse/lib/dom-from-string";
import { getHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
import { emojiUnescape } from "discourse/lib/text";
@@ -48,10 +49,9 @@ export function linkSeenHashtagsInContext(
);
});
return slugs
.map((slug) => slug.toLowerCase())
.uniq()
.filter((slug) => !checkedHashtags.has(slug));
return uniqueItemsFromArray(slugs.map((slug) => slug.toLowerCase())).filter(
(slug) => !checkedHashtags.has(slug)
);
}
function _findAndReplaceSeenHashtagPlaceholder(
@@ -1,4 +1,5 @@
import { ajax } from "discourse/lib/ajax";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import getURL from "discourse/lib/get-url";
import { userPath } from "discourse/lib/url";
import { formatUsername } from "discourse/lib/utilities";
@@ -80,12 +81,9 @@ export function linkSeenMentions(element, siteSettings) {
const names = mentions.map((mention) => mention.innerText.slice(1));
updateFound(mentions, names);
return names
.uniq()
.filter(
(name) =>
!checked[name] && name.length >= siteSettings.min_username_length
);
return uniqueItemsFromArray(names).filter(
(name) => !checked[name] && name.length >= siteSettings.min_username_length
);
}
export async function fetchUnseenMentions({ names, topicId, allowedNames }) {
@@ -1,6 +1,8 @@
// Used for Category.asyncFindByIds
//
// It's a cache that handles multiple lookups at a time.
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
export class MultiCache {
constructor(cb) {
this.cb = cb;
@@ -22,7 +24,7 @@ export class MultiCache {
this.fetchTimes = [this.fetchTimes[this.fetchTimes.length - 1], new Date()];
const notFound = [];
ids = ids.uniq();
ids = uniqueItemsFromArray(ids);
for (const id of ids) {
if (!this.values.has(id)) {
@@ -1,6 +1,7 @@
import { dasherize, underscore } from "@ember/string";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseComputed from "discourse/lib/decorators";
import RestModel from "discourse/models/rest";
import I18n, { i18n } from "discourse-i18n";
@@ -50,8 +51,10 @@ export default class Reviewable extends RestModel {
@discourseComputed("humanNoun")
flaggedReviewableContextQuestion(humanNoun) {
const uniqueReviewableScores =
this.reviewable_scores.uniqBy("score_type.type");
const uniqueReviewableScores = uniqueItemsFromArray(
this.reviewable_scores,
"score_type.type"
);
if (uniqueReviewableScores.length === 1) {
if (uniqueReviewableScores[0].score_type.type === "notify_moderators") {
@@ -62,9 +65,11 @@ export default class Reviewable extends RestModel {
}
const listOfQuestions = I18n.listJoiner(
uniqueReviewableScores
.map((score) => score.score_type.title.toLowerCase())
.uniq(),
uniqueItemsFromArray(
uniqueReviewableScores.map((score) =>
score.score_type.title.toLowerCase()
)
),
i18n("review.context_question.delimiter")
);
@@ -12,6 +12,7 @@ import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { url } from "discourse/lib/computed";
import {
INTERFACE_COLOR_MODES,
@@ -1160,8 +1161,7 @@ export default class User extends RestModel.extend(Evented) {
}
});
return titles
.uniq()
return uniqueItemsFromArray(titles)
.sort()
.map((title) => {
return {
@@ -1240,7 +1240,7 @@ export default class User extends RestModel.extend(Evented) {
calculateMutedIds(notificationLevel, id, type) {
const muted_ids = this.get(type);
if (notificationLevel === NotificationLevels.MUTED) {
return muted_ids.concat(id).uniq();
return uniqueItemsFromArray(muted_ids.concat(id));
} else {
return muted_ids.filter((existing_id) => existing_id !== id);
}
@@ -1,3 +1,5 @@
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
export default class MentionsParser {
constructor(engine) {
this.engine = engine;
@@ -6,7 +8,7 @@ export default class MentionsParser {
parse(markdown) {
const tokens = this.engine.parse(markdown);
const mentions = this.#parse(tokens);
return [...new Set(mentions)];
return uniqueItemsFromArray(mentions);
}
#parse(tokens) {
@@ -1,5 +1,6 @@
import { mentionRegex } from "pretty-text/mentions";
import { ajax } from "discourse/lib/ajax";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import getURL from "discourse/lib/get-url";
import { isBoundary } from "discourse/static/prosemirror/lib/markdown-it";
import { i18n } from "discourse-i18n";
@@ -159,7 +160,7 @@ const extension = {
async function fetchMentions(names, context) {
// only fetch new mentions that are not already validated
names = names.uniq().filter((name) => {
names = uniqueItemsFromArray(names).filter((name) => {
return !VALID_MENTIONS.has(name) && !INVALID_MENTIONS.has(name);
});
@@ -1,4 +1,5 @@
import { h } from "virtual-dom";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { iconNode } from "discourse/lib/icon-library";
import { replaceEmoji } from "discourse/widgets/emoji";
import { createWidget } from "discourse/widgets/widget";
@@ -41,7 +42,10 @@ export default createWidget("post-links", {
}
// only show incoming
const links = this.attrs.links.filter((l) => l.reflection).uniqBy("title");
const links = uniqueItemsFromArray(
this.attrs.links.filter((l) => l.reflection),
"title"
);
if (links.length === 0) {
return;
@@ -3,6 +3,7 @@ import { module, test } from "qunit";
import {
removeValueFromArray,
removeValuesFromArray,
uniqueItemsFromArray,
} from "discourse/lib/array-tools";
module("Unit | Lib | array-tools", function () {
@@ -176,4 +177,206 @@ module("Unit | Lib | array-tools", function () {
assert.deepEqual(twice, [3], "second call is a no-op");
});
});
module("uniqueItemsFromArray()", function () {
test("throws when input is not an array", function (assert) {
assert.throws(
() => uniqueItemsFromArray(null),
/expects an array/,
"null is rejected"
);
assert.throws(
() => uniqueItemsFromArray(undefined),
/expects an array/,
"undefined is rejected"
);
assert.throws(
() => uniqueItemsFromArray("not array"),
/expects an array/,
"string is rejected"
);
assert.throws(
() => uniqueItemsFromArray({}),
/expects an array/,
"object is rejected"
);
assert.throws(
() => uniqueItemsFromArray(123),
/expects an array/,
"number is rejected"
);
});
test("returns a new empty array for empty input", function (assert) {
const input = [];
const result = uniqueItemsFromArray(input);
assert.notStrictEqual(
result,
input,
"returns a new array instance even for empty input"
);
assert.deepEqual(result, [], "result is empty");
});
test("deduplicates primitives by strict equality", function (assert) {
const input = [1, 1, 2, 3, 2, 3, 4, 4, 4];
const result = uniqueItemsFromArray(input);
assert.deepEqual(result, [1, 2, 3, 4], "unique primitives retained");
assert.notStrictEqual(
result,
input,
"does not return original array reference"
);
});
test("retains first occurrence order", function (assert) {
const input = ["b", "a", "b", "c", "a", "d"];
const result = uniqueItemsFromArray(input);
assert.deepEqual(
result,
["b", "a", "c", "d"],
"order corresponds to first occurrences"
);
});
test("uses identity for objects and symbols", function (assert) {
const a1 = { id: 1 };
const a2 = { id: 1 }; // different reference
const b = { id: 2 };
const s1 = Symbol("x");
const s2 = Symbol("x");
const input = [a1, a1, a2, b, s1, s1, s2];
const result = uniqueItemsFromArray(input);
assert.strictEqual(result[0], a1, "first a1 kept");
assert.strictEqual(result[1], a2, "distinct object with same shape kept");
assert.strictEqual(result[2], b, "b kept");
assert.strictEqual(result[3], s1, "first symbol for x is kept");
assert.strictEqual(result[4], s2, "second symbol for x is kept");
assert.strictEqual(
result.length,
5,
"duplicates by reference removed only"
);
});
test("returns TrackedArray when input is TrackedArray", function (assert) {
const tracked = new TrackedArray([1, 2, 2, 3, 3, 3]);
const result = uniqueItemsFromArray(tracked);
assert.true(result instanceof TrackedArray, "result is a TrackedArray");
assert.deepEqual(Array.from(result), [1, 2, 3], "deduplicated correctly");
assert.notStrictEqual(
result,
tracked,
"returns a new TrackedArray instance"
);
});
test("does not mutate the input array", function (assert) {
const input = [1, 1, 2];
const snapshot = input.slice();
const result = uniqueItemsFromArray(input);
assert.deepEqual(input, snapshot, "input remains unchanged");
assert.deepEqual(result, [1, 2], "result is deduplicated");
});
test("handles heterogeneous arrays", function (assert) {
const sym = Symbol("s");
const input = [0, false, null, undefined, "", sym, 0, false, NaN, NaN];
const result = uniqueItemsFromArray(input);
// Set treats NaN as the same value, which is desired here
assert.strictEqual(
result.length,
7,
"expected number of unique elements"
);
assert.deepEqual(
result.slice(0, 6),
[0, false, null, undefined, "", sym],
"first occurrences retained"
);
assert.true(Number.isNaN(result[6]), "NaN retained once");
});
test("treats NaN as a single unique value", function (assert) {
const result = uniqueItemsFromArray([NaN, NaN, 1, NaN]);
assert.strictEqual(result.length, 2, "NaN appears once among uniques");
assert.true(Number.isNaN(result[0]), "NaN present");
});
// selector-based behavior
test("selector: number index picks property by numeric key", function (assert) {
const input = [
["a", 1],
["a", 2],
["b", 3],
["b", 4],
];
const result = uniqueItemsFromArray(input, 0);
assert.deepEqual(
result,
[
["a", 1],
["b", 3],
],
"unique by index 0"
);
});
test("selector: string key picks shallow property", function (assert) {
const input = [
{ id: 1, name: "x" },
{ id: 1, name: "x2" },
{ id: 2, name: "y" },
];
const result = uniqueItemsFromArray(input, "id");
assert.strictEqual(result.length, 2, "two unique ids");
assert.deepEqual(
result.map((o) => o.name),
["x", "y"],
"keeps first by id"
);
});
test("selector: dotted path uses Ember get for nested property access", function (assert) {
const input = [
{ user: { id: 10 } },
{ user: { id: 10 } },
{ user: { id: 11 } },
];
const result = uniqueItemsFromArray(input, "user.id");
assert.strictEqual(result.length, 2, "two unique nested ids");
assert.deepEqual(
result.map((o) => o.user.id),
[10, 11],
"keeps first by nested id"
);
});
test("selector: function selector determines uniqueness", function (assert) {
const input = [
{ id: 1, name: "Alice" },
{ id: 1, name: "Alice2" },
{ id: 2, name: "Bob" },
];
const result = uniqueItemsFromArray(input, (o) => o.id);
assert.strictEqual(result.length, 2, "two unique ids");
assert.deepEqual(
result.map((o) => o.name),
["Alice", "Bob"],
"keeps first by function selector"
);
});
});
});
@@ -1,6 +1,7 @@
import { computed } from "@ember/object";
import { readOnly } from "@ember/object/computed";
import { classNames } from "@ember-decorators/component";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { makeArray } from "discourse/lib/helpers";
import MultiSelectComponent from "select-kit/components/multi-select";
import {
@@ -41,7 +42,9 @@ export default class ListSetting extends MultiSelectComponent {
deselect(value) {
this.onChangeChoices &&
this.onChangeChoices([...new Set([value, ...makeArray(this.choices)])]);
this.onChangeChoices(
uniqueItemsFromArray([value, ...makeArray(this.choices)])
);
super.deselect(...arguments);
}
@@ -5,6 +5,7 @@ import { isPresent } from "@ember/utils";
import { classNames } from "@ember-decorators/component";
import { and, not } from "truth-helpers";
import componentForCollection from "discourse/helpers/component-for-collection";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { makeArray } from "discourse/lib/helpers";
import { i18n } from "discourse-i18n";
import SelectKitComponent, {
@@ -131,7 +132,7 @@ export default class MultiSelect extends SelectKitComponent {
);
this.selectKit.change(
[...new Set(newValues)],
uniqueItemsFromArray(newValues),
newContent.length
? newContent
: makeArray(this.defaultItem(value, value))
@@ -13,6 +13,7 @@ import {
} from "@ember-decorators/component";
import { createPopper } from "@popperjs/core";
import { Promise } from "rsvp";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import discourseDebounce from "discourse/lib/debounce";
import { bind as bindDecorator } from "discourse/lib/decorators";
import deprecated from "discourse/lib/deprecated";
@@ -729,9 +730,9 @@ export default class SelectKit extends Component {
content = this.selectKit.modifyContent(content).filter(Boolean);
if (this.selectKit.valueProperty) {
content = content.uniqBy(this.selectKit.valueProperty);
content = uniqueItemsFromArray(content, this.selectKit.valueProperty);
} else {
content = content.uniq();
content = uniqueItemsFromArray(content);
}
if (this.selectKit.options.limitMatches) {
@@ -1,6 +1,7 @@
import { action, computed } from "@ember/object";
import { service } from "@ember/service";
import { attributeBindings, classNames } from "@ember-decorators/component";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { bind } from "discourse/lib/decorators";
import { makeArray } from "discourse/lib/helpers";
import MultiSelectComponent from "select-kit/components/multi-select";
@@ -67,14 +68,14 @@ export default class TagChooser extends MultiSelectComponent {
@computed("tags.[]")
get value() {
return makeArray(this.tags).uniq();
return uniqueItemsFromArray(makeArray(this.tags));
}
@computed("tags.[]")
get content() {
return makeArray(this.tags)
.uniq()
.map((t) => this.defaultItem(t, t));
return uniqueItemsFromArray(makeArray(this.tags)).map((t) =>
this.defaultItem(t, t)
);
}
@action
@@ -112,10 +113,9 @@ export default class TagChooser extends MultiSelectComponent {
};
if (selectedTags.length || this.blockedTags.length) {
data.selected_tags = selectedTags
.concat(this.blockedTags)
.uniq()
.slice(0, 100);
data.selected_tags = uniqueItemsFromArray(
selectedTags.concat(this.blockedTags)
).slice(0, 100);
}
if (!this.everyTag) {
@@ -158,6 +158,6 @@ export default class TagChooser extends MultiSelectComponent {
results = results.sort((a, b) => a.id > b.id);
}
return results.uniqBy("id");
return uniqueItemsFromArray(results, "id");
}
}
@@ -1,6 +1,7 @@
import { computed } from "@ember/object";
import { service } from "@ember/service";
import { classNames } from "@ember-decorators/component";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { bind } from "discourse/lib/decorators";
import { makeArray } from "discourse/lib/helpers";
import MultiSelectComponent from "select-kit/components/multi-select";
@@ -27,14 +28,14 @@ export default class TagGroupChooser extends MultiSelectComponent {
@computed("tagGroups.[]")
get value() {
return makeArray(this.tagGroups).uniq();
return uniqueItemsFromArray(makeArray(this.tagGroups));
}
@computed("tagGroups.[]")
get content() {
return makeArray(this.tagGroups)
.uniq()
.map((t) => this.defaultItem(t, t));
return uniqueItemsFromArray(makeArray(this.tagGroups)).map((t) =>
this.defaultItem(t, t)
);
}
validateCreate(filter, content) {
@@ -1,5 +1,6 @@
import { cached, tracked } from "@glimmer/tracking";
import { setOwner } from "@ember/owner";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
export default class ChatMessagesManager {
@tracked messages = [];
@@ -27,10 +28,10 @@ export default class ChatMessagesManager {
}
addMessages(messages = []) {
this.messages = this.messages
.concat(messages)
.uniqBy("id")
.sort((a, b) => a.createdAt - b.createdAt);
this.messages = uniqueItemsFromArray(
this.messages.concat(messages),
"id"
).sort((a, b) => a.createdAt - b.createdAt);
}
findMessage(messageId) {
@@ -5,6 +5,7 @@ import { cancel, next } from "@ember/runloop";
import Service, { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { bind } from "discourse/lib/decorators";
import deprecated from "discourse/lib/deprecated";
import discourseLater from "discourse/lib/later";
@@ -392,10 +393,11 @@ export default class Chat extends Service {
}
upsertDmChannelForUser(channel, user) {
const usernames = (channel.chatable.users || [])
.map((item) => item.username)
.concat(user.username)
.uniq();
const usernames = uniqueItemsFromArray(
(channel.chatable.users || [])
.map((item) => item.username)
.concat(user.username)
);
return this.upsertDmChannel({ usernames });
}
@@ -411,8 +413,12 @@ export default class Chat extends Service {
return ajax("/chat/api/direct-message-channels.json", {
method: "POST",
data: {
target_usernames: targets.usernames?.uniq(),
target_groups: targets.groups?.uniq(),
target_usernames: targets.usernames
? uniqueItemsFromArray(targets.usernames)
: null,
target_groups: targets.groups
? uniqueItemsFromArray(targets.groups)
: null,
upsert: opts.upsert,
name: opts.name,
},
@@ -434,7 +440,7 @@ export default class Chat extends Service {
// participant to fetch the channel for.
getDmChannelForUsernames(usernames) {
return ajax("/chat/direct_messages.json", {
data: { usernames: usernames.uniq().join(",") },
data: { usernames: uniqueItemsFromArray(usernames).join(",") },
});
}
@@ -9,6 +9,7 @@ import DButton from "discourse/components/d-button";
import categoryBadge from "discourse/helpers/category-badge";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import DMenu from "float-kit/components/d-menu";
export default class AiSplitTopicSuggester extends Component {
@@ -88,10 +89,10 @@ export default class AiSplitTopicSuggester extends Component {
if (this.args.currentValue) {
if (Array.isArray(this.args.currentValue)) {
const updatedTags = [...this.args.currentValue, suggestion];
this.args.updateAction([...new Set(updatedTags)]);
this.args.updateAction(uniqueItemsFromArray(updatedTags));
} else {
const updatedTags = [this.args.currentValue, suggestion];
this.args.updateAction([...new Set(updatedTags)]);
this.args.updateAction(uniqueItemsFromArray(updatedTags));
}
} else {
if (Array.isArray(suggestion)) {
@@ -1,3 +1,4 @@
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { renderIcon } from "discourse/lib/icon-library";
import { i18n } from "discourse-i18n";
import DateWithZoneHelper from "./date-with-zone-helper";
@@ -141,7 +142,7 @@ export default class LocalDateBuilder {
});
});
return previewedTimezones.uniqBy("timezone");
return uniqueItemsFromArray(previewedTimezones, "timezone");
}
_isEqualZones(timezoneA, timezoneB) {
@@ -6,6 +6,7 @@ import { service } from "@ember/service";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { and } from "truth-helpers";
import DButton from "discourse/components/d-button";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { bind } from "discourse/lib/decorators";
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
import CustomReaction from "../models/discourse-reactions-custom-reaction";
@@ -26,7 +27,7 @@ export default class DiscourseReactionsCounter extends Component {
}
reactionsChanged(data) {
data.reactions.uniq().forEach((reaction) => {
uniqueItemsFromArray(data.reactions).forEach((reaction) => {
this.getUsers(reaction);
});
}
@@ -2,6 +2,7 @@
import Component from "@ember/component";
import { classNames, tagName } from "@ember-decorators/component";
import icon from "discourse/helpers/d-icon";
import { uniqueItemsFromArray } from "discourse/lib/array-tools";
import { afterRender } from "discourse/lib/decorators";
import { REPLACEMENTS } from "discourse/lib/icon-library";
import discourseLater from "discourse/lib/later";
@@ -22,7 +23,7 @@ export default class StyleguideIcons extends Component {
if (symbols.length > 0) {
let ids = Array.from(symbols).map((item) => item.id);
ids.push(...Object.keys(REPLACEMENTS));
this.set("iconIds", [...new Set(ids.sort())]);
this.set("iconIds", uniqueItemsFromArray(ids).sort());
} else {
// Let's try again a short time later if there are no svgs loaded yet
discourseLater(this, this.setIconIds, 1500);