From e6edd52047cc79bfdec2cd127a09a6db2e69f7dd Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 4 Sep 2024 09:38:22 +0100 Subject: [PATCH] DEV: Remove widget wrapper from poll plugin (#28666) - Uses `helper.renderGlimmer` with GJS to render the ` { let result = transformWithCallbacks(p); - // these would conflict with computed properties with identical names - // in the post model if we kept them. - delete result.new_user; - delete result.deleted; - delete result.shareUrl; - delete result.firstPost; - delete result.usernameUrl; - result.customShare = `${topicUrl}/${p.post_number}`; - result.asPost = this.store.createRecord("post", result); + result.asPost = this.store.createRecord("post", p); return result; }); }); @@ -882,18 +874,8 @@ createWidget("post-article", { this.state.repliesAbove = posts.map((p) => { let result = transformWithCallbacks(p); - // We don't want to overwrite CPs - we are doing something a bit weird - // here by creating a post object from a transformed post. They aren't - // 100% the same. - delete result.new_user; - delete result.deleted; - delete result.shareUrl; - delete result.firstPost; - delete result.usernameUrl; - delete result.topicNotificationLevel; - result.customShare = `${topicUrl}/${p.post_number}`; - result.asPost = this.store.createRecord("post", result); + result.asPost = this.store.createRecord("post", p); return result; }); }); diff --git a/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js b/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js index 43e76006cfa..d1ad9eb5081 100644 --- a/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js +++ b/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js @@ -11,6 +11,7 @@ import I18n from "discourse-i18n"; export default class PollBreakdownModal extends Component { @service dialog; + @service siteSettings; model = null; charts = null; @@ -19,7 +20,7 @@ export default class PollBreakdownModal extends Component { displayMode = "percentage"; init() { - this.set("groupedBy", this.model.groupableUserFields[0]); + this.set("groupedBy", this.groupableUserFields[0]?.id); loadScript("/javascripts/Chart.min.js") .then(() => loadScript("/javascripts/chartjs-plugin-datalabels.min.js")) .then(() => { @@ -33,17 +34,19 @@ export default class PollBreakdownModal extends Component { return pollTitle ? htmlSafe(pollTitle) : topicTitle; } - @discourseComputed("model.groupableUserFields") - groupableUserFields(fields) { - return fields.map((field) => { - const transformed = field.split("_").filter(Boolean); + get groupableUserFields() { + return this.siteSettings.poll_groupable_user_fields + .split("|") + .filter(Boolean) + .map((field) => { + const transformed = field.split("_").filter(Boolean); - if (transformed.length > 1) { - transformed[0] = classify(transformed[0]); - } + if (transformed.length > 1) { + transformed[0] = classify(transformed[0]); + } - return { id: field, label: transformed.join(" ") }; - }); + return { id: field, label: transformed.join(" ") }; + }); } @discourseComputed("model.poll.options") diff --git a/plugins/poll/assets/javascripts/discourse/components/poll.gjs b/plugins/poll/assets/javascripts/discourse/components/poll.gjs index d3344502235..71734f00e3f 100644 --- a/plugins/poll/assets/javascripts/discourse/components/poll.gjs +++ b/plugins/poll/assets/javascripts/discourse/components/poll.gjs @@ -42,11 +42,10 @@ export default class PollComponent extends Component { @service dialog; @service modal; - @tracked vote = this.args.attrs.vote || []; - @tracked poll = this.args.attrs.poll; + @tracked vote = this.args.post.polls_votes?.[this.args.poll.name] || []; @tracked preloadedVoters = this.defaultPreloadedVoters(); @tracked voterListExpanded = false; - @tracked hasSavedVote = this.args.attrs.hasSavedVote; + @tracked hasSavedVote = this.vote.length > 0; @tracked showResults = @@ -123,13 +122,17 @@ export default class PollComponent extends Component { this.vote = [...this.vote]; }; + get poll() { + return this.args.poll; + } + defaultPreloadedVoters() { const preloadedVoters = {}; - if (this.poll.public && this.args.preloadedVoters) { - Object.keys(this.args.preloadedVoters).forEach((key) => { + if (this.poll.public && this.poll.preloaded_voters) { + Object.keys(this.poll.preloaded_voters).forEach((key) => { preloadedVoters[key] = { - voters: this.args.preloadedVoters[key], + voters: this.poll.preloaded_voters[key], loading: false, }; }); @@ -148,15 +151,17 @@ export default class PollComponent extends Component { } get id() { - return this.args.attrs.id; + return `${this.args.poll.name}-${this.args.post.id}`; } get post() { - return this.args.attrs.post; + return this.args.post; } get groupableUserFields() { - return this.args.attrs.groupableUserFields; + return this.siteSettings.poll_groupable_user_fields + .split("|") + .filter(Boolean); } get isStaff() { @@ -164,7 +169,7 @@ export default class PollComponent extends Component { } get titleHTML() { - return htmlSafe(this.args.attrs.titleHTML); + return htmlSafe(this.args.titleHTML); } get topicArchived() { @@ -192,7 +197,7 @@ export default class PollComponent extends Component { } get status() { - return this.poll.get("status"); + return this.poll.status; } @action @@ -206,7 +211,7 @@ export default class PollComponent extends Component { } try { - const poll = await ajax("/polls/vote", { + const { poll } = await ajax("/polls/vote", { type: "PUT", data: { post_id: this.post.id, @@ -216,7 +221,8 @@ export default class PollComponent extends Component { }); this.hasSavedVote = true; - this.poll.setProperties(poll); + Object.assign(this.poll, poll); + this.appEvents.trigger("poll:voted", poll, this.post, this.vote); if (this.poll.results !== ON_CLOSE) { @@ -243,7 +249,7 @@ export default class PollComponent extends Component { } get options() { - let enrichedOptions = this.poll.get("options"); + let enrichedOptions = this.poll.options; if (this.isRankedChoice) { enrichedOptions.forEach((candidate) => { @@ -262,11 +268,11 @@ export default class PollComponent extends Component { } get voters() { - return this.poll.get("voters"); + return this.poll.voters; } get rankedChoiceOutcome() { - return this.poll.get("ranked_choice_outcome") || []; + return this.poll.ranked_choice_outcome || []; } get min() { @@ -574,7 +580,7 @@ export default class PollComponent extends Component { }, }) .then(() => { - this.poll.set("status", status); + this.poll.status = status; if ( this.poll.results === ON_CLOSE || @@ -606,7 +612,10 @@ export default class PollComponent extends Component { @action showBreakdown() { this.modal.show(PollBreakdownModal, { - model: this.args.attrs, + model: { + poll: this.poll, + post: this.post, + }, }); } @@ -660,121 +669,129 @@ export default class PollComponent extends Component { } } diff --git a/plugins/poll/assets/javascripts/discourse/initializers/extend-for-poll.gjs b/plugins/poll/assets/javascripts/discourse/initializers/extend-for-poll.gjs new file mode 100644 index 00000000000..baf99ea1e0d --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/initializers/extend-for-poll.gjs @@ -0,0 +1,121 @@ +import { tracked } from "@glimmer/tracking"; +import EmberObject from "@ember/object"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { bind } from "discourse-common/utils/decorators"; +import { PIE_CHART_TYPE } from "../components/modal/poll-ui-builder"; +import Poll from "../components/poll"; + +function attachPolls(elem, helper) { + let pollNodes = [...elem.querySelectorAll(".poll")]; + pollNodes = pollNodes.filter( + (node) => node.parentNode.tagName !== "BLOCKQUOTE" + ); + if (!pollNodes.length || !helper) { + return; + } + + const post = helper.getModel(); + const polls = post.pollsObject; + + pollNodes.forEach((pollNode) => { + const pollName = pollNode.dataset.pollName; + let poll = polls[pollName]; + let pollPost = post; + + const quotedId = pollNode.closest(".expanded-quote")?.dataset.postId; + if (quotedId && post.quoted[quotedId]) { + pollPost = EmberObject.create(post.quoted[quotedId]); + poll = new TrackedObject(pollPost.polls.find((p) => p.name === pollName)); + } + + if (poll) { + const titleHTML = pollNode.querySelector(".poll-title")?.outerHTML; + + const newPollNode = document.createElement("div"); + Object.assign(newPollNode.dataset, { + pollName: poll.name, + pollType: poll.type, + }); + newPollNode.classList.add("poll-outer"); + if (poll.chart_type === PIE_CHART_TYPE) { + newPollNode.classList.add("pie"); + } + + pollNode.replaceWith(newPollNode); + helper.renderGlimmer(newPollNode, ); + } + }); +} + +function initializePolls(api) { + api.modifyClass( + "controller:topic", + (Superclass) => + class extends Superclass { + subscribe() { + super.subscribe(...arguments); + this.messageBus.subscribe( + `/polls/${this.model.id}`, + this._onPollMessage + ); + } + + unsubscribe() { + this.messageBus.unsubscribe("/polls/*", this._onPollMessage); + super.unsubscribe(...arguments); + } + + @bind + _onPollMessage(msg) { + const post = this.get("model.postStream").findLoadedPost(msg.post_id); + if (post) { + post.polls = msg.polls; + } + } + } + ); + + api.modifyClass( + "model:post", + (Superclass) => + class extends Superclass { + @tracked pollsObject = new TrackedObject(); + @tracked _polls; + + get polls() { + return this._polls; + } + + set polls(value) { + this._polls = value; + this._refreshPollsObject(); + } + + _refreshPollsObject() { + for (const rawPoll of this.polls) { + const name = rawPoll.name; + this.pollsObject[name] ||= new TrackedObject(); + Object.assign(this.pollsObject[name], rawPoll); + } + } + } + ); + + api.decorateCookedElement(attachPolls, { onlyStream: true }); + + const siteSettings = api.container.lookup("service:site-settings"); + if (siteSettings.poll_enabled) { + api.addSearchSuggestion("in:polls"); + } +} + +export default { + name: "extend-for-poll", + + initialize() { + withPluginApi("0.8.7", initializePolls); + }, +}; diff --git a/plugins/poll/assets/javascripts/discourse/initializers/extend-for-poll.js b/plugins/poll/assets/javascripts/discourse/initializers/extend-for-poll.js deleted file mode 100644 index 37dfc3517b1..00000000000 --- a/plugins/poll/assets/javascripts/discourse/initializers/extend-for-poll.js +++ /dev/null @@ -1,150 +0,0 @@ -import EmberObject from "@ember/object"; -import { withPluginApi } from "discourse/lib/plugin-api"; -import WidgetGlue from "discourse/widgets/glue"; -import { getRegister } from "discourse-common/lib/get-owner"; -import { bind, observes } from "discourse-common/utils/decorators"; - -const PLUGIN_ID = "discourse-poll"; -let _glued = []; -let _interval = null; - -function rerender() { - _glued.forEach((g) => g.queueRerender()); -} - -function cleanUpPolls() { - if (_interval) { - clearInterval(_interval); - _interval = null; - } - - _glued.forEach((g) => g.cleanUp()); - _glued = []; -} - -function initializePolls(api) { - const register = getRegister(api), - pollGroupableUserFields = api.container.lookup( - "service:site-settings" - ).poll_groupable_user_fields; - cleanUpPolls(); - - api.modifyClass("controller:topic", { - pluginId: PLUGIN_ID, - - subscribe() { - this._super(...arguments); - this.messageBus.subscribe(`/polls/${this.model.id}`, this._onPollMessage); - }, - - unsubscribe() { - this.messageBus.unsubscribe("/polls/*", this._onPollMessage); - this._super(...arguments); - }, - - @bind - _onPollMessage(msg) { - const post = this.get("model.postStream").findLoadedPost(msg.post_id); - post?.set("polls", msg.polls); - }, - }); - - api.modifyClass("model:post", { - pluginId: PLUGIN_ID, - _polls: null, - pollsObject: null, - - // we need a proper ember object so it is bindable - @observes("polls") - pollsChanged() { - const polls = this.polls; - if (polls) { - this._polls = this._polls || {}; - polls.forEach((p) => { - const existing = this._polls[p.name]; - if (existing) { - this._polls[p.name].setProperties(p); - } else { - this._polls[p.name] = EmberObject.create(p); - } - }); - this.set("pollsObject", this._polls); - rerender(); - } - }, - }); - - function attachPolls(elem, helper) { - let pollNodes = [...elem.querySelectorAll(".poll")]; - pollNodes = pollNodes.filter( - (node) => node.parentNode.tagName !== "BLOCKQUOTE" - ); - if (!pollNodes.length || !helper) { - return; - } - - const post = helper.getModel(); - api.preventCloak(post.id); - post.pollsChanged(); - - const polls = post.pollsObject || {}; - const votes = post.polls_votes || {}; - - _interval = _interval || setInterval(rerender, 30000); - - pollNodes.forEach((pollNode) => { - const pollName = pollNode.dataset.pollName; - let poll = polls[pollName]; - let pollPost = post; - let vote = votes[pollName] || []; - - const quotedId = pollNode.closest(".expanded-quote")?.dataset.postId; - if (quotedId && post.quoted[quotedId]) { - pollPost = post.quoted[quotedId]; - pollPost = EmberObject.create(pollPost); - poll = EmberObject.create(pollPost.polls.findBy("name", pollName)); - vote = pollPost.polls_votes || {}; - vote = vote[pollName] || []; - } - - if (poll) { - const titleElement = pollNode.querySelector(".poll-title"); - - const attrs = { - id: `${pollName}-${pollPost.id}`, - post: pollPost, - poll, - vote, - hasSavedVote: vote.length > 0, - titleHTML: titleElement?.outerHTML, - groupableUserFields: (pollGroupableUserFields || "") - .split("|") - .filter(Boolean), - _postCookedWidget: helper.widget, - }; - const glue = new WidgetGlue("discourse-poll", register, attrs); - glue.appendTo(pollNode); - _glued.push(glue); - } - }); - } - - api.includePostAttributes("polls", "polls_votes"); - api.decorateCookedElement(attachPolls, { - onlyStream: true, - }); - api.cleanupStream(cleanUpPolls); - - const siteSettings = api.container.lookup("service:site-settings"); - if (siteSettings.poll_enabled) { - api.addSearchSuggestion("in:polls"); - } -} - -export default { - name: "extend-for-poll", - - initialize() { - withPluginApi("0.8.7", initializePolls); - }, -}; diff --git a/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js b/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js deleted file mode 100644 index 8c7ca6d219d..00000000000 --- a/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js +++ /dev/null @@ -1,36 +0,0 @@ -import { hbs } from "ember-cli-htmlbars"; -import RenderGlimmer from "discourse/widgets/render-glimmer"; -import { createWidget } from "discourse/widgets/widget"; -import { PIE_CHART_TYPE } from "../components/modal/poll-ui-builder"; - -export default createWidget("discourse-poll", { - tagName: "div", - buildKey: (attrs) => `poll-${attrs.id}`, - services: ["dialog"], - - buildAttributes(attrs) { - let cssClasses = "poll-outer"; - if (attrs.poll.chart_type === PIE_CHART_TYPE) { - cssClasses += " pie"; - } - return { - class: cssClasses, - "data-poll-name": attrs.poll.name, - "data-poll-type": attrs.poll.type, - }; - }, - - html(attrs) { - return [ - new RenderGlimmer( - this, - "div.poll", - hbs``, - { - attrs, - preloadedVoters: attrs.poll.preloaded_voters, - } - ), - ]; - }, -}); diff --git a/plugins/poll/test/javascripts/component/poll-test.js b/plugins/poll/test/javascripts/component/poll-test.js index 7ff004b0e13..f453fa641b5 100644 --- a/plugins/poll/test/javascripts/component/poll-test.js +++ b/plugins/poll/test/javascripts/component/poll-test.js @@ -1,15 +1,10 @@ import EmberObject from "@ember/object"; import { click, render } from "@ember/test-helpers"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; import hbs from "htmlbars-inline-precompile"; import { module, test } from "qunit"; import { setupRenderingTest } from "discourse/tests/helpers/component-test"; import pretender, { response } from "discourse/tests/helpers/create-pretender"; -import { - count, - exists, - query, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; import I18n from "discourse-i18n"; let requests = 0; @@ -48,112 +43,86 @@ module("Poll | Component | poll", function (hooks) { test("shows vote", async function (assert) { this.setProperties({ - attributes: EmberObject.create({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - user_id: 29, - }), - poll: EmberObject.create({ - name: "poll", - type: "regular", - status: "closed", - results: "always", - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 1 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, - ], - voters: 1, - chart_type: "bar", - }), - vote: [], - groupableUserFields: [], + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + }), + poll: new TrackedObject({ + name: "poll", + type: "regular", + status: "closed", + results: "always", + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 1 }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + ], + voters: 1, + chart_type: "bar", }), - preloadedVoters: [], }); - await render( - hbs`` - ); + await render(hbs``); - assert.deepEqual( - Array.from(queryAll(".results li .option p")).map( - (span) => span.innerText - ), - ["100% yes", "0% no"] - ); + assert.dom(".results li:nth-of-type(1) .option p").hasText("100% yes"); + assert.dom(".results li:nth-of-type(2) .option p").hasText("0% no"); }); test("does not show results after voting when results are to be shown only on closed", async function (assert) { this.setProperties({ - attributes: EmberObject.create({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - user_id: 29, - }), - hasSavedVote: true, - poll: EmberObject.create({ - name: "poll", - type: "regular", - status: "open", - results: "on_close", - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes" }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no" }, - ], - voters: 1, - chart_type: "bar", - }), - vote: ["1f972d1df351de3ce35a787c89faad29"], - groupableUserFields: [], + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + }), + poll: new TrackedObject({ + name: "poll", + type: "regular", + status: "open", + results: "on_close", + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes" }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no" }, + ], + voters: 1, + chart_type: "bar", }), - preloadedVoters: [], }); - await render( - hbs`` - ); + await render(hbs``); - assert.ok(exists("ul.options"), "options are shown"); - assert.ok(!exists("ul.results"), "results are not shown"); + assert.dom("ul.options").exists("options are shown"); + assert.dom("ul.results").doesNotExist("results are not shown"); }); test("can vote", async function (assert) { this.setProperties({ - attributes: EmberObject.create({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - user_id: 29, - }), - poll: EmberObject.create({ - name: "poll", - type: "regular", - status: "open", - results: "always", - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, - ], - voters: 0, - chart_type: "bar", - }), - vote: [], - groupableUserFields: [], + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + }), + poll: new TrackedObject({ + name: "poll", + type: "regular", + status: "open", + results: "always", + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + ], + voters: 0, + chart_type: "bar", }), - preloadedVoters: [], }); - await render( - hbs`` - ); + await render(hbs``); requests = 0; @@ -161,100 +130,84 @@ module("Poll | Component | poll", function (hooks) { "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button" ); assert.strictEqual(requests, 1); - assert.strictEqual(count(".chosen"), 1); + assert.dom(".chosen").exists({ count: 1 }); await click(".toggle-results"); - assert.strictEqual( - count("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']"), - 1 - ); + assert + .dom("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']") + .exists({ count: 1 }); }); test("cannot vote if not member of the right group", async function (assert) { this.setProperties({ - attributes: EmberObject.create({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - user_id: 29, - }), - poll: EmberObject.create({ - name: "poll", - type: "regular", - status: "open", - results: "always", - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, - ], - voters: 0, - chart_type: "bar", - groups: "foo", - }), - vote: [], - groupableUserFields: [], + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + }), + poll: new TrackedObject({ + name: "poll", + type: "regular", + status: "open", + results: "always", + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + ], + voters: 0, + chart_type: "bar", + groups: "foo", }), - preloadedVoters: [], }); - await render( - hbs`` - ); + await render(hbs``); requests = 0; await click( "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button" ); - assert.strictEqual( - query(".poll-container .alert").innerText, - I18n.t("poll.results.groups.title", { groups: "foo" }) - ); + assert + .dom(".poll-container .alert") + .hasText(I18n.t("poll.results.groups.title", { groups: "foo" })); assert.strictEqual(requests, 0); - assert.ok(!exists(".chosen")); + assert.dom(".chosen").doesNotExist(); }); test("voting on a multiple poll with no min attribute", async function (assert) { this.setProperties({ - attributes: EmberObject.create({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - user_id: 29, - }), - poll: EmberObject.create({ - name: "poll", - type: "multiple", - status: "open", - results: "always", - max: 2, - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, - ], - voters: 0, - chart_type: "bar", - }), - vote: [], - groupableUserFields: [], + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + user_id: 29, + }), + poll: EmberObject.create({ + name: "poll", + type: "multiple", + status: "open", + results: "always", + max: 2, + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + ], + voters: 0, + chart_type: "bar", }), - preloadedVoters: [], }); - await render( - hbs`` - ); + await render(hbs``); - assert.ok(exists(".poll-buttons .cast-votes:disabled")); + assert.dom(".poll-buttons .cast-votes:disabled").exists(); await click( "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button" ); await click(".poll-buttons .cast-votes"); - assert.ok(exists(".chosen")); + assert.dom(".chosen").exists(); }); });